diff --git a/README.md b/README.md index 2e265426..e7752abe 100644 --- a/README.md +++ b/README.md @@ -531,6 +531,10 @@ socket-patch scan --json --mode hosted --yes > Already-vendored packages are **skipped by plain `--mode agent`** (the committed > artifact is the patch); a newer available patch still appears in the JSON `updates[]` > array — re-run `scan --mode vendored` to take it. +> +> Hosted-managed dependencies get the same signal: `updates[]` also consults the +> `.socket/vendor/redirect-state.json` ledger, so a superseded hosted patch shows up in +> read-only `scan --json` — re-run `scan --mode hosted` to take it. ### `apply` diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index f8c61e0a..bcf5331c 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -177,6 +177,32 @@ pub(super) async fn preverify_vendor_baselines( mismatched } +/// Fold the hosted redirect ledger's patch records into the manifest view +/// update detection consults. Hosted mode persists its purl→uuid records ONLY +/// in `.socket/vendor/redirect-state.json` — it never writes +/// `.socket/manifest.json` — so without this fold a pure hosted project's +/// `updates[]` (the documented CI signal, see CLI_CONTRACT.md) is structurally +/// empty and a superseding patch is never reported. An existing manifest entry +/// wins a collision (that PURL is manifest-owned), matching VEX's +/// `augment_with_redirect`. Pure / no I/O so it's unit-testable. +pub(super) fn merge_redirect_records_for_updates( + manifest: Option, + redirect: Option<&socket_patch_core::patch::redirect::RedirectState>, +) -> Option { + let records = redirect.map(|s| &s.records).filter(|r| !r.is_empty()); + let Some(records) = records else { + return manifest; + }; + let mut merged = manifest.unwrap_or_default(); + for (purl, record) in records { + merged + .patches + .entry(purl.clone()) + .or_insert_with(|| record.clone()); + } + Some(merged) +} + /// Cross-reference an existing manifest against discovery results to find /// PURLs whose newest available patch UUID differs from the locally-recorded /// one. Used by both the discovery JSON path and the table-print path. @@ -682,6 +708,91 @@ mod tests { assert_eq!(updates[0].new_uuid, "uuid-new"); } + // ---- merge_redirect_records_for_updates --------------------------------- + // Hosted mode records patches ONLY in the redirect ledger — these pin that + // ledger-only projects still surface `updates[]` (the documented CI + // signal) through the merged manifest view. + + fn ledger_with(entries: &[(&str, &str)]) -> socket_patch_core::patch::redirect::RedirectState { + let mut state = socket_patch_core::patch::redirect::RedirectState::new(); + let manifest = crate::commands::scan::tests::manifest_with(entries); + state.records.extend(manifest.patches); + state + } + + #[test] + fn ledger_only_project_reports_superseding_patch_in_updates() { + // Pure hosted project: NO .socket/manifest.json, one redirected patch + // recorded in the ledger; discovery now offers a different (newer) + // uuid. The merged view must make detect_updates flag it — this was + // structurally impossible before the fold (manifest-only detection). + let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-old")]); + let merged = merge_redirect_records_for_updates(None, Some(&ledger)); + let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; + let updates = detect_updates(merged.as_ref(), &pkgs); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].purl, "pkg:npm/foo@1.0"); + assert_eq!(updates[0].old_uuid, "uuid-old"); + assert_eq!(updates[0].new_uuid, "uuid-new"); + } + + #[test] + fn ledger_record_matching_the_candidate_is_not_an_update() { + // The redirected patch is still the top offer — no nag. + let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-a")]); + let merged = merge_redirect_records_for_updates(None, Some(&ledger)); + let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])]; + assert!(detect_updates(merged.as_ref(), &pkgs).is_empty()); + } + + #[test] + fn manifest_entry_wins_a_collision_with_a_ledger_record() { + // A PURL present in both stores is manifest-owned (same precedence as + // VEX's augment_with_redirect): the manifest's uuid is the "old" side. + let manifest = + crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-manifest")]); + let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-ledger")]); + let merged = merge_redirect_records_for_updates(Some(manifest), Some(&ledger)); + let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; + let updates = detect_updates(merged.as_ref(), &pkgs); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].old_uuid, "uuid-manifest"); + } + + #[test] + fn ledger_and_manifest_cover_disjoint_purls() { + // A mixed project (some deps applied via manifest, some hosted via + // ledger) gets update detection across BOTH stores. + let manifest = + crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-f1")]); + let ledger = ledger_with(&[("pkg:npm/bar@2.0", "uuid-b1")]); + let merged = merge_redirect_records_for_updates(Some(manifest), Some(&ledger)); + let pkgs = vec![ + batch_with("pkg:npm/foo@1.0", &["uuid-f2"]), + batch_with("pkg:npm/bar@2.0", &["uuid-b2"]), + ]; + let mut updates = detect_updates(merged.as_ref(), &pkgs); + updates.sort_by(|a, b| a.purl.cmp(&b.purl)); + assert_eq!(updates.len(), 2); + assert_eq!(updates[0].old_uuid, "uuid-b1"); + assert_eq!(updates[1].old_uuid, "uuid-f1"); + } + + #[test] + fn absent_or_empty_ledger_leaves_the_manifest_view_untouched() { + assert!(merge_redirect_records_for_updates(None, None).is_none()); + let empty = socket_patch_core::patch::redirect::RedirectState::new(); + assert!(merge_redirect_records_for_updates(None, Some(&empty)).is_none()); + let manifest = + crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); + let merged = merge_redirect_records_for_updates(Some(manifest.clone()), Some(&empty)); + assert_eq!( + merged.unwrap().patches.len(), + manifest.patches.len(), + "an empty ledger adds nothing" + ); + } + // ---- collect_vuln_ids -------------------------------------------------- /// Build a single-patch package whose patch carries the given CVE and diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 340bddf3..1a9fbf7f 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -236,6 +236,31 @@ 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. + let existing_ledger = + match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await { + Ok(state) => state, + Err(mut corrupt) => { + if !args.common.dry_run { + corrupt.quarantine().await; + } + let message = corrupt.to_string(); + eprintln!("{message}"); + if args.common.json { + emit_json_error(scan_result.take(), &message); + } + return 1; + } + }; + // 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 @@ -462,20 +487,6 @@ pub(super) async fn run_redirect( } if !args.common.dry_run { - for (rel, content) in &rewrite.files { - let path = args.common.cwd.join(rel); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = std::fs::write(&path, content) { - let message = format!("failed to write {rel}: {e}"); - eprintln!("{message}"); - if args.common.json { - emit_json_error(scan_result.take(), &message); - } - return 1; - } - } // Ledger (mirrors the vendor state.json shape): recorded edits for a // future revert + the patch records (file hashes + vulnerabilities) so // a post-install `socket-patch vex` can attest the redirected patches. @@ -484,13 +495,15 @@ pub(super) async fn run_redirect( // hosted patch), and clobbering the file would lose the original // pre-redirect values a future revert needs. New edits APPEND (revert // walks them in reverse); records are keyed by PURL, newest wins. + // + // Persisted BEFORE the project files, and atomically (stage + fsync + + // rename, like the sibling vendor ledger): a crash between the two + // then leaves a complete ledger whose recorded originals simply match + // files that were never rewritten — instead of rewritten files whose + // pre-redirect originals never reached any ledger (a healing re-run + // records no edits for already-redirected entries). if !rewrite.edits.is_empty() || !records.is_empty() || !migration_edits.is_empty() { - let vendor_dir = args.common.cwd.join(".socket").join("vendor"); - let _ = std::fs::create_dir_all(&vendor_dir); - let mut ledger = - socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd) - .await - .unwrap_or_else(RedirectState::new); + let mut ledger = existing_ledger.unwrap_or_else(RedirectState::new); // Ledgers written before the mode-string rename carry // `"mode": "redirect"`; normalize on rewrite so the on-disk // ledger converges on the documented "hosted" name (the @@ -504,10 +517,10 @@ pub(super) async fn run_redirect( // The ledger is the only revert path and the VEX record store — // a swallowed write failure would leave the rewritten lockfiles // unrevertable while reporting success. - if let Err(e) = std::fs::write( - vendor_dir.join("redirect-state.json"), - format!("{}\n", serde_json::to_string_pretty(&ledger).unwrap()), - ) { + if let Err(e) = + socket_patch_core::patch::redirect::save_redirect_state(&args.common.cwd, &ledger) + .await + { let message = format!("failed to write .socket/vendor/redirect-state.json: {e}"); eprintln!("{message}"); if args.common.json { @@ -516,6 +529,20 @@ pub(super) async fn run_redirect( return 1; } } + for (rel, content) in &rewrite.files { + let path = args.common.cwd.join(rel); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(&path, content) { + let message = format!("failed to write {rel}: {e}"); + eprintln!("{message}"); + if args.common.json { + emit_json_error(scan_result.take(), &message); + } + return 1; + } + } } // Cross-mode takeover: a committed vendored ledger (`.socket/vendor/state.json`) @@ -529,7 +556,9 @@ pub(super) async fn run_redirect( // deleting the other mode's ledger; reconciliation is deferred (see PR Scope). // Read after the ledger write above so a non-dry-run reflects this run. let mut takeover_warnings: Vec = Vec::new(); - let superseded = super::classify_overlap_takeover(&args.common.cwd).await.redirect; + let superseded = super::classify_overlap_takeover(&args.common.cwd) + .await + .redirect; if !superseded.is_empty() { takeover_warnings.push(serde_json::json!({ "code": super::REDIRECT_SUPERSEDES_VENDORED, diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index b76700ab..dc2afe4b 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -35,8 +35,8 @@ mod hosted; mod vendor_flow; use self::discovery::{ - collect_vuln_ids, detect_updates, lockfile_supplement, preverify_vendor_baselines, - severity_order, vendored_ledger_supplement, + collect_vuln_ids, detect_updates, lockfile_supplement, merge_redirect_records_for_updates, + preverify_vendor_baselines, severity_order, vendored_ledger_supplement, }; use self::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; use self::hosted::run_redirect; @@ -456,7 +456,12 @@ pub(super) const VENDOR_SUPERSEDES_REDIRECT: &str = "vendor_supersedes_redirect" /// 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 { + // A malformed redirect ledger classifies like a missing one here — this + // path only feeds takeover WARNINGS, and the corruption itself is already + // a hard error on every path that would write (`run_redirect`) or attest + // (`vex`) from the ledger. + let Ok(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 { @@ -520,11 +525,15 @@ pub(super) async fn classify_overlap_takeover(cwd: &Path) -> OverlapTakeover { return out; }; let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let mut vendor_by_purl: std::collections::HashMap = - std::collections::HashMap::new(); + let mut vendor_by_purl: std::collections::HashMap< + String, + &socket_patch_core::vendor::VendorEntry, + > = std::collections::HashMap::new(); for (key, entry) in &vendor.entries { vendor_by_purl.entry(canon(key)).or_insert(entry); - vendor_by_purl.entry(canon(&entry.base_purl)).or_insert(entry); + vendor_by_purl + .entry(canon(&entry.base_purl)) + .or_insert(entry); } // The scan inventory keeps only http(s) `resolved` URLs and DROPS our own // `file:.socket/vendor/…` specs (see `lock_inventory`), so a @@ -1087,7 +1096,24 @@ pub async fn run(mut args: ScanArgs) -> i32 { // non-JSON table-print path (counts `updates_available`). // (`manifest_path`/`socket_dir` are resolved at the top of `run`.) let existing_manifest = read_manifest(&manifest_path).await.ok().flatten(); - let updates = detect_updates(existing_manifest.as_ref(), &all_packages_with_patches); + // Hosted mode records its patches ONLY in the redirect ledger (it never + // writes the manifest), so fold the ledger's purl→uuid records into the + // view update detection sees — otherwise a pure hosted project's + // `updates[]` (the documented CI signal) stays structurally empty and a + // superseding patch is never reported. The envelope schema is unchanged. + // A malformed ledger is only warned about here — this is a read-only + // consult, and the hosted write path hard-errors on it. + let redirect_state = + match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await { + Ok(state) => state, + Err(corrupt) => { + eprintln!("Warning: {corrupt}"); + None + } + }; + let update_manifest = + merge_redirect_records_for_updates(existing_manifest.clone(), redirect_state.as_ref()); + let updates = detect_updates(update_manifest.as_ref(), &all_packages_with_patches); if args.common.json { let mut result = serde_json::json!({ @@ -2095,7 +2121,10 @@ mod tests { "hosted flow must not warn when the lock is vendored: {takeover:?}" ); // Truthful direction: vendored won ⇒ the redirect ledger is the stale one. - assert_eq!(takeover.vendored, vec!["pkg:npm/minimist@1.2.2".to_string()]); + assert_eq!( + takeover.vendored, + vec!["pkg:npm/minimist@1.2.2".to_string()] + ); // Pre-fix the hosted flow keyed off the raw overlap, which is non-empty // — it WOULD have wrongly told the user to delete the live ledger. assert!(!overlapping_ledger_purls(root).await.is_empty()); @@ -2119,7 +2148,10 @@ mod tests { "vendored flow must not warn when the lock is hosted: {takeover:?}" ); // Truthful direction: hosted won ⇒ the vendored ledger is the stale one. - assert_eq!(takeover.redirect, vec!["pkg:npm/minimist@1.2.2".to_string()]); + assert_eq!( + takeover.redirect, + vec!["pkg:npm/minimist@1.2.2".to_string()] + ); } #[tokio::test] diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 51e887ef..29041ed5 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -505,7 +505,12 @@ pub(crate) async fn generate_vex_from_manifest_path( // redirect ledgers' embedded copies must still attest. let manifest = augment_with_detached(common, manifest_file.unwrap_or_else(PatchManifest::new)).await; - let (manifest, redirected) = augment_with_redirect(common, manifest).await; + let (manifest, redirected) = match augment_with_redirect(common, manifest).await { + Ok(augmented) => augmented, + Err(corrupt) => { + return Err(fail(common, "redirect_ledger_corrupt", corrupt.to_string()).await); + } + }; if manifest.patches.is_empty() { if !had_manifest_file { return Err(fail( @@ -551,22 +556,25 @@ async fn augment_with_detached(common: &GlobalArgs, mut manifest: PatchManifest) /// `(redirected)`). Redirected patches have no `.socket/manifest.json` record /// by design — the lockfile rewrite + this ledger IS the persistence — so, /// like detached vendored patches, they must still be attestable. An existing -/// manifest entry wins a collision (that PURL is manifest-owned). A missing or -/// unreadable ledger leaves the manifest unchanged and returns no redirected -/// PURLs. +/// manifest entry wins a collision (that PURL is manifest-owned). A missing +/// ledger leaves the manifest unchanged and returns no redirected PURLs; a +/// MALFORMED ledger is a hard error — attesting with its records silently +/// dropped would produce a false document. async fn augment_with_redirect( common: &GlobalArgs, mut manifest: PatchManifest, -) -> (PatchManifest, Vec) { +) -> Result<(PatchManifest, Vec), socket_patch_core::patch::redirect::CorruptRedirectState> +{ let mut redirected = Vec::new(); - if let Some(state) = socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await + if let Some(state) = + socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await? { for (purl, record) in state.records { redirected.push(purl.clone()); manifest.patches.entry(purl).or_insert(record); } } - (manifest, redirected) + Ok((manifest, redirected)) } /// Fire `vex_failed` telemetry and build the matching [`VexGenError`]. diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index d302aa48..e323bf7c 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1132,11 +1132,12 @@ async fn no_redirectable_patch_leaves_bun_lockb_alone() { ); } -/// A ledger that cannot be written is an ERROR, not a silent success: the -/// lockfile has already been rewritten, and +/// An unusable ledger is an ERROR, not a silent success: /// `.socket/vendor/redirect-state.json` is the only revert path (and the VEX -/// record store), so swallowing the write failure would leave the repo -/// redirected with no way back while reporting success. +/// record store). A DIRECTORY squatting on the ledger path makes it +/// unloadable, so the run must fail closed BEFORE rewriting anything — the +/// old flow rewrote the lockfile first and only then discovered the ledger +/// could not be persisted, leaving the repo redirected with no way back. #[tokio::test] #[serial] async fn unwritable_ledger_fails_the_run() { @@ -1147,17 +1148,18 @@ async fn unwritable_ledger_fails_the_run() { let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); - // Occupy the ledger path with a DIRECTORY so the ledger write must fail. + // Occupy the ledger path with a DIRECTORY so the ledger cannot be loaded + // (or written). std::fs::create_dir_all(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); let code = run(redirect_args(tmp.path(), server.uri())).await; - assert_eq!(code, 1, "a failed ledger write must flip the exit code"); - // The failure is about the ledger, not the rewrite: the lockfile edit - // landed before the ledger write was attempted. + assert_eq!(code, 1, "an unusable ledger must flip the exit code"); + // Fail-closed ordering: the ledger problem surfaces before any project + // file is touched, so the lockfile still points at the upstream registry. let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); assert!( - lock.contains(HOSTED_URL), - "the lockfile rewrite precedes the ledger write; got:\n{lock}" + !lock.contains(HOSTED_URL), + "an unusable ledger must abort before the lockfile rewrite; got:\n{lock}" ); } @@ -1888,62 +1890,67 @@ async fn redirect_json_mode_failures_emit_error_envelope() { assert_error_envelope(&out, "reference-resolve failure"); } -/// The write-failure bail-outs (legs 3-4 of the four `--json` failure -/// exits) must also emit the machine-readable envelope: a rewritten -/// lockfile that cannot be written back, and a revert ledger that cannot -/// be persisted. Both are driven with real filesystem obstructions so the -/// run reaches the write in question and fails there. (Legs 1-2 — the -/// discovery-detail and reference-resolve failures — are pinned by -/// `redirect_json_mode_failures_emit_error_envelope` above.) +/// Shared by the write-failure envelope tests below: even a run that dies on +/// a filesystem obstruction must exit 1 with a machine-readable `--json` +/// error envelope on stdout. +fn assert_write_failure_envelope(out: &std::process::Output, leg: &str) { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(1), + "{leg}: failure exit; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( + "{leg}: --json stdout must be a parseable envelope even on failure ({e}); \ + stdout=\n{stdout}" + ) + }); + assert_eq!(v["status"], "error", "{leg}: status; stdout=\n{stdout}"); + assert!( + v["error"].as_str().is_some_and(|m| !m.is_empty()), + "{leg}: envelope must carry the error message; stdout=\n{stdout}" + ); + assert_eq!( + v["redirect"]["mode"], "hosted", + "{leg}: envelope must identify the mode; stdout=\n{stdout}" + ); +} + +/// Shared driver for the write-failure legs: a hosted `scan --redirect --json` +/// subprocess against the obstructed project in `tmp`. +async fn run_hosted_json_scan(tmp: &std::path::Path, server: &MockServer) -> std::process::Output { + scrubbed_cli() + .args([ + "scan", + "--redirect", + "--yes", + "--json", + "--cwd", + tmp.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch") +} + +/// Leg 3 of the four `--json` failure exits: the rewritten lockfile cannot +/// be written back (read-only file; the rewriter read it fine moments +/// earlier). A real filesystem obstruction drives the run to the write in +/// question and fails it there. (Legs 1-2 — the discovery-detail and +/// reference-resolve failures — are pinned by +/// `redirect_json_mode_failures_emit_error_envelope` above; leg 4 — the +/// ledger write — is pinned by the unix-only +/// `redirect_ledger_write_failure_leaves_project_files_untouched` below.) #[tokio::test] #[serial] async fn redirect_json_mode_write_failures_emit_error_envelope() { - fn assert_error_envelope(out: &std::process::Output, leg: &str) { - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - assert_eq!( - out.status.code(), - Some(1), - "{leg}: failure exit; stdout=\n{stdout}\nstderr=\n{stderr}" - ); - let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { - panic!( - "{leg}: --json stdout must be a parseable envelope even on failure ({e}); \ - stdout=\n{stdout}" - ) - }); - assert_eq!(v["status"], "error", "{leg}: status; stdout=\n{stdout}"); - assert!( - v["error"].as_str().is_some_and(|m| !m.is_empty()), - "{leg}: envelope must carry the error message; stdout=\n{stdout}" - ); - assert_eq!( - v["redirect"]["mode"], "hosted", - "{leg}: envelope must identify the mode; stdout=\n{stdout}" - ); - } - async fn run_leg(tmp: &std::path::Path, server: &MockServer) -> std::process::Output { - scrubbed_cli() - .args([ - "scan", - "--redirect", - "--yes", - "--json", - "--cwd", - tmp.to_str().unwrap(), - "--api-url", - &server.uri(), - "--org", - ORG, - "--api-token", - "fake", - ]) - .output() - .expect("run socket-patch") - } - - // Leg 3 — the rewritten lockfile cannot be written back (read-only - // file; the rewriter read it fine moments earlier). let server = MockServer::start().await; mock_discovery(&server).await; mock_reference(&server).await; @@ -1954,19 +1961,242 @@ async fn redirect_json_mode_write_failures_emit_error_envelope() { let mut perms = std::fs::metadata(&lock).unwrap().permissions(); perms.set_readonly(true); std::fs::set_permissions(&lock, perms).unwrap(); - let out = run_leg(tmp.path(), &server).await; - assert_error_envelope(&out, "lockfile-write failure"); + let out = run_hosted_json_scan(tmp.path(), &server).await; + assert_write_failure_envelope(&out, "lockfile-write failure"); +} - // Leg 4 — the revert ledger cannot be persisted: a DIRECTORY squats on - // `.socket/vendor/redirect-state.json`, so `fs::write` fails after the - // lockfile rewrite succeeded. +/// Leg 4 of the four `--json` failure exits: the revert ledger cannot be +/// persisted — `.socket/vendor` is read-only, so the atomic writer's stage +/// file cannot be created. The ledger is written BEFORE the project files +/// (its recorded originals are the only revert path), so the failure must +/// also leave the lockfile untouched — not rewritten-but-unrevertable. +/// +/// unix-only: the obstruction is a read-only DIRECTORY, and Windows ignores +/// FILE_ATTRIBUTE_READONLY on directories for file creation, so the stage +/// file would be created fine there (leg 3's read-only FILE does obstruct on +/// Windows and stays cross-platform). +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn redirect_ledger_write_failure_leaves_project_files_untouched() { let server = MockServer::start().await; mock_discovery(&server).await; mock_reference(&server).await; mock_view(&server).await; let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); - std::fs::create_dir_all(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); - let out = run_leg(tmp.path(), &server).await; - assert_error_envelope(&out, "ledger-write failure"); + let lock_before = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + let mut perms = std::fs::metadata(&vendor_dir).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&vendor_dir, perms.clone()).unwrap(); + let out = run_hosted_json_scan(tmp.path(), &server).await; + // Restore writability so the tempdir can be cleaned up. + perms.set_readonly(false); + std::fs::set_permissions(&vendor_dir, perms).unwrap(); + assert_write_failure_envelope(&out, "ledger-write failure"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(), + lock_before, + "a failed ledger write must leave the project files untouched \ + (ledger-before-files ordering)" + ); +} + +/// A MALFORMED redirect ledger (torn write, truncation, bad hand-edit) must +/// abort a hosted run before anything is written. The old tolerant load +/// returned `None` for it, so `run_redirect` started a FRESH ledger and +/// overwrote the corrupt file — permanently destroying every previously +/// recorded pre-redirect original (the only revert path) with exit 0. +#[tokio::test] +#[serial] +async fn corrupt_ledger_fails_closed_and_preserves_the_bytes() { + const TORN: &[u8] = b"{ \"version\": 1, \"mode\": \"hosted\", \"edits\": [ { \"path\": \"packa"; + + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let lock_before = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write(vendor_dir.join("redirect-state.json"), TORN).unwrap(); + + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--yes", + "--json", + "--cwd", + tmp.path().to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(1), + "a corrupt ledger must be a hard error, not a silent fresh start; \ + stdout=\n{stdout}" + ); + let v: serde_json::Value = + serde_json::from_str(&stdout).expect("--json stdout must stay parseable on failure"); + assert_eq!(v["status"], "error"); + let message = v["error"].as_str().unwrap_or_default(); + assert!( + message.contains("redirect-state.json"), + "error must name the ledger file: {message}" + ); + assert!( + message.contains("redirect-state.json.corrupt"), + "error must point at the moved-aside file: {message}" + ); + + // Nothing was rewritten, and the corrupt bytes survived verbatim in the + // quarantine file — never overwritten by a fresh ledger. + assert_eq!( + std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(), + lock_before, + "the project must be untouched" + ); + assert_eq!( + std::fs::read(vendor_dir.join("redirect-state.json.corrupt")).unwrap(), + TORN, + "the corrupt ledger bytes must be preserved for recovery" + ); + assert!( + !vendor_dir.join("redirect-state.json").exists(), + "no fresh ledger may be written over the failure" + ); +} + +/// `--dry-run` over a corrupt ledger reports the same hard error but moves +/// nothing: a dry run must not mutate the project, quarantine included. +#[tokio::test] +#[serial] +async fn corrupt_ledger_dry_run_errors_without_moving_the_file() { + const TORN: &[u8] = b"{ not json"; + + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write(vendor_dir.join("redirect-state.json"), TORN).unwrap(); + + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--yes", + "--json", + "--dry-run", + "--cwd", + tmp.path().to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(1), + "dry-run must report the corruption a real run would refuse on; \ + stdout=\n{stdout}" + ); + assert_eq!( + std::fs::read(vendor_dir.join("redirect-state.json")).unwrap(), + TORN, + "dry-run must not move or rewrite the malformed ledger" + ); + assert!( + !vendor_dir.join("redirect-state.json.corrupt").exists(), + "dry-run must not quarantine" + ); +} + +/// D2 regression: hosted mode records patches ONLY in the redirect ledger — +/// it never writes `.socket/manifest.json` — so `updates[]` (the documented +/// read-only CI signal) must consult the ledger too. A pure hosted project +/// whose redirected patch has been superseded used to report `updates: []` +/// forever. +#[tokio::test] +#[serial] +async fn scan_updates_reports_superseding_patch_for_ledger_only_project() { + const OLD_UUID: &str = "99999999-9999-4999-8999-999999999999"; + + let server = MockServer::start().await; + // Discovery offers ONLY the new uuid; the ledger records the old one. + mock_discovery(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + // Ledger-only persistence, exactly as a previous hosted run left it. + let mut ledger = socket_patch_core::patch::redirect::RedirectState::new(); + ledger.records.insert( + PURL.to_string(), + PatchRecord { + uuid: OLD_UUID.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write( + vendor_dir.join("redirect-state.json"), + format!("{}\n", serde_json::to_string_pretty(&ledger).unwrap()), + ) + .unwrap(); + + // Plain read-only `scan --json` — the nightly CI shape from the finding. + let out = scrubbed_cli() + .args([ + "scan", + "--json", + "--cwd", + tmp.path().to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(out.status.code(), Some(0), "stdout=\n{stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("parseable envelope"); + let updates = v["updates"].as_array().expect("updates array"); + assert_eq!( + updates.len(), + 1, + "the ledger-recorded patch was superseded — updates[] must say so; \ + stdout=\n{stdout}" + ); + assert_eq!(updates[0]["purl"], PURL); + assert_eq!(updates[0]["oldUuid"], OLD_UUID); + assert_eq!(updates[0]["newUuid"], UUID); } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0ff40eb5..12ed53b2 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -25,7 +25,10 @@ use crate::vendor::yarn_berry_lock::yarnrc_compression_level; pub mod golang_local; mod state; -pub use state::{load_redirect_state, RedirectState, REDIRECT_STATE_REL}; +pub use state::{ + load_redirect_state, save_redirect_state, CorruptRedirectState, RedirectState, + REDIRECT_STATE_REL, +}; /// One ecosystem's integrity hashes (mirrors the TS `PatchArtifactIntegrity`). #[derive(Debug, Clone, Default, Deserialize)] diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index a3c280b5..4633b6d6 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -11,12 +11,13 @@ //! key the manifest and VEX use). use std::collections::BTreeMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use super::FileEdit; use crate::manifest::schema::PatchRecord; +use crate::utils::fs::atomic_write_bytes; /// Repo-relative path of the redirect ledger. pub const REDIRECT_STATE_REL: &str = ".socket/vendor/redirect-state.json"; @@ -55,13 +56,119 @@ impl Default for RedirectState { } } -/// Load the redirect ledger. Missing OR malformed → `None` (VEX then simply -/// has nothing extra to attest, and per-entry verification still fails closed -/// downstream) rather than aborting the command. -pub async fn load_redirect_state(project_root: &Path) -> Option { +/// A redirect ledger that exists on disk but cannot be loaded (torn write, +/// truncation, hand-editing gone wrong, or an unreadable file). The ledger is +/// the ONLY store of the pre-redirect lockfile originals a future revert +/// needs, so a loader that shrugged this off as "no ledger" would let the +/// next hosted run start fresh and silently overwrite that revert data. +/// Instead every load distinguishes absent (fine, fresh start) from malformed +/// (this error), and the hosted writer refuses to proceed. +#[derive(Debug)] +pub struct CorruptRedirectState { + /// Absolute path of the malformed ledger. + pub path: PathBuf, + /// What went wrong reading/parsing it. + pub detail: String, + /// Where [`CorruptRedirectState::quarantine`] moved the file, when it did. + pub quarantined_to: Option, +} + +impl CorruptRedirectState { + /// Move the malformed ledger aside to `redirect-state.json.corrupt` so no + /// later run can overwrite the revert data it may still hold. Never + /// clobbers an existing `.corrupt` file (an earlier quarantine may hold + /// older revert data); on any failure the original file simply stays put + /// — the caller's hard error already prevents overwriting it. + pub async fn quarantine(&mut self) { + let target = match self.path.parent() { + Some(parent) => parent.join("redirect-state.json.corrupt"), + None => return, + }; + if !matches!(tokio::fs::try_exists(&target).await, Ok(false)) { + return; + } + if tokio::fs::rename(&self.path, &target).await.is_ok() { + self.quarantined_to = Some(target); + } + } +} + +impl std::fmt::Display for CorruptRedirectState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "the redirect ledger {} is malformed ({}); it records the \ + pre-redirect lockfile values a future revert needs, so it will \ + not be overwritten. ", + self.path.display(), + self.detail + )?; + match &self.quarantined_to { + Some(target) => write!( + f, + "The unreadable file was moved aside to {}; to recover, repair \ + its JSON and rename it back to redirect-state.json, or restore \ + the ledger and the rewritten files from version control. If \ + the revert data is expendable, delete the moved-aside file and \ + re-run.", + target.display() + ), + None => write!( + f, + "To recover, repair its JSON, restore it from version control, \ + or move it aside if the revert data is expendable, then re-run." + ), + } + } +} + +impl std::error::Error for CorruptRedirectState {} + +/// Load the redirect ledger. Missing → `Ok(None)` (a fresh start is fine). +/// Present but unreadable/malformed → [`CorruptRedirectState`], so no caller +/// can mistake a torn ledger for "no ledger" and overwrite the revert data it +/// still holds (see the type's docs). Read-only consumers may degrade a +/// malformed ledger to "nothing to consult", but must surface it; the hosted +/// writer must abort. +pub async fn load_redirect_state( + project_root: &Path, +) -> Result, CorruptRedirectState> { + let path = project_root.join(REDIRECT_STATE_REL); + let bytes = match tokio::fs::read(&path).await { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(CorruptRedirectState { + path, + detail: format!("unreadable: {e}"), + quarantined_to: None, + }); + } + }; + match serde_json::from_slice(&bytes) { + Ok(state) => Ok(Some(state)), + Err(e) => Err(CorruptRedirectState { + path, + detail: format!("invalid JSON: {e}"), + quarantined_to: None, + }), + } +} + +/// Persist the redirect ledger atomically (stage + fsync + rename, the same +/// hardened writer the sibling vendor ledger uses). A bare `fs::write` +/// truncates the target first, so a crash or `ENOSPC` mid-write would tear +/// the only store of the pre-redirect originals a future revert needs. +pub async fn save_redirect_state( + project_root: &Path, + state: &RedirectState, +) -> std::io::Result<()> { let path = project_root.join(REDIRECT_STATE_REL); - let bytes = tokio::fs::read(&path).await.ok()?; - serde_json::from_slice(&bytes).ok() + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let json = serde_json::to_string_pretty(state).map_err(std::io::Error::other)?; + atomic_write_bytes(&path, format!("{json}\n").as_bytes()).await } #[cfg(test)] @@ -118,7 +225,7 @@ mod tests { #[tokio::test] async fn load_missing_ledger_is_none() { let tmp = tempfile::tempdir().unwrap(); - assert!(load_redirect_state(tmp.path()).await.is_none()); + assert!(load_redirect_state(tmp.path()).await.unwrap().is_none()); } #[tokio::test] @@ -137,7 +244,7 @@ mod tests { .await .unwrap(); - let loaded = load_redirect_state(tmp.path()).await.unwrap(); + let loaded = load_redirect_state(tmp.path()).await.unwrap().unwrap(); assert!(loaded.records.contains_key("pkg:npm/left-pad@1.3.0")); } @@ -156,18 +263,121 @@ mod tests { ) .await .unwrap(); - let loaded = load_redirect_state(tmp.path()).await.unwrap(); + let loaded = load_redirect_state(tmp.path()).await.unwrap().unwrap(); assert_eq!(loaded.mode, "redirect"); } #[tokio::test] - async fn load_malformed_ledger_is_none() { + async fn load_malformed_ledger_is_a_hard_error_naming_the_file() { + // A torn/hand-mangled ledger must NOT load as "no ledger": the old + // tolerant `None` let the next hosted run start a fresh ledger and + // silently overwrite the only copy of the pre-redirect revert data. let tmp = tempfile::tempdir().unwrap(); let dir = tmp.path().join(".socket/vendor"); tokio::fs::create_dir_all(&dir).await.unwrap(); tokio::fs::write(dir.join("redirect-state.json"), b"{ not json") .await .unwrap(); - assert!(load_redirect_state(tmp.path()).await.is_none()); + let err = load_redirect_state(tmp.path()).await.unwrap_err(); + assert_eq!(err.path, dir.join("redirect-state.json")); + let message = err.to_string(); + assert!( + message.contains("redirect-state.json"), + "error must name the file: {message}" + ); + assert!( + message.contains("revert"), + "error must explain what is at stake: {message}" + ); + // The pure load never mutates the project. + assert!(dir.join("redirect-state.json").exists()); + assert!(!dir.join("redirect-state.json.corrupt").exists()); + } + + #[tokio::test] + async fn quarantine_moves_the_malformed_ledger_aside_preserving_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".socket/vendor"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("redirect-state.json"), b"{ torn ledger") + .await + .unwrap(); + let mut err = load_redirect_state(tmp.path()).await.unwrap_err(); + err.quarantine().await; + assert_eq!( + err.quarantined_to.as_deref(), + Some(dir.join("redirect-state.json.corrupt").as_path()) + ); + assert!( + err.to_string().contains("redirect-state.json.corrupt"), + "error must point at the moved-aside file: {err}" + ); + assert!(!dir.join("redirect-state.json").exists()); + assert_eq!( + tokio::fs::read(dir.join("redirect-state.json.corrupt")) + .await + .unwrap(), + b"{ torn ledger", + "quarantine must preserve the corrupt bytes verbatim" + ); + } + + #[tokio::test] + async fn quarantine_never_clobbers_an_earlier_corrupt_snapshot() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".socket/vendor"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write( + dir.join("redirect-state.json.corrupt"), + b"older revert data", + ) + .await + .unwrap(); + tokio::fs::write(dir.join("redirect-state.json"), b"{ newer torn") + .await + .unwrap(); + let mut err = load_redirect_state(tmp.path()).await.unwrap_err(); + err.quarantine().await; + assert!(err.quarantined_to.is_none()); + assert_eq!( + tokio::fs::read(dir.join("redirect-state.json.corrupt")) + .await + .unwrap(), + b"older revert data", + "an earlier quarantine snapshot must never be overwritten" + ); + assert!( + dir.join("redirect-state.json").exists(), + "with the quarantine slot taken the malformed file stays put" + ); + } + + #[tokio::test] + async fn save_writes_atomically_and_round_trips() { + let tmp = tempfile::tempdir().unwrap(); + let mut state = RedirectState::new(); + state + .records + .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); + // Creates `.socket/vendor` itself. + save_redirect_state(tmp.path(), &state).await.unwrap(); + + let loaded = load_redirect_state(tmp.path()).await.unwrap().unwrap(); + assert!(loaded.records.contains_key("pkg:npm/left-pad@1.3.0")); + let text = tokio::fs::read_to_string(tmp.path().join(REDIRECT_STATE_REL)) + .await + .unwrap(); + assert!(text.ends_with('\n'), "ledger keeps its trailing newline"); + // The atomic writer must not leave its stage file behind. + let mut entries = tokio::fs::read_dir(tmp.path().join(".socket/vendor")) + .await + .unwrap(); + while let Some(entry) = entries.next_entry().await.unwrap() { + let name = entry.file_name().to_string_lossy().into_owned(); + assert!( + !name.starts_with(".socket-stage-"), + "stage litter left behind: {name}" + ); + } } }