Skip to content

Wire vendored cargo patches through Cargo.toml and tag the copy - #254

Merged
Mikola Lysenko (mikolalysenko) merged 13 commits into
mainfrom
feat/cargo-vendor-manifest-patch
Sep 25, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 13 commits into
mainfrom
feat/cargo-vendor-manifest-patch

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

Branch feat/cargo-vendor-manifest-patch: 10 signed commits on main @ 1bfe5326; 43 files, +10558/−851.

Summary

Vendored cargo patches move their wiring out of .cargo/config* and into the project itself, so the patch is recoverable from the files a scanner already reads:

  • Wiring lives in the workspace-root Cargo.toml: [patch.crates-io] <name>-socket-<uuid8> = { package = "<name>", path = ".socket/vendor/cargo/<uuid>/<name>-<version>" }. Pre-v5 .cargo/config* wiring migrates on the next vendor / scan / get / repair, and every revert path removes both spellings.
  • The vendored copy's version is tagged: 1.0.4 becomes 1.0.4+socket.<uuid>, and a version that already carries build metadata keeps it (2.0.1+zstd.1.5.2 → 2.0.1+zstd.1.5.2.socket.<uuid>). Cargo ignores build metadata when matching requirements, so =1.0.4, 1 and a transitive ^1 all still resolve to the copy — and Cargo.lock now records which patch was actually built.

Two consequences worth stating plainly:

  • CARGO_PKG_VERSION inside the patched crate carries the tag. Documented in the CHANGELOG and docs/ecosystems.md.
  • A Socket-owned key, not the bare crate name. Cargo lets any config-file [patch] entry replace a manifest entry with the same key, whatever its version, so a bare key let a user's cfg-if config entry silently win over the patched copy. With the Socket key a same-version conflict is a loud cargo error instead.

Why

A vendored Cargo.lock entry only loses its source and checksum, so the lock could not say which patch produced it, and .cargo/config* can hold proxy credentials and registry tokens that a scanner should not have to ingest. Tagging closes both: the identity is in the lock, and the file that carries it is one every Rust project already commits.

Three bugs this also fixes

Found by a 1,232-cell real-cargo matrix (cargo 1.41 → stable, lock formats v1–v4, both modes), all pre-existing on main:

  • Build-metadata versions could never be vendored (397259cf). parse_name_version returned the purl's name and version raw, but the API percent-encodes them, so vendored cargo compared 0.11.0%2Bwasi-snapshot-preview1 against the lock's 0.11.0+wasi-snapshot-preview1 and refused. wasi is in most Rust dependency trees. The parsers now decode each component exactly once, after the split; two double-decode paths (python_crawler, takeover::find_record_key) are removed. This touches all eight ecosystems, and subsumes the golang-only decode main shipped in Stop hosted Go redirects claiming unpatched deps #252.
  • CRLF Cargo.lock was rewritten as LF (4247daa8), so vendor --revert did not restore the original bytes. toml_edit renders LF only; the lock edits now map the rendering back onto the original text, the way Cargo.toml already did.
  • The documented old-cargo floor was wrong (a18ffd14). Cargo before 1.45 resolves every source-less lock entry for a crate through whichever [patch] key sorts last, so two vendored versions of one crate fail --locked with did not resolve to any crates. Measured across 1.41.1–stable in docker: 1.41–1.44 refuse the adversarial key order, 1.45+ resolve either order, each entry to its own copy — no silent mis-resolution anywhere. The e2e previously passed only because its test UUIDs happened to sort favourably; it now asserts the key order itself and exercises both sides of the boundary. A second vendored version warns cargo_multi_version_old_cargo unless the project pins cargo ≥ 1.45 via rust-version or a toolchain file.

A [patch] key scheme that makes the lowest version's key sort last would let two versions build on 1.41–1.44 as well. It is not adopted here: it would replace the documented key with a version-rank encoding in every committed manifest and lean on an undocumented cargo iteration order, to rescue a case that currently fails loudly rather than silently. The rule and the reproduction are in a18ffd14's commit message if we want it later.

Refusals (all fail before any write)

cargo_manifest_unreadable, cargo_manifest_unparseable, cargo_manifest_symlink_unsupported, cargo_manifest_not_workspace_root, cargo_manifest_patch_source_alias (a [patch."https://github.com/rust-lang/crates.io-index"] table, which cargo treats as crates-io and which would silently drop our table), cargo_lock_untaggable, cargo_copy_untaggable, and user_authored_patch_entry. Dry runs preview each of them.

Testing

  • cargo test -p socket-patch-core 4883 pass, -p socket-patch-cli 3992 pass, clippy --workspace --all-features --all-targets -D warnings clean.
  • Real-cargo suites across SOCKET_PATCH_CARGO_E2E_LOCK_VERSION 1–4, plus docker rust:1.41-slim / rust:1.56-slim old-toolchain legs (14/14): the lock and copy carry the tag, cargo metadata reports it, --locked --offline builds the patched bytes, and revert is byte-identical.
  • Every fix in the review rounds was reverted in turn to confirm its test fails without it.

Known gaps, stated in the tests rather than papered over: a mixed-ending lock cannot revert byte-identically (a removed-then-reinserted source line has no recoverable original ending), and toml_edit still drops a UTF-8 BOM from a lock.


Note

High Risk
Major semver behavior change to committed Cargo.toml/Cargo.lock wiring plus cross-ecosystem PURL decoding; incorrect tagging or migration could break --locked builds or mis-attribute patches in VEX.

Overview
v5 breaking change for vendored Rust: patch wiring moves from .cargo/config* into the workspace-root Cargo.toml under Socket-owned keys (<crate>-socket-<uuid8>), and vendored copies plus detached Cargo.lock entries are rewritten to tagged versions (<ver>+socket.<uuid>) so scanners and builds can recover which patch was applied. Pre-v5 config wiring migrates on vendor / scan / get / repair; hosted takeover and VEX discovery are updated to read manifest wiring first and treat tag/ledger mismatches as dead wiring.

Also fixes three pre-existing gaps: percent-decoded PURL coordinates across ecosystems (so build-metadata crates like wasi can vendored), CRLF-preserving Cargo.lock edits on revert, and accurate cargo ≥ 1.45 documentation/warning for two vendored versions of one crate. CI adds a cargo-old-toolchains job (docker rust:1.41-slim / 1.56-slim) and the cargo e2e suite grows to cover migration, multi-version, old toolchains, transitive deps, and refusal cases.

Reviewed by Cursor Bugbot for commit a18ffd1. Configure here.

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>
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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
Comment thread crates/socket-patch-core/src/vendor/cargo_manifest.rs Dismissed
Comment thread crates/socket-patch-core/src/vendor/cargo_manifest.rs Dismissed
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>
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(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>
@mikolalysenko
Mikola Lysenko (mikolalysenko) merged commit 2390c5f into main Sep 25, 2026
101 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the feat/cargo-vendor-manifest-patch branch September 25, 2026 16:22
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.

3 participants