Skip to content

Agent-mode cargo apply patches only the first of several registry/src index dirs (cargo 1.84 vs 1.85+ hashes), so one cargo keeps building the unpatched crate while apply and VEX report success #339

Description

[agent] Found by the scheduled Cargo bug-hunt routine (ledger #315).

Summary

$CARGO_HOME/registry/src/ routinely holds more than one extracted copy of the same crates.io crate, one per registry-source directory:

  • index.crates.io-6f17d22bba15001f is used by cargo 1.70–1.84.
  • index.crates.io-1949cf8c6b5b557f is used by cargo ≥ 1.85, where the source-id hash changed.
  • github.com-1ecc6299db9ec823 is the git index, the default before 1.70.

The agent-mode crawler lists every one of those directories, but the cargo arm of dispatch_find merges with merge_first_wins. So apply patches only the first copy in read_dir order, and reports applied/success. A project whose cargo reads one of the other directories keeps building the unpatched crate, and vex still attests not_affected.

This happens on any machine where two cargo versions share a CARGO_HOME. A typical case is a project pinned by rust-toolchain.toml to ≤ 1.84 on a machine whose default stable toolchain is ≥ 1.85.

Impact

apply exits 0 with success, and VEX says not_affected, but cargo build --locked for the pinned-toolchain project compiles the vulnerable sources. The docs promise the opposite ("the patch affects every project on the machine", docs/ecosystems.md "Cargo: shared registry cache", README.md and CLI_CONTRACT.md).

Repro

The patch is a hand-staged .socket/ (stage.py is below). It appends pub fn socket_patched() to cfg-if, and main.rs calls it.

W=$(mktemp -d); cd "$W"; export CARGO_HOME=$W/cargo-home; mkdir src
printf '[package]\nname = "app"\nversion = "0.1.0"\nedition = "2018"\n\n[dependencies]\ncfg-if = "=1.0.0"\n' > Cargo.toml
printf '[toolchain]\nchannel = "1.84.1"\n' > rust-toolchain.toml
echo 'fn main(){ println!("{}", cfg_if::socket_patched()); }' > src/main.rs
cargo +1.93.1 fetch -q      # another project / the default stable toolchain filled the shared cache
cargo fetch -q              # this project's pinned 1.84.1
ls cargo-home/registry/src/ # index.crates.io-1949cf8c6b5b557f  index.crates.io-6f17d22bba15001f
python3 stage.py . pkg:cargo/[email protected] "$(ls -d cargo-home/registry/src/*/cfg-if-1.0.0 | head -1)" src/lib.rs
socket-patch apply --json --offline   # "status": "success", 1 applied
grep -c socket_patched cargo-home/registry/src/*/cfg-if-1.0.0/src/lib.rs
#   index.crates.io-1949cf8c6b5b557f/...: 1
#   index.crates.io-6f17d22bba15001f/...: 0
cargo build --locked --offline        # (cargo 1.84.1) error[E0425]: cannot find function `socket_patched`
socket-patch vex --offline -O vex.json   # "status": "not_affected"

stage.py writes .socket/manifest.json (setup.manual = ["cargo"], one file src/lib.rs with beforeHash/afterHash as git-blob SHA-256) plus both blobs:

import sys, json, hashlib, os
proj, purl, src, rel = sys.argv[1:5]
def g(b): return hashlib.sha256(b"blob %d\0" % len(b) + b).hexdigest()
before = open(os.path.join(src, rel), "rb").read()
after = before + b"\n/// socket marker\npub fn socket_patched() -> u32 { 1 }\n"
s = os.path.join(proj, ".socket"); os.makedirs(os.path.join(s, "blobs"), exist_ok=True)
m = {"setup": {"manual": ["cargo"]}, "patches": {purl: {"uuid": "11111111-2222-4333-8444-555555555555",
     "exportedAt": "2026-01-01T00:00:00Z", "files": {rel: {"beforeHash": g(before), "afterHash": g(after)}},
     "vulnerabilities": {"GHSA-xxxx-xxxx-xxxx": {"cves": ["CVE-2024-1"], "summary": "s", "severity": "high", "description": "d"}},
     "description": "m", "license": "MIT", "tier": "free"}}}
json.dump(m, open(os.path.join(s, "manifest.json"), "w"), indent=2)
for b in (before, after): open(os.path.join(s, "blobs", g(b)), "wb").write(b)

Which copy gets patched depends only on the directory listing order, not on which cargo the project uses. With the order above, a project pinned to 1.93 is patched and one pinned to 1.84 isn't. If the order were reversed, it would be the other way round.

Expected vs actual

  • Expected. docs/ecosystems.md says "Agent mode patches the crate in place wherever the crawler finds it. For a non-vendored crate that means the shared $CARGO_HOME/registry cache: the patch affects every project on the machine." So every extracted copy of name@version under registry/src/* should be patched, the way merge_variant_copies already does for gem's coexisting stores. At a minimum, apply should warn that other copies remain, and vex must not attest when the copy the project's cargo reads is unpatched.
  • Actual. Only the first listed copy is patched. apply reports success, and vex attests not_affected.

Matrix

The project is pinned with rust-toolchain.toml. The shared CARGO_HOME was filled by 1.93.1 and then by 1.84.1.

OS project cargo result
Linux (sandbox, ext4) 1.84.1 fail: apply success, 1949cf… patched, 6f17d2… unpatched, build E0425, vex not_affected. Reproduced 2×
Linux (sandbox, ext4) 1.93.1 pass (its copy happens to be listed first)
Linux (GH ubuntu-latest) 1.84.1 pass: 6f17d2… listed first and patched, 1949cf… unpatched
Linux (GH ubuntu-latest) 1.93.1 fail: build FAIL, vex not_affected
macOS (GH macos-latest) 1.84.1 pass (6f17d2… first)
macOS (GH macos-latest) 1.93.1 fail: build FAIL, vex not_affected
Windows (GH windows-latest) 1.84.1 fail: 1949cf… first, build FAIL, vex not_affected
Windows (GH windows-latest) 1.93.1 pass

On every OS, one of the two cargos is left unpatched while apply says success. Which one it is depends on the filesystem's listing order: on the GitHub Linux and macOS runners it's the current 1.93 toolchain that ends up unpatched.

Versions

  • main f6b7fb9 (CLI 4.0.0): fails as above.
  • Release 4.0.0: identical (reproduced).
  • Release 3.3.0: with the same staged manifest, apply reports partialFailure and patches nothing, so there was no false success there. I didn't bisect further.

Suspect code

  • crates/socket-patch-cli/src/ecosystem_dispatch.rs:290-302: the cargo scan_ecosystem! uses on_match = merge_first_wins, so only one path per purl is kept across source roots.
  • crates/socket-patch-core/src/crawlers/cargo_crawler.rs:272-283: get_registry_src_paths returns the index dirs in unsorted read_dir order.

Probe run (the workflow on the throwaway branch bughunt/cargo/20260930-index-dirs): https://github.com/SocketDev/socket-patch/actions/runs/36742610070

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions