From fa50673ba8088c869ecd1f12f950397c3e23aab3 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 12 Aug 2026 16:41:42 -0700 Subject: [PATCH 1/2] fix(scan): warn when a mode takeover leaves the other ledger stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching a project's patch mode rewired the lockfile to the new mode but left the displaced mode's ledger on disk asserting wiring that is no longer live (hosted: .socket/vendor/redirect-state.json; vendored: .socket/vendor/state.json + orphaned tarballs). Both scans reported `warnings: []`, so anything auditing a ledger as "what is live" (including `vex`) was misled. Detect the overlap (PURLs claimed by BOTH ledgers) and emit a takeover warning in each flow — `redirect_supersedes_vendored` from the hosted flow, `vendor_supersedes_redirect` from the vendored flow — surfaced in both the JSON `warnings[]` and stderr, naming the displaced package(s) and the stale ledger/orphaned artifacts to clean up. Neither mode silently mutates or deletes the other's ledger; reconciliation is deferred. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/scan/hosted.rs | 19 ++ .../socket-patch-cli/src/commands/scan/mod.rs | 216 ++++++++++++++++++ .../src/commands/scan/vendor_flow.rs | 27 ++- 3 files changed, 261 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 9bca8e63..8bb220bc 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -493,6 +493,21 @@ pub(super) async fn run_redirect( } } + // Cross-mode takeover: this hosted redirect rewired the lockfile, but a + // committed vendored ledger (`.socket/vendor/state.json`) may still claim + // the same package(s) — their tarballs are now orphaned and that ledger is + // stale. Detect + warn (JSON `warnings[]` and stderr) WITHOUT deleting the + // other mode's ledger; full reconciliation is deferred (see PR Scope). + // Read after the ledger write above so a non-dry-run reflects this run. + let mut takeover_warnings: Vec = Vec::new(); + let superseded = super::overlapping_ledger_purls(&args.common.cwd).await; + if !superseded.is_empty() { + takeover_warnings.push(serde_json::json!({ + "code": super::REDIRECT_SUPERSEDES_VENDORED, + "detail": super::mode_takeover_detail(&superseded, /*current_is_hosted=*/ true), + })); + } + // Emit an OpenVEX attestation when `--vex` was requested. The redirected // bytes are fetched from the hosted patch server at install time, so the // PURLs CONFIRMED REDIRECTED BY THIS RUN are attested from the ledger @@ -534,6 +549,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_warnings.iter().cloned()); let mut result = serde_json::json!({ "status": "success", "redirect": { @@ -599,6 +615,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_warnings { + eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); + } if let Some(statements) = vex_statements { eprintln!( "Wrote OpenVEX document with {} statement(s) to {} (redirected patches are \ diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 40da341e..58d3e523 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -415,6 +415,93 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) - } } +// --------------------------------------------------------------------------- +// Cross-mode ledger takeover detection (hosted ⇄ vendored) +// --------------------------------------------------------------------------- +// +// Hosted mode writes `.socket/vendor/redirect-state.json`; vendored mode +// writes `.socket/vendor/state.json` (+ committed tarballs). Switching a +// project's mode rewires the lockfile to the NEW mode but leaves the OLD +// mode's ledger on disk asserting wiring that is no longer live (and, for +// vendored→hosted, the orphaned tarball behind). Anything auditing a ledger +// as "what is live" (including `vex`) is then misled. Detect the overlap so +// each flow can warn; reconciliation (removing the stale ledger / orphaned +// artifacts) is deliberately deferred so neither mode silently mutates the +// other's ledger. + +/// Warning code emitted by the HOSTED flow when it just redirected package(s) +/// a committed vendored ledger still claims (its tarballs are now orphaned). +pub(super) const REDIRECT_SUPERSEDES_VENDORED: &str = "redirect_supersedes_vendored"; + +/// Warning code emitted by the VENDORED flow when it just vendored package(s) +/// a committed hosted redirect ledger still claims. +pub(super) const VENDOR_SUPERSEDES_REDIRECT: &str = "vendor_supersedes_redirect"; + +/// The PURLs claimed by BOTH the hosted redirect ledger +/// (`.socket/vendor/redirect-state.json`) and the vendored state ledger +/// (`.socket/vendor/state.json`) in `cwd`, sorted. A non-empty result means +/// one mode has taken the lockfile over from the other for these package(s) +/// while the displaced mode's ledger stayed on disk — exactly one of the two +/// ledgers is stale for each PURL (a package's lockfile entry can point only +/// one way). Empty when either ledger is missing/empty/unreadable, or when the +/// two ledgers describe disjoint packages (a legitimate split: some redirected, +/// others vendored) — so there are no false positives. +pub(super) async fn overlapping_ledger_purls(cwd: &Path) -> Vec { + let Some(redirect) = socket_patch_core::patch::redirect::load_redirect_state(cwd).await else { + return Vec::new(); + }; + let Ok(vendor) = socket_patch_core::vendor::load_state(cwd).await else { + return Vec::new(); + }; + if redirect.records.is_empty() || vendor.entries.is_empty() { + return Vec::new(); + } + // Canonicalize both sides (drop qualifiers, percent-decode) so the API + // purl form the redirect records carry matches the vendor entry's base + // purl — mirrors `vendored_ledger_supplement`. + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let redirect_purls: std::collections::BTreeSet = + redirect.records.keys().map(|p| canon(p)).collect(); + let mut vendor_purls: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for (key, entry) in &vendor.entries { + vendor_purls.insert(canon(key)); + vendor_purls.insert(canon(&entry.base_purl)); + } + redirect_purls + .intersection(&vendor_purls) + .cloned() + .collect() +} + +/// Human-readable detail for a mode-takeover warning naming the displaced +/// package(s). `current_is_hosted` selects the direction: `true` when a +/// hosted redirect displaced a vendored ledger, `false` when a vendored run +/// displaced a hosted redirect ledger. +pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: bool) -> String { + let list = superseded.join(", "); + if current_is_hosted { + 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 \ + lockfile points at the hosted patch server, not the vendored files. \ + Remove the stale vendored ledger and orphaned artifacts (run \ + `socket-patch vendor --revert` before redirecting, or delete the \ + orphaned `.socket/vendor//` tree) so audits and VEX do not read \ + superseded wiring." + ) + } else { + format!( + "vendored artifacts superseded the hosted redirect ledger for: {list}. \ + `.socket/vendor/redirect-state.json` still records a hosted redirect for \ + these package(s), but the lockfile now points at the committed \ + `.socket/vendor/` files. Remove the stale redirect ledger \ + (`.socket/vendor/redirect-state.json`) so audits and VEX do not read \ + superseded wiring." + ) + } +} + pub async fn run(mut args: ScanArgs) -> i32 { apply_env_toggles(&args.common); @@ -1614,4 +1701,133 @@ mod tests { assert_eq!(out.chars().count(), 76); assert!(out.ends_with("...")); } + + // ---- cross-mode ledger takeover (hosted ⇄ vendored) -------------------- + // Switching a project's patch mode rewires the lockfile to the new mode + // but leaves the OLD mode's ledger on disk asserting stale wiring. These + // pin the detection + warning that flags it (the sweep's + // stale-ledger-on-mode-takeover finding). + + const TAKEOVER_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + fn takeover_record() -> PatchRecord { + PatchRecord { + uuid: TAKEOVER_UUID.to_string(), + exported_at: "2026-01-01T00:00:00Z".to_string(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: "MIT".to_string(), + tier: "free".to_string(), + } + } + + /// Write a hosted redirect ledger (`.socket/vendor/redirect-state.json`) + /// recording a redirect for each PURL. + async fn write_redirect_ledger(root: &Path, purls: &[&str]) { + use socket_patch_core::patch::redirect::RedirectState; + let mut state = RedirectState::new(); + for purl in purls { + state.records.insert((*purl).to_string(), takeover_record()); + } + let dir = root.join(".socket/vendor"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write( + dir.join("redirect-state.json"), + serde_json::to_string_pretty(&state).unwrap(), + ) + .await + .unwrap(); + } + + /// Write a vendored state ledger (`.socket/vendor/state.json`) with one + /// entry per PURL, in the committed camelCase wire shape. + async fn write_vendor_ledger(root: &Path, purls: &[&str]) { + let entries: serde_json::Map = purls + .iter() + .map(|purl| { + ( + (*purl).to_string(), + serde_json::json!({ + "ecosystem": "npm", + "basePurl": purl, + "uuid": TAKEOVER_UUID, + "artifact": { + "path": format!(".socket/vendor/npm/{TAKEOVER_UUID}/pkg.tgz"), + }, + "wiring": [], + }), + ) + }) + .collect(); + let state = serde_json::json!({ "version": 1, "entries": entries }); + 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(); + } + + #[tokio::test] + async fn overlapping_ledgers_flag_the_taken_over_package() { + // Both ledgers claim minimist ⇒ one mode took the lockfile over from + // the other and the displaced ledger is stale. The detection names + // exactly the overlapping PURL. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + write_vendor_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + + let superseded = overlapping_ledger_purls(root).await; + assert_eq!(superseded, vec!["pkg:npm/minimist@1.2.2".to_string()]); + } + + #[tokio::test] + async fn single_ledger_present_flags_nothing() { + // A first-time redirect (only the redirect ledger, no vendored ledger) + // displaces nothing — no warning. Guards against warning on the FIRST + // scan of a fresh project. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + assert!(overlapping_ledger_purls(root).await.is_empty()); + + // And a project with no ledgers at all. + let tmp2 = tempfile::tempdir().unwrap(); + assert!(overlapping_ledger_purls(tmp2.path()).await.is_empty()); + } + + #[tokio::test] + async fn disjoint_ledgers_are_not_a_takeover() { + // A legitimate split — one package redirected, a DIFFERENT one + // vendored — is not a takeover: neither ledger's wiring is stale. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + write_vendor_ledger(root, &["pkg:npm/lodash@4.17.21"]).await; + assert!(overlapping_ledger_purls(root).await.is_empty()); + } + + #[test] + 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. + 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")); + + // Hosted displaced a vendored ledger: point at the vendored ledger + + // orphaned artifacts. + 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")); + + // The two warning codes are distinct routing tags. + assert_ne!(VENDOR_SUPERSEDES_REDIRECT, REDIRECT_SUPERSEDES_VENDORED); + } } 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 5534a392..59a1937a 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -20,7 +20,7 @@ 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}; +use crate::json_envelope::{Command as EnvelopeCommand, Envelope, RunWarning}; use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; use super::{ @@ -53,6 +53,29 @@ async fn preview_vendor_json(cwd: &Path, selected: &[PatchSearchResult]) -> serd serde_json::json!({ "dryRun": true, "patches": patches }) } +/// 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) { + let superseded = super::overlapping_ledger_purls(cwd).await; + 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 @@ -113,6 +136,7 @@ async fn run_scan_vendor_step( // previous run may still sit in the lockfile, so the // state-based migration-risk advisory still applies. note_classic_migration_risk(&mut env, &common.cwd, common); + note_vendor_supersedes_redirect(&mut env, &common.cwd, common).await; drop(guard); return Ok((false, env)); } @@ -151,6 +175,7 @@ async fn run_scan_vendor_step( env.mark_partial_failure(); } note_classic_migration_risk(&mut env, &common.cwd, common); + note_vendor_supersedes_redirect(&mut env, &common.cwd, common).await; Ok((has_errors, env)) } From dbe7f946bb9e38dbea42369b0b51b5da111abf2b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 13 Aug 2026 10:51:32 -0700 Subject: [PATCH 2/2] fix(scan): derive mode-takeover direction from the live lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlap between the hosted and vendored ledgers only proves both name the same package(s) — not which mode won. Each flow assumed the running command displaced the other, so a hosted dry-run/no-op (or a vendored run that did not rewire the overlap) emitted the OPPOSITE `*_supersedes_*` warning and pointed cleanup at the ledger matching the LIVE lockfile — it could tell the user to delete the ledger for what is actually installed. Decide direction from the actual current lockfile wiring instead. `classify_overlap_takeover` reads the scan inventory (a `patch.socket.dev` `resolved` ⇒ hosted is live) and the vendored ledger's wired lockfile(s) (a live `.socket/vendor//` marker ⇒ vendored is live), then buckets each overlapping PURL by the mode the lock actually proves. The hosted flow warns only for the hosted-live subset, the vendored flow only for the vendored-live subset, and a PURL the lock proves neither way stays silent. Remediation now always points at the ledger that does NOT match the live lockfile. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/scan/hosted.rs | 16 +- .../socket-patch-cli/src/commands/scan/mod.rs | 274 ++++++++++++++++++ .../src/commands/scan/vendor_flow.rs | 7 +- 3 files changed, 290 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 8bb220bc..4a0eb031 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -493,14 +493,18 @@ pub(super) async fn run_redirect( } } - // Cross-mode takeover: this hosted redirect rewired the lockfile, but a - // committed vendored ledger (`.socket/vendor/state.json`) may still claim - // the same package(s) — their tarballs are now orphaned and that ledger is - // stale. Detect + warn (JSON `warnings[]` and stderr) WITHOUT deleting the - // other mode's ledger; full reconciliation is deferred (see PR Scope). + // Cross-mode takeover: a committed vendored ledger (`.socket/vendor/state.json`) + // may still claim package(s) this project also has a hosted redirect ledger + // for — their tarballs would then be orphaned and that ledger stale. But the + // overlap alone does NOT prove hosted won: only warn for the package(s) the + // LIVE lockfile actually routes to `patch.socket.dev` (see + // `classify_overlap_takeover`), so a dry-run / no-op over a lock that still + // points at the vendored files stays silent instead of pointing cleanup at + // the live vendored ledger. Warn (JSON `warnings[]` and stderr) WITHOUT + // deleting the other mode's ledger; reconciliation is deferred (see PR Scope). // Read after the ledger write above so a non-dry-run reflects this run. let mut takeover_warnings: Vec = Vec::new(); - let superseded = super::overlapping_ledger_purls(&args.common.cwd).await; + let superseded = super::classify_overlap_takeover(&args.common.cwd).await.redirect; if !superseded.is_empty() { takeover_warnings.push(serde_json::json!({ "code": super::REDIRECT_SUPERSEDES_VENDORED, diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 58d3e523..71600338 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -428,6 +428,15 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) - // each flow can warn; reconciliation (removing the stale ledger / orphaned // artifacts) is deliberately deferred so neither mode silently mutates the // other's ledger. +// +// The overlap alone only proves BOTH ledgers name the same package(s) — NOT +// which one won. The takeover DIRECTION is decided by the ACTUAL current +// lockfile wiring for each overlapping package (see `classify_overlap_takeover`), +// never by which command happens to be running: a hosted dry-run/no-op over a +// lock that still points at the vendored files must not tell the user to delete +// the live vendored ledger (and vice-versa). Remediation always points at the +// ledger that does NOT match the live lock; a package the lock proves neither +// way stays silent. /// Warning code emitted by the HOSTED flow when it just redirected package(s) /// a committed vendored ledger still claims (its tarballs are now orphaned). @@ -473,6 +482,110 @@ pub(super) async fn overlapping_ledger_purls(cwd: &Path) -> Vec { .collect() } +/// The overlapping PURLs split by which mode the LIVE lockfile actually wires +/// them to right now — the truth source for takeover direction. +/// +/// `redirect` holds the overlap PURLs the lock currently routes to the hosted +/// patch server (`patch.socket.dev`): hosted genuinely won the lockfile, so the +/// vendored ledger entry (and its now-orphaned tarball) is the stale one and +/// `redirect_supersedes_vendored` is truthful. `vendored` holds the PURLs the +/// lock currently routes to a committed `.socket/vendor//` artifact: +/// vendored won, the redirect ledger record is stale, and +/// `vendor_supersedes_redirect` is truthful. +/// +/// A PURL the lock proves NEITHER way — a dry-run/no-op that did not rewire it, +/// a half-migrated lock naming both, or an ecosystem whose live spec we cannot +/// read — lands in neither bucket, so the caller stays SILENT instead of +/// guessing the direction from which command happened to run (the +/// takeover-direction bug: a hosted no-op pointing cleanup at the live vendored +/// ledger). +#[derive(Debug, Default, PartialEq)] +pub(super) struct OverlapTakeover { + /// Overlap PURLs whose vendored ledger is stale (lock points hosted). + pub redirect: Vec, + /// Overlap PURLs whose redirect ledger is stale (lock points vendored). + pub vendored: Vec, +} + +pub(super) async fn classify_overlap_takeover(cwd: &Path) -> OverlapTakeover { + let overlap = overlapping_ledger_purls(cwd).await; + let mut out = OverlapTakeover::default(); + if overlap.is_empty() { + return out; + } + // Re-load the vendored ledger to recover each overlapping entry's uuid + + // the lockfiles it wired (revert reads the same set); `overlapping_ledger_purls` + // already proved it loads and is non-empty. + let Ok(vendor) = socket_patch_core::vendor::load_state(cwd).await else { + return out; + }; + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let mut vendor_by_purl: std::collections::HashMap = + std::collections::HashMap::new(); + for (key, entry) in &vendor.entries { + vendor_by_purl.entry(canon(key)).or_insert(entry); + vendor_by_purl.entry(canon(&entry.base_purl)).or_insert(entry); + } + // The scan inventory keeps only http(s) `resolved` URLs and DROPS our own + // `file:.socket/vendor/…` specs (see `lock_inventory`), so a + // `patch.socket.dev` resolved for a purl is a purl-scoped proof the lock now + // points at hosted. + let inventory = socket_patch_core::vendor::lock_inventory::inventory_project(cwd).await; + for purl in overlap { + let hosted_live = socket_patch_core::vendor::lock_inventory::lookup(&inventory, &purl) + .and_then(|e| e.resolved.as_deref()) + .is_some_and(|r| r.contains("patch.socket.dev")); + let vendored_live = match vendor_by_purl.get(&purl) { + Some(entry) => vendored_wiring_live(cwd, entry).await, + None => false, + }; + match (hosted_live, vendored_live) { + (true, false) => out.redirect.push(purl), + (false, true) => out.vendored.push(purl), + // Both (a half-migrated lock naming both) or neither (no rewire / + // unreadable) does not prove a single direction — stay silent. + _ => {} + } + } + out.redirect.sort(); + out.vendored.sort(); + out +} + +/// Whether the LIVE lockfile still wires `entry` to its committed +/// `.socket/vendor//` artifact. Reads the lockfile(s) this entry +/// recorded editing (the same set `--revert` walks) and looks for that exact +/// vendored-path marker — the anchor `parse_vendor_path` recovers and the +/// vendor drift-guards match on. `None`/unreadable/absent ⇒ `false` (the +/// caller then stays silent rather than assume vendored is live). +async fn vendored_wiring_live(cwd: &Path, entry: &socket_patch_core::vendor::VendorEntry) -> bool { + let Some(marker) = + socket_patch_core::vendor::path::vendor_uuid_dir_rel(&entry.ecosystem, &entry.uuid) + else { + return false; + }; + let mut files: Vec<&str> = entry.wiring.iter().map(|w| w.file.as_str()).collect(); + files.sort(); + files.dedup(); + for file in files { + // state.json is tamper-able: only ever READ a plain in-project relative + // lockfile name — never one that could climb out of `cwd`. + if file.is_empty() + || file.starts_with('/') + || file.starts_with('\\') + || file.split(['/', '\\']).any(|c| c == "..") + { + continue; + } + if let Ok(text) = tokio::fs::read_to_string(cwd.join(file)).await { + if text.contains(&marker) { + return true; + } + } + } + false +} + /// Human-readable detail for a mode-takeover warning naming the displaced /// package(s). `current_is_hosted` selects the direction: `true` when a /// hosted redirect displaced a vendored ledger, `false` when a vendored run @@ -1830,4 +1943,165 @@ mod tests { // The two warning codes are distinct routing tags. assert_ne!(VENDOR_SUPERSEDES_REDIRECT, REDIRECT_SUPERSEDES_VENDORED); } + + // ---- 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 + // from the ACTUAL current lockfile wiring, so a dry-run/no-op can never emit + // the wrong `*_supersedes_*` warning and point cleanup at the LIVE ledger. + + /// Like [`write_vendor_ledger`] but each entry records wiring the + /// `package-lock.json` — the file the direction check reads to see whether + /// the lock still points at the committed `.socket/vendor/` artifact. + async fn write_vendor_ledger_wired(root: &Path, purls: &[&str]) { + let entries: serde_json::Map = purls + .iter() + .map(|purl| { + ( + (*purl).to_string(), + serde_json::json!({ + "ecosystem": "npm", + "basePurl": purl, + "uuid": TAKEOVER_UUID, + "artifact": { + "path": format!(".socket/vendor/npm/{TAKEOVER_UUID}/pkg.tgz"), + }, + "wiring": [{ + "file": "package-lock.json", + "kind": "npm_lock_entry", + "action": "rewritten", + }], + }), + ) + }) + .collect(); + let state = serde_json::json!({ "version": 1, "entries": entries }); + 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(); + } + + /// A `package-lock.json` whose single dep resolves to the committed + /// `.socket/vendor/` artifact — vendored is what the lock actually wires. + async fn write_lock_pointing_at_vendored(root: &Path, name: &str, version: &str) { + let lock = serde_json::json!({ + "name": "app", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "app", "version": "0.0.0" }, + format!("node_modules/{name}"): { + "version": version, + "resolved": format!( + "file:.socket/vendor/npm/{TAKEOVER_UUID}/{name}-{version}.tgz" + ), + }, + }, + }); + tokio::fs::write( + root.join("package-lock.json"), + serde_json::to_string_pretty(&lock).unwrap(), + ) + .await + .unwrap(); + } + + /// A `package-lock.json` whose single dep resolves to the hosted patch + /// server — hosted is what the lock actually wires. + async fn write_lock_pointing_at_hosted(root: &Path, name: &str, version: &str) { + let lock = serde_json::json!({ + "name": "app", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "app", "version": "0.0.0" }, + format!("node_modules/{name}"): { + "version": version, + "resolved": format!( + "https://patch.socket.dev/npm/{name}/-/{name}-{version}.tgz" + ), + "integrity": format!("sha512-{}", "a".repeat(86)), + }, + }, + }); + tokio::fs::write( + root.join("package-lock.json"), + serde_json::to_string_pretty(&lock).unwrap(), + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn hosted_flow_stays_silent_when_the_lock_still_points_at_vendored() { + // Both ledgers claim minimist, but the LIVE lockfile still resolves it + // to the committed `.socket/vendor/` artifact — vendored is live. A + // hosted dry-run/no-op must NOT emit `redirect_supersedes_vendored`, + // which would point cleanup at the LIVE vendored ledger (the bug). + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; + write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; + + let takeover = classify_overlap_takeover(root).await; + // The hosted flow keys its warning off `.redirect` — empty here, so it + // stays silent instead of accusing the live vendored ledger. + assert!( + takeover.redirect.is_empty(), + "hosted flow must not warn when the lock is vendored: {takeover:?}" + ); + // Truthful direction: vendored won ⇒ the redirect ledger is the stale one. + assert_eq!(takeover.vendored, vec!["pkg:npm/minimist@1.2.2".to_string()]); + // Pre-fix the hosted flow keyed off the raw overlap, which is non-empty + // — it WOULD have wrongly told the user to delete the live ledger. + assert!(!overlapping_ledger_purls(root).await.is_empty()); + } + + #[tokio::test] + async fn vendored_flow_stays_silent_when_the_lock_still_points_at_hosted() { + // Mirror: both ledgers claim minimist, but the LIVE lockfile resolves it + // to the hosted patch server — hosted is live. A vendored dry-run/no-op + // must NOT emit `vendor_supersedes_redirect` and point cleanup at the + // live redirect ledger. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; + write_lock_pointing_at_hosted(root, "minimist", "1.2.2").await; + + let takeover = classify_overlap_takeover(root).await; + assert!( + takeover.vendored.is_empty(), + "vendored flow must not warn when the lock is hosted: {takeover:?}" + ); + // Truthful direction: hosted won ⇒ the vendored ledger is the stale one. + assert_eq!(takeover.redirect, vec!["pkg:npm/minimist@1.2.2".to_string()]); + } + + #[tokio::test] + async fn overlap_without_a_lock_to_prove_direction_stays_silent_both_ways() { + // Both ledgers overlap, but no lockfile proves which mode is live. Rather + // than guess the direction from which command is running, both flows stay + // silent — the raw overlap still fires, only the direction is gated. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; + + let takeover = classify_overlap_takeover(root).await; + assert!( + takeover.redirect.is_empty() && takeover.vendored.is_empty(), + "no lock proof ⇒ no directional warning: {takeover:?}" + ); + assert_eq!( + overlapping_ledger_purls(root).await, + vec!["pkg:npm/minimist@1.2.2".to_string()] + ); + } } 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 59a1937a..a0f5ec2f 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -62,7 +62,12 @@ async fn preview_vendor_json(cwd: &Path, selected: &[PatchSearchResult]) -> serd /// [`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) { - let superseded = super::overlapping_ledger_purls(cwd).await; + // 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; }