From 8e784cae3264337b498d2c7a70954d3e68411a8f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 14 Aug 2026 07:15:02 -0700 Subject: [PATCH 1/3] fix(cargo): fully migrate hosted<->vendored modes A cargo mode takeover used to exit 0 while leaving the project unbuildable in both modes (audit findings C1-C7): - hosted scan over a vendored crate left the vendored [patch.crates-io] entry behind, so every `cargo build --locked` failed (C1); - vendor over a hosted redirect left the Cargo.toml registry = "socket-patch-..." pin, which [patch.crates-io] cannot apply over - unbuildable online and offline (C2), silently via the plain `vendor` command (C7); - a vendored->hosted->vendored round trip overwrote the unrecoverable crates.io lock originals in the vendored ledger with the hosted sparse-index URL + patched checksum, so revert restored a dead grant-tokenized URL (C3); - the takeover classifier could never prove hosted for cargo (lock-inventory resolved is None), so it INVERTED the direction and told users to delete the LIVE redirect ledger (C4/C4b); - the displaced vendored entry was unreclaimable by GC/prune forever (no cargo in-use probe, C5); - the remediation text offered deleting the .socket/vendor// tree while [patch.crates-io] still referenced it - hard resolution failure on every cargo command (C6). A takeover now leaves the project FULLY in the new mode, or refuses: - hosted scan reverts each claimed purl's vendored state per purl (lock originals restored, [patch] entry dropped, tree + ledger entry removed) before redirecting, and refuses the purl when the vendored ledger is missing/corrupt; - every vendored flow (vendor, scan --mode vendored) reverts the purl's hosted edits from the redirect ledger FileEdits first (Cargo.toml pin, Cargo.lock source/checksum, registries block) and drops the ledger record; drifted files refuse fail-closed, and the cargo backend refuses (hosted_redirect_live) when hosted wiring is live with no ledger to revert it; - the classifier gains a cargo lock-shape probe (socket-patch registry index vs detached-with-[patch]), so the direction follows the lock; - persist_vendor_entry carries the cargo lock originals forward on re-vendor, keeping the crates.io fragment as the ledger's only home; - dispatch_in_use_one gains the cargo probe so GC/prune reclaims genuinely stale vendored entries; - both takeover remediation texts now recommend only the safe sequence (vendor --revert / re-run the vendored flow) and warn against hand-deleting ledgers or the vendor tree. Red-then-green: the new real-cargo migration suite (tests/mode_migration_cargo.rs, adapted from the audit probes) fails all four scenarios on the previous code and passes with this change, proving fresh-checkout `cargo build --locked` in the terminal state of every migration direction. Co-Authored-By: Claude Fable 5 --- .../src/commands/scan/hosted.rs | 168 +++- .../socket-patch-cli/src/commands/scan/mod.rs | 351 +++++++- .../src/commands/scan/vendor_flow.rs | 33 +- .../socket-patch-cli/src/commands/vendor.rs | 235 +++++ .../tests/mode_migration_cargo.rs | 849 ++++++++++++++++++ .../src/patch/redirect/mod.rs | 4 +- .../src/patch/redirect/state.rs | 24 + .../src/patch/redirect/takeover.rs | 403 +++++++++ crates/socket-patch-core/src/vendor/cargo.rs | 244 +++++ .../src/vendor/cargo_config.rs | 35 + .../src/vendor/cargo_lock.rs | 33 + crates/socket-patch-core/src/vendor/mod.rs | 2 +- 12 files changed, 2326 insertions(+), 55 deletions(-) create mode 100644 crates/socket-patch-cli/tests/mode_migration_cargo.rs create mode 100644 crates/socket-patch-core/src/patch/redirect/takeover.rs diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 340bddf3..a1193a62 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -236,6 +236,166 @@ pub(super) async fn run_redirect( } } + // Cross-mode takeover (cargo): a purl this run is about to redirect may + // still be VENDORED — a committed `[patch.crates-io]` path entry, a + // detached Cargo.lock entry, a committed copy, and a vendored ledger + // entry. The hosted rewriters know nothing about that wiring, so + // redirecting on top of it would leave BOTH wirings in place and cargo + // then refuses every `--locked` build over the now-unused `[patch]` + // entry while this run reports success. A takeover must leave the + // project FULLY hosted: revert each such purl's vendored state first + // (the exact per-purl machinery `vendor --revert` runs — restore the + // lock originals from the ledger, drop the `[patch]` entry, remove the + // committed tree and the ledger entry), and only then redirect. This + // ordering also hands the redirect the PRISTINE crates.io lock fragment + // to record as its own revert original, keeping the originals chain + // intact across repeated mode migrations. A purl whose vendored state + // cannot be cleanly reverted (revert failure, or vendored wiring with a + // missing/corrupt ledger) is REFUSED — skipped with an actionable + // error — never half-migrated. + let mut takeover_pre_warnings: Vec = Vec::new(); + if !candidates.iter().any(|(p, ..)| p.starts_with("pkg:cargo/")) { + // No cargo candidates — nothing to reconcile. + } else { + use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let vendor_state = socket_patch_core::vendor::load_state(&args.common.cwd).await; + let patch_entries = + socket_patch_core::vendor::cargo_config::read_patch_entries(&args.common.cwd).await; + let mut refused: Vec = Vec::new(); + for (purl, _uuid, ..) in &candidates { + if !purl.starts_with("pkg:cargo/") { + continue; + } + let stripped = strip_purl_qualifiers(purl); + let ledger_entry = vendor_state + .as_ref() + .ok() + .and_then(|s| socket_patch_core::vendor::lookup_entry(&s.entries, stripped)) + .cloned(); + if let Some(entry) = ledger_entry { + if args.common.dry_run { + takeover_pre_warnings.push(serde_json::json!({ + "code": "redirect_would_revert_vendored", + "detail": format!( + "{purl} is currently vendored; the hosted redirect will \ + revert its vendored wiring, ledger entry, and committed \ + artifact first, then redirect (mode takeover)" + ), + })); + continue; + } + let outcome = + crate::commands::vendor::dispatch_revert_one(&entry, &args.common.cwd, false) + .await; + if !outcome.success { + refused.push(purl.clone()); + takeover_pre_warnings.push(serde_json::json!({ + "code": "redirect_vendored_revert_failed", + "detail": format!( + "{purl} is vendored and its vendored state could not be \ + reverted ({}); NOT redirected — run `socket-patch vendor \ + --revert` to clean up, then re-run `scan --mode hosted`", + outcome.error.as_deref().unwrap_or("unknown error") + ), + })); + continue; + } + // Drop the reverted entry and persist per purl so a crash + // mid-run leaves a ledger matching the on-disk wiring. + // Re-loaded fresh each iteration (each iteration saves): the + // saved file is the truth. + let mut state = match socket_patch_core::vendor::load_state(&args.common.cwd).await + { + Ok(s) => s, + Err(e) => { + refused.push(purl.clone()); + takeover_pre_warnings.push(serde_json::json!({ + "code": "redirect_vendored_revert_failed", + "detail": format!( + "{purl}: vendored wiring reverted but the vendored \ + ledger could not be re-read ({e}); NOT redirected — \ + fix .socket/vendor/state.json and re-run" + ), + })); + continue; + } + }; + state + .entries + .retain(|k, e| canon(k) != canon(purl) && canon(&e.base_purl) != canon(purl)); + if let Err(e) = + socket_patch_core::vendor::save_state(&args.common.cwd, &state).await + { + // The wiring is reverted but the ledger still claims it; + // redirecting now would leave a ledger asserting wiring + // that is gone. Fail closed for this purl. + refused.push(purl.clone()); + takeover_pre_warnings.push(serde_json::json!({ + "code": "redirect_vendored_revert_failed", + "detail": format!( + "{purl}: vendored wiring reverted but the vendored ledger \ + could not be updated ({e}); NOT redirected — fix \ + .socket/vendor/state.json and re-run" + ), + })); + continue; + } + takeover_pre_warnings.push(serde_json::json!({ + "code": "redirect_takeover_reverted_vendored", + "detail": format!( + "{purl} was vendored; reverted its vendored wiring, ledger \ + entry, and committed artifact before redirecting (mode \ + takeover: the project is now fully hosted for this package)" + ), + })); + } else { + // No usable ledger entry. If socket-owned vendored wiring for + // this crate is nevertheless present, the ledger is missing or + // corrupt — the originals needed to revert are unrecoverable, + // so redirecting on top would wedge the project. Refuse. + let name = parse_purl_simple(purl).map(|(_, name, _)| name); + let wired = name + .as_deref() + .is_some_and(|n| patch_entries.get(n).is_some_and(|i| i.socket_owned)); + if wired { + refused.push(purl.clone()); + takeover_pre_warnings.push(serde_json::json!({ + "code": "redirect_vendored_revert_failed", + "detail": format!( + "{purl} has socket-owned vendored wiring in \ + .cargo/config.toml but no usable vendored ledger entry \ + (.socket/vendor/state.json is missing or corrupt); NOT \ + redirected — restore the ledger or remove the vendored \ + wiring manually, then re-run" + ), + })); + } + } + } + if !refused.is_empty() { + for purl in &refused { + if let Some((_, uuid, ..)) = candidates.iter().find(|(p, ..)| p == purl) { + skipped.push(serde_json::json!({ + "purl": purl, "uuid": uuid, "reason": "vendored_revert_failed", + })); + } + } + let refused_names: std::collections::HashSet<(String, String)> = candidates + .iter() + .filter(|(p, ..)| refused.contains(p)) + .filter_map(|(p, ..)| { + parse_purl_simple(p).map(|(_, name, version)| (name, version)) + }) + .collect(); + candidates.retain(|(p, ..)| !refused.contains(p)); + overrides.retain(|o| { + o.ecosystem != "cargo" + || !refused_names.contains(&(o.name.clone(), o.version.clone())) + }); + } + } + // bun.lockb auto-migration: the redirect rewriter only edits the TEXT // lockfile, so a project locked to a binary `bun.lockb` must be re-locked // to `bun.lock` first. `bun install --save-text-lockfile --frozen-lockfile @@ -529,7 +689,9 @@ pub(super) async fn run_redirect( // deleting the other mode's ledger; reconciliation is deferred (see PR Scope). // Read after the ledger write above so a non-dry-run reflects this run. let mut takeover_warnings: Vec = Vec::new(); - let superseded = super::classify_overlap_takeover(&args.common.cwd).await.redirect; + let superseded = super::classify_overlap_takeover(&args.common.cwd) + .await + .redirect; if !superseded.is_empty() { takeover_warnings.push(serde_json::json!({ "code": super::REDIRECT_SUPERSEDES_VENDORED, @@ -578,6 +740,7 @@ pub(super) async fn run_redirect( warnings.extend(migration_warnings.iter().cloned()); warnings.extend(rush_warnings.iter().cloned()); warnings.extend(pnpm_warnings.iter().cloned()); + warnings.extend(takeover_pre_warnings.iter().cloned()); warnings.extend(takeover_warnings.iter().cloned()); // Nest the redirect result under `redirect` inside the classic scan // object (built by `run`, threaded in via `scan_result`), mirroring @@ -649,6 +812,9 @@ pub(super) async fn run_redirect( for w in &pnpm_warnings { eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); } + for w in &takeover_pre_warnings { + eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); + } for w in &takeover_warnings { eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index b76700ab..105370c7 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -520,11 +520,15 @@ pub(super) async fn classify_overlap_takeover(cwd: &Path) -> OverlapTakeover { return out; }; let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let mut vendor_by_purl: std::collections::HashMap = - std::collections::HashMap::new(); + let mut vendor_by_purl: std::collections::HashMap< + String, + &socket_patch_core::vendor::VendorEntry, + > = std::collections::HashMap::new(); for (key, entry) in &vendor.entries { vendor_by_purl.entry(canon(key)).or_insert(entry); - vendor_by_purl.entry(canon(&entry.base_purl)).or_insert(entry); + vendor_by_purl + .entry(canon(&entry.base_purl)) + .or_insert(entry); } // The scan inventory keeps only http(s) `resolved` URLs and DROPS our own // `file:.socket/vendor/…` specs (see `lock_inventory`), so a @@ -532,12 +536,25 @@ pub(super) async fn classify_overlap_takeover(cwd: &Path) -> OverlapTakeover { // points at hosted. let inventory = socket_patch_core::vendor::lock_inventory::inventory_project(cwd).await; for purl in overlap { - let hosted_live = socket_patch_core::vendor::lock_inventory::lookup(&inventory, &purl) - .and_then(|e| e.resolved.as_deref()) - .is_some_and(|r| r.contains("patch.socket.dev")); - let vendored_live = match vendor_by_purl.get(&purl) { - Some(entry) => vendored_wiring_live(cwd, entry).await, - None => false, + // Cargo needs its own probe: the scan inventory records `resolved: + // None` for every cargo entry (a Cargo.lock `source` is an index URL, + // not a tarball URL), so the generic `patch.socket.dev` check below + // can never prove hosted for cargo — and the vendored substring scan + // alone then INVERTS the direction after a hosted takeover (the + // takeover-direction bug, cargo edition). The cargo classifier reads + // the lock entry's actual shape instead — the lock is the truth + // source both modes rewire. + let (hosted_live, vendored_live) = if purl.starts_with("pkg:cargo/") { + classify_cargo_overlap(cwd, &purl, vendor_by_purl.get(&purl).copied()).await + } else { + let hosted_live = socket_patch_core::vendor::lock_inventory::lookup(&inventory, &purl) + .and_then(|e| e.resolved.as_deref()) + .is_some_and(|r| r.contains("patch.socket.dev")); + let vendored_live = match vendor_by_purl.get(&purl) { + Some(entry) => vendored_wiring_live(cwd, entry).await, + None => false, + }; + (hosted_live, vendored_live) }; match (hosted_live, vendored_live) { (true, false) => out.redirect.push(purl), @@ -552,6 +569,64 @@ pub(super) async fn classify_overlap_takeover(cwd: &Path) -> OverlapTakeover { out } +/// Cargo takeover direction, proven from the `Cargo.lock` entry's shape — +/// the one file BOTH modes rewire, in mutually exclusive ways: +/// +/// * `source` = a Socket hosted patch registry index (matched against the +/// config-declared `[registries.socket-patch-*]` URLs, plus the +/// `patch.socket.dev` host for configs that were already cleaned up) ⇒ +/// hosted is live; +/// * entry DETACHED (no `source` — the vendored shape) with the +/// `[patch.crates-io]` entry pointing into this vendor entry's committed +/// `.socket/vendor/cargo//` copy ⇒ vendored is live; +/// * anything else (crates.io / other registry / entry or lock missing) ⇒ +/// neither proven, stay silent. +async fn classify_cargo_overlap( + cwd: &Path, + purl: &str, + entry: Option<&socket_patch_core::vendor::VendorEntry>, +) -> (bool, bool) { + use socket_patch_core::vendor::{cargo_config, cargo_lock}; + let Some(rest) = purl.strip_prefix("pkg:cargo/") else { + return (false, false); + }; + let Some((name, version)) = rest.rsplit_once('@') else { + return (false, false); + }; + match cargo_lock::probe_lock_entry(cwd, name, version).await { + cargo_lock::LockEntryProbe::Source(src) => { + let hosted = src.contains("patch.socket.dev") + || cargo_config::socket_registry_indexes(cwd) + .await + .iter() + .any(|(_, index)| *index == src); + (hosted, false) + } + cargo_lock::LockEntryProbe::Detached => { + let vendored = match entry { + Some(entry) => { + match socket_patch_core::vendor::path::vendor_uuid_dir_rel( + &entry.ecosystem, + &entry.uuid, + ) { + Some(marker) => cargo_config::read_patch_entries(cwd) + .await + .get(name) + .and_then(|i| i.path.as_deref()) + .is_some_and(|p| { + p.replace('\\', "/").starts_with(&format!("{marker}/")) + }), + None => false, + } + } + None => false, + }; + (false, vendored) + } + _ => (false, false), + } +} + /// Whether the LIVE lockfile still wires `entry` to its committed /// `.socket/vendor//` artifact. Reads the lockfile(s) this entry /// recorded editing (the same set `--revert` walks) and looks for that exact @@ -593,28 +668,70 @@ async fn vendored_wiring_live(cwd: &Path, entry: &socket_patch_core::vendor::Ven pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: bool) -> String { let list = superseded.join(", "); if current_is_hosted { + // NEVER offer deleting the `.socket/vendor//` tree here: for + // cargo the leftover `[patch.crates-io]` entry still points at that + // tree, and deleting it hard-fails every cargo invocation ("failed to + // load source for dependency"). `vendor --revert` is the safe path — + // it unwinds the wiring, the ledger entry, and the tree together. format!( "hosted redirect superseded the vendored ledger for: {list}. \ `.socket/vendor/state.json` still claims these package(s) and their \ - committed tarball(s) under `.socket/vendor/` are now orphaned — the \ + committed artifacts under `.socket/vendor/` are now orphaned — the \ lockfile points at the hosted patch server, not the vendored files. \ - Remove the stale vendored ledger and orphaned artifacts (run \ - `socket-patch vendor --revert` before redirecting, or delete the \ - orphaned `.socket/vendor//` tree) so audits and VEX do not read \ - superseded wiring." + Run `socket-patch vendor --revert` to remove the stale vendored \ + wiring, ledger entries, and artifacts together so audits and VEX do \ + not read superseded wiring; do not delete the `.socket/vendor/` \ + tree by hand while lockfile or config wiring still references it." ) } else { + // NEVER advise deleting the redirect ledger by hand: it may hold the + // only revert data (FileEdit originals) and VEX records for OTHER + // packages that are still hosted-redirected. The vendored flows + // reconcile per package — reverting the stale hosted edits and + // dropping exactly the superseded ledger records. format!( "vendored artifacts superseded the hosted redirect ledger for: {list}. \ `.socket/vendor/redirect-state.json` still records a hosted redirect for \ these package(s), but the lockfile now points at the committed \ - `.socket/vendor/` files. Remove the stale redirect ledger \ - (`.socket/vendor/redirect-state.json`) so audits and VEX do not read \ - superseded wiring." + `.socket/vendor/` files. Re-run `socket-patch vendor` (or `scan --mode \ + vendored`) to reconcile: it reverts the stale hosted edits for these \ + package(s) and drops their redirect-ledger records. Do not delete \ + `.socket/vendor/redirect-state.json` by hand — it may hold live \ + revert data and VEX records for other packages." ) } } +/// Cross-mode takeover advisory shared by every VENDORED flow (`vendor`, +/// `scan --mode vendored`): when this ledger and a committed hosted redirect +/// ledger both claim package(s) AND the live lockfile proves vendored won, +/// the redirect ledger records for those package(s) are stale. Warn once at +/// the envelope level (JSON `warnings[]` and stderr) without deleting +/// anything — the per-package reconciliation lives in the vendor engine. +pub(super) async fn note_vendor_supersedes_redirect( + env: &mut crate::json_envelope::Envelope, + cwd: &Path, + common: &GlobalArgs, +) { + // Only warn for the package(s) the LIVE lockfile actually routes to the + // committed `.socket/vendor/` files — the direction the lock proves, not + // the fact that this happens to be a vendored flow. A dry-run / no-op + // over a lock that still points at the hosted patch server stays silent + // instead of pointing cleanup at the live redirect ledger. + let superseded = classify_overlap_takeover(cwd).await.vendored; + if superseded.is_empty() { + return; + } + let detail = mode_takeover_detail(&superseded, /*current_is_hosted=*/ false); + if !common.silent && !common.json { + eprintln!("Warning ({VENDOR_SUPERSEDES_REDIRECT}): {detail}"); + } + env.warnings.push(crate::json_envelope::RunWarning { + code: VENDOR_SUPERSEDES_REDIRECT.to_string(), + detail, + }); +} + pub async fn run(mut args: ScanArgs) -> i32 { apply_env_toggles(&args.common); @@ -1966,22 +2083,206 @@ mod tests { fn takeover_detail_names_direction_package_and_remediation() { let purls = vec!["pkg:npm/minimist@1.2.2".to_string()]; - // Vendored displaced a hosted redirect: point at the redirect ledger. + // Vendored displaced a hosted redirect: name the stale ledger, but + // NEVER advise deleting it by hand — it may hold the only revert data + // and VEX records for OTHER still-live redirects. The safe sequence + // is re-running the vendored flow, which reconciles per package. let vendored = mode_takeover_detail(&purls, /*current_is_hosted=*/ false); assert!(vendored.contains("pkg:npm/minimist@1.2.2")); assert!(vendored.contains("redirect-state.json")); + assert!( + !vendored.contains("Remove the stale redirect ledger"), + "must not advise deleting the redirect ledger: {vendored}" + ); + assert!( + vendored.contains("Do not delete"), + "must warn against hand-deleting the ledger: {vendored}" + ); - // Hosted displaced a vendored ledger: point at the vendored ledger + - // orphaned artifacts. + // Hosted displaced a vendored ledger: `vendor --revert` is the ONLY + // offered remediation. Deleting the `.socket/vendor//` tree by + // hand hard-breaks cargo resolution while `[patch.crates-io]` still + // references it. let hosted = mode_takeover_detail(&purls, /*current_is_hosted=*/ true); assert!(hosted.contains("pkg:npm/minimist@1.2.2")); assert!(hosted.contains("state.json")); assert!(hosted.contains("orphaned")); + assert!(hosted.contains("vendor --revert")); + assert!( + !hosted.contains("or delete the orphaned"), + "deleting the vendor tree must not be offered as an equal \ + alternative: {hosted}" + ); // The two warning codes are distinct routing tags. assert_ne!(VENDOR_SUPERSEDES_REDIRECT, REDIRECT_SUPERSEDES_VENDORED); } + // ---- cargo takeover direction (lock-shape probe) ------------------------ + // The scan inventory records `resolved: None` for every cargo entry, so + // the generic patch.socket.dev check can never prove hosted for cargo — + // pre-fix, a genuine vendored→hosted cargo takeover classified as + // (hosted=false, vendored=true) and the warning INVERTED: the vendored + // flow told the user to delete the LIVE redirect ledger. These pin the + // cargo-specific lock-shape classifier. + + const CARGO_PURL: &str = "pkg:cargo/cfg-if@1.0.4"; + const CARGO_INDEX: &str = "sparse+http://127.0.0.1:5555/index/"; + + /// A vendored state ledger with one CARGO entry wired the way the cargo + /// backend records it (.cargo/config.toml patch entry + Cargo.lock edit). + async fn write_cargo_vendor_ledger(root: &Path) { + let state = serde_json::json!({ + "version": 1, + "entries": { + CARGO_PURL: { + "ecosystem": "cargo", + "basePurl": CARGO_PURL, + "uuid": TAKEOVER_UUID, + "artifact": { + "path": format!( + ".socket/vendor/cargo/{TAKEOVER_UUID}/cfg-if-1.0.4" + ), + }, + "wiring": [ + { + "file": ".cargo/config.toml", + "kind": "cargo_patch_entry", + "action": "added", + }, + { + "file": "Cargo.lock", + "kind": "cargo_lock_entry", + "action": "rewritten", + }, + ], + }, + }, + }); + let dir = root.join(".socket/vendor"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write( + dir.join("state.json"), + serde_json::to_string_pretty(&state).unwrap(), + ) + .await + .unwrap(); + } + + /// The mixed state a pre-fix vendored→hosted cargo takeover left behind: + /// the lock rewired to the hosted sparse index (declared as a + /// socket-patch registry in the config), while the vendored + /// `[patch.crates-io]` entry ALSO survives in the config. + async fn write_cargo_hosted_takeover_files(root: &Path) { + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + tokio::fs::write( + root.join(".cargo/config.toml"), + format!( + "[patch.crates-io]\ncfg-if = {{ path = \".socket/vendor/cargo/{TAKEOVER_UUID}/cfg-if-1.0.4\" }}\n\n\ + [registries.socket-patch-{TAKEOVER_UUID}]\nindex = \"{CARGO_INDEX}\"\n" + ), + ) + .await + .unwrap(); + tokio::fs::write( + root.join("Cargo.lock"), + format!( + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CARGO_INDEX}\"\nchecksum = \"{}\"\n", + "a".repeat(64) + ), + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn cargo_takeover_classifies_hosted_when_the_lock_points_at_the_socket_registry() { + // The lock's source is the config-declared socket-patch sparse index + // (a localhost URL — the probe must not depend on the + // patch.socket.dev host). Hosted won; the vendored ledger is stale — + // even though the leftover [patch.crates-io] marker would satisfy the + // generic wiring scan (the pre-fix inversion). + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &[CARGO_PURL]).await; + write_cargo_vendor_ledger(root).await; + write_cargo_hosted_takeover_files(root).await; + + let takeover = classify_overlap_takeover(root).await; + assert_eq!( + takeover.redirect, + vec![CARGO_PURL.to_string()], + "hosted direction must be provable for cargo: {takeover:?}" + ); + assert!( + takeover.vendored.is_empty(), + "the INVERSE warning must not fire (pre-fix bug): {takeover:?}" + ); + } + + #[tokio::test] + async fn cargo_takeover_classifies_vendored_when_the_lock_is_detached() { + // The genuine vendored-live shape: detached lock entry (no source) + + // [patch.crates-io] pointing at the entry's committed copy. The + // redirect ledger is the stale one. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &[CARGO_PURL]).await; + write_cargo_vendor_ledger(root).await; + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + tokio::fs::write( + root.join(".cargo/config.toml"), + format!( + "[patch.crates-io]\ncfg-if = {{ path = \".socket/vendor/cargo/{TAKEOVER_UUID}/cfg-if-1.0.4\" }}\n" + ), + ) + .await + .unwrap(); + tokio::fs::write( + root.join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n", + ) + .await + .unwrap(); + + let takeover = classify_overlap_takeover(root).await; + assert_eq!( + takeover.vendored, + vec![CARGO_PURL.to_string()], + "{takeover:?}" + ); + assert!(takeover.redirect.is_empty(), "{takeover:?}"); + } + + #[tokio::test] + async fn cargo_takeover_stays_silent_when_the_lock_points_at_crates_io() { + // Both ledgers claim the purl but a third party re-resolved the lock + // back to crates.io: neither mode is live — no directional warning. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &[CARGO_PURL]).await; + write_cargo_vendor_ledger(root).await; + tokio::fs::write( + root.join("Cargo.lock"), + format!( + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"{}\"\n", + "b".repeat(64) + ), + ) + .await + .unwrap(); + + let takeover = classify_overlap_takeover(root).await; + assert!( + takeover.redirect.is_empty() && takeover.vendored.is_empty(), + "{takeover:?}" + ); + } + // ---- takeover DIRECTION follows the live lock, not the command --------- // The overlap alone only proves both ledgers name the same package; it does // NOT prove which mode won. `classify_overlap_takeover` decides direction @@ -2095,7 +2396,10 @@ mod tests { "hosted flow must not warn when the lock is vendored: {takeover:?}" ); // Truthful direction: vendored won ⇒ the redirect ledger is the stale one. - assert_eq!(takeover.vendored, vec!["pkg:npm/minimist@1.2.2".to_string()]); + assert_eq!( + takeover.vendored, + vec!["pkg:npm/minimist@1.2.2".to_string()] + ); // Pre-fix the hosted flow keyed off the raw overlap, which is non-empty // — it WOULD have wrongly told the user to delete the live ledger. assert!(!overlapping_ledger_purls(root).await.is_empty()); @@ -2119,7 +2423,10 @@ mod tests { "vendored flow must not warn when the lock is hosted: {takeover:?}" ); // Truthful direction: hosted won ⇒ the vendored ledger is the stale one. - assert_eq!(takeover.redirect, vec!["pkg:npm/minimist@1.2.2".to_string()]); + assert_eq!( + takeover.redirect, + vec!["pkg:npm/minimist@1.2.2".to_string()] + ); } #[tokio::test] diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 4851dad4..34566ce7 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -21,11 +21,12 @@ use crate::commands::get::{download_and_apply_patches, download_patch_records, D use crate::commands::vendor::{ note_classic_migration_risk, reconcile_dropped, track_outcomes_for_vendor, vendor_records, }; -use crate::json_envelope::{Command as EnvelopeCommand, Envelope, RunWarning}; +use crate::json_envelope::{Command as EnvelopeCommand, Envelope}; use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; use super::{ - discover_selected, download_params, embed_vex_into_json, emit_discovery_error_json, ScanArgs, + discover_selected, download_params, embed_vex_into_json, emit_discovery_error_json, + note_vendor_supersedes_redirect, ScanArgs, }; /// Dry-run preview for `scan --vendor`: classify each selected patch @@ -80,34 +81,6 @@ fn scan_vendor_service_config( } } -/// Cross-mode takeover advisory for the scan-driven vendor step: when this -/// vendored run's ledger (`.socket/vendor/state.json`) and a committed hosted -/// redirect ledger (`.socket/vendor/redirect-state.json`) both claim the same -/// package(s), the redirect ledger is now stale — the lockfile points at the -/// committed `.socket/vendor/` files, not the hosted patch server. Warn once -/// at the envelope level (JSON `warnings[]` and stderr), mirroring -/// [`note_classic_migration_risk`]; the stale ledger is NOT deleted here -/// (reconciliation is deferred — see the redirect twin in `hosted.rs`). -async fn note_vendor_supersedes_redirect(env: &mut Envelope, cwd: &Path, common: &GlobalArgs) { - // Only warn for the package(s) the LIVE lockfile actually routes to the - // committed `.socket/vendor/` files — the direction the lock proves, not the - // fact that this happens to be the vendored flow. A dry-run / no-op over a - // lock that still points at the hosted patch server stays silent instead of - // pointing cleanup at the live redirect ledger. - let superseded = super::classify_overlap_takeover(cwd).await.vendored; - if superseded.is_empty() { - return; - } - let detail = super::mode_takeover_detail(&superseded, /*current_is_hosted=*/ false); - if !common.silent && !common.json { - eprintln!("Warning ({}): {detail}", super::VENDOR_SUPERSEDES_REDIRECT); - } - env.warnings.push(RunWarning { - code: super::VENDOR_SUPERSEDES_REDIRECT.to_string(), - detail, - }); -} - /// The vendor step shared by `scan --vendor`'s JSON and interactive /// paths: acquire the apply lock, stage patch sources, and drive /// [`vendor_records`] — manifest mode (`detached_records: None`, records diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index e95638ad..895b2f7f 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -194,6 +194,13 @@ pub(crate) async fn dispatch_revert_one( async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option { match entry.ecosystem.as_str() { "npm" => vendor::npm_flavor::vendored_entry_in_use(entry, project_root).await, + // Cargo probes the lock entry's shape: detached + `[patch]` pointing + // at this entry's copy = in use; a registry source (crates.io + // re-resolve or a hosted takeover) or a missing entry = reclaimable + // (the revert restores / keeps the registry resolution and drops the + // dead wiring). Without this, a vendored entry displaced by a hosted + // takeover survives every `scan --prune` forever. + "cargo" => vendor::cargo::vendored_entry_in_use(entry, project_root).await, _ => None, } } @@ -407,6 +414,12 @@ pub async fn run(args: VendorArgs) -> i32 { } note_classic_migration_risk(&mut env, &args.common.cwd, &args.common); + // Same cross-mode takeover advisory the scan-driven vendored flow emits: + // the standalone `vendor` command is the PRIMARY hosted→vendored + // migration entry point, so it must surface a redirect ledger that this + // run (or an earlier one) superseded — silence here left the stale + // ledger feeding VEX indefinitely. + super::scan::note_vendor_supersedes_redirect(&mut env, &args.common.cwd, &args.common).await; if args.common.json { println!("{}", env.to_pretty_json()); @@ -548,6 +561,16 @@ pub(crate) async fn persist_vendor_entry( } } } + // Same invariant for the cargo lock originals: the removed + // `source`/`checksum` pair is NOT recoverable offline — the ledger + // entry is its only home. A re-vendor over already-detached wiring + // records `lock: None` (there was nothing left to detach), so + // inserting the fresh entry verbatim would silently destroy the + // first run's originals and a later `--revert` could only shrug. + // Carry them forward from the entry being replaced. + if entry.lock.is_none() { + entry.lock = prev.lock.clone(); + } } let new_uuid = entry.uuid.clone(); state.entries.insert(candidate.clone(), entry); @@ -883,6 +906,13 @@ pub(crate) async fn vendor_records( let mut matched: HashSet = HashSet::new(); let mut handled_bases: HashSet = HashSet::new(); + // The hosted redirect ledger, for cross-mode takeovers: vendoring a purl + // it still claims must revert the hosted edits FIRST (see the hook in the + // dispatch loop below). Loaded once; mutated + persisted per reverted + // purl. + let mut redirect_ledger = + socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await; + for (purl, pkg_path) in &all_packages { let is_variant_eco = Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()); @@ -932,6 +962,112 @@ pub(crate) async fn vendor_records( } matched.insert(candidate.clone()); + // Cross-mode takeover (cargo): vendoring over a LIVE hosted + // redirect must first revert the hosted edits from the redirect + // ledger — `[patch.crates-io]` only patches crates-io-sourced + // deps, so vendoring on top of the `registry = "socket-patch-…"` + // pin leaves the project unbuildable in BOTH modes while this + // run reports success — and the pre-revert also hands the vendor + // detach the PRISTINE crates.io lock fragment to record as the + // ledger's unrecoverable originals (not the hosted values). A + // purl whose hosted edits cannot be cleanly reverted is REFUSED; + // the backend's own fail-closed guard (`hosted_redirect_live`) + // backstops states with no usable ledger at all. + if candidate.starts_with("pkg:cargo/") { + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let claimed = redirect_ledger + .as_ref() + .is_some_and(|l| l.records.keys().any(|k| canon(k) == canon(candidate))); + if claimed && common.dry_run { + record_warning( + env, + candidate, + &VendorWarning::new( + "vendor_would_revert_redirect", + format!( + "{} is hosted-redirected; a non-dry-run vendor will \ + revert the hosted redirect edits first, then vendor \ + (mode takeover)", + normalize_purl(candidate) + ), + ), + common, + ); + } else if claimed { + let ledger = redirect_ledger.as_mut().expect("claimed implies Some"); + match socket_patch_core::patch::redirect::revert_cargo_redirect_purl( + &common.cwd, + ledger, + candidate, + ) + .await + { + Ok(_) => { + if let Err(e) = + socket_patch_core::patch::redirect::persist_redirect_state( + &common.cwd, + ledger, + ) + .await + { + // The hosted edits are reverted but the ledger + // still claims them; vendoring now would leave + // a ledger asserting wiring that is gone. Fail + // closed for this purl. + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()) + .with_error( + "redirect_ledger_write_failed", + format!( + "reverted the hosted redirect but could not \ + update .socket/vendor/redirect-state.json: {e}" + ), + ), + ); + continue; + } + record_warning( + env, + candidate, + &VendorWarning::new( + "vendor_takeover_reverted_redirect", + format!( + "{} was hosted-redirected; reverted the hosted \ + edits (Cargo.toml registry pin, Cargo.lock \ + source/checksum, registries block) and dropped \ + the redirect-ledger record before vendoring \ + (mode takeover)", + normalize_purl(candidate) + ), + ), + common, + ); + } + Err(detail) => { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( + "redirect_revert_failed", + format!( + "cannot vendor over the live hosted redirect: \ + {detail}" + ), + ), + ); + if !common.silent && !common.json { + eprintln!( + "Cannot vendor {}: cannot revert the hosted redirect: \ + {detail}", + normalize_purl(candidate) + ); + } + continue; + } + } + } + } + let outcome = dispatch_vendor_one( candidate, pkg_path, @@ -1948,6 +2084,105 @@ mod gc_tests { assert!(wet.unused_reverted.is_empty(), "{wet:?}"); } + /// A vendored CARGO entry displaced by a hosted takeover (its lock entry + /// re-sourced to a socket-patch sparse index) is reclaimable by the GC: + /// pre-fix, `dispatch_in_use_one` had no cargo probe (`None` = keep), so + /// the stale ledger entry, the committed tree, and the build-breaking + /// `[patch.crates-io]` entry survived every `scan --prune` forever. + #[tokio::test] + async fn vendor_gc_reclaims_cargo_entry_displaced_by_hosted_takeover() { + const CARGO_PURL: &str = "pkg:cargo/cfg-if@1.0.4"; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let socket = root.join(".socket"); + tokio::fs::create_dir_all(socket.join(format!("vendor/cargo/{UUID}/cfg-if-1.0.4"))) + .await + .unwrap(); + tokio::fs::write( + socket.join(format!("vendor/cargo/{UUID}/cfg-if-1.0.4/lib.rs")), + b"// patched", + ) + .await + .unwrap(); + + // Manifest still carries the patch (so pass (a) keeps it; the + // lock-shape probe (b) is what must reclaim it). + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + CARGO_PURL.to_string(), + socket_patch_core::manifest::schema::PatchRecord { + uuid: UUID.to_string(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }, + ); + let manifest_path = socket.join("manifest.json"); + write_manifest(&manifest_path, &manifest).await.unwrap(); + + let mut state = VendorState::default(); + let mut entry = entry(false); + entry.ecosystem = "cargo".into(); + entry.base_purl = CARGO_PURL.into(); + entry.artifact.path = format!(".socket/vendor/cargo/{UUID}/cfg-if-1.0.4"); + state.entries.insert(CARGO_PURL.to_string(), entry); + save_state(root, &state).await.unwrap(); + + // The mixed hosted-takeover state: [patch] entry survives, lock + // re-sourced to the socket-patch sparse index. + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + tokio::fs::write( + root.join(".cargo/config.toml"), + format!( + "[patch.crates-io]\ncfg-if = {{ path = \".socket/vendor/cargo/{UUID}/cfg-if-1.0.4\" }}\n" + ), + ) + .await + .unwrap(); + tokio::fs::write( + root.join("Cargo.lock"), + format!( + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"sparse+http://127.0.0.1:5555/index/\"\nchecksum = \"{}\"\n", + "a".repeat(64) + ), + ) + .await + .unwrap(); + + let common = GlobalArgs { + cwd: root.to_path_buf(), + json: true, + silent: true, + ..GlobalArgs::default() + }; + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert_eq!(out.unused_reverted, vec![CARGO_PURL.to_string()], "{out:?}"); + assert!(out.failed.is_empty(), "{out:?}"); + assert!(load_state(root).await.unwrap().entries.is_empty()); + assert!( + !root.join(format!(".socket/vendor/cargo/{UUID}")).exists(), + "committed tree reclaimed" + ); + // The build-breaking leftover [patch.crates-io] entry is gone; the + // hosted lock wiring is left exactly as it was (still hosted-live). + let cfg = tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap_or_default(); + assert!(!cfg.contains("patch.crates-io"), "{cfg}"); + let lock = tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(); + assert!( + lock.contains("sparse+http://127.0.0.1:5555/index/"), + "{lock}" + ); + } + /// (c) uuid dirs with no owning ledger entry are swept (wet) / counted /// (dry). #[tokio::test] diff --git a/crates/socket-patch-cli/tests/mode_migration_cargo.rs b/crates/socket-patch-cli/tests/mode_migration_cargo.rs new file mode 100644 index 00000000..4ac68815 --- /dev/null +++ b/crates/socket-patch-cli/tests/mode_migration_cargo.rs @@ -0,0 +1,849 @@ +//! Real-cargo mode-migration e2e: vendored ⇄ hosted takeovers must leave the +//! project FULLY in the new mode — or refuse. +//! +//! Adapted from the audit probes that empirically proved findings C1–C7 (the +//! cargo mode-takeover bug class): both directions used to exit 0 while +//! leaving the project unbuildable under `--locked` (leftover +//! `[patch.crates-io]` after a hosted takeover; a surviving +//! `registry = "socket-patch-…"` Cargo.toml pin after a vendored takeover), a +//! double takeover destroyed the unrecoverable crates.io lock originals in +//! the vendored ledger, and the takeover classifier then emitted an INVERTED +//! warning telling the user to delete the live ledger. +//! +//! Each scenario drives the REAL binary against real cargo (network used for +//! the crates.io fixture build only; the hosted registry is wiremock) and +//! proves the terminal state with `cargo build --locked` on a fresh checkout. +//! +//! Skips (println) when `cargo` is missing or crates.io is unreachable for +//! the fixture build; all assertions after that are hard. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const DEP: &str = "cfg-if"; +const UUID_V: &str = "2b3c4d5e-6f70-4a1b-8c2d-0123456789ab"; // vendored patch +const UUID_H: &str = "6b7c8d9e-0f1a-4a1b-8c2d-3e4f5a6b7c8d"; // hosted patch +const TOKEN: &str = "33333333-3333-4333-8333-333333333333"; +const GHSA: &str = "GHSA-migr-cargo-test"; +/// Doc comment required: cfg-if denies `missing_docs` and path deps get no +/// `--cap-lints allow`. +const PATCH_SUFFIX: &str = + "\n/// Socket-patch capstone marker (added by the patch).\npub fn socket_patched() -> u32 { 1 }\n"; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn run_socket(cwd: &Path, args: &[&str], cargo_home: &Path) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { + cmd.env_remove(&k); + } + } + cmd.env_remove("VIRTUAL_ENV"); + cmd.env("CARGO_HOME", cargo_home); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn cargo(cwd: &Path, args: &[&str], cargo_home: &Path) -> Output { + Command::new("cargo") + .args(args) + .current_dir(cwd) + .env("CARGO_HOME", cargo_home) + .env_remove("CARGO_TARGET_DIR") + .output() + .expect("failed to run cargo") +} + +fn assert_build_ok(tag: &str, out: &Output) { + assert!( + out.status.success(), + "{tag} must succeed, got {}:\nstdout=<<<{}>>>\nstderr=<<<{}>>>", + out.status, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + +fn git_sha256(content: &[u8]) -> String { + compute_git_sha256_from_bytes(content) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn stage_patch(proj: &Path, purl: &str, before: &[u8], after: &[u8]) { + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { purl: { + "uuid": UUID_V, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "src/lib.rs": { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(after), + }}, + "vulnerabilities": { GHSA: { + "cves": ["CVE-2026-88888"], + "summary": "migration vuln", "severity": "high", "description": "d", + }}, + "description": "migration patch", "license": "MIT", "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(after)), after).unwrap(); +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +fn locked_version(lock_text: &str, name: &str) -> Option { + let needle = format!("name = \"{name}\""); + let mut lines = lock_text.lines(); + while let Some(line) = lines.next() { + if line.trim() == needle { + for l in lines.by_ref() { + let t = l.trim(); + if let Some(v) = t.strip_prefix("version = \"") { + return Some(v.trim_end_matches('"').to_string()); + } + if t == "[[package]]" { + break; + } + } + } + } + None +} + +fn package_block(lock_text: &str, name: &str) -> Option { + let needle = format!("name = \"{name}\""); + lock_text + .split("[[package]]") + .find(|block| block.lines().any(|l| l.trim() == needle)) + .map(str::to_string) +} + +fn find_registry_crate(cargo_home: &Path, leaf: &str) -> Option { + let src = cargo_home.join("registry").join("src"); + for entry in std::fs::read_dir(&src).ok()? { + let candidate = entry.ok()?.path().join(leaf); + if candidate.is_dir() { + return Some(candidate); + } + } + None +} + +fn sparse_index_rel(name: &str) -> String { + match name.len() { + 1 => format!("1/{name}"), + 2 => format!("2/{name}"), + 3 => format!("3/{}/{name}", &name[..1]), + _ => format!("{}/{}/{name}", &name[..2], &name[2..4]), + } +} + +fn build_patched_crate( + stage_root: &Path, + crate_dir: &Path, + version: &str, + patched: &[u8], +) -> Vec { + let leaf = format!("{DEP}-{version}"); + let pkg_dir = stage_root.join(&leaf); + copy_dir_recursive(crate_dir, &pkg_dir); + let _ = std::fs::remove_file(pkg_dir.join(".cargo-checksum.json")); + std::fs::write(pkg_dir.join("src/lib.rs"), patched).unwrap(); + let mut bytes = Vec::new(); + { + let enc = flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::new(6)); + let mut builder = tar::Builder::new(enc); + builder.append_dir_all(&leaf, &pkg_dir).unwrap(); + builder.into_inner().unwrap().finish().unwrap(); + } + bytes +} + +/// Consumer crate fixture: build once against real crates.io to populate the +/// private CARGO_HOME + Cargo.lock. `None` (with a SKIP println) when the +/// toolchain or network is unavailable. +fn stage_fixture(tmp: &Path) -> Option<(PathBuf, PathBuf, String, PathBuf)> { + let proj = tmp.join("proj"); + let cargo_home = tmp.join("cargo-home"); + std::fs::create_dir_all(proj.join("src")).unwrap(); + std::fs::create_dir_all(&cargo_home).unwrap(); + std::fs::write( + proj.join("Cargo.toml"), + format!( + "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{DEP} = \"1.0\"\n" + ), + ) + .unwrap(); + std::fs::write( + proj.join("src/main.rs"), + "fn main() { println!(\"baseline\"); }\n", + ) + .unwrap(); + let build = cargo(&proj, &["build", "-q"], &cargo_home); + if !build.status.success() { + println!( + "SKIP: baseline cargo build failed (no cargo or no network):\n{}", + String::from_utf8_lossy(&build.stderr) + ); + return None; + } + let lock_text = std::fs::read_to_string(proj.join("Cargo.lock")).unwrap(); + let version = locked_version(&lock_text, DEP).unwrap(); + let crate_dir = find_registry_crate(&cargo_home, &format!("{DEP}-{version}")).unwrap(); + Some((proj, cargo_home, version, crate_dir)) +} + +/// Mount the full hosted-mode mock set (discovery + reference + view + sparse +/// index + download) for patch UUID_H over `purl`. +async fn mount_hosted_mocks( + server: &MockServer, + purl: &str, + version: &str, + crate_bytes: &[u8], + orig: &[u8], + patched: &[u8], +) -> String { + let cksum = sha256_hex(crate_bytes); + let index_url = format!("sparse+{}/index/", server.uri()); + let hosted_url = format!( + "{}/patch/cargo/{DEP}/{version}/{TOKEN}/{UUID_H}/{DEP}-{version}.crate", + server.uri() + ); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": purl, + "patches": [{ + "uuid": UUID_H, "purl": purl, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "cargo migration fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID_H, "purl": purl, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + UUID_H: { + "status": "granted", + "url": hosted_url, + "purl": purl, + "artifacts": [{ + "kind": "tarball", + "url": hosted_url, + "integrity": { "sha256": cksum } + }], + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": index_url, + "identifiers": { + "name": DEP, + "version": version, + "cargoCksumSha256": cksum, + } + } + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID_H}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID_H, + "purl": purl, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "src/lib.rs": { + "beforeHash": compute_git_sha256_from_bytes(orig), + "afterHash": compute_git_sha256_from_bytes(patched), + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2026-2222"], + "summary": "migration vuln", "severity": "high", "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/index/config.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "dl": format!("{}/dl", server.uri()), + "api": server.uri(), + }))) + .mount(server) + .await; + let index_line = serde_json::json!({ + "name": DEP, "vers": version, "deps": [], "cksum": cksum, + "features": {}, "yanked": false, + }) + .to_string(); + Mock::given(method("GET")) + .and(path(format!("/index/{}", sparse_index_rel(DEP)))) + .respond_with(ResponseTemplate::new(200).set_body_raw(index_line, "text/plain")) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/dl/{DEP}/{version}/download"))) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(crate_bytes.to_vec(), "application/octet-stream"), + ) + .mount(server) + .await; + index_url +} + +/// Copy ONLY the committable files to a fresh dir (the fresh-checkout proof). +fn fresh_checkout(proj: &Path, tmp: &Path, tag: &str) -> (PathBuf, PathBuf) { + let fresh = tmp.join(format!("fresh-{tag}")); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(proj.join("Cargo.toml"), fresh.join("Cargo.toml")).unwrap(); + std::fs::copy(proj.join("Cargo.lock"), fresh.join("Cargo.lock")).unwrap(); + if proj.join(".cargo").exists() { + copy_dir_recursive(&proj.join(".cargo"), &fresh.join(".cargo")); + } + copy_dir_recursive(&proj.join("src"), &fresh.join("src")); + if proj.join(".socket").exists() { + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + } + let home = tmp.join(format!("fresh-home-{tag}")); + std::fs::create_dir_all(&home).unwrap(); + (fresh, home) +} + +fn read(proj: &Path, rel: &str) -> String { + std::fs::read_to_string(proj.join(rel)).unwrap_or_default() +} + +fn vendor_ledger_claims(proj: &Path, purl: &str) -> bool { + read(proj, ".socket/vendor/state.json").contains(purl) +} + +// ── C1 / C4b: vendored → hosted takeover ──────────────────────────────────── +// The hosted scan must revert the vendored state first, leave the project +// PURELY hosted (fresh checkout builds under --locked), and no later vendored +// no-op may emit the inverted `vendor_supersedes_redirect` warning. +#[tokio::test(flavor = "multi_thread")] +async fn vendored_then_hosted_takeover_leaves_pure_hosted() { + let tmp = tempfile::tempdir().unwrap(); + let Some((proj, cargo_home, version, crate_dir)) = stage_fixture(tmp.path()) else { + return; + }; + let purl = format!("pkg:cargo/{DEP}@{version}"); + let orig = std::fs::read(crate_dir.join("src/lib.rs")).unwrap(); + let patched: Vec = [orig.as_slice(), PATCH_SUFFIX.as_bytes()].concat(); + stage_patch(&proj, &purl, &orig, &patched); + + // A: vendor (offline). + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + &cargo_home, + ); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + assert!( + vendor_ledger_claims(&proj, &purl), + "vendored ledger claims the purl" + ); + + // B: hosted redirect over the vendored state — the takeover. + let server = MockServer::start().await; + let crate_bytes = + build_patched_crate(&tmp.path().join("stage"), &crate_dir, &version, &patched); + mount_hosted_mocks(&server, &purl, &version, &crate_bytes, &orig, &patched).await; + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ], + &cargo_home, + ); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); + assert_eq!(envelope["redirect"]["redirected"], 1, "{stdout}"); + // The takeover is surfaced, and it really reverted the vendored state. + assert!( + stdout.contains("redirect_takeover_reverted_vendored"), + "takeover warning missing: {stdout}" + ); + + // The project is FULLY hosted: no leftover [patch.crates-io], no vendored + // ledger claim, no committed vendor tree; the hosted wiring is present. + let config = read(&proj, ".cargo/config.toml"); + assert!( + !config.contains("[patch.crates-io]"), + "leftover [patch.crates-io] breaks every --locked build (C1): {config}" + ); + assert!( + config.contains(&format!("[registries.socket-patch-{UUID_H}]")), + "{config}" + ); + assert!( + !vendor_ledger_claims(&proj, &purl), + "the displaced vendored ledger entry must be dropped: {}", + read(&proj, ".socket/vendor/state.json") + ); + assert!( + !proj.join(format!(".socket/vendor/cargo/{UUID_V}")).exists(), + "the orphaned committed tree must be removed" + ); + assert!( + proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted ledger written" + ); + let lock_block = package_block(&read(&proj, "Cargo.lock"), DEP).unwrap_or_default(); + assert!( + lock_block.contains("sparse+"), + "lock points hosted: {lock_block}" + ); + + // C: fresh checkout builds under --locked (the CI contract the pre-fix + // mixed state broke with "cannot update the lock file"). + let (fresh, home) = fresh_checkout(&proj, tmp.path(), "c"); + assert_build_ok( + "cargo fetch --locked", + &cargo(&fresh, &["fetch", "--locked"], &home), + ); + assert_build_ok( + "cargo build --locked", + &cargo(&fresh, &["build", "--locked"], &home), + ); + + // D: a later vendored-flow no-op must NOT emit the inverted + // vendor_supersedes_redirect warning (C4b: pre-fix it told the user to + // delete the LIVE hosted ledger while the lock pointed at the sparse + // index). Empty API + no manifest = the no-manifest no-op path. + std::fs::remove_file(proj.join(".socket/manifest.json")).unwrap(); + let empty = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], "canAccessPaidPatches": false, + }))) + .mount(&empty) + .await; + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "scan", + "--mode", + "vendored", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &empty.uri(), + "--org", + ORG, + "--api-token", + "fake", + ], + &cargo_home, + ); + assert_eq!(code, 0, "vendored no-op failed: {stdout}\n{stderr}"); + assert!( + !stdout.contains("vendor_supersedes_redirect") + && !stderr.contains("vendor_supersedes_redirect"), + "the inverted takeover warning must not fire (C4b): {stdout}\n{stderr}" + ); +} + +// ── C2 / C7: hosted → vendored takeover via the plain `vendor` command ────── +// The primary migration entry point must revert the hosted edits first (from +// the redirect ledger), surface the takeover, leave the project PURELY +// vendored (fresh checkout builds offline under --locked), and a final +// `vendor --revert` must restore the pristine pre-hosted project. +#[tokio::test(flavor = "multi_thread")] +async fn hosted_then_vendored_takeover_leaves_pure_vendored() { + let tmp = tempfile::tempdir().unwrap(); + let Some((proj, cargo_home, version, crate_dir)) = stage_fixture(tmp.path()) else { + return; + }; + let purl = format!("pkg:cargo/{DEP}@{version}"); + let orig = std::fs::read(crate_dir.join("src/lib.rs")).unwrap(); + let patched: Vec = [orig.as_slice(), PATCH_SUFFIX.as_bytes()].concat(); + let toml_pristine = read(&proj, "Cargo.toml"); + let lock_pristine = std::fs::read(proj.join("Cargo.lock")).unwrap(); + + // A: hosted redirect. + let server = MockServer::start().await; + let crate_bytes = + build_patched_crate(&tmp.path().join("stage"), &crate_dir, &version, &patched); + mount_hosted_mocks(&server, &purl, &version, &crate_bytes, &orig, &patched).await; + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ], + &cargo_home, + ); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + assert!( + read(&proj, "Cargo.toml").contains("socket-patch-"), + "hosted pin present" + ); + + // B: plain `vendor` over the hosted state — the takeover. + stage_patch(&proj, &purl, &orig, &patched); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + &cargo_home, + ); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); + assert_eq!(envelope["summary"]["applied"], 1, "{stdout}"); + // C7: the PLAIN vendor command surfaces the takeover (pre-fix: silent). + assert!( + stdout.contains("vendor_takeover_reverted_redirect"), + "takeover advisory missing from the plain vendor envelope: {stdout}" + ); + + // The project is FULLY vendored: the hosted Cargo.toml pin and the + // registries block are gone, [patch.crates-io] + detached lock are in, + // and the hosted ledger record is dropped. + let toml = read(&proj, "Cargo.toml"); + assert!( + !toml.contains("socket-patch-"), + "the hosted registry pin must be reverted (C2 — [patch.crates-io] \ + cannot apply over it and the project is unbuildable): {toml}" + ); + let config = read(&proj, ".cargo/config.toml"); + assert!(config.contains("[patch.crates-io]"), "{config}"); + assert!( + !config.contains("[registries.socket-patch-"), + "the now-unused registries block must be dropped: {config}" + ); + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "the emptied hosted ledger must be removed: {}", + read(&proj, ".socket/vendor/redirect-state.json") + ); + let lock_block = package_block(&read(&proj, "Cargo.lock"), DEP).unwrap_or_default(); + assert!( + !lock_block.contains("source ="), + "vendored lock entry is detached: {lock_block}" + ); + + // C: the vendored contract — fresh checkout, EMPTY home, offline locked + // build (pre-fix: "no matching package named cfg-if found"). + let (fresh, home) = fresh_checkout(&proj, tmp.path(), "c"); + assert_build_ok( + "cargo build --locked --offline (vendored fresh checkout)", + &cargo(&fresh, &["build", "--locked", "--offline"], &home), + ); + + // D: revert restores the PRISTINE pre-hosted project — the crates.io + // lock fragment (not a dead grant-tokenized sparse URL) and the plain + // Cargo.toml dep. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + &cargo_home, + ); + assert_eq!(code, 0, "revert failed: {stdout}\n{stderr}"); + assert_eq!( + std::fs::read(proj.join("Cargo.lock")).unwrap(), + lock_pristine, + "Cargo.lock restored byte-identical to the pre-hosted pristine" + ); + assert_eq!(read(&proj, "Cargo.toml"), toml_pristine); + assert!( + !read(&proj, ".cargo/config.toml").contains("[patch.crates-io]"), + "vendored wiring gone after revert" + ); +} + +// ── C3: double takeover A→B→A must preserve the crates.io lock originals ──── +// vendored → hosted → vendored again: the vendored ledger must carry the +// PRISTINE crates.io source+checksum (pre-fix it silently recorded the hosted +// sparse-index URL + patched checksum as the "originals"), and a final revert +// must restore the byte-identical pristine lock. +#[tokio::test(flavor = "multi_thread")] +async fn double_takeover_a_b_a_preserves_lock_originals() { + let tmp = tempfile::tempdir().unwrap(); + let Some((proj, cargo_home, version, crate_dir)) = stage_fixture(tmp.path()) else { + return; + }; + let purl = format!("pkg:cargo/{DEP}@{version}"); + let orig = std::fs::read(crate_dir.join("src/lib.rs")).unwrap(); + let patched: Vec = [orig.as_slice(), PATCH_SUFFIX.as_bytes()].concat(); + stage_patch(&proj, &purl, &orig, &patched); + let toml_pristine = read(&proj, "Cargo.toml"); + let lock_pristine = std::fs::read(proj.join("Cargo.lock")).unwrap(); + let pristine_block = package_block(&String::from_utf8_lossy(&lock_pristine), DEP).unwrap(); + let pristine_checksum = pristine_block + .lines() + .find_map(|l| l.trim().strip_prefix("checksum = \"")) + .map(|s| s.trim_end_matches('"').to_string()) + .expect("pristine lock has a checksum"); + + // A: vendor. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + &cargo_home, + ); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + + // B: hosted takeover. + let server = MockServer::start().await; + let crate_bytes = + build_patched_crate(&tmp.path().join("stage"), &crate_dir, &version, &patched); + mount_hosted_mocks(&server, &purl, &version, &crate_bytes, &orig, &patched).await; + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ], + &cargo_home, + ); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + + // A again: vendor back. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + &cargo_home, + ); + assert_eq!(code, 0, "re-vendor failed: {stdout}\n{stderr}"); + + // The vendored ledger's lock originals are the PRISTINE crates.io values + // — the only offline-recoverable home of the registry checksum. + let state: serde_json::Value = + serde_json::from_str(&read(&proj, ".socket/vendor/state.json")).unwrap(); + let entry = &state["entries"][&purl]; + assert_eq!( + entry["lock"]["source"], "registry+https://github.com/rust-lang/crates.io-index", + "C3: the ledger must keep the crates.io source, not the hosted sparse \ + index: {entry}" + ); + assert_eq!( + entry["lock"]["checksum"], + pristine_checksum.as_str(), + "C3: the ledger must keep the registry tarball checksum: {entry}" + ); + + // The vendored contract still holds after the round trip. + let (fresh, home) = fresh_checkout(&proj, tmp.path(), "aba"); + assert_build_ok( + "cargo build --locked --offline (A->B->A fresh checkout)", + &cargo(&fresh, &["build", "--locked", "--offline"], &home), + ); + + // Revert: byte-identical pristine lock + Cargo.toml, no residue. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + &cargo_home, + ); + assert_eq!(code, 0, "revert failed: {stdout}\n{stderr}"); + assert_eq!( + std::fs::read(proj.join("Cargo.lock")).unwrap(), + lock_pristine, + "the documented byte-identical pre-vendor Cargo.lock restore (C3)" + ); + assert_eq!(read(&proj, "Cargo.toml"), toml_pristine); + let config = read(&proj, ".cargo/config.toml"); + assert!(!config.contains("[patch.crates-io]"), "{config}"); +} + +// ── FAIL CLOSED: vendoring over a hosted redirect with no ledger refuses ──── +// When the redirect ledger is gone the hosted originals are unrecoverable — +// the vendor run must refuse the purl with an actionable error instead of +// creating the mixed unbuildable state and reporting success. +#[tokio::test(flavor = "multi_thread")] +async fn vendor_over_hosted_without_ledger_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let Some((proj, cargo_home, version, crate_dir)) = stage_fixture(tmp.path()) else { + return; + }; + let purl = format!("pkg:cargo/{DEP}@{version}"); + let orig = std::fs::read(crate_dir.join("src/lib.rs")).unwrap(); + let patched: Vec = [orig.as_slice(), PATCH_SUFFIX.as_bytes()].concat(); + + let server = MockServer::start().await; + let crate_bytes = + build_patched_crate(&tmp.path().join("stage"), &crate_dir, &version, &patched); + mount_hosted_mocks(&server, &purl, &version, &crate_bytes, &orig, &patched).await; + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ], + &cargo_home, + ); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + + // The revert data is gone. + std::fs::remove_file(proj.join(".socket/vendor/redirect-state.json")).unwrap(); + let toml_before = read(&proj, "Cargo.toml"); + let lock_before = read(&proj, "Cargo.lock"); + + stage_patch(&proj, &purl, &orig, &patched); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + &cargo_home, + ); + assert_eq!(code, 1, "must fail closed: {stdout}\n{stderr}"); + assert!( + stdout.contains("hosted_redirect_live"), + "actionable refusal code missing: {stdout}" + ); + // Nothing was half-applied: the hosted wiring is untouched and no + // vendored artifact/wiring was created. + assert_eq!(read(&proj, "Cargo.toml"), toml_before); + assert_eq!(read(&proj, "Cargo.lock"), lock_before); + assert!(!read(&proj, ".cargo/config.toml").contains("[patch.crates-io]")); + assert!(!vendor_ledger_claims(&proj, &purl)); +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0ff40eb5..af230458 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -25,7 +25,9 @@ use crate::vendor::yarn_berry_lock::yarnrc_compression_level; pub mod golang_local; mod state; -pub use state::{load_redirect_state, RedirectState, REDIRECT_STATE_REL}; +mod takeover; +pub use state::{load_redirect_state, persist_redirect_state, RedirectState, REDIRECT_STATE_REL}; +pub use takeover::{revert_cargo_redirect_purl, CargoRedirectRevert}; /// One ecosystem's integrity hashes (mirrors the TS `PatchArtifactIntegrity`). #[derive(Debug, Clone, Default, Deserialize)] diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index a3c280b5..5e59c308 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -64,6 +64,30 @@ pub async fn load_redirect_state(project_root: &Path) -> Option { serde_json::from_slice(&bytes).ok() } +/// Persist the redirect ledger — pretty JSON + trailing newline, matching the +/// hosted flow's writer. An EMPTY ledger (no edits, no records) is DELETED +/// instead: a residual empty file would keep takeover-overlap detection and +/// VEX reading a ledger that asserts nothing. +pub async fn persist_redirect_state( + project_root: &Path, + state: &RedirectState, +) -> std::io::Result<()> { + let path = project_root.join(REDIRECT_STATE_REL); + if state.edits.is_empty() && state.records.is_empty() { + match tokio::fs::remove_file(&path).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + return Ok(()); + } + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let json = serde_json::to_string_pretty(state).map_err(std::io::Error::other)?; + tokio::fs::write(&path, format!("{json}\n")).await +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs new file mode 100644 index 00000000..fc1ee16f --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -0,0 +1,403 @@ +//! Cross-mode takeover: per-purl revert of a HOSTED cargo redirect, driven by +//! the redirect ledger's recorded [`FileEdit`]s. +//! +//! The vendored flows (`vendor`, `scan --mode vendored`) call this BEFORE +//! vendoring a package the hosted redirect ledger still claims, so a +//! hosted→vendored migration leaves the project FULLY in vendored mode: +//! Cargo.toml loses its `registry = "socket-patch-…"` pin, Cargo.lock gets its +//! original crates.io `source`/`checksum` back (so the subsequent vendor +//! detach records the PRISTINE originals in the vendor ledger, not the hosted +//! values), and the now-unused `[registries.socket-patch-…]` block is dropped. +//! Without this, `[patch.crates-io]` cannot even apply (it only patches +//! crates-io-sourced deps) and the project is unbuildable in both modes. +//! +//! FAIL CLOSED: a file that matches neither the recorded redirected fragment +//! nor the recorded original has drifted — the revert refuses (`Err`) rather +//! than half-applying, and the caller must then refuse to vendor that purl. + +use std::collections::HashSet; +use std::path::Path; + +use serde_json::Value; + +use crate::utils::purl::{normalize_purl, parse_cargo_purl, strip_purl_qualifiers}; + +use super::state::RedirectState; +use super::FileEdit; + +/// What [`revert_cargo_redirect_purl`] rewrote. +#[derive(Debug, Default)] +pub struct CargoRedirectRevert { + /// Repo-relative files this revert actually rewrote or removed. + pub reverted_files: Vec, +} + +/// Read a project file, distinguishing missing (`Ok(None)`) from unreadable. +async fn read_rel(project_root: &Path, rel: &str) -> Result, String> { + match tokio::fs::read_to_string(project_root.join(rel)).await { + Ok(c) => Ok(Some(c)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("read {rel}: {e}")), + } +} + +async fn write_rel(project_root: &Path, rel: &str, content: &str) -> Result<(), String> { + tokio::fs::write(project_root.join(rel), content) + .await + .map_err(|e| format!("write {rel}: {e}")) +} + +/// Revert every hosted-redirect edit the ledger records for `purl` (a cargo +/// package), then drop that purl's record and edits from `state`. The caller +/// persists the mutated ledger (see `persist_redirect_state`). +/// +/// Chained re-redirects (the same purl redirected at successive patch uuids) +/// unwind newest-first: each edit's `new` fragment is replaced by its +/// `original`, and an intermediate edit whose `original` is already live is a +/// no-op. `[registries.socket-patch-…]` blocks tied to this purl's uuids are +/// removed only when nothing in Cargo.toml / Cargo.lock still references them. +pub async fn revert_cargo_redirect_purl( + project_root: &Path, + state: &mut RedirectState, + purl: &str, +) -> Result { + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let target = canon(purl); + let Some(record_key) = state.records.keys().find(|k| canon(k) == target).cloned() else { + return Err(format!( + "the redirect ledger records no hosted redirect for {purl}" + )); + }; + let Some((name, version)) = parse_cargo_purl(&target) else { + return Err(format!("not a cargo purl: {purl}")); + }; + let (name, version) = (name.to_string(), version.to_string()); + let lock_key = format!("{name}@{version}"); + + let is_wiring_edit = |e: &FileEdit| { + (e.kind == "redirect_cargo_toml_dep" && e.key.as_deref() == Some(name.as_str())) + || (e.kind == "redirect_cargo_lock_entry" + && e.key.as_deref() == Some(lock_key.as_str())) + }; + // Registry blocks tie to this purl via the `socket-patch-` names in + // its record + wiring edits (a patch uuid is per purl, so this cannot + // claim another package's block). + let mut uuids: HashSet = HashSet::new(); + uuids.insert(state.records[&record_key].uuid.clone()); + let uuid_re = + regex::Regex::new(r"socket-patch-([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})") + .expect("static regex"); + for e in state.edits.iter().filter(|e| is_wiring_edit(e)) { + for v in [&e.original, &e.new] { + if let Some(s) = v.as_ref().and_then(Value::as_str) { + for c in uuid_re.captures_iter(s) { + uuids.insert(c[1].to_string()); + } + } + } + } + let is_registry_edit = |e: &FileEdit| { + e.kind == "redirect_cargo_registry" + && e.key + .as_deref() + .and_then(|k| k.strip_prefix("socket-patch-")) + .is_some_and(|u| uuids.contains(u)) + }; + + let mine: Vec = state + .edits + .iter() + .enumerate() + .filter(|(_, e)| is_wiring_edit(e) || is_registry_edit(e)) + .map(|(i, _)| i) + .collect(); + + let mut out = CargoRedirectRevert::default(); + // Newest-first: the hosted flow appends edits, so reverse index order + // unwinds re-redirect chains correctly (each step's `original` is the + // previous step's `new`), and the registry-block removals — recorded + // before their wiring edits — run last, after the references are gone. + for &i in mine.iter().rev() { + let edit = state.edits[i].clone(); + match edit.kind.as_str() { + "redirect_cargo_toml_dep" | "redirect_cargo_lock_entry" => { + let (Some(new), Some(orig)) = ( + edit.new.as_ref().and_then(Value::as_str), + edit.original.as_ref().and_then(Value::as_str), + ) else { + return Err(format!( + "the redirect ledger edit for {} in {} records no original \ + fragment; cannot revert the hosted redirect", + name, edit.path + )); + }; + let Some(content) = read_rel(project_root, &edit.path).await? else { + return Err(format!( + "{} no longer exists; cannot revert the recorded hosted \ + redirect for {name}@{version}", + edit.path + )); + }; + if content.contains(new) { + let reverted = content.replacen(new, orig, 1); + write_rel(project_root, &edit.path, &reverted).await?; + out.reverted_files.push(edit.path.clone()); + } else if content.contains(orig) { + // Already at (or unwound to) the pre-redirect fragment. + } else { + return Err(format!( + "the {} entry for {name}@{version} has drifted from the \ + recorded hosted redirect (neither the redirected nor the \ + original fragment is present); refusing to touch it — \ + re-run `scan --mode hosted` to normalize the redirect, \ + or restore the crates.io wiring manually, then re-run", + edit.path + )); + } + } + "redirect_cargo_registry" => { + let Some(block) = edit.new.as_ref().and_then(Value::as_str) else { + continue; // nothing recorded to remove — leave the config + }; + let Some(content) = read_rel(project_root, &edit.path).await? else { + continue; // config already gone + }; + if !content.contains(block) { + continue; // block already removed + } + // Keep the block while anything still references its registry + // name or index URL (defensive — a hand-edited project may + // have pinned another dep to it). + let reg = edit.key.as_deref().unwrap_or_default(); + let index = block + .split('"') + .nth(1) + .map(str::to_string) + .unwrap_or_default(); + let mut referenced = false; + for probe in ["Cargo.toml", "Cargo.lock"] { + if let Some(text) = read_rel(project_root, probe).await? { + if (!reg.is_empty() && text.contains(reg)) + || (!index.is_empty() && text.contains(&index)) + { + referenced = true; + break; + } + } + } + if referenced { + continue; + } + let mut trimmed = content.replacen(block, "", 1); + // Collapse the blank separator the rewrite inserted. + while trimmed.contains("\n\n\n") { + trimmed = trimmed.replace("\n\n\n", "\n\n"); + } + let trimmed = trimmed.trim_start_matches('\n').to_string(); + if trimmed.trim().is_empty() { + let path = project_root.join(&edit.path); + match tokio::fs::remove_file(&path).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("remove {}: {e}", edit.path)), + } + // Best-effort: prune a now-empty `.cargo/` dir. + if let Some(parent) = path.parent() { + let _ = tokio::fs::remove_dir(parent).await; + } + } else { + write_rel(project_root, &edit.path, &trimmed).await?; + } + out.reverted_files.push(edit.path.clone()); + } + _ => {} + } + } + + // Only after every inverse applied cleanly: drop this purl's edits and + // record from the ledger (the caller persists it). + let drop: HashSet = mine.into_iter().collect(); + let mut idx = 0usize; + state.edits.retain(|_| { + let keep = !drop.contains(&idx); + idx += 1; + keep + }); + state.records.remove(&record_key); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::schema::PatchRecord; + use std::collections::BTreeMap; + use std::collections::HashMap; + + const UUID: &str = "6b7c8d9e-0f1a-4a1b-8c2d-3e4f5a6b7c8d"; + const PURL: &str = "pkg:cargo/cfg-if@1.0.4"; + const INDEX: &str = "sparse+http://127.0.0.1:5555/index/"; + const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; + + fn record() -> PatchRecord { + PatchRecord { + uuid: UUID.to_string(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + } + } + + fn pristine_toml() -> String { + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ncfg-if = \"1.0\"\n" + .to_string() + } + + fn pristine_lock_block() -> String { + format!( + "[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CRATES_IO}\"\nchecksum = \"{}\"", + "9".repeat(64) + ) + } + + /// Run the real hosted rewriter over a pristine project, write its output + /// to a tempdir, and return the resulting ledger — the exact state the + /// takeover revert consumes in production. + async fn redirected_fixture() -> (tempfile::TempDir, RedirectState) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let lock = format!( + "# This file is automatically @generated by Cargo.\nversion = 4\n\n{}\n", + pristine_lock_block() + ); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), pristine_toml()); + files.insert("Cargo.lock".into(), lock.clone()); + let dep: crate::patch::redirect::DepOverride = serde_json::from_value(serde_json::json!({ + "ecosystem": "cargo", + "name": "cfg-if", + "version": "1.0.4", + "token": "tok", + "patchUuid": UUID, + "artifactUrl": format!("http://127.0.0.1:5555/cfg-if-1.0.4.crate"), + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": INDEX, + "identifiers": { + "name": "cfg-if", "version": "1.0.4", + "cargoCksumSha256": "a".repeat(64), + }, + }, + "integrity": { "sha256": "a".repeat(64) }, + })) + .unwrap(); + let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &[dep]); + tokio::fs::write(root.join("Cargo.toml"), &pristine_toml()) + .await + .unwrap(); + tokio::fs::write(root.join("Cargo.lock"), &lock) + .await + .unwrap(); + for (rel, content) in &rewrite.files { + let path = root.join(rel); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.unwrap(); + } + tokio::fs::write(&path, content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + state.records.insert(PURL.to_string(), record()); + (tmp, state) + } + + #[tokio::test] + async fn reverts_toml_lock_and_registry_block_and_drops_ledger_entries() { + let (tmp, mut state) = redirected_fixture().await; + let root = tmp.path(); + // Sanity: the fixture really is hosted-wired. + let toml = tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(); + assert!(toml.contains("socket-patch-"), "{toml}"); + + let out = revert_cargo_redirect_purl(root, &mut state, PURL) + .await + .expect("revert succeeds"); + assert!(!out.reverted_files.is_empty()); + + let toml = tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(); + assert_eq!(toml, pristine_toml(), "Cargo.toml restored byte-identical"); + let lock = tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(); + assert!( + lock.contains(CRATES_IO), + "crates.io source restored: {lock}" + ); + assert!(!lock.contains("sparse+"), "hosted index gone: {lock}"); + assert!( + !root.join(".cargo/config.toml").exists(), + "socket-only config removed" + ); + assert!(state.records.is_empty(), "record dropped"); + assert!(state.edits.is_empty(), "edits dropped"); + } + + #[tokio::test] + async fn preserves_user_config_content_when_removing_the_registry_block() { + let (tmp, mut state) = redirected_fixture().await; + let root = tmp.path(); + // Prepend user content to the config the rewrite created. + let cfg_path = root.join(".cargo/config.toml"); + let cfg = tokio::fs::read_to_string(&cfg_path).await.unwrap(); + tokio::fs::write(&cfg_path, format!("[net]\nretry = 2\n{cfg}")) + .await + .unwrap(); + + revert_cargo_redirect_purl(root, &mut state, PURL) + .await + .expect("revert succeeds"); + let cfg = tokio::fs::read_to_string(&cfg_path).await.unwrap(); + assert!(cfg.contains("[net]"), "user content kept: {cfg}"); + assert!(!cfg.contains("socket-patch-"), "block removed: {cfg}"); + } + + #[tokio::test] + async fn refuses_on_drifted_lock_fail_closed() { + let (tmp, mut state) = redirected_fixture().await; + let root = tmp.path(); + // A third party re-resolved the lock to a shape the ledger never saw. + tokio::fs::write( + root.join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"registry+https://corp.example/index\"\n", + ) + .await + .unwrap(); + let records_before = state.records.len(); + let edits_before = state.edits.len(); + + let err = revert_cargo_redirect_purl(root, &mut state, PURL) + .await + .expect_err("drifted lock must refuse"); + assert!(err.contains("drifted"), "{err}"); + // The ledger keeps everything on refusal. + assert_eq!(state.records.len(), records_before); + assert_eq!(state.edits.len(), edits_before); + } + + #[tokio::test] + async fn missing_record_is_an_error() { + let tmp = tempfile::tempdir().unwrap(); + let mut state = RedirectState::new(); + let err = revert_cargo_redirect_purl(tmp.path(), &mut state, PURL) + .await + .expect_err("no record"); + assert!(err.contains("records no hosted redirect"), "{err}"); + } +} diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 86c630e5..6038c6a6 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -64,6 +64,73 @@ fn is_legacy_redirect_path(path: &str) -> bool { norm.starts_with(&format!("{LEGACY_CARGO_PATCHES_DIR}/")) } +/// Is this vendored cargo entry still consumed by the project's `Cargo.lock` +/// dependency graph? The lock is the truth source: +/// +/// * entry absent from the lock → `Some(false)` (the dependency left the +/// graph; the `[patch]` would be unused); +/// * entry carries a registry `source` (crates.io re-resolve or a hosted +/// socket-patch takeover) → `Some(false)` — the committed copy is NOT what +/// the lock consumes, so GC may reclaim the entry (its revert restores / +/// keeps the registry resolution and drops the dead `[patch]` wiring); +/// * entry detached AND the `[patch.crates-io]` entry points at THIS entry's +/// committed copy → `Some(true)` (the wired vendored shape); +/// * detached but the `[patch]` points elsewhere / is gone → `Some(false)` +/// (nothing consumes the copy; the revert re-attaches the recorded +/// registry originals, repairing the half-wired lock); +/// * no readable lock → `None` (cannot determine — callers keep, fail-safe). +pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { + let (name, version) = parse_cargo_purl(&entry.base_purl)?; + match cargo_lock::probe_lock_entry(project_root, name, version).await { + cargo_lock::LockEntryProbe::NoLockfile => None, + cargo_lock::LockEntryProbe::EntryMissing => Some(false), + cargo_lock::LockEntryProbe::Source(_) => Some(false), + cargo_lock::LockEntryProbe::Detached => { + let marker = vendor_uuid_dir_rel("cargo", &entry.uuid)?; + let entries = cargo_config::read_patch_entries(project_root).await; + let wired = entries + .get(name) + .and_then(|i| i.path.as_deref()) + .is_some_and(|p| p.replace('\\', "/").starts_with(&format!("{marker}/"))); + Some(wired) + } + } +} + +/// A LIVE hosted-redirect wiring for `name`+`version`: the lock resolves it +/// from a Socket hosted patch registry, or Cargo.toml pins it to a +/// `socket-patch-` registry (the shapes `scan --mode hosted` writes). +/// Registry indexes are matched against the config-declared +/// `[registries.socket-patch-*]` URLs, not a hardcoded host, so test +/// registries are recognised too. `Some(description)` when residue is found. +async fn hosted_redirect_residue(project_root: &Path, name: &str, version: &str) -> Option { + let socket_indexes = cargo_config::socket_registry_indexes(project_root).await; + if let cargo_lock::LockEntryProbe::Source(src) = + cargo_lock::probe_lock_entry(project_root, name, version).await + { + if src.contains("patch.socket.dev") || socket_indexes.iter().any(|(_, index)| *index == src) + { + return Some(format!( + "Cargo.lock resolves {name}@{version} from the Socket hosted patch \ + registry ({src})" + )); + } + } + if let Ok(toml) = tokio::fs::read_to_string(project_root.join("Cargo.toml")).await { + let c = regex::escape(name); + let re = regex::Regex::new(&format!( + r#"(?m)^\s*{c}\s*=\s*\{{[^}}\n]*registry\s*=\s*"socket-patch-[0-9a-fA-F-]{{36}}""# + )) + .expect("static regex"); + if re.is_match(&toml) { + return Some(format!( + "Cargo.toml pins `{name}` to a socket-patch hosted registry" + )); + } + } + None +} + /// The config `[patch]` entry points at THIS copy and the lock entry no /// longer needs detaching: either there is no lockfile (nothing to edit — the /// first build generates a path-form lock), or the entry exists with no @@ -391,6 +458,30 @@ pub async fn vendor_cargo_crate( return done(result, None, dry_warnings); } + // Cross-mode takeover guard (fail-closed): a LIVE hosted-redirect wiring + // for this crate must be reverted from the redirect ledger BEFORE + // vendoring — the CLI vendored flows do exactly that. Reaching this point + // with the residue still present means the redirect ledger is missing or + // corrupt (no recorded originals to revert with); proceeding would bake + // the hosted registry values into this entry's lock originals as if they + // were pristine, leave Cargo.toml pinned to the hosted registry, and + // report success on an unbuildable half-migrated project. Refuse with the + // manual remediation instead. Runs after the dry-run branch: a preview + // must not report the wet run's ledger-driven revert as a failure. + if let Some(residue) = hosted_redirect_residue(project_root, name, version).await { + return refused( + "hosted_redirect_live", + format!( + "{residue}, but no redirect ledger record can revert it \ + (.socket/vendor/redirect-state.json is missing or does not \ + record this package); restore the ledger, or manually remove \ + the `registry = \"socket-patch-…\"` key from Cargo.toml, \ + restore the crates.io source/checksum in Cargo.lock, and drop \ + the `[registries.socket-patch-…]` block, then re-run" + ), + ); + } + // Hot path: already in sync → touch nothing (entry stays with the caller's // existing ledger record, which holds the unrecoverable lock originals). if wiring_in_sync(project_root, name, version, ©_rel).await { @@ -1828,4 +1919,157 @@ mod tests { .await; expect_refused(outcome, "vendor_service_offline_conflict"); } + + // ── cross-mode takeover: in-use probe + fail-closed hosted guard ───── + + fn ledger_entry_for(uuid: &str) -> VendorEntry { + VendorEntry { + ecosystem: "cargo".into(), + base_purl: PURL.into(), + uuid: uuid.into(), + artifact: VendorArtifact { + path: format!(".socket/vendor/cargo/{uuid}/cfg-if-1.0.4"), + sha256: String::new(), + size: None, + platform_locked: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: None, + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + } + } + + /// The lockfile-in-use probe for cargo (GC/prune reclaim): detached lock + /// + our `[patch]` = in use; a registry source (hosted takeover or a + /// crates.io re-resolve), a missing entry, or a foreign `[patch]` target + /// = reclaimable; no lock = undeterminable (keep, fail-safe). + #[tokio::test] + async fn test_vendored_entry_in_use_probe() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let entry_probe = ledger_entry_for(UUID); + + // No lockfile: undeterminable. + tokio::fs::remove_file(root.join("Cargo.lock")) + .await + .unwrap(); + assert_eq!(vendored_entry_in_use(&entry_probe, root).await, None); + tokio::fs::write(root.join("Cargo.lock"), lock_body()) + .await + .unwrap(); + + // Registry-sourced (pre-vendor / re-resolved): not consumed. + assert_eq!(vendored_entry_in_use(&entry_probe, root).await, Some(false)); + + // Fully vendored: detached lock + our [patch] entry ⇒ in use. + let (result, entry, _w) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert_eq!(vendored_entry_in_use(&entry, root).await, Some(true)); + + // Hosted takeover shape: the lock re-sourced to a socket-patch sparse + // index (the [patch] entry survives, but nothing consumes the copy). + tokio::fs::write( + root.join("Cargo.lock"), + format!( + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"sparse+http://127.0.0.1:5555/index/\"\nchecksum = \"{}\"\n", + "a".repeat(64) + ), + ) + .await + .unwrap(); + assert_eq!(vendored_entry_in_use(&entry, root).await, Some(false)); + + // Dependency left the lock graph entirely: reclaimable. + tokio::fs::write( + root.join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .await + .unwrap(); + assert_eq!(vendored_entry_in_use(&entry, root).await, Some(false)); + + // Detached lock but the [patch] points at ANOTHER uuid's copy: this + // entry's artifact is not what the lock consumes. + tokio::fs::write( + root.join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n", + ) + .await + .unwrap(); + assert_eq!( + vendored_entry_in_use(&ledger_entry_for(UUID2), root).await, + Some(false) + ); + } + + /// FAIL CLOSED: vendoring over a LIVE hosted redirect with no ledger to + /// revert it must refuse — proceeding would record the hosted registry + /// values as the entry's "originals" and leave Cargo.toml pinned to the + /// hosted registry (unbuildable in both modes) while reporting success. + #[tokio::test] + async fn test_refuses_live_hosted_redirect_without_ledger() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let index = "sparse+http://127.0.0.1:5555/index/"; + // The hosted rewriter's output shapes: registry pin in Cargo.toml, + // socket-patch registries block, lock re-sourced to the index. + tokio::fs::write( + root.join("Cargo.toml"), + format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ncfg-if = {{ version = \"1\", registry = \"socket-patch-{UUID}\" }}\n" + ), + ) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + tokio::fs::write( + root.join(".cargo/config.toml"), + format!("[registries.socket-patch-{UUID}]\nindex = \"{index}\"\n"), + ) + .await + .unwrap(); + tokio::fs::write( + root.join("Cargo.lock"), + format!( + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{index}\"\nchecksum = \"{}\"\n", + "a".repeat(64) + ), + ) + .await + .unwrap(); + + let detail = expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "hosted_redirect_live", + ); + assert!(detail.contains("redirect-state.json"), "{detail}"); + // Nothing was half-vendored. + assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); + + // The Cargo.toml pin ALONE (lock already detached — the legacy + // hosted→vendored terminal state) is refused too: the in-sync hot + // path must not report already_vendored over a broken manifest pin. + tokio::fs::write( + root.join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n", + ) + .await + .unwrap(); + expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "hosted_redirect_live", + ); + } } diff --git a/crates/socket-patch-core/src/vendor/cargo_config.rs b/crates/socket-patch-core/src/vendor/cargo_config.rs index d2557ba1..a1a21529 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -96,6 +96,41 @@ pub async fn read_patch_entries(project_root: &Path) -> HashMap]` sparse-index URLs +/// declared in the project's cargo config, as `(registry_name, index_url)` +/// pairs. Reads BOTH `.cargo/config` and `.cargo/config.toml` (a mixed / +/// legacy state may hold blocks in either file). Read-only; missing or +/// malformed files contribute nothing. This is how takeover logic proves a +/// `Cargo.lock` `source` points at Socket's hosted patch registry without +/// depending on the index URL's host (test registries are localhost). +pub async fn socket_registry_indexes(project_root: &Path) -> Vec<(String, String)> { + let mut out = Vec::new(); + for file in [".cargo/config", ".cargo/config.toml"] { + let Ok(content) = fs::read_to_string(project_root.join(file)).await else { + continue; + }; + let Ok(doc) = content.parse::() else { + continue; + }; + let Some(registries) = doc.get("registries").and_then(Item::as_table_like) else { + continue; + }; + for (name, item) in registries.iter() { + if !name.starts_with("socket-patch-") { + continue; + } + let index = item + .as_table_like() + .and_then(|t| t.get("index")) + .and_then(Item::as_str); + if let Some(index) = index { + out.push((name.to_string(), index.to_string())); + } + } + } + out +} + // ── config-file resolution + read-or-create write ──────────────────────────── /// Resolve the config file under `/.cargo/`. Prefers an existing diff --git a/crates/socket-patch-core/src/vendor/cargo_lock.rs b/crates/socket-patch-core/src/vendor/cargo_lock.rs index 8cb5f4e1..2b3014cb 100644 --- a/crates/socket-patch-core/src/vendor/cargo_lock.rs +++ b/crates/socket-patch-core/src/vendor/cargo_lock.rs @@ -201,6 +201,39 @@ pub async fn read_locked_versions(project_root: &Path) -> Option LockEntryProbe { + let Ok((_path, mut doc)) = read_lock(project_root).await else { + return LockEntryProbe::NoLockfile; + }; + let Some(table) = find_package_mut(&mut doc, name, version) else { + return LockEntryProbe::EntryMissing; + }; + match table.get("source").and_then(Item::as_str) { + Some(s) => LockEntryProbe::Source(s.to_string()), + None => LockEntryProbe::Detached, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 74b382b8..e8175f9f 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -50,7 +50,7 @@ pub mod bun_lock; pub(crate) mod bun_lock_text; pub mod cargo; pub mod cargo_config; -pub(crate) mod cargo_lock; +pub mod cargo_lock; pub(crate) mod common; pub mod composer_lock; pub mod gem; From 2ec77aad682c187e79ed8a3bc174be7ab33755a2 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 14 Aug 2026 20:12:34 -0400 Subject: [PATCH 2/3] fix(takeover): stage cargo revert writes revert_cargo_redirect_purl unwound the ledger's edits newest-first and wrote each inverse to disk as it went, so a later drifted edit returned Err with the earlier ones already written. Reverting a hosted redirect whose Cargo.toml a third party had re-pinned restored Cargo.lock to crates.io and then refused on the manifest, leaving the crate pinned to the socket-patch registry with a lock that no longer resolves there -- a project that builds in neither mode. The ledger still recorded the redirect as live and the caller reported the package as untouched ("cannot vendor over the live hosted redirect"), so nothing pointed at the half-reverted files and every retry refused on the same drift. Each inverse is now resolved against a staged view of the files and nothing is written until all of them resolve. Reads go through the staged map, which keeps the two orderings the unwind depends on: a re-redirect chain still sees the previous step's result, and the registry block still sees the wiring reverts when it probes whether anything references it. Only an I/O fault during the final flush can still stop mid-set, and that surfaces as Err naming the file. The new unit test drifts only Cargo.toml, which unwinds after Cargo.lock, and asserts all three files are byte-identical after the refusal; it fails on the previous code with Cargo.lock already rewritten. Co-authored-by: Claude Fable 5 --- .../src/patch/redirect/takeover.rs | 138 +++++++++++++++--- 1 file changed, 121 insertions(+), 17 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index b18743df..325de7d3 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -14,8 +14,15 @@ //! FAIL CLOSED: a file that matches neither the recorded redirected fragment //! nor the recorded original has drifted — the revert refuses (`Err`) rather //! than half-applying, and the caller must then refuse to vendor that purl. +//! Refusing has to leave the project byte-identical across ALL the files the +//! ledger claims, not just the one that drifted: the caller reports the purl +//! as untouched ("cannot vendor over the live hosted redirect"), so an +//! already-rewritten Cargo.lock behind that message would be a half-hosted +//! project nobody is told about, and every retry refuses on the same drift. +//! So each inverse is resolved against a staged view and NOTHING reaches disk +//! until all of them have resolved. -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::path::Path; use serde_json::Value; @@ -47,6 +54,50 @@ async fn write_rel(project_root: &Path, rel: &str, content: &str) -> Result<(), .map_err(|e| format!("write {rel}: {e}")) } +/// Files the unwind has decided but not yet written: `Some(content)` to +/// write, `None` to remove. +type Staged = BTreeMap>; + +/// Read a project file through the staged writes, so each unwind step sees +/// what the earlier steps decided. Both the re-redirect chain (a step's +/// `original` is the previous step's `new`) and the registry block's +/// still-referenced probe depend on that view, and neither may depend on the +/// bytes having landed. +async fn staged_read( + staged: &Staged, + project_root: &Path, + rel: &str, +) -> Result, String> { + match staged.get(rel) { + Some(pending) => Ok(pending.clone()), + None => read_rel(project_root, rel).await, + } +} + +/// Write the staged files. Only reached once every inverse resolved, so a +/// drift refusal never gets here; an I/O fault partway through is the one +/// remaining way to stop mid-set, and it surfaces as `Err` with the write +/// already reported by path. +async fn flush_staged(project_root: &Path, staged: &Staged) -> Result<(), String> { + for (rel, pending) in staged { + let Some(content) = pending else { + let path = project_root.join(rel); + match tokio::fs::remove_file(&path).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("remove {rel}: {e}")), + } + // Best-effort: prune a now-empty `.cargo/` dir. + if let Some(parent) = path.parent() { + let _ = tokio::fs::remove_dir(parent).await; + } + continue; + }; + write_rel(project_root, rel, content).await?; + } + Ok(()) +} + /// Revert every hosted-redirect edit the ledger records for `purl` (a cargo /// package), then drop that purl's record and edits from `state`. The caller /// persists the mutated ledger (see `persist_redirect_state`). @@ -113,6 +164,7 @@ pub async fn revert_cargo_redirect_purl( .collect(); let mut out = CargoRedirectRevert::default(); + let mut staged: Staged = Staged::new(); // Newest-first: the hosted flow appends edits, so reverse index order // unwinds re-redirect chains correctly (each step's `original` is the // previous step's `new`), and the registry-block removals — recorded @@ -131,7 +183,7 @@ pub async fn revert_cargo_redirect_purl( name, edit.path )); }; - let Some(content) = read_rel(project_root, &edit.path).await? else { + let Some(content) = staged_read(&staged, project_root, &edit.path).await? else { return Err(format!( "{} no longer exists; cannot revert the recorded hosted \ redirect for {name}@{version}", @@ -140,7 +192,7 @@ pub async fn revert_cargo_redirect_purl( }; if content.contains(new) { let reverted = content.replacen(new, orig, 1); - write_rel(project_root, &edit.path, &reverted).await?; + staged.insert(edit.path.clone(), Some(reverted)); out.reverted_files.push(edit.path.clone()); } else if content.contains(orig) { // Already at (or unwound to) the pre-redirect fragment. @@ -159,7 +211,7 @@ pub async fn revert_cargo_redirect_purl( let Some(block) = edit.new.as_ref().and_then(Value::as_str) else { continue; // nothing recorded to remove — leave the config }; - let Some(content) = read_rel(project_root, &edit.path).await? else { + let Some(content) = staged_read(&staged, project_root, &edit.path).await? else { continue; // config already gone }; if !content.contains(block) { @@ -176,7 +228,7 @@ pub async fn revert_cargo_redirect_purl( .unwrap_or_default(); let mut referenced = false; for probe in ["Cargo.toml", "Cargo.lock"] { - if let Some(text) = read_rel(project_root, probe).await? { + if let Some(text) = staged_read(&staged, project_root, probe).await? { if (!reg.is_empty() && text.contains(reg)) || (!index.is_empty() && text.contains(&index)) { @@ -194,7 +246,7 @@ pub async fn revert_cargo_redirect_purl( // of deleting it: the original bytes are the user's. if let Some(orig) = edit.original.as_ref().and_then(Value::as_str) { let reverted = content.replacen(block, orig, 1); - write_rel(project_root, &edit.path, &reverted).await?; + staged.insert(edit.path.clone(), Some(reverted)); out.reverted_files.push(edit.path.clone()); continue; } @@ -205,18 +257,9 @@ pub async fn revert_cargo_redirect_purl( } let trimmed = trimmed.trim_start_matches('\n').to_string(); if trimmed.trim().is_empty() { - let path = project_root.join(&edit.path); - match tokio::fs::remove_file(&path).await { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("remove {}: {e}", edit.path)), - } - // Best-effort: prune a now-empty `.cargo/` dir. - if let Some(parent) = path.parent() { - let _ = tokio::fs::remove_dir(parent).await; - } + staged.insert(edit.path.clone(), None); } else { - write_rel(project_root, &edit.path, &trimmed).await?; + staged.insert(edit.path.clone(), Some(trimmed)); } out.reverted_files.push(edit.path.clone()); } @@ -224,6 +267,10 @@ pub async fn revert_cargo_redirect_purl( } } + // Every inverse resolved — only now does any of it reach disk, so a + // refusal above left the project exactly as it was found. + flush_staged(project_root, &staged).await?; + // Only after every inverse applied cleanly: drop this purl's edits and // record from the ledger (the caller persists it). let drop: HashSet = mine.into_iter().collect(); @@ -401,6 +448,63 @@ mod tests { assert_eq!(state.edits.len(), edits_before); } + /// The unwind runs newest-first (edits are recorded config, manifest, + /// lock), so Cargo.lock's inverse resolves BEFORE Cargo.toml's. Drifting + /// only Cargo.toml therefore refuses at a point where the lock's inverse + /// has already been decided — and the caller reports the purl as + /// untouched ("cannot vendor over the live hosted redirect"), so a + /// revert that had written the lock by then would leave the project + /// half-hosted behind a message saying nothing happened. + #[tokio::test] + async fn a_later_drifted_edit_leaves_every_earlier_file_untouched() { + let (tmp, mut state) = redirected_fixture().await; + let root = tmp.path(); + let drifted_toml = + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ncfg-if = { version = \"1.0\", registry = \"corp-mirror\" }\n"; + tokio::fs::write(root.join("Cargo.toml"), drifted_toml) + .await + .unwrap(); + let lock_before = tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(); + let cfg_before = tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap(); + assert!( + lock_before.contains("sparse+"), + "fixture is hosted-wired: {lock_before}" + ); + + let err = revert_cargo_redirect_purl(root, &mut state, PURL) + .await + .expect_err("drifted manifest must refuse"); + assert!(err.contains("drifted"), "{err}"); + + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock_before, + "Cargo.lock must be untouched — its inverse resolved before the refusal" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + drifted_toml, + "Cargo.toml untouched" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap(), + cfg_before, + ".cargo/config.toml untouched" + ); + assert!(!state.records.is_empty(), "ledger keeps the record"); + assert!(!state.edits.is_empty(), "ledger keeps the edits"); + } + #[tokio::test] async fn missing_record_is_an_error() { let tmp = tempfile::tempdir().unwrap(); From 8ef6efa45e18b09799ebdaaa230b80602d911e98 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 14 Aug 2026 20:45:26 -0400 Subject: [PATCH 3/3] fix(scan): lead takeover cleanup with auto-reconcile Refines the scan/mod.rs conflict resolution in the preceding merge of #187 and #191, which crossed with this branch's takeover work. The vendored-direction remediation had taken #191's text verbatim, which tells the operator to hand-edit .socket/vendor/redirect-state.json and delete each superseded package's `records` entry plus its matching `edits`. #191 could not have known this branch makes that automatic: re-running `socket-patch vendor` (or `scan --mode vendored`) now reverts the stale hosted edits from the ledger and drops both halves of the entry itself. The text leads with that and keeps #191's by-hand procedure as the fallback, along with its records-AND-edits reasoning and its never-delete-the-ledger-file warning. Restores the comment explaining why the ledger is never offered up for hand deletion. The hosted direction keeps #191's per-package `remove ` guidance; its comment now also records why `vendor --revert` is not offered -- it unwinds every vendored package, including ones still live in the lockfile. Both sides' tests pass together: #191's following_the_vendored_remediation_clears_the_warning and hosted_remediation_states_removes_full_blast_radius alongside this branch's cargo classifier and GC reclaim tests. Co-authored-by: Claude Fable 5 --- .../socket-patch-cli/src/commands/scan/mod.rs | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 615aa0b8..93b76485 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -642,12 +642,12 @@ pub(super) async fn classify_overlap_takeover(cwd: &Path) -> OverlapTakeover { for purl in overlap { // Cargo needs its own probe: the scan inventory records `resolved: // None` for every cargo entry (a Cargo.lock `source` is an index URL, - // not a tarball URL), and the generic `vendored_wiring_live` substring - // scan keeps matching the leftover `[patch.crates-io]` marker after a - // hosted takeover — INVERTING the direction (the takeover-direction - // bug, cargo edition). The cargo classifier reads the lock entry's - // actual shape instead — the lock is the one file BOTH modes rewire, - // in mutually exclusive ways. + // not a tarball URL), so `hosted_wiring_live`'s inventory proof can + // never fire for cargo — and the vendored substring scan alone then + // INVERTS the direction after a hosted takeover (the + // takeover-direction bug, cargo edition). The cargo classifier reads + // the lock entry's actual shape instead — the lock is the truth + // source both modes rewire, in mutually exclusive ways. let (hosted_live, vendored_live) = if purl.starts_with("pkg:cargo/") { classify_cargo_overlap(cwd, &purl, vendor_by_purl.get(&purl).copied()).await } else { @@ -864,10 +864,12 @@ async fn vendored_wiring_live(cwd: &Path, entry: &socket_patch_core::vendor::Ven pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: bool) -> String { let list = superseded.join(", "); if current_is_hosted { - // Deleting the `.socket/vendor//` tree by hand is NEVER offered: - // for cargo a leftover `[patch.crates-io]` entry still points at that + // NEVER offer deleting the `.socket/vendor//` tree here: for + // cargo the leftover `[patch.crates-io]` entry still points at that // tree, and deleting it hard-fails every cargo invocation ("failed to - // load source for dependency"). + // load source for dependency"). Nor `vendor --revert`, which unwinds + // EVERY vendored package including the ones still live in the + // lockfile — `remove ` is the per-package equivalent. format!( "hosted redirect superseded the vendored ledger for: {list}. \ `.socket/vendor/state.json` still claims these package(s) and their \ @@ -888,22 +890,31 @@ pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: boo would break or be mass-reverted." ) } else { + // NEVER advise deleting the redirect ledger by hand: it may hold the + // only revert data (FileEdit originals) and VEX records for OTHER + // packages that are still hosted-redirected. The vendored flows + // reconcile per package — reverting the stale hosted edits and + // dropping exactly the superseded ledger records. format!( "vendored artifacts superseded the hosted redirect ledger for: {list}. \ `.socket/vendor/redirect-state.json` still records a hosted redirect for \ these package(s), but the lockfile now points at the committed \ - `.socket/vendor/` files. Clean up per package: edit \ - `.socket/vendor/redirect-state.json` and delete only these package(s)' \ - entries under `records` AND their matching entries under `edits`, so \ - audits and VEX do not read superseded wiring. Both halves matter: the \ - leftover `edits` are that package's stale pre-redirect originals, \ - which a later redirect revert would replay over the live vendored \ - wiring — and an `edits` entry left behind still names the package, so \ - a ledger whose last record you just deleted keeps reading as \ - superseded and this warning keeps firing. Do not delete the ledger \ - file itself: it may still hold live redirect records for other \ - package(s), plus the recorded pre-redirect lockfile originals \ - (`edits`) a future revert needs for them." + `.socket/vendor/` files. Re-run `socket-patch vendor` (or `scan \ + --mode vendored`) to reconcile these package(s) automatically: it \ + reverts their stale hosted edits from the ledger and drops both \ + halves of each superseded entry — the `records` entry AND its \ + matching `edits`. To clean up by hand instead, delete only these \ + package(s)' entries under `records` AND their matching entries \ + under `edits`, so audits and VEX do not read superseded wiring. \ + Both halves matter: the leftover `edits` are that package's stale \ + pre-redirect originals, which a later redirect revert would replay \ + over the live vendored wiring — and an `edits` entry left behind \ + still names the package, so a ledger whose last record you just \ + deleted keeps reading as superseded and this warning keeps firing. \ + Do not delete the ledger file itself: it may still hold live \ + redirect records for other package(s), plus the recorded \ + pre-redirect lockfile originals (`edits`) a future revert needs \ + for them." ) } }