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/cfg-if@1.0.0 "$(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


Backlog review — 2026-10-08

Priority: P2 → P1. Multiple Cargo registry cache hashes leave a consumed copy unpatched while VEX reports success.

Activity

  1. mikolalysenko commented on Sep 30, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triaged: priority:p2 (Cargo). Not a duplicate, and no open or merged PR fixes it. Confirmed on main f6b7fb9: the cargo arm of dispatch_find (ecosystem_dispatch.rs) merges with merge_first_wins, and get_registry_src_paths returns the index dirs in read_dir order, so only one extracted copy per purl is patched. #338 is in the same crawler (get_crate_source_paths), but its cause is different: it never reads .cargo/config* source replacement. So the two are cross-linked, not clustered. A fix to one should keep the other in mind, since both change which copies apply targets.


    Generated by Claude Code

  2. mikolalysenko commented on Oct 1, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Re-triage on main 2463257 (after the v5 consolidation in #277): still reproduces. It also affects global mode.

    I set CARGO_HOME to a dir with two index dirs, registry/src/index.crates.io-1949cf8c6b5b557f/cfg-if-1.0.4 and registry/src/index.crates.io-6f17d22bba15001f/cfg-if-1.0.4, then ran socket-patch scan -g --mode agent --ecosystems cargo --json. It reports applied: 1 with status: success, but only the 1949cf… copy has the patched src/lib.rs. The 6f17d22… copy, which cargo ≤1.84 builds from, is untouched. CargoCrawler::crawl_all still dedupes by PURL across index dirs (seen set, crates/socket-patch-core/src/crawlers/cargo_crawler.rs), so -g / --global-prefix runs hit the same first-dir-wins behaviour as project runs.


    Generated by Claude Code

  3. mikolalysenko commented on Oct 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Re-checked on main 045d7ec, after #517 ("Fix agent vex checking only one installed copy", #516). This issue still reproduces: with two registry/src/index.crates.io-* dirs that each hold an unpatched cfg-if-1.0.0, apply --offline reports "1 of 1 targeted patch applied" but writes only the -1949cf8c6b5b557f copy (the -6f17d22bba15001f copy stays at the original bytes), and vex --offline still emits not_affected. The #516 every-copy rule doesn't reach cargo, which suggests the cargo crawler still returns only one copy per crate@version.


    Generated by Claude Code

  4. mikolalysenko commented on Oct 8, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Re-checked on main 9472be4. This still reproduces, and it now also reaches the CI gate that #1029 extended to cargo: apply --check passes over the unpatched copy.

    Repro (Linux, cargo 1.93.1, private CARGO_HOME, agent mode via a local patch-API stand-in):

    1. scan --mode agent patches registry/src/index.crates.io-1949cf8c6b5b557f/cfg-if-1.0.4/src/lib.rs.
    2. I added registry/src/index.crates.io-6f17d22bba15001f/cfg-if-1.0.4 with the original bytes. That's the dir cargo ≤1.84 builds from.
    3. socket-patch apply --check --json returned status: success, one event skipped / already_patched, exit 0, twice.

    For comparison, apply --check on a single-copy cache correctly fails when that one copy's src/lib.rs is put back to the original (failed / not_applied, exit 1). So the new check works per copy, but it only sees the copy the crawler's first-dir-wins dedupe returns (CargoCrawler::crawl_all's seen set), the same root cause as above. A cargo ≤1.84 CI job that gates on apply --check passes, then builds the unpatched crate.


    Generated by Claude Code

  5. added a commit that references this issue on Oct 8, 2026
  6. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    v5 triage: P2, not a release blocker. Retain multi-toolchain agent cache-copy selection at P2. This is real, but not a blocker for the default hosted/vendored first-run workflow.

    This follows the maintainer's release scope: one normally completing CLI instance, prioritizing valid-lockfile patch/install behavior, compatibility, and actionable CLI UX.

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