Skip to content

Fix ten hosted cargo redirect bugs found by a real-cargo matrix - #255

Merged
Mikola Lysenko (mikolalysenko) merged 21 commits into
feat/cargo-vendor-manifest-patchfrom
fix/cargo-hosted-matrix-bugs
Sep 25, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 21 commits into
feat/cargo-vendor-manifest-patchfrom
fix/cargo-hosted-matrix-bugs

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Branch fix/cargo-hosted-matrix-bugs, stacked on #254 (commits are keyed by subject below; shas move on each restack) (feat/cargo-vendor-manifest-patch). 21 signed commits, 73 files, +5665/−168 — 58 of those files are new shared golden fixtures under tests/fixtures/redirect/cargo/.

Summary

Ten defects in hosted cargo redirects, every one found by running the real CLI against real cargo rather than by reading code. A 1,232-cell matrix (cargo 1.41 → stable, lock formats v1–v4, 22 project shapes, 6 modes) drove the discovery; each fix carries a real-cargo regression test and was reverted in turn to confirm the test fails without it.

Fix What was wrong
pin and revert each cargo version separately Multi-version: plan_cargo_toml matched a crate by name without its version, so every same-named declaration was pinned to the last patch's registry. Each version is now pinned and reverted separately.
repoint a v1 lock's [root] table A v1 lock's [root] table kept full-id references on crates.io after the rewrite.
never pin a manifest under target/ Workspace-member discovery walked into target/, and followed symlinks out of the project — a symlinked member could be written to outside the repo.
spend one cargo edit per occurrence A crate declared identically in two dependency tables produced two identical ledger edits, which were deduped into one; remove then reported success and left a pin and a [registries] block behind.
see a hosted pin in a workspace member A member's own declaration was left on crates.io while the run reported the crate redirected; and the cross-mode takeover guard could not see table-form, renamed, or member-manifest pins.
restore a cargo config's exact trailing bytes remove appended a blank line to an existing legacy .cargo/config, and left a config it had created behind. Reverts are now byte-exact, including files with no trailing newline.
remove cargo patches across line-ending changes A CRLF-recorded ledger stopped matching once a checkout converted line endings, so remove/rollback failed.
remove each cargo version from older ledgers Ledgers written by older CLIs could not have their first-redirected version removed.
refuse cargo requirements the patch misses A declaration whose requirement excludes the patched version was reported not_found; it is now redirect_cargo_toml_dep_unrewritable, naming each requirement and its manifest.
refuse a lockless cargo pin See below.

The one behaviour change to weigh

registry = "socket-patch-<uuid>" only reaches the declarations it sits on. If another package in the graph also depends on the patched crate, that package keeps resolving it from crates.io — so the build gets a patched copy for the root and an unpatched copy for everyone else, while the scan reports the crate redirected and vex attests it. That shape is now refused with redirect_cargo_transitive_dependents, pointing at --mode vendored.

Answering that question needs Cargo.lock. A project without one was previously pinned unconditionally, so the silently-wrong case was the common one — and Rust libraries routinely gitignore their lock. With no lock, refuse a lockless cargo pin answers it from the manifests instead: a crate declared beside any other dependency, or beside a workspace member this run could not read, is refused redirect_cargo_lockless_dependents.

This turns a previously-succeeding common shape into a loud skip. It is narrower than it sounds — a lockless project whose only dependency is the patched crate still redirects, and a path dependency on a manifest the same run pins does not count as company — but it is the change most likely to be noticed, and it is the one I would most like a second opinion on. The alternative is to keep silently half-patching those projects.

Cross-repo parity

redirect_cargo_transitive_dependents and redirect_cargo_toml_dep_unrewritable are the shared names; depscan's TypeScript twin adopts both in SocketDev/depscan#SERVERPR. Four new shared golden cases (requirement-excludes-patched-version, transitive-dependents-git, path-dependency, lockless-dependents) plus shared-dependency pin the contract for both implementations.

Testing

cargo test -p socket-patch-core 4922 pass, -p socket-patch-cli 4002 pass, clippy --workspace --all-features --all-targets -D warnings clean. Real-cargo suites across lock formats v1–v4 including e2e_redirect_cargo_shapes 8/8 each, plus the docker 1.41/1.56 legs 14/14.

One trap worth recording: cargo test -p socket-patch-cli --test <name> does not re-uplift debug/socket-patch, so two worktrees sharing a CARGO_TARGET_DIR will silently run each other's binary — e2e_redirect_cargo_shapes reported 0/8 four times before that was spotted. The suites now assert the binary's identity before running.


Note

High Risk
Changes hosted cargo lockfile/manifest rewriting and introduces new skip/refusal paths (including lockless projects with multiple deps), which can break workflows that previously appeared to succeed while building mixed patched/unpatched graphs.

Overview
Hosted cargo redirects are reworked so pins stay consistent with what cargo --locked actually resolves: workspace-member and in-root path Cargo.toml files are included in the same transaction as the root, semver-aware pinning applies per declaration when multiple versions of a crate are locked, and v1 Cargo.lock handling repoints [root] plus dependent full-ids via a dedicated redirect_cargo_lock_reference edit (so remove works in any order).

Fail-closed refusals replace cases that previously reported success while leaving unpatched copies in the graph: redirect_cargo_transitive_dependents when another lock package also depends on the crate, redirect_cargo_lockless_dependents when there is no Cargo.lock but other dependencies (or unread workspace members) might pull the crate in, and clearer redirect_cargo_toml_dep_unrewritable when requirements do not match the patched version. CRLF cargo files are rewritten with endings preserved; ledger recording no longer collapses duplicate manifest edits from two dependency sections, and appended .cargo/config fragments revert byte-for-byte.

Adds cargo_workspace::member_manifests, wires it into scan --mode hosted, extends CI with e2e_redirect_cargo_shapes, and documents the contract in CHANGELOG / CLI_CONTRACT.

Reviewed by Cursor Bugbot for commit 92ad228. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issues.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 92ad228. Configure here.

Comment thread crates/socket-patch-core/src/vendor/cargo.rs
Comment thread crates/socket-patch-core/src/utils/cargo_workspace.rs
A project that locks two versions of one crate (cfg-if 1.0.4 beside a
renamed cfg-if-legacy at 0.1.10) had every same-named declaration pinned
to the last patch's registry: the manifest planner matched the crate name
only, so `^0.1.10` pointed at a registry that serves 1.0.4 and
`cargo fetch --locked` failed. `get <uuid>` for one version broke it the
same way.

The planner now pins a declaration only when its version requirement
selects the patched version. A requirement that also matches another
locked version refuses the dep (nothing written); `workspace = true`
inheritors of an entry naming another version are skipped.

`remove` had the matching defect: manifest edits are keyed by crate name,
so removing one version also reverted the other version's pin (leaving its
lock entry hosted) and dropped its registry edit from the ledger, so the
created `.cargo/config.toml` survived the second removal. Manifest edits
whose pin names a sibling version's registry lineage are no longer claimed.

Golden: cargo/cargo/multi-version (the depscan TS twin lags; list it in
TS_LAGGING on the next submodule bump). Real-cargo regression:
e2e_redirect_cargo_shapes multi-version (fresh `cargo fetch --locked` +
offline build links both patched copies; removing both purls restores
every byte).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`remove` of a hosted cargo patch deleted the `[registries.socket-patch-…]`
block it had appended to an existing config but kept the blank separator
line it inserted before it, so a legacy `.cargo/config` holding
`[net]\nretry = 2\n` came back as `[net]\nretry = 2\n\n`. It also collapsed
every run of three newlines anywhere in the file, rewriting the user's own
spacing.

The block now leaves through the replay path's fragment removal, which
takes the block's own separator and nothing else.

Real-cargo regression: e2e_redirect_cargo_shapes legacy-config.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Hosted mode pinned only the root Cargo.toml. In a workspace whose member
declares the patched crate itself (`cfg-if = "1.0.4"` beside siblings
that inherit `[workspace.dependencies]`), the member stayed on crates.io
while Cargo.lock was repointed at the patch registry, so
`cargo fetch --locked` failed — and the dep was still reported
redirected and attested.

`scan`/`get --mode hosted` now read every workspace member manifest
(`[workspace] members` globs minus `exclude`, plus in-root path
dependencies) and the rewriter plans them all in the dep's single
transaction: each direct declaration is pinned, `workspace = true`
inheritors resolve against the root's entry, and a member that cannot be
pinned refuses the dep everywhere. `remove` already reverts edits by path;
its registry-block reference probe now covers member manifests too.

Golden: cargo/cargo/workspace-member (the depscan TS twin is handed no
member manifests and lags; list it in TS_LAGGING on the next submodule
bump). Real-cargo regression: e2e_redirect_cargo_shapes
workspace-direct-member.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The real-cargo shape suite now also runs `socket-patch vex` over each
fresh checkout (ledger kept, patch server admitted) and requires exactly
the shape's patches to be attested — multi-version and workspace-member
redirects included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every hosted cargo planner matched LF text, so a Windows checkout with
CRLF Cargo.toml / Cargo.lock was refused outright
(redirect_cargo_toml_dep_unrewritable / redirect_cargo_lock_pkg_not_found).

A manifest, lock or config whose every line break is CRLF is now planned
as LF and written back as CRLF, and its recorded edit fragments are
stored CRLF so `remove` and rollback find them in the file. Files with
mixed endings are left alone and keep refusing where the grammar does not
match. The shared fragment remover (used for the appended registry block)
inverts CRLF files as LF so it no longer strands a `\r` line.

Golden: cargo/cargo/crlf (CRLF manifest, lock and legacy config; the
depscan TS twin lags — list it in TS_LAGGING on the next submodule bump).
Real-cargo regression: e2e_redirect_cargo_shapes crlf (endings kept,
fresh `cargo fetch --locked` + offline build, byte-identical remove).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A hosted cargo redirect pins a crate with `registry = "…"` on its
manifest declaration, which reaches only that declaration: a crate the
project gets purely through another dependency cannot be redirected in
hosted mode. That stays a refusal — nothing is written, recorded or
attested, and a requested VEX fails the run — but the warning said only
"no [dependencies] entry for X in Cargo.toml", which reads like a
discovery bug.

When Cargo.lock resolves the crate at the patched version the warning
now says it is a transitive-only dependency, that it was NOT redirected
and stays unpatched, and that `scan --mode vendored` (whose
`[patch.crates-io]` covers the whole graph) or a direct declaration can
patch it. The code (`redirect_cargo_toml_dep_not_found`) is unchanged.

Golden: cargo/cargo/transitive-refusal (pins the refusal and its warning
code). In-process: the refusal writes nothing, is not attested, and the
envelope carries the transitive-only warning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The multi-version, workspace-member, legacy-config and CRLF shapes ran
only against the toolchain's own v4 lock, so nothing in-repo checked
them against a committed v1 lock (full-id dependency edges, checksums
in `[metadata]`) or v2/v3.

The shape suite now honours SOCKET_PATCH_CARGO_E2E_LOCK_VERSION like
the other real-cargo suites and joins the cargo-vex-matrix CI job. The
shared lock re-encoder kept only dependency names, which cannot tell
two locked versions of one crate apart; it now keeps each edge's
package id and writes the shortest form cargo writes for that format
(`name`, `name version`, or the full id; always the full id in v1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A hosted cargo redirect pins a crate with `registry = "…"` on each
manifest declaration and repoints its one Cargo.lock entry. When a
crates.io (or git) crate in the lock also depends on it — cfg-if, libc
and serde usually are both direct and transitive — that edge keeps
resolving from crates.io. The repointed lock then fails
`cargo fetch --locked`, a plain build adds the crates.io copy back
beside the patched one, and the dependent crate compiles the unpatched
code, while scan reported the crate redirected and VEX attested it.
The same happened for a path package whose manifest the rewriter never
saw (outside the project root, or behind a symlink).

Before committing a dep, every Cargo.lock package that depends on it
(any edge spelling: `name`, `name version`, or the full v1 id) must be
a source-less package whose manifest was planned and pinned. Otherwise
the dep is skipped with the new additive warning
`redirect_cargo_transitive_dependents`, which names the dependents,
says the crate was NOT redirected and stays unpatched, and points to
`scan --mode vendored`. Nothing is written, recorded or attested.

Golden: cargo/cargo/shared-dependency (the depscan TS twin lags — list
it in TS_LAGGING on the next submodule bump). Unit: registry, git, v1
full-id and unplanned-path dependents refuse; another version's edge
and a planned member do not. Real cargo: e2e_redirect_cargo_shapes
direct-and-transitive (cfg-if beside crc32fast) is refused and the
untouched project still fetches `--locked`; before this change the
same shape reported redirected: 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Workspace member discovery checked the project root only lexically. A
literal `members = ["linked"]` entry or a path dependency through a
symlinked directory was returned as a rewrite target, and `scan --mode
hosted` then wrote the registry pin into a Cargo.toml outside the
project (the whole-run symlink guard checks only the file itself, not
its parent directories). The same link matched by a `crates/*` glob
was skipped instead, because the glob does not follow links.

Discovery now returns only manifests reached without crossing a
symbolic link: a symlinked member directory (literal or glob), a
symlinked path dependency, a symlinked intermediate directory and a
symlinked Cargo.toml are all left out. Cargo still reads those
manifests, so a crate one of them depends on is refused by the
Cargo.lock dependents check (redirect_cargo_transitive_dependents),
exactly like a member or path dependency outside the root.

Unit: symlinked literal/glob/nested members, a symlinked path
dependency and manifest, and out-of-root members/path dependencies are
not returned. In-process: an out-of-root path dependency and a glob or
literal symlinked member each refuse the crate loudly and leave every
file, the one outside the project included, untouched (the literal
case reported redirected: 1 and wrote outside the project before).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A v1 Cargo.lock names every dependency by its full id, so repointing a
crate also rewrites its dependents' references. Each dependent's whole
`[[package]]` block was recorded as one edit. When one block referenced
two patched packages (the root, for two cfg-if versions or any two
patched crates it declares) the second edit's `original` was the first
edit's `new`, and removing the first-applied purl alone found neither
fragment: `remove`, scoped rollback and the hosted-to-vendored takeover
refused as "drifted" unless purls went in exact reverse apply order.

The dependents' references are now one `redirect_cargo_lock_reference`
edit holding just the quoted full id `"name version (source)"`. It
names that package exactly, so its inverse puts back every occurrence,
independently of any other package's edits; the per-purl revert and the
whole-ledger replay both handle it. The v1 whole-block edits were never
released, so no existing ledger carries them.

Unit: two cfg-if versions and two crates sharing a dependent block each
remove in both orders byte-for-byte (both refused as drifted before); a
full id named by two members replays clean. Real cargo:
e2e_redirect_cargo_shapes now removes every multi-patch shape in apply
order and in reverse, from the same post-scan state, and passes with
SOCKET_PATCH_CARGO_E2E_LOCK_VERSION=1, 2 and 4.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The earlier bug-H fix removed the appended `[registries.…]` block with
the shared fragment remover, which collapses the whole trailing newline
run at EOF to one newline. That fixed a config ending in exactly one
newline but lost a config's trailing blank line (restored byte-for-byte
before that change) and still added a newline to a config without one.

The block removal now inverts exactly what the rewriter appended: the
recorded fragment plus the single blank separator before it. The
newline a config without a final one needs rides in the recorded
fragment, so that case is exact too; content the user appended after
the block is kept; a config the rewrite created still ends empty and is
deleted. `remove` and the whole-ledger replay share the helper (replay
no longer leaves an emptied created config behind). All-CRLF configs
are inverted as LF, and a fragment recorded with the other line endings
still matches. Existing ledgers keep reverting as before.

Unit: the bug-H revert test covers no final newline (LF and CRLF),
trailing blank lines (LF and CRLF) and a whitespace-only config through
both `remove` and the replay; helper pins for every append shape. Real
cargo: e2e_redirect_cargo_shapes config-unterminated and
config-trailing-blank restore the config byte-for-byte.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The CRLF support records a CRLF project's edit fragments with CRLF, and
the ledger is JSON, so git line-ending normalization never touches
them while it does rewrite the committed Cargo.toml / Cargo.lock /
.cargo/config. A hosted redirect scanned on Windows (core.autocrlf)
therefore could not be removed on a Linux checkout, nor an LF scan on
a CRLF checkout: `remove`, rollback and the vendored takeover matched
fragments byte-for-byte and refused as "drifted", and the suggested
re-scan was a no-op that kept the same fragments.

The cargo per-purl revert and the whole-ledger replay now match
fragments regardless of CRLF/LF: an all-CRLF file is matched as LF and
written back CRLF, any other file is tried with the recorded fragments
and then with their LF forms. The file keeps its current line endings.
The registry-block checks and removals use the same matching. Drift
of the text itself still refuses exactly as before.

Unit: a ledger recorded CRLF reverts LF files and a ledger recorded LF
reverts CRLF files, each through `remove` and through the replay, with
the ledger round-tripped through JSON (all four refused before).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Manifest edits are keyed by crate name, so the per-version revert told
two redirected versions' edits apart by the registry each pin names.
Ledgers an older CLI wrote with its name-only matcher break that rule:
each version's run pinned BOTH declarations to its own registry, and a
project it mis-pinned is later repaired by superseding that pin. On
such a ledger `remove pkg:cargo/cfg-if@1.0.4` claimed half of a
declaration's re-pin chain (or the superseded mis-pin) and refused as
"drifted" — for exactly the users bug B had already hurt — and the
other removal order dropped a still-pinned registry edit, leaving the
created .cargo/config.toml behind.

With another version of the crate still redirected, each manifest edit
is now attributed to the version its declaration's requirement selects
(the planner's own rule); a line without a readable requirement falls
back to the registry it pins. A sibling version's registry is never
claimed, and a still-referenced block keeps its ledger edit when
another recorded wiring edit still pins to it, so the removal that
retires that pin removes the block. A block kept for a hand pin still
leaves the ledger, and the whole-ledger replay now keeps such a block
too instead of deleting it from under the hand pin.

Unit: a name-only two-version ledger and a repaired mis-pin ledger each
remove in both orders back to the pristine project with no config and
an empty ledger (removing 1.0.4 first refused before); the replay keeps
a hand-pinned block.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The hosted flow appends a run's edits to the redirect ledger, skipping
any edit already in it. The check also ran against the edits appended
earlier in the same run, so identical edits from one rewrite collapsed
into one. The cargo rewriter records one edit per occurrence, and a
Cargo.toml that declares the crate with the same line in two sections
(`[dependencies]` and `[dev-dependencies]`) yields two identical edits.
With only one in the ledger, `remove` reverted one pin, kept the other
and the registry block it references, and still reported success.

Edits are now deduplicated only against the ledger as the run found it,
so a re-run still records nothing twice.

Real cargo: e2e_redirect_cargo_shapes two-sections (scan, fresh
`--locked` build, VEX, byte-identical `remove`); it failed on the
leftover pin before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CLI_CONTRACT.md still said the hosted rewriter reads only root candidate
files, but cargo now also reads and pins workspace-member and in-root
path-dependency manifests, which can show up in `rewrittenFiles`. The
contract now covers that (members globs minus `exclude`, no symlinks,
never `.socket/`), the new `redirect_cargo_transitive_dependents`
refusal, the transitive-only and multi-version refusals, and CRLF
handling across a checkout conversion.

CHANGELOG [Unreleased] gains Fixed entries for this branch's
user-visible hosted cargo changes, and docs/ecosystems.md notes that
hosted cargo reaches direct dependencies only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A crate declared only with version requirements the patched version
does not satisfy resolves those declarations to another version, so a
pin cannot reach the locked crate. Hosted cargo now refuses it with
redirect_cargo_toml_dep_unrewritable, the code the Socket backend
uses for the same shape, instead of reporting it not found.

Shared golden fixtures pin both refusals the backend and CLI must
agree on: requirement-excludes-patched-version, path-dependency and
transitive-dependents-git (a git crate also depending on the patched
crate).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The hosted rewriter pins the patched crate in every manifest that
declares it, workspace members included, but the guard that refuses to
vendor on top of a live hosted redirect read the root Cargo.toml alone.
A member-only pin was invisible, so with the redirect ledger gone
vendor wired [patch.crates-io] beside a member still resolving the
crate from the hosted registry — where [patch.crates-io] does not
reach — and reported success.

The guard now reads the root manifest and every member manifest the
rewriter itself discovers, and the refusal names the file it found.

Real cargo: mode_migration_cargo's takeover refusal gains the shape
the guard exists for — the lock restored from version control while a
table-form manifest pin survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A v1 Cargo.lock written before cargo dropped [root] keeps the root
package in that table, whose dependencies spell full package ids. The
reference rewrite walked the [[package]] array only, so a redirected
crate left [root] naming the crates.io id of a package the lock no
longer held: cargo build --locked then fails, an unlocked build
discards the lock and re-resolves, and the scan reported the crate
redirected either way.

The walk now covers that table too, recorded by the same
every-occurrence reference edit, so remove still restores the lock byte
for byte in any order. The vendored backend has read [root] since it
learned v1 locks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One scan records one cargo edit per occurrence, so a manifest that
declares the crate with the same line in two sections leaves two
identical edits in the ledger, and the per-purl revert unwinds one
occurrence per edit. The whole-ledger replay — the path a run whose
patch-record fetch failed leaves behind, records empty — instead
refused as soon as the fragment appeared more than once, and that
refusal dropped every other cargo edit with it: the project stayed
fully redirected, with a suggested remedy (re-run the scan) that
cannot change a duplication the manifest itself carries.

The replay now allows as many occurrences as there are identical edits
left to spend on them, and still refuses a surplus no edit accounts
for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Member discovery skipped cargo's build directory only while expanding
a glob: a literal `members = ["target/gen"]` entry, or a path
dependency pointing into target/, was returned and pinned — which the
key predicate's own doc comment said could not happen. The next
`cargo clean` deletes that manifest, and a recorded hosted edit whose
file no longer exists refuses the rollback of every other cargo edit.

A `target` segment is now excluded wherever a member path is accepted,
in discovery and in the key predicate both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A `registry = …` pin reaches only the declarations it sits on, so a
crate another package also pulls in is refused — but that check reads
Cargo.lock, and a project without one was pinned unconditionally. Rust
libraries routinely gitignore their lock, so the silent shape was the
common one: the scan reported the crate redirected, vex attested it,
and the next build resolved the patched copy for the root and an
unpatched crates.io copy for whatever else depends on the crate.

Without a lock the dependents question is now answered from the
manifests instead: a crate declared beside any other dependency — or
beside a workspace member this run could not read (a members glob, or
a member outside the project or behind a symbolic link) — is refused
`redirect_cargo_lockless_dependents`, whose detail names both remedies.
A path dependency on a manifest the same run pins is not company, and
a project whose only dependency is the patched crate has nothing that
could pull it in and still redirects.

Real cargo: e2e_redirect_cargo_shapes lockless-other-dependencies, the
direct-and-transitive project with its lock deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mikolalysenko
Mikola Lysenko (mikolalysenko) merged commit 44c629f into feat/cargo-vendor-manifest-patch Sep 25, 2026
411 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the fix/cargo-hosted-matrix-bugs branch September 25, 2026 16:22
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Sep 25, 2026
* feat(vendor): wire cargo patches through Cargo.toml [patch]

Vendored cargo patches are now wired through a Socket-owned
[patch.crates-io] key in the workspace-root Cargo.toml instead of
.cargo/config, so the patch uuid is visible from the manifest alone.
Legacy config wiring is migrated on re-run, repair and uuid bumps.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(vendor): tag vendored cargo copies with +socket.<uuid>

A vendored cargo copy's own Cargo.toml version is now rewritten to
<version>+socket.<uuid> (existing build metadata is kept:
2.0.1+zstd.1.5.2.socket.<uuid>), and the detached Cargo.lock entry
records that tagged version, with every reference that spells the old
version rewritten, in lock formats v1-v4. This is the lock cargo itself
writes for the tagged copy, so the patch uuid of the copy that actually
builds can be read from Cargo.lock alone. The patched crate now sees
the tag in CARGO_PKG_VERSION.

A lock that cannot be kept consistent is refused before anything is
written (cargo_lock_untaggable). Revert restores the original lock byte
for byte. A uuid bump re-tags the copy and lock, and a re-run or repair
tags projects vendored before this change (cargo_version_tagged). VEX
discovery treats the tagged lock version as the primary identity signal.
The old-toolchain e2e tests prefer the rust:1.41-slim and rust:1.56-slim
docker images.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(vendor): harden tagged cargo copies after review

- Keep a recorded whole-tree inventory when repair tags a pre-tag copy
  instead of re-baselining it over unverified bytes; the inventory check
  accepts the copy's Cargo.toml only with exactly this uuid's tag
  dropped, the cargo inventory carries forward for the same copy, and
  repair refreshes it only from a member-verified rebuild.
- Select Cargo.lock entries by rank (this uuid's tagged copy, another
  tagged copy, a registry entry, then an untagged sourceless one), so a
  user's same-version path fork locked beside the copy is never
  restored, retagged or counted as a multi-source conflict; the revert
  spells the restored registry entry by its full id, exactly as cargo
  writes it.
- GC keeps an entry whose lock tag is stale while the manifest still
  wires its copy (cargo re-locks to it; the next re-run retags).
- Revert drops the tag the first build locked when no lock originals
  were recorded (vendored before any Cargo.lock), so a hosted takeover
  finds the crate again.
- Dry runs preview "would tag" and the cargo_lock_untaggable /
  cargo_copy_untaggable refusals.
- lock_inventory lists a sourced Socket-tagged entry with no verifier.
- Docs: semver Version equality/ordering sees the tag; 1.56 is built in
  CI docker; an untagged detached entry counts only beside an untagged
  copy.
- Tests: fork-shape lock units (v1/v4, cargo's exact lock), vendor-flow
  tests for revert/dry-run/untaggable copies, tag-aware inventory and
  carry-forward units, VEX discovery copy-tag units, VEX e2e split and
  ledger leg, and real-cargo cells for a transitive registry dependent,
  the repair inventory refresh and a lockless vendor + hosted takeover.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(vendor): cover cargo tag rollbacks and 1.41 index

- A failed Cargo.lock retag write (read-only project root) untags the
  copy again, through both the vendor re-run and repair's migration.
- repair's migration over a copy whose Cargo.toml cannot take the tag
  reports cargo_version_untagged and writes nothing.
- The cargo 1.41 two-version leg now proves the documented remedy: a
  populated crates.io index in $CARGO_HOME builds and runs both patched
  copies with no network, instead of only accepting the failure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(vendor): see every hosted pin shape before vendoring

The cross-mode guard that refuses to wire [patch.crates-io] on top of a
live hosted redirect read Cargo.toml with a single-line regex, so it saw
only `<crate> = { ... registry = "socket-patch-<uuid>" }`. The hosted
rewriter also writes a standalone `registry = ...` line under a
`[dependencies.<crate>]` header, pins renamed declarations by their
`package` key, and accepts a quoted key — all read as "not redirected".

With a Cargo.lock restored from version control (so the lock probe sees
crates.io again) and no redirect ledger, vendor then reported success on
a project pinned to the hosted registry and patched through
[patch.crates-io] at once: cargo resolves the crate from a non-crates.io
source, which [patch.crates-io] does not cover, so the build either
fails or silently links the unpatched copy.

The probe now reuses the rewriter's own manifest reader, so detection
and writing stay in step, and the refusal names the registry it found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(vendor): pin the cargo tag allowance to this patch

A cargo patch that edits the crate's own Cargo.toml verifies with the
Socket version tag dropped. The whole-tree inventory check drops only
THIS entry's tag; the per-file check dropped whatever Socket tag it
found, so a copy under <uuidA>'s path whose manifest is tagged for
<uuidB> — a hand edit, a merged vendored tree, a half-applied uuid bump
— verified clean and repair reported it healthy, while VEX discovery
refused the same bytes as dead wiring. Cargo builds it as the version
the tag names, which is not the version the detached lock pins.

Cargo entries record no file inventory, so this per-file path is the
live one for every vendored crate, not a legacy fallback. It now
applies the same uuid pin, with the four-arm test the inventory path
already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(vendor): assert the old-toolchain index constraint

The two-version old-toolchain test only printed whether a build without
--offline worked, and reached the documented 1.41 remedy (a populated
crates.io index in $CARGO_HOME) only when the plain --offline build
happened to fail. Both arms run with no network, so the unasserted run
could only fail, for a reason nothing checked — and a cargo that stopped
needing the index would have skipped the remedy and still passed.

Both directions are now asserted: the run without --offline must fail
on the unreachable crates.io index, and below 1.56 the empty-CARGO_HOME
--offline run must fail with the missing registry index before the
seeded index is required to build and run both patched copies.

The docs said 1.56 needs --offline; what 1.56 needs is --offline or a
reachable index, exactly like 1.41, which additionally needs the index
populated. Both the changelog and the ecosystem notes now say that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(purl): decode build-metadata versions before matching

The patches API serves canonical PURLs, so a semver build-metadata
version arrives percent-encoded — pkg:cargo/wasi@0.11.0%2Bwasi-snapshot-
preview1. The parsers compared that raw spelling against Cargo.lock's
0.11.0+wasi-snapshot-preview1, never matched, and refused the package
with vendor_fetched_missing and then locked_version_mismatch. It failed
closed, but no cargo crate with build metadata could be vendored at all,
and wasi is in most Rust dependency graphs.

Every ecosystem's parse now percent-decodes the namespace, name and
version exactly once, after the / and @ split and before the callers'
path-safety guards, so an escaped separator still cannot introduce a
path segment and an already-decoded %2B cannot become a second +. The
crawler and the ledger revert paths that decoded by hand, or fed an
already-canonical PURL back in, drop their extra pass.

Hosted mode already went through a decoding parse and is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(vendor): keep the Cargo.lock line endings

toml_edit renders LF only and drops the \r of every CRLF line, including
the ones no edit touched, so vendoring rewrote a lock committed with
Windows line endings as all-LF. vendor --revert then "restored" that
file, so the rollback differed from the original in every line ending
and was not byte-identical. The copy's Cargo.toml already mapped its
rendering back onto the original endings; the lock did not.

The lock edits — detach, retag and restore — now go through the same
reconciliation, in lock formats v1 through v4. A CRLF lock stays CRLF, a
missing trailing newline stays missing, and every line a mixed-ending
lock's edit leaves alone keeps its own ending. A line the edit rewrites,
removes or re-inserts still takes the file's dominant ending, because
its original one is not recoverable from the text, so a mixed lock is
promised an unchanged remainder rather than a byte-identical revert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(vendor): state the real cargo floor for two versions

Cargo before 1.56 resolves every source-less Cargo.lock entry for a crate
through ONE [patch.crates-io] path — the entry whose key sorts last — so
with two vendored versions one lock entry is pinned to the other
version's copy and cargo build --locked fails closed with "patch for
<crate> did not resolve to any crates". The docs claimed older cargo
needed only a populated crates.io index; the index does not help. The
old-toolchain e2e passed because its fixture uuids happened to sort so
that the lower version's key came last, the one order 1.41 can take.

Verified on the rust:1.41-slim and rust:1.56-slim images with two crates
(cfg-if 1.0.5/0.1.10, lazy_static 1.5.0/0.2.11) and with three versions
(bitflags 1.2.1/0.9.1/0.8.2): only the key sort order decides, the lock
entry order does not, and 1.56 builds either way.

The e2e fixture now uses uuids whose keys sort adversarially, asserts
that order so a future uuid cannot quietly make the test toothless, and
pins the refusal below 1.56 while requiring 1.56 to build both copies.
Vendoring a second version of a crate warns with
cargo_multi_version_old_cargo unless the project's rust-version or
rust-toolchain[.toml] promises cargo 1.56 or newer; socket-patch never
runs cargo, so those files are the only signal it has. A single vendored
version still builds on 1.41.

A key-naming scheme that puts the lowest version's key last does make
1.41 resolve every entry correctly, but it would replace the documented
<name>-socket-<uuid8> key with a version-rank encoding in every project's
committed manifest, and rest correctness on an undocumented cargo
iteration order. Left out deliberately; the failure is loud, not silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(vendor): check the wasi oracle at compile time

The build-metadata e2e vendored `wasi` and then ran a consumer binary
calling the patched marker fns. `wasi` declares 46 `extern "C"` WASI
syscalls that only resolve on wasm targets; GNU and Apple linkers
dead-strip the never-called wrappers, but MSVC refuses the executable
(`LNK1120: 46 unresolved externals`), so the test failed on the
windows-latest leg. The fresh-checkout `cargo build` of the same binary
consumer would have failed identically.

Keep `wasi` as the fixture (it is the real-world build-metadata crate
the bug hit) and make the consumer a library whose oracle is
compile-time: the patch suffix exposes `SOCKET_PATCHED` and
`SOCKET_PKG_VERSION` constants and the consumer's `src/lib.rs` holds a
`const fn bytes_eq` plus two `const _: () = assert!(...)` items, so
`cargo build --locked --offline` of the library proves both that the
patched bytes are what cargo compiled and that `CARGO_PKG_VERSION`
carries the exact `<version>.socket.<uuid>` tag, without linking
anything on any OS. The byte-identical revert leg and the zero-downloads
assertion are unchanged.

Negative controls, run by hand against the vendored tree: removing the
suffix from the copy fails with E0425 (`SOCKET_PATCHED` not found in
`wasi`); stripping the tag from the copy's manifest and lock entry fails
with E0080 (the const assertion). Verified under cargo 1.93.1 and
1.83.0 and all four lock formats.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(vex): label cargo lock shapes in assert messages

CodeQL's default setup raised 14 new `rust/cleartext-logging` alerts
on this branch, all in `assert_attested`'s panic messages. The SARIF
code flows start at the `uuid` reads inside `cargo_tag::tag_version`'s
`format!` (CodeQL's name heuristics classify any `uid`/`uuid`-named
identifier as sensitive), pass through the test's `tagged_version(U)`
wrapper into the `copy`/`locked` strings of
`cargo_vendored_a_attests_in_every_lock_version`, and end in the `what`
label interpolated into every assertion message of the helper.

A Socket patch uuid is a public identifier, so the alerts are false
positives, but they gate the PR and no CodeQL config file exists to
exclude tests. Break the path where the value entered the messages: the
loop now labels its two cases `tagged` / `untagged` instead of
embedding the tagged version strings, and `assert_attested` documents
that `what` is a shape label so the next test does not reintroduce the
flow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Fix ten hosted cargo redirect bugs found by a real-cargo matrix (#255)

* fix(redirect): pin and revert each cargo version separately

A project that locks two versions of one crate (cfg-if 1.0.4 beside a
renamed cfg-if-legacy at 0.1.10) had every same-named declaration pinned
to the last patch's registry: the manifest planner matched the crate name
only, so `^0.1.10` pointed at a registry that serves 1.0.4 and
`cargo fetch --locked` failed. `get <uuid>` for one version broke it the
same way.

The planner now pins a declaration only when its version requirement
selects the patched version. A requirement that also matches another
locked version refuses the dep (nothing written); `workspace = true`
inheritors of an entry naming another version are skipped.

`remove` had the matching defect: manifest edits are keyed by crate name,
so removing one version also reverted the other version's pin (leaving its
lock entry hosted) and dropped its registry edit from the ledger, so the
created `.cargo/config.toml` survived the second removal. Manifest edits
whose pin names a sibling version's registry lineage are no longer claimed.

Golden: cargo/cargo/multi-version (the depscan TS twin lags; list it in
TS_LAGGING on the next submodule bump). Real-cargo regression:
e2e_redirect_cargo_shapes multi-version (fresh `cargo fetch --locked` +
offline build links both patched copies; removing both purls restores
every byte).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): restore a cargo config's bytes on hosted remove

`remove` of a hosted cargo patch deleted the `[registries.socket-patch-…]`
block it had appended to an existing config but kept the blank separator
line it inserted before it, so a legacy `.cargo/config` holding
`[net]\nretry = 2\n` came back as `[net]\nretry = 2\n\n`. It also collapsed
every run of three newlines anywhere in the file, rewriting the user's own
spacing.

The block now leaves through the replay path's fragment removal, which
takes the block's own separator and nothing else.

Real-cargo regression: e2e_redirect_cargo_shapes legacy-config.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): pin cargo workspace members' own declarations

Hosted mode pinned only the root Cargo.toml. In a workspace whose member
declares the patched crate itself (`cfg-if = "1.0.4"` beside siblings
that inherit `[workspace.dependencies]`), the member stayed on crates.io
while Cargo.lock was repointed at the patch registry, so
`cargo fetch --locked` failed — and the dep was still reported
redirected and attested.

`scan`/`get --mode hosted` now read every workspace member manifest
(`[workspace] members` globs minus `exclude`, plus in-root path
dependencies) and the rewriter plans them all in the dep's single
transaction: each direct declaration is pinned, `workspace = true`
inheritors resolve against the root's entry, and a member that cannot be
pinned refuses the dep everywhere. `remove` already reverts edits by path;
its registry-block reference probe now covers member manifests too.

Golden: cargo/cargo/workspace-member (the depscan TS twin is handed no
member manifests and lags; list it in TS_LAGGING on the next submodule
bump). Real-cargo regression: e2e_redirect_cargo_shapes
workspace-direct-member.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(redirect): attest every cargo shape post-install

The real-cargo shape suite now also runs `socket-patch vex` over each
fresh checkout (ledger kept, patch server admitted) and requires exactly
the shape's patches to be attested — multi-version and workspace-member
redirects included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): redirect CRLF cargo projects in hosted mode

Every hosted cargo planner matched LF text, so a Windows checkout with
CRLF Cargo.toml / Cargo.lock was refused outright
(redirect_cargo_toml_dep_unrewritable / redirect_cargo_lock_pkg_not_found).

A manifest, lock or config whose every line break is CRLF is now planned
as LF and written back as CRLF, and its recorded edit fragments are
stored CRLF so `remove` and rollback find them in the file. Files with
mixed endings are left alone and keep refusing where the grammar does not
match. The shared fragment remover (used for the appended registry block)
inverts CRLF files as LF so it no longer strands a `\r` line.

Golden: cargo/cargo/crlf (CRLF manifest, lock and legacy config; the
depscan TS twin lags — list it in TS_LAGGING on the next submodule bump).
Real-cargo regression: e2e_redirect_cargo_shapes crlf (endings kept,
fresh `cargo fetch --locked` + offline build, byte-identical remove).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): say why a transitive-only crate is not redirected

A hosted cargo redirect pins a crate with `registry = "…"` on its
manifest declaration, which reaches only that declaration: a crate the
project gets purely through another dependency cannot be redirected in
hosted mode. That stays a refusal — nothing is written, recorded or
attested, and a requested VEX fails the run — but the warning said only
"no [dependencies] entry for X in Cargo.toml", which reads like a
discovery bug.

When Cargo.lock resolves the crate at the patched version the warning
now says it is a transitive-only dependency, that it was NOT redirected
and stays unpatched, and that `scan --mode vendored` (whose
`[patch.crates-io]` covers the whole graph) or a direct declaration can
patch it. The code (`redirect_cargo_toml_dep_not_found`) is unchanged.

Golden: cargo/cargo/transitive-refusal (pins the refusal and its warning
code). In-process: the refusal writes nothing, is not attested, and the
envelope carries the transitive-only warning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(redirect): run the cargo shape suite in every lock format

The multi-version, workspace-member, legacy-config and CRLF shapes ran
only against the toolchain's own v4 lock, so nothing in-repo checked
them against a committed v1 lock (full-id dependency edges, checksums
in `[metadata]`) or v2/v3.

The shape suite now honours SOCKET_PATCH_CARGO_E2E_LOCK_VERSION like
the other real-cargo suites and joins the cargo-vex-matrix CI job. The
shared lock re-encoder kept only dependency names, which cannot tell
two locked versions of one crate apart; it now keeps each edge's
package id and writes the shortest form cargo writes for that format
(`name`, `name version`, or the full id; always the full id in v1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): refuse a cargo crate other crates depend on

A hosted cargo redirect pins a crate with `registry = "…"` on each
manifest declaration and repoints its one Cargo.lock entry. When a
crates.io (or git) crate in the lock also depends on it — cfg-if, libc
and serde usually are both direct and transitive — that edge keeps
resolving from crates.io. The repointed lock then fails
`cargo fetch --locked`, a plain build adds the crates.io copy back
beside the patched one, and the dependent crate compiles the unpatched
code, while scan reported the crate redirected and VEX attested it.
The same happened for a path package whose manifest the rewriter never
saw (outside the project root, or behind a symlink).

Before committing a dep, every Cargo.lock package that depends on it
(any edge spelling: `name`, `name version`, or the full v1 id) must be
a source-less package whose manifest was planned and pinned. Otherwise
the dep is skipped with the new additive warning
`redirect_cargo_transitive_dependents`, which names the dependents,
says the crate was NOT redirected and stays unpatched, and points to
`scan --mode vendored`. Nothing is written, recorded or attested.

Golden: cargo/cargo/shared-dependency (the depscan TS twin lags — list
it in TS_LAGGING on the next submodule bump). Unit: registry, git, v1
full-id and unplanned-path dependents refuse; another version's edge
and a planned member do not. Real cargo: e2e_redirect_cargo_shapes
direct-and-transitive (cfg-if beside crc32fast) is refused and the
untouched project still fetches `--locked`; before this change the
same shape reported redirected: 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): never follow a symlink to a cargo member

Workspace member discovery checked the project root only lexically. A
literal `members = ["linked"]` entry or a path dependency through a
symlinked directory was returned as a rewrite target, and `scan --mode
hosted` then wrote the registry pin into a Cargo.toml outside the
project (the whole-run symlink guard checks only the file itself, not
its parent directories). The same link matched by a `crates/*` glob
was skipped instead, because the glob does not follow links.

Discovery now returns only manifests reached without crossing a
symbolic link: a symlinked member directory (literal or glob), a
symlinked path dependency, a symlinked intermediate directory and a
symlinked Cargo.toml are all left out. Cargo still reads those
manifests, so a crate one of them depends on is refused by the
Cargo.lock dependents check (redirect_cargo_transitive_dependents),
exactly like a member or path dependency outside the root.

Unit: symlinked literal/glob/nested members, a symlinked path
dependency and manifest, and out-of-root members/path dependencies are
not returned. In-process: an out-of-root path dependency and a glob or
literal symlinked member each refuse the crate loudly and leave every
file, the one outside the project included, untouched (the literal
case reported redirected: 1 and wrote outside the project before).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): remove v1-lock cargo patches in any order

A v1 Cargo.lock names every dependency by its full id, so repointing a
crate also rewrites its dependents' references. Each dependent's whole
`[[package]]` block was recorded as one edit. When one block referenced
two patched packages (the root, for two cfg-if versions or any two
patched crates it declares) the second edit's `original` was the first
edit's `new`, and removing the first-applied purl alone found neither
fragment: `remove`, scoped rollback and the hosted-to-vendored takeover
refused as "drifted" unless purls went in exact reverse apply order.

The dependents' references are now one `redirect_cargo_lock_reference`
edit holding just the quoted full id `"name version (source)"`. It
names that package exactly, so its inverse puts back every occurrence,
independently of any other package's edits; the per-purl revert and the
whole-ledger replay both handle it. The v1 whole-block edits were never
released, so no existing ledger carries them.

Unit: two cfg-if versions and two crates sharing a dependent block each
remove in both orders byte-for-byte (both refused as drifted before); a
full id named by two members replays clean. Real cargo:
e2e_redirect_cargo_shapes now removes every multi-patch shape in apply
order and in reverse, from the same post-scan state, and passes with
SOCKET_PATCH_CARGO_E2E_LOCK_VERSION=1, 2 and 4.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): restore a cargo config's exact trailing bytes

The earlier bug-H fix removed the appended `[registries.…]` block with
the shared fragment remover, which collapses the whole trailing newline
run at EOF to one newline. That fixed a config ending in exactly one
newline but lost a config's trailing blank line (restored byte-for-byte
before that change) and still added a newline to a config without one.

The block removal now inverts exactly what the rewriter appended: the
recorded fragment plus the single blank separator before it. The
newline a config without a final one needs rides in the recorded
fragment, so that case is exact too; content the user appended after
the block is kept; a config the rewrite created still ends empty and is
deleted. `remove` and the whole-ledger replay share the helper (replay
no longer leaves an emptied created config behind). All-CRLF configs
are inverted as LF, and a fragment recorded with the other line endings
still matches. Existing ledgers keep reverting as before.

Unit: the bug-H revert test covers no final newline (LF and CRLF),
trailing blank lines (LF and CRLF) and a whitespace-only config through
both `remove` and the replay; helper pins for every append shape. Real
cargo: e2e_redirect_cargo_shapes config-unterminated and
config-trailing-blank restore the config byte-for-byte.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): remove cargo patches across line-ending changes

The CRLF support records a CRLF project's edit fragments with CRLF, and
the ledger is JSON, so git line-ending normalization never touches
them while it does rewrite the committed Cargo.toml / Cargo.lock /
.cargo/config. A hosted redirect scanned on Windows (core.autocrlf)
therefore could not be removed on a Linux checkout, nor an LF scan on
a CRLF checkout: `remove`, rollback and the vendored takeover matched
fragments byte-for-byte and refused as "drifted", and the suggested
re-scan was a no-op that kept the same fragments.

The cargo per-purl revert and the whole-ledger replay now match
fragments regardless of CRLF/LF: an all-CRLF file is matched as LF and
written back CRLF, any other file is tried with the recorded fragments
and then with their LF forms. The file keeps its current line endings.
The registry-block checks and removals use the same matching. Drift
of the text itself still refuses exactly as before.

Unit: a ledger recorded CRLF reverts LF files and a ledger recorded LF
reverts CRLF files, each through `remove` and through the replay, with
the ledger round-tripped through JSON (all four refused before).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): remove each cargo version from older ledgers

Manifest edits are keyed by crate name, so the per-version revert told
two redirected versions' edits apart by the registry each pin names.
Ledgers an older CLI wrote with its name-only matcher break that rule:
each version's run pinned BOTH declarations to its own registry, and a
project it mis-pinned is later repaired by superseding that pin. On
such a ledger `remove pkg:cargo/cfg-if@1.0.4` claimed half of a
declaration's re-pin chain (or the superseded mis-pin) and refused as
"drifted" — for exactly the users bug B had already hurt — and the
other removal order dropped a still-pinned registry edit, leaving the
created .cargo/config.toml behind.

With another version of the crate still redirected, each manifest edit
is now attributed to the version its declaration's requirement selects
(the planner's own rule); a line without a readable requirement falls
back to the registry it pins. A sibling version's registry is never
claimed, and a still-referenced block keeps its ledger edit when
another recorded wiring edit still pins to it, so the removal that
retires that pin removes the block. A block kept for a hand pin still
leaves the ledger, and the whole-ledger replay now keeps such a block
too instead of deleting it from under the hand pin.

Unit: a name-only two-version ledger and a repaired mis-pin ledger each
remove in both orders back to the pristine project with no config and
an empty ledger (removing 1.0.4 first refused before); the replay keeps
a hand-pinned block.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): keep identical hosted edits from one scan

The hosted flow appends a run's edits to the redirect ledger, skipping
any edit already in it. The check also ran against the edits appended
earlier in the same run, so identical edits from one rewrite collapsed
into one. The cargo rewriter records one edit per occurrence, and a
Cargo.toml that declares the crate with the same line in two sections
(`[dependencies]` and `[dev-dependencies]`) yields two identical edits.
With only one in the ledger, `remove` reverted one pin, kept the other
and the registry block it references, and still reported success.

Edits are now deduplicated only against the ledger as the run found it,
so a re-run still records nothing twice.

Real cargo: e2e_redirect_cargo_shapes two-sections (scan, fresh
`--locked` build, VEX, byte-identical `remove`); it failed on the
leftover pin before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: describe the hosted cargo redirect fixes

CLI_CONTRACT.md still said the hosted rewriter reads only root candidate
files, but cargo now also reads and pins workspace-member and in-root
path-dependency manifests, which can show up in `rewrittenFiles`. The
contract now covers that (members globs minus `exclude`, no symlinks,
never `.socket/`), the new `redirect_cargo_transitive_dependents`
refusal, the transitive-only and multi-version refusals, and CRLF
handling across a checkout conversion.

CHANGELOG [Unreleased] gains Fixed entries for this branch's
user-visible hosted cargo changes, and docs/ecosystems.md notes that
hosted cargo reaches direct dependencies only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(redirect): refuse cargo requirements the patch misses

A crate declared only with version requirements the patched version
does not satisfy resolves those declarations to another version, so a
pin cannot reach the locked crate. Hosted cargo now refuses it with
redirect_cargo_toml_dep_unrewritable, the code the Socket backend
uses for the same shape, instead of reporting it not found.

Shared golden fixtures pin both refusals the backend and CLI must
agree on: requirement-excludes-patched-version, path-dependency and
transitive-dependents-git (a git crate also depending on the patched
crate).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(vendor): see a hosted pin in a workspace member

The hosted rewriter pins the patched crate in every manifest that
declares it, workspace members included, but the guard that refuses to
vendor on top of a live hosted redirect read the root Cargo.toml alone.
A member-only pin was invisible, so with the redirect ledger gone
vendor wired [patch.crates-io] beside a member still resolving the
crate from the hosted registry — where [patch.crates-io] does not
reach — and reported success.

The guard now reads the root manifest and every member manifest the
rewriter itself discovers, and the refusal names the file it found.

Real cargo: mode_migration_cargo's takeover refusal gains the shape
the guard exists for — the lock restored from version control while a
table-form manifest pin survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(redirect): repoint a v1 lock's [root] table

A v1 Cargo.lock written before cargo dropped [root] keeps the root
package in that table, whose dependencies spell full package ids. The
reference rewrite walked the [[package]] array only, so a redirected
crate left [root] naming the crates.io id of a package the lock no
longer held: cargo build --locked then fails, an unlocked build
discards the lock and re-resolves, and the scan reported the crate
redirected either way.

The walk now covers that table too, recorded by the same
every-occurrence reference edit, so remove still restores the lock byte
for byte in any order. The vendored backend has read [root] since it
learned v1 locks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(redirect): spend one cargo edit per occurrence

One scan records one cargo edit per occurrence, so a manifest that
declares the crate with the same line in two sections leaves two
identical edits in the ledger, and the per-purl revert unwinds one
occurrence per edit. The whole-ledger replay — the path a run whose
patch-record fetch failed leaves behind, records empty — instead
refused as soon as the fragment appeared more than once, and that
refusal dropped every other cargo edit with it: the project stayed
fully redirected, with a suggested remedy (re-run the scan) that
cannot change a duplication the manifest itself carries.

The replay now allows as many occurrences as there are identical edits
left to spend on them, and still refuses a surplus no edit accounts
for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(redirect): never pin a manifest under target/

Member discovery skipped cargo's build directory only while expanding
a glob: a literal `members = ["target/gen"]` entry, or a path
dependency pointing into target/, was returned and pinned — which the
key predicate's own doc comment said could not happen. The next
`cargo clean` deletes that manifest, and a recorded hosted edit whose
file no longer exists refuses the rollback of every other cargo edit.

A `target` segment is now excluded wherever a member path is accepted,
in discovery and in the key predicate both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(redirect): refuse a lockless cargo pin

A `registry = …` pin reaches only the declarations it sits on, so a
crate another package also pulls in is refused — but that check reads
Cargo.lock, and a project without one was pinned unconditionally. Rust
libraries routinely gitignore their lock, so the silent shape was the
common one: the scan reported the crate redirected, vex attested it,
and the next build resolved the patched copy for the root and an
unpatched crates.io copy for whatever else depends on the crate.

Without a lock the dependents question is now answered from the
manifests instead: a crate declared beside any other dependency — or
beside a workspace member this run could not read (a members glob, or
a member outside the project or behind a symbolic link) — is refused
`redirect_cargo_lockless_dependents`, whose detail names both remedies.
A path dependency on a manifest the same run pins is not company, and
a project whose only dependency is the patched crate has nothing that
could pull it in and still redirects.

Real cargo: e2e_redirect_cargo_shapes lockless-other-dependencies, the
direct-and-transitive project with its lock deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants