diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e7d6450a..14cc0c8e 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -247,15 +247,15 @@ pub(super) async fn run_redirect( } } - // Load the existing redirect ledger BEFORE any file is written — bun - // migration included. The ledger is the only store of the pre-redirect - // originals a future revert needs, so a malformed (torn/hand-mangled) - // ledger must abort the run while the project is still untouched: the old - // tolerant load treated it as "no ledger" and the merge below would have - // started fresh, silently overwriting that revert data. The malformed - // file is moved aside to redirect-state.json.corrupt (never clobbered) - // so recovery stays possible; a dry-run reports the same hard error but - // moves nothing. + // Load the existing redirect ledger BEFORE any file is written — the + // cargo takeover reverts and the bun migration included. The ledger is + // the only store of the pre-redirect originals a future revert needs, so + // a malformed (torn/hand-mangled) ledger must abort the run while the + // project is still untouched: the old tolerant load treated it as "no + // ledger" and the merge below would have started fresh, silently + // overwriting that revert data. The malformed file is moved aside to + // redirect-state.json.corrupt (never clobbered) so recovery stays + // possible; a dry-run reports the same hard error but moves nothing. let existing_ledger = match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await { Ok(state) => state, @@ -272,6 +272,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 @@ -700,6 +860,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()); warnings.extend(prune_warnings.iter().cloned()); // Nest the redirect result under `redirect` inside the classic scan @@ -772,6 +933,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 31ef48e4..93b76485 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -640,12 +640,25 @@ pub(super) async fn classify_overlap_takeover(cwd: &Path) -> OverlapTakeover { } let inventory = socket_patch_core::vendor::lock_inventory::inventory_project(cwd).await; for purl in overlap { - let record_uuid = redirect_uuid_by_purl.get(&purl).copied(); - let hosted_live = - hosted_wiring_live(cwd, &purl, record_uuid, &redirect_files, &inventory).await; - 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 `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 { + let record_uuid = redirect_uuid_by_purl.get(&purl).copied(); + let hosted_live = + hosted_wiring_live(cwd, &purl, record_uuid, &redirect_files, &inventory).await; + 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), @@ -660,6 +673,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 provably wires `purl` to a HOSTED patch /// artifact. Two proofs, tried in order: /// @@ -793,10 +864,16 @@ 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"). 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 \ - 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. \ Clean up per package: run `socket-patch remove ` for each \ package listed above, so audits and VEX do not read superseded \ @@ -813,26 +890,65 @@ 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." ) } } +/// 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); @@ -2219,22 +2335,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 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 5927ed20..5ede5957 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 21c51589..33adc711 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, } } @@ -442,6 +449,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()); @@ -563,8 +576,8 @@ pub(crate) async fn persist_vendor_entry( // stale `.socket/vendor/` pointer), the wiring records for surfaces this // run left in sync (e.g. package.json + pnpm-lock.yaml when only the new // pnpm-workspace.yaml override was added on a pnpm >= 11 upgrade), the - // pnpm created-surface bookkeeping, and the takeover flag. See - // [`vendor::carry_forward_wiring`]. + // pnpm created-surface bookkeeping, the cargo lock originals, and the + // takeover flag. See [`vendor::carry_forward_wiring`]. let prev = state.entries.get(&candidate).cloned(); if let Some(prev) = &prev { vendor::carry_forward_wiring(prev, &mut entry); @@ -903,6 +916,20 @@ 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. A MALFORMED ledger is held as the hard error it is: this loop + // WRITES the ledger for cargo takeovers, and with its records unreadable + // a claimed purl is indistinguishable from an unclaimed one — so every + // cargo purl fails closed with the corruption surfaced (non-cargo purls + // never touch the redirect ledger here and proceed). + let (mut redirect_ledger, redirect_ledger_corrupt) = + match socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await { + Ok(state) => (state, None), + Err(corrupt) => (None, Some(corrupt)), + }; + for (purl, pkg_path) in &all_packages { let is_variant_eco = Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()); @@ -952,6 +979,128 @@ 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/") { + if let Some(corrupt) = &redirect_ledger_corrupt { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( + "redirect_ledger_corrupt", + format!( + "cannot vendor over a possibly-live hosted redirect: \ + {corrupt}" + ), + ), + ); + if !common.silent && !common.json { + eprintln!("Cannot vendor {}: {corrupt}", normalize_purl(candidate)); + } + continue; + } + 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, @@ -1983,6 +2132,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 e5c97d3f..2ab84ac6 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -26,10 +26,12 @@ use crate::vendor::yarn_berry_lock::yarnrc_compression_level; pub mod golang_local; mod state; +mod takeover; pub use state::{ - load_redirect_state, save_redirect_state, CorruptRedirectState, RedirectState, - REDIRECT_STATE_REL, + load_redirect_state, persist_redirect_state, save_redirect_state, CorruptRedirectState, + 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 4633b6d6..e019d1ca 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -171,6 +171,26 @@ pub async fn save_redirect_state( atomic_write_bytes(&path, format!("{json}\n").as_bytes()).await } +/// Persist the redirect ledger via [`save_redirect_state`]'s atomic 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<()> { + if state.edits.is_empty() && state.records.is_empty() { + let path = project_root.join(REDIRECT_STATE_REL); + match tokio::fs::remove_file(&path).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + return Ok(()); + } + save_redirect_state(project_root, state).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..325de7d3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -0,0 +1,517 @@ +//! 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. +//! 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::{BTreeMap, 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}")) +} + +/// 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`). +/// +/// 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(); + 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 + // 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) = staged_read(&staged, 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); + 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. + } 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) = staged_read(&staged, 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) = staged_read(&staged, project_root, probe).await? { + if (!reg.is_empty() && text.contains(reg)) + || (!index.is_empty() && text.contains(&index)) + { + referenced = true; + break; + } + } + } + if referenced { + continue; + } + // A REGENERATED block (`action: "rewritten"` — the rewriter + // replaced a degraded/commented region in place and recorded + // it as `original`) restores that pre-existing region instead + // 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); + staged.insert(edit.path.clone(), Some(reverted)); + out.reverted_files.push(edit.path.clone()); + 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() { + staged.insert(edit.path.clone(), None); + } else { + staged.insert(edit.path.clone(), Some(trimmed)); + } + out.reverted_files.push(edit.path.clone()); + } + _ => {} + } + } + + // 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(); + 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); + } + + /// 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(); + 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 469a4f37..3d2f52fe 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 @@ -534,6 +601,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 { @@ -2310,4 +2401,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 e39f5467..db909eac 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -98,6 +98,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 b3eaf5ee..336a5b99 100644 --- a/crates/socket-patch-core/src/vendor/cargo_lock.rs +++ b/crates/socket-patch-core/src/vendor/cargo_lock.rs @@ -209,6 +209,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, + } +} + /// Number of `[[package]]` entries matching `name`+`version`. More than one /// means the lock resolves the same name+version from multiple sources (e.g. /// registry + git fork), the shape whose `dependencies` arrays use full diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 2f4b5ba0..a4ea5881 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; diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs index f47f17b9..c6bc16f2 100644 --- a/crates/socket-patch-core/src/vendor/state.rs +++ b/crates/socket-patch-core/src/vendor/state.rs @@ -301,16 +301,24 @@ impl Default for VendorState { /// create recorded by the first vendoring is not lost when a re-vendor /// finds the surface already present (revert byte-restores an emptied /// table/file only when it knows vendor created it); +/// * carries forward the cargo lock originals — the removed +/// `source`/`checksum` pair is NOT recoverable offline, so the ledger +/// entry is its only home. A re-vendor over already-detached wiring +/// records `lock: None` (there was nothing left to detach), and taking +/// the fresh entry verbatim would destroy the first run's originals; /// * preserves the go-patch-takeover flag. /// /// The union + meta merge are scoped to a re-vendor of the SAME patch /// generation (`prev.uuid == entry.uuid`): a new-uuid re-vendor rewires every /// surface fresh under the new uuid, so the prior uuid's records name nothing /// the new entry left behind and carrying them forward would only dangle. -/// The original-fill and takeover flag are safe (identity-matched) either way -/// and run unconditionally. +/// The original-fill, lock originals, and takeover flag are safe +/// (identity-matched) either way and run unconditionally. pub fn carry_forward_wiring(prev: &VendorEntry, entry: &mut VendorEntry) { entry.took_over_go_patches = entry.took_over_go_patches || prev.took_over_go_patches; + if entry.lock.is_none() { + entry.lock = prev.lock.clone(); + } for rec in &mut entry.wiring { if rec.action == WiringAction::Rewritten && rec.original.is_none() {