Skip to content

Commit aafa230

Browse files
committed
Patch every registry index copy of a crate
A shared CARGO_HOME keeps one extracted copy of a crate per registry index dir: cargo 1.70-1.84 and cargo 1.85+ each unpack and build their own. Agent mode only patched, checked and restored the copy in the first dir, so a project built by the other cargo kept compiling the unpatched crate while apply reported success and vex attested not_affected. Carry every copy the crawler finds, so apply patches them all, apply --check and vex fail on an unpatched one, and rollback restores each one. Fixes #339 Assisted-by: Claude Code:claude-opus-5-5
1 parent 3a0bac4 commit aafa230

4 files changed

Lines changed: 212 additions & 5 deletions

File tree

‎crates/socket-patch-cli/src/ecosystem_dispatch.rs‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,8 @@ fn push_path(out: &mut HashMap<String, Vec<PathBuf>>, purl: String, path: PathBu
228228
}
229229
}
230230

231-
/// Default merge for the single-copy ecosystems (cargo / go / composer /
232-
/// nuget / deno): keep the FIRST path discovered per PURL, matching the
231+
/// Default merge for the single-copy ecosystems (go / composer / nuget /
232+
/// deno): keep the FIRST path discovered per PURL, matching the
233233
/// historical `HashMap<String, PathBuf>` first-wins contract exactly. These
234234
/// ecosystems resolve one logical install per `name@version`, but the same
235235
/// install is legitimately reachable from several source roots (e.g. NuGet's
@@ -277,6 +277,26 @@ fn merge_variant_copies(
277277
}
278278
}
279279

280+
/// Cargo merge: keep EVERY distinct copy, one per source root. Each root
281+
/// of the shared registry cache is one registry index dir
282+
/// (`index.crates.io-6f17d22bba15001f` for cargo 1.70–1.84,
283+
/// `index.crates.io-1949cf8c6b5b557f` for cargo ≥ 1.85, the pre-1.70 git
284+
/// index), and each holds its own extracted copy that a different cargo
285+
/// builds from. First-wins patched, checked and restored only the copy in
286+
/// the first dir in listing order, so a project on the other cargo kept
287+
/// building the unpatched crate while `apply` reported success and `vex`
288+
/// attested it (#339). A `cargo vendor` tree or `--global-prefix` is a
289+
/// single root, so it still yields one copy.
290+
fn merge_cargo_copies(
291+
out: &mut HashMap<String, Vec<PathBuf>>,
292+
_purls: &[String],
293+
packages: HashMap<String, CrawledPackage>,
294+
) {
295+
for (purl, pkg) in packages {
296+
push_path(out, purl, pkg.path);
297+
}
298+
}
299+
280300
/// npm merge: the npm crawler returns EVERY physical copy of each PURL
281301
/// (nested duplicates, diamonds, `file:` dups), so fold every path in.
282302
/// A one-path `HashMap<String, PathBuf>` would silently drop every copy
@@ -430,7 +450,8 @@ async fn dispatch_find(
430450
using_label = "cargo crate sources",
431451
err_label = "Cargo crates",
432452
purls_override = passthrough_purls,
433-
on_match = merge_first_wins,
453+
// One copy per registry index dir, each built by another cargo.
454+
on_match = merge_cargo_copies,
434455
);
435456

436457
scan_ecosystem!(
@@ -559,7 +580,8 @@ pub async fn find_all_packages_for_purls(
559580
// (base-PURL keyed) instead of first-wins: the gem crawler surfaces
560581
// coexisting bundler stores whose copies apply must ALL patch. The
561582
// rollback variant below gets the same multi-copy carry from
562-
// `merge_qualified`'s `push_path`. Single-copy ecosystems keep true
583+
// `merge_qualified`'s `push_path`. Cargo keeps every registry index
584+
// copy (`merge_cargo_copies`); the single-copy ecosystems keep true
563585
// first-wins via their own `merge_first_wins` wiring in
564586
// `dispatch_find`.
565587
dispatch_find(partitioned, options, silent, merge_variant_copies, None).await
@@ -1283,6 +1305,34 @@ mod tests {
12831305
);
12841306
}
12851307

1308+
// ---- merge_cargo_copies ------------------------------------------------
1309+
1310+
#[test]
1311+
fn merge_cargo_copies_keeps_every_registry_index_copy() {
1312+
// #339: the macro calls on_match once per registry index dir; each
1313+
// dir's copy is built by a different cargo, so all are carried in
1314+
// discovery order, and a path re-observed is recorded once.
1315+
let mut out: HashMap<String, Vec<PathBuf>> = HashMap::new();
1316+
for path in [
1317+
"/src/index-1949cf/cfg-if-1.0.0",
1318+
"/src/index-6f17d2/cfg-if-1.0.0",
1319+
] {
1320+
merge_cargo_copies(&mut out, &[], packages(&[("pkg:cargo/cfg-if@1.0.0", path)]));
1321+
}
1322+
merge_cargo_copies(
1323+
&mut out,
1324+
&[],
1325+
packages(&[("pkg:cargo/cfg-if@1.0.0", "/src/index-6f17d2/cfg-if-1.0.0")]),
1326+
);
1327+
assert_eq!(
1328+
out.get("pkg:cargo/cfg-if@1.0.0"),
1329+
Some(&vec![
1330+
PathBuf::from("/src/index-1949cf/cfg-if-1.0.0"),
1331+
PathBuf::from("/src/index-6f17d2/cfg-if-1.0.0"),
1332+
])
1333+
);
1334+
}
1335+
12861336
// ---- merge_variant_copies ----------------------------------------------
12871337

12881338
#[test]

‎crates/socket-patch-cli/tests/e2e_cargo.rs‎

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,154 @@ async fn sync_keeps_shared_cache_entry_behind_a_cargo_vendor_dir() {
552552
);
553553
assert_eq!(std::fs::read(itoa_old.join("src/lib.rs")).unwrap(), orig);
554554
}
555+
556+
/// #339: `$CARGO_HOME/registry/src` holds one extracted copy of a crate
557+
/// per registry index dir (`index.crates.io-6f17d22bba15001f` for cargo
558+
/// 1.70–1.84, `index.crates.io-1949cf8c6b5b557f` for cargo ≥ 1.85), so
559+
/// two toolchains sharing a `CARGO_HOME` each build their own copy. Agent
560+
/// mode must patch, check, attest and restore EVERY copy, not just the
561+
/// first index dir in listing order: otherwise the project built by the
562+
/// other cargo compiles the unpatched crate while `apply` reports success
563+
/// and `vex` attests `not_affected`.
564+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
565+
async fn agent_mode_covers_every_registry_index_copy() {
566+
let server = start_proxy().await;
567+
let proxy_url = server.uri();
568+
let dir = tempfile::tempdir().unwrap();
569+
let root = dir.path();
570+
let cwd = root.to_str().unwrap();
571+
572+
std::fs::write(
573+
root.join("Cargo.toml"),
574+
"[package]\nname = \"myapp\"\nversion = \"0.1.0\"\n\n[dependencies]\ncfg-if = \"=1.0.0\"\n",
575+
)
576+
.unwrap();
577+
std::fs::write(
578+
root.join("Cargo.lock"),
579+
"version = 3\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.0\"\n\
580+
source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\n\
581+
[[package]]\nname = \"myapp\"\nversion = \"0.1.0\"\ndependencies = [\n \"cfg-if\",\n]\n",
582+
)
583+
.unwrap();
584+
585+
let orig: &[u8] = b"pub fn cfg() {}\n";
586+
let patched: &[u8] = b"pub fn cfg() {}\npub fn socket_patched() {}\n";
587+
let src = root.join(".cargo/registry/src");
588+
let copies = [
589+
fake_registry_crate(
590+
&src.join("index.crates.io-1949cf8c6b5b557f"),
591+
"cfg-if",
592+
"1.0.0",
593+
orig,
594+
),
595+
fake_registry_crate(
596+
&src.join("index.crates.io-6f17d22bba15001f"),
597+
"cfg-if",
598+
"1.0.0",
599+
orig,
600+
),
601+
];
602+
let lib = |copy: &PathBuf| std::fs::read(copy.join("src/lib.rs")).unwrap();
603+
604+
let socket = root.join(".socket");
605+
let purl = "pkg:cargo/cfg-if@1.0.0";
606+
let manifest = serde_json::json!({ "patches": {
607+
purl: manifest_record(&socket, "33900000-0000-4000-8000-000000000001", orig, patched),
608+
}});
609+
std::fs::write(
610+
socket.join("manifest.json"),
611+
serde_json::to_string_pretty(&manifest).unwrap(),
612+
)
613+
.unwrap();
614+
615+
// apply patches both copies.
616+
let out = run(
617+
&["apply", "--json", "--offline", "--cwd", cwd],
618+
root,
619+
&proxy_url,
620+
)
621+
.await;
622+
let stdout = String::from_utf8_lossy(&out.stdout);
623+
let stderr = String::from_utf8_lossy(&out.stderr);
624+
assert!(
625+
out.status.success(),
626+
"apply must exit 0\nstdout:\n{stdout}\nstderr:\n{stderr}"
627+
);
628+
for copy in &copies {
629+
assert_eq!(
630+
lib(copy),
631+
patched,
632+
"apply left {} unpatched\nstdout:\n{stdout}",
633+
copy.display()
634+
);
635+
}
636+
637+
// A copy re-extracted pristine (another cargo fetched it again) must
638+
// fail the CI gate instead of reading as in sync.
639+
std::fs::write(copies[1].join("src/lib.rs"), orig).unwrap();
640+
let out = run(
641+
&["apply", "--check", "--json", "--offline", "--cwd", cwd],
642+
root,
643+
&proxy_url,
644+
)
645+
.await;
646+
let stdout = String::from_utf8_lossy(&out.stdout);
647+
assert!(
648+
!out.status.success(),
649+
"apply --check must fail while {} is unpatched\nstdout:\n{stdout}",
650+
copies[1].display()
651+
);
652+
653+
// ... and must keep vex from attesting the patch.
654+
let out = run(
655+
&[
656+
"vex",
657+
"--offline",
658+
"--product",
659+
"pkg:cargo/myapp@0.1.0",
660+
"--cwd",
661+
cwd,
662+
],
663+
root,
664+
&proxy_url,
665+
)
666+
.await;
667+
let stdout = String::from_utf8_lossy(&out.stdout);
668+
let stderr = String::from_utf8_lossy(&out.stderr);
669+
assert!(
670+
!stdout.contains("not_affected"),
671+
"vex attested a patch one built copy lacks\nstdout:\n{stdout}\nstderr:\n{stderr}"
672+
);
673+
674+
// Re-apply fixes the pristine copy; rollback then restores both.
675+
let out = run(
676+
&["apply", "--json", "--offline", "--cwd", cwd],
677+
root,
678+
&proxy_url,
679+
)
680+
.await;
681+
assert!(out.status.success(), "re-apply must exit 0");
682+
for copy in &copies {
683+
assert_eq!(lib(copy), patched, "re-apply left {}", copy.display());
684+
}
685+
let out = run(
686+
&["rollback", purl, "--json", "--offline", "--cwd", cwd],
687+
root,
688+
&proxy_url,
689+
)
690+
.await;
691+
let stdout = String::from_utf8_lossy(&out.stdout);
692+
let stderr = String::from_utf8_lossy(&out.stderr);
693+
assert!(
694+
out.status.success(),
695+
"rollback must exit 0\nstdout:\n{stdout}\nstderr:\n{stderr}"
696+
);
697+
for copy in &copies {
698+
assert_eq!(
699+
lib(copy),
700+
orig,
701+
"rollback left {} patched\nstdout:\n{stdout}",
702+
copy.display()
703+
);
704+
}
705+
}

‎crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
//! merge functions behave identically, so the test would prove nothing.
2727
//!
2828
//! npm / cargo / golang / composer / nuget are NOT release-variant
29-
//! ecosystems (their crawlers wire `merge_first_wins` in both resolvers), so a
29+
//! ecosystems (their crawlers wire a fixed merge in both resolvers), so a
3030
//! qualified PURL there is genuinely unsupported and those fixtures keep
3131
//! bare PURLs.
3232

‎docs/ecosystems.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,12 @@ crate that means the **shared** `$CARGO_HOME/registry` cache: the patch affects
10991099
project on the machine, and is silently reset by `cargo clean` or a cache prune. Use
11001100
`--mode vendored` for a project-local, committable patch.
11011101

1102+
The cache holds one extracted copy of a crate per registry index directory under
1103+
`registry/src` (cargo 1.70–1.84 and cargo 1.85+ use different ones), so two toolchains
1104+
sharing a `CARGO_HOME` each build their own copy. `apply`, `apply --check`, `vex` and
1105+
`rollback` cover every copy: an unpatched one fails the check and keeps `vex` from
1106+
attesting the patch.
1107+
11021108
Nothing deletes a crate from the cache when the project stops using it, so the
11031109
manifest record stays the only way to restore that copy: `rollback` (and `remove`)
11041110
also restore it after the crate was moved to vendored mode, and `scan --prune` /

0 commit comments

Comments
 (0)