From 0b7714bfa3a9c3d8c554fcd11ffafeef5dac5476 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 1 Sep 2026 13:53:50 -0400 Subject: [PATCH 1/3] fix: 39 coverage-audit bug fixes across vendor, redirect, and CLI command flows Full-coverage audit of never-executed code paths (host+docker lcov at d5e1815) surfaced 120 suspected bugs; 39 survived 3-reviewer adversarial refutation panels and every one reproduced with a failing test before its fix. Classes: - orphaned vendor-artifact husks on failure paths (npm_common, npm_lock, composer_lock, pnpm_lock_legacy, gem): missing done_failure_unstage twins of the fixed cargo shape - keep_artifact / --preserve-state contract violations (bun_lock, gem, pnpm_lock_legacy): empty-wiring revert refusal fired despite documented skip; legacy revert helpers missed already-converged liveness checks - drift-keep gates missing in pypi revert family (pipenv/poetry/pdm): vendored wheel dir deleted while a drift-skipped lock still references it - wired-rebuild safety: gem/nuget_feed/composer/pypi rebuild failures deleted the live artifact dir the wired lock still points at; pypi in-sync rebuild now honors the wired sha256/wheel-path pin - NuGet packageSourceMapping: nuget.org seed suppressed by whole-file substring probe -> socket-only exclusive mapping (NU1100 for every other package); insert_nuget_source replacen could drop the source while mapping+ledger were still recorded - scan --prune: RevertOutcome::kept_artifact ignored (drift-kept entries pruned + artifacts destroyed); corrupt vendor ledger silently defeated every vendored prune safeguard - redirect: requirements.txt rewriter silently dropped unparseable granted deps (entry-not-found parity warning added); pnpm-trust removal lacked the duplicate-occurrence guard; remove_fragment_once newline-eat merged the following line; takeover alias-keyed edits stranded/mis-claimed - registry_fetch: 128MB cap enforced only after full read; composer flat zip layouts refused; vendor/mod zip-entry cap gated on declared size (unbounded read_to_end) - get --json double JSON document on engine hard error; rollback --ecosystems replayed other ecosystems' leftover hosted edits; scan hosted --dry-run previewed against vendored wiring; apply --silent exited 1 mutely when patch sources unavailable; repair --vendor pre-delete destroyed healthy soft-reconstructed artifacts - pnpm-workspace.yaml flow-sequence packages parsed as zero members (pre-existing RED tests un-ignored); authed batch 404 no longer an empty success; cargo config inline-table crates-io entries now visible to remove/read; uv: dependency-groups-only deps refused up front, empty overrides array no longer renders malformed, direct-URL requires-dist fails closed; golang missing-target precheck no longer fails healthy wired re-runs 5311 tests green (196 binaries, full workspace). Co-Authored-By: Claude Fable 5 --- .../src/commands/fetch_stage.rs | 9 +- crates/socket-patch-cli/src/commands/get.rs | 10 + .../src/commands/repair_vendor.rs | 78 ++- .../socket-patch-cli/src/commands/rollback.rs | 7 +- .../src/commands/scan/discovery.rs | 218 +++++- .../src/commands/scan/hosted.rs | 59 +- .../socket-patch-cli/src/commands/vendor.rs | 204 +++++- .../coverage_fix_apply_silent_mute_exit.rs | 182 +++++ .../tests/coverage_fix_get_double_json.rs | 96 +++ .../coverage_fix_repair_vendor_predelete.rs | 335 +++++++++ ...ge_fix_rollback_ecosystem_scoped_replay.rs | 139 ++++ ...erage_fix_scan_discovery_corrupt_ledger.rs | 192 ++++++ ...overage_fix_scan_hosted_dryrun_vendored.rs | 404 +++++++++++ .../socket-patch-cli/tests/scan_vendor_e2e.rs | 110 ++- crates/socket-patch-core/src/api/client.rs | 89 ++- .../src/package_json/find.rs | 57 +- .../src/patch/redirect/mod.rs | 321 ++++++++- .../src/patch/redirect/replay.rs | 117 +++- .../src/patch/redirect/takeover.rs | 280 +++++++- .../socket-patch-core/src/vendor/bun_lock.rs | 48 +- .../src/vendor/cargo_config.rs | 156 ++++- crates/socket-patch-core/src/vendor/common.rs | 78 ++- .../src/vendor/composer_lock.rs | 289 +++++++- crates/socket-patch-core/src/vendor/gem.rs | 401 ++++++++++- crates/socket-patch-core/src/vendor/golang.rs | 58 ++ crates/socket-patch-core/src/vendor/mod.rs | 101 ++- .../src/vendor/npm_common.rs | 171 ++++- .../socket-patch-core/src/vendor/npm_lock.rs | 97 ++- .../src/vendor/nuget_feed.rs | 177 ++++- .../src/vendor/pnpm_lock_legacy.rs | 246 ++++++- crates/socket-patch-core/src/vendor/pypi.rs | 633 +++++++++++++++++- .../src/vendor/pypi_pipenv.rs | 39 ++ .../socket-patch-core/src/vendor/pypi_uv.rs | 542 +++++++++++++-- .../src/vendor/registry_fetch.rs | 188 +++++- 34 files changed, 5860 insertions(+), 271 deletions(-) create mode 100644 crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs create mode 100644 crates/socket-patch-cli/tests/coverage_fix_get_double_json.rs create mode 100644 crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs create mode 100644 crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_replay.rs create mode 100644 crates/socket-patch-cli/tests/coverage_fix_scan_discovery_corrupt_ledger.rs create mode 100644 crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index 90bdfd0b..f806b3ee 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -74,8 +74,11 @@ pub(crate) enum StageOutcome { /// Shared offline diagnostic: patches with no usable local source while /// `--offline` is set (first five PURLs, then the `repair` hint). +/// Prints even under `--silent` (errors only, NEVER nothing — an exit-1 +/// run with zero output is undiagnosable); `--json` mutes stderr and the +/// caller's envelope is the machine channel instead. fn report_offline_missing(common: &GlobalArgs, purls: &[&str]) { - if common.silent || common.json { + if common.json { return; } eprintln!( @@ -291,7 +294,9 @@ pub(crate) async fn stage_patch_sources( &missing_package_archives, ); if !uncovered.is_empty() { - if !quiet { + // An error, not progress chatter: prints even under --silent + // (same rule as report_offline_missing above). + if !common.json { eprintln!("Some artifacts could not be downloaded. Cannot apply patches."); } return Ok(StageOutcome::Unavailable); diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 7fcad9f1..6faf0b3e 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -2329,6 +2329,16 @@ pub async fn run(args: GetArgs) -> i32 { }; let (code, mut result_json) = download_and_apply_patches(&selected, ¶ms).await; + // A download-phase HARD error (unreadable manifest, unwritable + // .socket, failed manifest write) is an `error`-status envelope the + // engine has ALREADY printed — printing below would put a second JSON + // document on stdout (get's `--json` contract is exactly one per + // run; `run_get_vendored_search` has the same guard). Per-patch + // failures are NOT this case: they ride a success-shaped + // (`partial_failure`) envelope the engine leaves for us to print. + if result_json["status"] == "error" { + return code; + } fold_narrowing_into_result(&mut result_json, &narrow_skips, &narrow_warnings); if args.common.json { diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index cf104f52..009d70ba 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -49,7 +49,7 @@ //! member-only state — never from the unverifiable live tree. use std::collections::{HashMap, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::CrawlerOptions; @@ -352,6 +352,38 @@ async fn remove_vendor_dir(cwd: &Path, eco: &str, uuid: &str) { } } +/// Move the live uuid dir aside (same parent, `.pre-rebuild`) so the +/// backends' rebuild-on-MISSING trigger fires while the bytes stay +/// recoverable: the dispatch can still refuse or fail — the in-hand +/// installed copy may itself be broken in ways no pre-rebuild rung probes +/// — and a failed dispatch replaced nothing, so the artifact +/// (member-healthy for a soft candidate, corrupt-but-diagnosable for a +/// pass-1 one) must be restorable instead of leaving the wired lockfiles +/// pointing at a bare ENOENT (see the NOTE above the staging step). +/// Returns `(live, kept)` for [`restore_aside_vendor_dir`]; on a rename +/// failure falls back to plain removal (the rebuild trigger must fire) +/// and returns `None`. +async fn set_aside_vendor_dir(cwd: &Path, eco: &str, uuid: &str) -> Option<(PathBuf, PathBuf)> { + let rel = vendor::path::vendor_uuid_dir_rel(eco, uuid)?; + let live = cwd.join(&rel); + let kept = cwd.join(format!("{rel}.pre-rebuild")); + // A crashed earlier run's leftover must not wedge the rename. + let _ = remove_tree(&kept).await; + if tokio::fs::rename(&live, &kept).await.is_ok() { + Some((live, kept)) + } else { + let _ = remove_tree(&live).await; + None + } +} + +/// Put the pre-rebuild bytes back after a dispatch that produced no +/// replacement (clearing any partial husk the failed backend left first). +async fn restore_aside_vendor_dir(live: &Path, kept: &Path) { + let _ = remove_tree(live).await; + let _ = tokio::fs::rename(kept, live).await; +} + /// The vendored-artifact phase of `repair`. Runs between the download and /// cleanup phases (and under `--download-only` — restoring artifacts IS /// repair's job). `manifest` is `None` when the project has no @@ -804,9 +836,10 @@ pub(crate) async fn repair_vendored_artifacts( } } - // NOTE: corrupt artifacts are NOT deleted here. Deletion waits until + // NOTE: corrupt artifacts are NOT deleted here. Clearing waits until // the rebuild loop below, where the patch sources and a pristine - // package source are both in hand — see the comment there. Destroying + // package source are both in hand (and even there it is a MOVE-ASIDE, + // restored when the dispatch fails) — see the comment there. Destroying // the corrupt copy before the rebuild-source ladder runs would, on any // no-source outcome (--offline, node_modules gone, fetch failure), // convert a corrupt-but-diagnosable integrity-mismatch state into a @@ -1035,20 +1068,24 @@ pub(crate) async fn repair_vendored_artifacts( continue; // failed above }; // Clear the live uuid dir only NOW — the patch sources and the - // pristine source are both in hand, so a rebuild WILL replace it. - // The backends' wired hot paths rebuild on MISSING (one uniform - // trigger for every ecosystem), and the live bytes must never - // blend into the rebuild: + // pristine source are both in hand. The backends' wired hot paths + // rebuild on MISSING (one uniform trigger for every ecosystem), + // and the live bytes must never blend into the rebuild: // - corrupt: the recorded fingerprint already condemned them; // - soft: the healthy-by-members live tree is exactly what cannot // be trusted — the fingerprint below derives from the // member-verified rebuild, never the live bytes. - // Deleting any earlier destroys evidence: with no rebuild source - // the corrupt copy is all a human has left to diagnose (and the - // lock still points at it — see the NOTE above the staging step). - if c.soft || c.reason == "vendor_artifact_corrupt" { - remove_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await; - } + // Cleared by MOVE-ASIDE, not deletion: an in-hand source does not + // make the dispatch infallible (the installed copy may itself be + // broken in ways no pre-rebuild rung probes), and a dispatch that + // refuses or fails replaced nothing — the bytes go back rather + // than leaving the wired lockfiles pointing at a bare ENOENT and + // destroying the evidence the NOTE above the staging step keeps. + let aside = if c.soft || c.reason == "vendor_artifact_corrupt" { + set_aside_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await + } else { + None + }; // For an unverified-source rebuild the rewired lockfile is the trust // anchor: snapshot the wiring files so a failed post-verify can put // them back byte-for-byte. The backend's re-wire may refresh the @@ -1089,6 +1126,9 @@ pub(crate) async fn repair_vendored_artifacts( .await; match outcome { None => { + if let Some((live, kept)) = &aside { + restore_aside_vendor_dir(live, kept).await; + } fail( env, quiet, @@ -1098,6 +1138,9 @@ pub(crate) async fn repair_vendored_artifacts( ); } Some(VendorOutcome::Refused { code, detail }) => { + if let Some((live, kept)) = &aside { + restore_aside_vendor_dir(live, kept).await; + } fail(env, quiet, &c.purl, code, detail); } Some(VendorOutcome::Done { @@ -1106,6 +1149,9 @@ pub(crate) async fn repair_vendored_artifacts( warnings, }) => { if !result.success { + if let Some((live, kept)) = &aside { + restore_aside_vendor_dir(live, kept).await; + } fail( env, quiet, @@ -1115,6 +1161,12 @@ pub(crate) async fn repair_vendored_artifacts( ); continue; } + // The rebuild replaced the artifact: the set-aside copy is + // condemned bytes now (post-verify failures below keep + // their existing nothing-kept contract). + if let Some((_, kept)) = &aside { + let _ = remove_tree(kept).await; + } for w in &warnings { // The Rebuilt event below carries the rebuild signal. if w.code != "vendor_artifact_rebuilt" { diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index c9f211d8..e111c234 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -1064,9 +1064,12 @@ pub async fn run(args: RollbackArgs) -> i32 { // edits without records) is vacuously "covered" by any scope; only // an UNSCOPED run may replay those leftover edits — a scoped // rollback of an unrelated purl must not unwind live redirects it - // was never asked about. + // was never asked about. `--ecosystems` counts as a scope here: + // recordless edits carry no purl to narrow by, so an eco-narrowed + // run leaves them to an unscoped rollback rather than replaying + // other ecosystems' edits behind the filter's back. Ok(Some(s)) => { - (!s.records.is_empty() || !scoped) + (!s.records.is_empty() || (!scoped && args.common.ecosystems.is_none())) && s.records.keys().all(|p| hosted_scope.contains(p)) } _ => false, diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 60e46fb5..dfe71cf6 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -142,8 +142,23 @@ pub(super) async fn vendored_ledger_supplement( if common.global || common.global_prefix.is_some() { return Vec::new(); } - let Ok(state) = socket_patch_core::vendor::load_state(&common.cwd).await else { - return Vec::new(); + let base_purls: Vec = match socket_patch_core::vendor::load_state(&common.cwd).await { + Ok(state) => state + .entries + .values() + .map(|entry| strip_purl_qualifiers(&entry.base_purl).to_string()) + .collect(), + // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above). + // Returning empty here silently dropped every vendored purl from + // `scanned_purls` — and since the `vendored_purl_keys` prune + // exemption degrades to empty on the same Err (fail-open by its + // documented contract), `scan --prune` then deleted still-vendored + // packages' manifest entries and blobs while their committed + // artifacts remained. Recover the vendored set from the committed + // ground truth instead: a manifest entry whose patch uuid owns a + // live `.socket/vendor//` artifact dir is vendored (the + // contract-documented recovery convention — see `vendor::path`). + Err(_) => vendored_purls_from_artifacts(common).await, }; let crawled_norm: HashSet = crawled .iter() @@ -151,8 +166,7 @@ pub(super) async fn vendored_ledger_supplement( .collect(); let mut seen: HashSet = HashSet::new(); let mut out = Vec::new(); - for entry in state.entries.values() { - let base = strip_purl_qualifiers(&entry.base_purl); + for base in &base_purls { let norm = normalize_purl(base).into_owned(); if crawled_norm.contains(&norm) || !seen.insert(norm) { continue; @@ -165,6 +179,37 @@ pub(super) async fn vendored_ledger_supplement( out } +/// Fallback source for [`vendored_ledger_supplement`] when the vendor ledger +/// is unreadable: base purls of manifest entries whose patch uuid owns a +/// live `.socket/vendor//` artifact dir. `vendor_uuid_dir_rel` +/// validates the (committed, tamper-able) uuid grammar fail-closed before +/// any disk probe. Entries without a live artifact dir are NOT recovered — +/// nothing committed consumes them, so they stay prunable. +async fn vendored_purls_from_artifacts(common: &GlobalArgs) -> Vec { + use socket_patch_core::manifest::operations::read_manifest; + use socket_patch_core::vendor::ecosystem_dir_for_purl; + use socket_patch_core::vendor::path::vendor_uuid_dir_rel; + + let Ok(Some(manifest)) = read_manifest(common.resolved_manifest_path()).await else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (purl, record) in &manifest.patches { + let base = strip_purl_qualifiers(purl); + let Some(eco) = ecosystem_dir_for_purl(base) else { + continue; + }; + let Some(rel) = vendor_uuid_dir_rel(eco, &record.uuid) else { + continue; + }; + match tokio::fs::metadata(common.cwd.join(&rel)).await { + Ok(md) if md.is_dir() => out.push(base.to_string()), + _ => {} + } + } + out +} + /// Vendor-mode pre-prompt check: uuids of selected patches whose installed /// files match NEITHER beforeHash nor afterHash — the patch was built /// against different bytes than the installed artifact. Vendoring still @@ -836,6 +881,171 @@ mod tests { ); } + // ---- vendored_ledger_supplement (corrupt-ledger fallback) --------------- + // The prune-safety chain for vendored packages: their purls enter + // `scanned_purls` via this supplement, which shields their manifest + // entries (and blobs) from `scan --prune`'s GC even when the + // `vendored_purl_keys` exemption degrades to empty (fail-open by its + // documented contract). A corrupt `.socket/vendor/state.json` + // (`load_state` → Err; a MISSING file is Ok(empty)) must therefore fall + // back to the committed ground truth — manifest entries whose patch uuid + // owns a live `.socket/vendor//` artifact dir — instead of + // silently returning empty and letting the prune delete still-vendored + // records. + + const VENDORED_UUID: &str = "11111111-1111-4111-8111-111111111111"; + + fn seed_manifest_entry(root: &std::path::Path, purl: &str, uuid: &str) { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let manifest = serde_json::json!({ + "patches": { + purl: { + "uuid": uuid, + "exportedAt": "2026-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "", + "license": "MIT", + "tier": "free", + } + } + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + } + + /// A truncated merge-resolution artifact: not valid JSON at all, so + /// `load_state` errs (fail-closed) rather than reading an empty ledger. + fn seed_corrupt_ledger(root: &std::path::Path) { + let vendor = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write(vendor.join("state.json"), b"{\"entries\": {").unwrap(); + } + + async fn supplement_in( + root: &std::path::Path, + crawled: &[socket_patch_core::crawlers::types::CrawledPackage], + ) -> Vec { + let args = GlobalArgs { + cwd: root.to_path_buf(), + ..GlobalArgs::default() + }; + vendored_ledger_supplement(&args, crawled).await + } + + #[tokio::test] + async fn corrupt_ledger_recovers_vendored_purls_from_committed_artifacts() { + let tmp = tempfile::tempdir().unwrap(); + seed_manifest_entry(tmp.path(), "pkg:cargo/foo@1.0.0", VENDORED_UUID); + seed_corrupt_ledger(tmp.path()); + std::fs::create_dir_all( + tmp.path() + .join(format!(".socket/vendor/cargo/{VENDORED_UUID}/foo-1.0.0")), + ) + .unwrap(); + + let out = supplement_in(tmp.path(), &[]).await; + assert_eq!( + out.iter().map(|p| p.purl.as_str()).collect::>(), + vec!["pkg:cargo/foo@1.0.0"], + "a corrupt ledger must fall back to the committed artifact dirs, \ + not silently drop the vendored purls from the scan" + ); + } + + #[tokio::test] + async fn corrupt_ledger_fallback_decodes_qualified_manifest_keys() { + // Manifest keys come API-encoded and possibly qualified; the + // fabricated purl must be the decoded base form (what the crawler + // and `scanned_purls` speak). + let tmp = tempfile::tempdir().unwrap(); + seed_manifest_entry( + tmp.path(), + "pkg:npm/%40scope/pkg@1.0.0?artifact_id=x", + VENDORED_UUID, + ); + seed_corrupt_ledger(tmp.path()); + std::fs::create_dir_all( + tmp.path() + .join(format!(".socket/vendor/npm/{VENDORED_UUID}")), + ) + .unwrap(); + + let out = supplement_in(tmp.path(), &[]).await; + assert_eq!( + out.iter().map(|p| p.purl.as_str()).collect::>(), + vec!["pkg:npm/@scope/pkg@1.0.0"], + ); + } + + #[tokio::test] + async fn corrupt_ledger_fallback_skips_entries_without_artifact_dirs() { + // A manifest entry with no live uuid dir has nothing committed + // consuming it — it is NOT resurrected, so a genuinely-stale entry + // stays prunable even while the ledger is corrupt. + let tmp = tempfile::tempdir().unwrap(); + seed_manifest_entry(tmp.path(), "pkg:cargo/foo@1.0.0", VENDORED_UUID); + seed_corrupt_ledger(tmp.path()); + + assert!(supplement_in(tmp.path(), &[]).await.is_empty()); + } + + #[tokio::test] + async fn corrupt_ledger_fallback_rejects_non_canonical_uuids() { + // The manifest is a committed, tamper-able file: a uuid that is not + // the exact canonical grammar must not drive any disk probe or + // fabrication (same fail-closed rule as `vendor_uuid_dir_rel`). + let tmp = tempfile::tempdir().unwrap(); + seed_manifest_entry(tmp.path(), "pkg:cargo/foo@1.0.0", "../../escape"); + seed_corrupt_ledger(tmp.path()); + std::fs::create_dir_all(tmp.path().join(".socket/vendor/cargo/escape")).unwrap(); + + assert!(supplement_in(tmp.path(), &[]).await.is_empty()); + } + + #[tokio::test] + async fn missing_ledger_still_yields_no_supplement() { + // A MISSING state.json is a deliberately-empty ledger (Ok path), not + // corruption — the fallback must not fire and invent vendored + // packages for a project that never vendored. + let tmp = tempfile::tempdir().unwrap(); + seed_manifest_entry(tmp.path(), "pkg:cargo/foo@1.0.0", VENDORED_UUID); + std::fs::create_dir_all( + tmp.path() + .join(format!(".socket/vendor/cargo/{VENDORED_UUID}")), + ) + .unwrap(); + + assert!(supplement_in(tmp.path(), &[]).await.is_empty()); + } + + #[tokio::test] + async fn corrupt_ledger_fallback_excludes_crawled_packages() { + // Same exclusion the healthy-ledger path applies: an installed + // (crawled) copy needs no fabricated supplement entry. + let tmp = tempfile::tempdir().unwrap(); + seed_manifest_entry(tmp.path(), "pkg:cargo/foo@1.0.0", VENDORED_UUID); + seed_corrupt_ledger(tmp.path()); + std::fs::create_dir_all( + tmp.path() + .join(format!(".socket/vendor/cargo/{VENDORED_UUID}")), + ) + .unwrap(); + let crawled = vec![socket_patch_core::crawlers::types::CrawledPackage { + name: "foo".to_string(), + version: "1.0.0".to_string(), + namespace: None, + purl: "pkg:cargo/foo@1.0.0".to_string(), + path: tmp.path().join("foo"), + }]; + + assert!(supplement_in(tmp.path(), &crawled).await.is_empty()); + } + // ---- 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 7ac265a4..00938f19 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -994,6 +994,12 @@ pub(crate) async fn run_redirect_selected( // with an actionable error — never half-migrated. let takeover_capable = |p: &str| p.starts_with("pkg:cargo/") || p.starts_with("pkg:npm/"); let mut takeover_pre_warnings: Vec = Vec::new(); + // Dry-run takeover previews: `(purl, uuid)` pairs whose vendored state + // the wet run would revert and then redirect. Withheld from the + // rewriters (their lock fragments still carry the vendored wiring the + // wet run reverts FIRST) and counted as redirected below, so the + // preview's envelope matches the wet run's outcome. + let mut dry_run_takeover: Vec<(String, String)> = Vec::new(); if !candidates.iter().any(|(p, ..)| takeover_capable(p)) { // No takeover-capable candidates — nothing to reconcile. } else { @@ -1003,7 +1009,7 @@ pub(crate) async fn run_redirect_selected( let patch_entries = socket_patch_core::vendor::cargo_config::read_patch_entries(&common.cwd).await; let mut refused: Vec = Vec::new(); - for (purl, _uuid, ..) in &candidates { + for (purl, uuid, ..) in &candidates { if !takeover_capable(purl) { continue; } @@ -1015,6 +1021,32 @@ pub(crate) async fn run_redirect_selected( .cloned(); if let Some(entry) = ledger_entry { if common.dry_run { + // Preview through the same per-purl revert machinery the + // wet run dispatches (write-free under dry_run): a + // vendored state the wet run would refuse to revert is + // refused here too, and one it would revert is announced + // as a takeover — never handed to the rewriters, which + // would preview against the still-vendored wiring and + // fail-closed refuse it, prescribing a manual + // `vendor --revert` for a purl this run just promised to + // revert itself while reporting `redirected: 0` for a + // migration the wet run lands. + let outcome = + crate::commands::vendor::dispatch_revert_one(&entry, &common.cwd, true) + .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; + } takeover_pre_warnings.push(serde_json::json!({ "code": "redirect_would_revert_vendored", "detail": format!( @@ -1023,6 +1055,7 @@ pub(crate) async fn run_redirect_selected( artifact first, then redirect (mode takeover)" ), })); + dry_run_takeover.push((purl.clone(), uuid.clone())); continue; } let outcome = @@ -1123,12 +1156,21 @@ pub(crate) async fn run_redirect_selected( })); } } - let refused_names: std::collections::HashSet<(String, String, String)> = candidates + } + // Purls leaving the rewrite set: refused takeovers, plus the dry-run + // takeover previews (still vendored on disk — the wet run reverts + // them before the rewriters ever see their files). + let withheld: Vec<&String> = refused + .iter() + .chain(dry_run_takeover.iter().map(|(p, _)| p)) + .collect(); + if !withheld.is_empty() { + let withheld_names: std::collections::HashSet<(String, String, String)> = candidates .iter() - .filter(|(p, ..)| refused.contains(p)) + .filter(|(p, ..)| withheld.contains(&p)) .filter_map(|(p, ..)| parse_purl_simple(p)) .collect(); - candidates.retain(|(p, ..)| !refused.contains(p)); + candidates.retain(|(p, ..)| !withheld.contains(&p)); overrides.retain(|o| { // Overrides built here carry the full coordinate in `name` // (namespace unset) — the same shape parse_purl_simple emits. @@ -1136,7 +1178,7 @@ pub(crate) async fn run_redirect_selected( Some(ns) if !ns.is_empty() => format!("{ns}/{}", o.name), _ => o.name.clone(), }; - !refused_names.contains(&(o.ecosystem.clone(), coord, o.version.clone())) + !withheld_names.contains(&(o.ecosystem.clone(), coord, o.version.clone())) }); } } @@ -1568,6 +1610,13 @@ pub(crate) async fn run_redirect_selected( ) .map(|(purl, uuid, _, _, _, _)| (purl.clone(), uuid.clone())) .collect(); + // Dry-run mode-takeover previews were withheld from the rewriters (their + // lock fragments still carry the vendored wiring the wet run reverts + // first), so the presence probe above cannot see them: the wet run + // reverts then redirects each one, and the preview's `redirected` count + // must report that outcome. Populated only under --dry-run. + let mut confirmed = confirmed; + confirmed.extend(dry_run_takeover); // Fetch the full patch view (file hashes + vulnerabilities) for each // CONFIRMED redirect and persist it so a post-install `socket-patch vex` diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 977284f4..1f397084 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1605,6 +1605,11 @@ pub(crate) struct VendorGcSummary { /// blob sweep reclaims the rest in the same pass; /// (c) sweep orphan uuid dirs. /// +/// A drift-skipped revert ([`RevertOutcome::kept_artifact`]) keeps the +/// ledger entry — and, in (b), the purl's manifest records — exactly like +/// every other `dispatch_revert_one` caller; the kept purl is counted +/// nowhere (nothing was reclaimed). +/// /// Detached entries are exempt from BOTH (a) (never manifest-tracked) and /// (b) (lockfile-invisible by design — the probe would always call them /// unused). A missing/unreadable manifest skips (a) only (a prune must @@ -1660,14 +1665,19 @@ pub(crate) async fn run_vendor_gc( continue; } let entry = state.entries.get(&purl).cloned().expect("listed above"); - if dispatch_revert_one(&entry, &common.cwd, false) - .await - .success - { + let outcome = dispatch_revert_one(&entry, &common.cwd, false).await; + if !outcome.success { + out.failed.push(purl); + } else if outcome.kept_artifact { + // Drift-skip keep (residual #131): the backend left the + // drifted lock alone and kept the artifacts, so the ledger + // entry must survive too (the RevertOutcome contract every + // other caller honors) — which also shields the uuid dir + // from the (c) orphan sweep. Nothing was reclaimed, so the + // purl is counted nowhere. + } else { state.entries.remove(&purl); out.dropped_reverted.push(purl); - } else { - out.failed.push(purl); } } } @@ -1693,13 +1703,18 @@ pub(crate) async fn run_vendor_gc( out.unused_reverted.push(purl); continue; } - if !dispatch_revert_one(&entry, &common.cwd, false) - .await - .success - { + let outcome = dispatch_revert_one(&entry, &common.cwd, false).await; + if !outcome.success { out.failed.push(purl); continue; } + if outcome.kept_artifact { + // Drift-skip keep (residual #131), same gate as (a) — and the + // purl's manifest records must survive too: pruning them would + // make the next `vendor` reconcile re-revert an entry whose + // backing record is gone (the `remove` caller's rationale). + continue; + } state.entries.remove(&purl); if let Some(m) = manifest.as_mut() { let base = strip_purl_qualifiers(&entry.base_purl).to_string(); @@ -2379,6 +2394,175 @@ mod gc_tests { ); } + /// The registry fragment recorded as the wiring `original` (pre-vendor). + fn registry_fragment() -> serde_json::Value { + serde_json::json!({ + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-orig==", + "license": "WTFPL" + }) + } + + /// `entry(false)` plus the wiring record a real vendor run records for + /// the package-lock entry — what lets the revert classify third-party + /// drift (live fragment neither ours nor the recorded original). + fn wired_entry() -> VendorEntry { + use socket_patch_core::vendor::state::{WiringAction, WiringRecord}; + let mut e = entry(false); + e.wiring.push(WiringRecord { + file: "package-lock.json".into(), + kind: "npm_lock_entry".into(), + action: WiringAction::Rewritten, + key: Some("node_modules/left-pad".into()), + original: Some(registry_fragment()), + new: Some(serde_json::json!({ + "version": "1.3.0", + "resolved": format!("file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"), + })), + }); + e + } + + /// [`gc_fixture`] with the ledger entry re-written as [`wired_entry`] + /// and the package-lock's `node_modules/left-pad` set to + /// `lock_fragment`. + async fn wired_gc_fixture( + lock_fragment: serde_json::Value, + ) -> (tempfile::TempDir, GlobalArgs, PathBuf) { + let (tmp, common, manifest_path) = gc_fixture(false).await; + let mut state = VendorState::default(); + state.entries.insert(PURL.to_string(), wired_entry()); + save_state(tmp.path(), &state).await.unwrap(); + tokio::fs::write( + tmp.path().join("package-lock.json"), + serde_json::to_vec(&serde_json::json!({ + "packages": { "node_modules/left-pad": lock_fragment } + })) + .unwrap(), + ) + .await + .unwrap(); + (tmp, common, manifest_path) + } + + /// The drifted lock fragment: a third party re-resolved the entry since + /// vendoring — neither ours nor the recorded pre-vendor original. + fn fork_fragment() -> serde_json::Value { + serde_json::json!({ + "version": "1.3.0", + "resolved": "https://example.com/their-fork.tgz" + }) + } + + /// (a) + drift-keep (residual #131): the patch left the manifest, but + /// the lock entry drifted since vendoring, so the revert leaves the + /// lock alone and returns success with `kept_artifact`. Per the + /// [`RevertOutcome::kept_artifact`] contract the GC must keep the + /// ledger entry — which also shields the uuid dir from the (c) orphan + /// sweep — and must NOT report the purl as cleanly reverted. Pre-fix + /// it pruned the entry, counted it `dropped_reverted`, and the sweep + /// then destroyed the kept artifacts. + #[tokio::test] + async fn vendor_gc_keeps_drift_skipped_manifest_dropped_entry() { + let (tmp, common, manifest_path) = wired_gc_fixture(fork_fragment()).await; + write_manifest(&manifest_path, &PatchManifest::new()) + .await + .unwrap(); + let lock_before = tokio::fs::read(tmp.path().join("package-lock.json")) + .await + .unwrap(); + + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert!( + out.dropped_reverted.is_empty(), + "a drift-kept entry must not be reported reverted: {out:?}" + ); + assert!(out.failed.is_empty(), "a keep is not a failure: {out:?}"); + assert!( + load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL), + "ledger entry must be kept" + ); + assert!( + tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "kept artifacts must survive the orphan sweep" + ); + assert_eq!( + tokio::fs::read(tmp.path().join("package-lock.json")) + .await + .unwrap(), + lock_before, + "drifted lock left alone" + ); + } + + /// (b) + drift-keep: the patch is still in the manifest, and the + /// in-use probe says the dependency no longer resolves through the + /// artifact — because the lock entry drifted to a third-party fork. + /// Same keep contract as (a), plus the purl's manifest records must + /// survive (pruning them would make the next `vendor` reconcile + /// re-revert an entry whose backing record is gone — the `remove` + /// caller's rationale). + #[tokio::test] + async fn vendor_gc_keeps_drift_skipped_unused_entry_and_manifest_record() { + let (tmp, common, manifest_path) = wired_gc_fixture(fork_fragment()).await; + + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert!( + out.unused_reverted.is_empty(), + "a drift-kept entry must not be reported reverted: {out:?}" + ); + assert!(out.failed.is_empty(), "a keep is not a failure: {out:?}"); + assert!( + load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL), + "ledger entry must be kept" + ); + assert!( + tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "kept artifacts must survive the orphan sweep" + ); + let manifest = read_manifest(&manifest_path).await.unwrap().unwrap(); + assert!( + manifest.patches.contains_key(PURL), + "the kept entry's manifest record must survive" + ); + } + + /// KEEP-GATE LIVENESS (mirrors in_process_vendor.rs's + /// `revert_completes_when_lock_already_matches_the_original`): a wired + /// entry whose lock fragment already equals the recorded pre-vendor + /// original is CONVERGED, not drifted — the keep gate must not block + /// the full reclaim. + #[tokio::test] + async fn vendor_gc_reclaims_converged_wired_unused_entry() { + let (tmp, common, manifest_path) = wired_gc_fixture(registry_fragment()).await; + + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert_eq!(out.unused_reverted, vec![PURL.to_string()], "{out:?}"); + assert!(out.failed.is_empty(), "{out:?}"); + assert!(load_state(tmp.path()).await.unwrap().entries.is_empty()); + assert!( + !tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "converged revert completes: artifacts reclaimed" + ); + let manifest = read_manifest(&manifest_path).await.unwrap().unwrap(); + assert!(!manifest.patches.contains_key(PURL), "{manifest:?}"); + } + /// (c) uuid dirs with no owning ledger entry are swept (wet) / counted /// (dry). #[tokio::test] diff --git a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs new file mode 100644 index 00000000..a56820e7 --- /dev/null +++ b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs @@ -0,0 +1,182 @@ +//! `apply --silent` sources-unavailable error-output contract tests. +//! +//! CLI_CONTRACT.md defines `--silent` as "Errors only" — never "nothing": +//! an exit-1 run with zero output is undiagnosable. The staging layer +//! (`fetch_stage::stage_patch_sources`) muted BOTH of its `Unavailable` +//! diagnostics under `--silent`: +//! +//! 1. `apply --silent --offline` with a manifest patch that has no local +//! blob/diff/package source exited 1 with zero bytes on stdout+stderr +//! (`report_offline_missing` returned early on `silent || json`). +//! 2. Online `apply --silent` whose downloads all fail (dead endpoint) +//! exited 1 with zero output (the "Some artifacts could not be +//! downloaded" line was gated on `!quiet`, i.e. muted by `--silent`). +//! +//! Same class previously fixed in four other apply paths (see +//! `cli_apply_silent.rs`), in scan, and in setup; `rollback --silent +//! --offline` (`cli_rollback_silent.rs`) pins the same rule for rollback. +//! +//! Under `--json` both diagnostics stay off stderr — the envelope is the +//! machine channel, and `apply_invariants.rs` pins its exact shape for +//! this path (partialFailure, empty events, NO top-level error record — +//! deliberately distinct from vendor's `no_local_source` hard error). + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use socket_patch_cli::args::GLOBAL_ARG_ENV_VARS; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +/// Run `socket-patch apply` in `cwd` with a scrubbed SOCKET_* environment +/// so ambient developer/CI configuration (tokens, silent toggles) can't +/// change the branch under test. +fn run_apply(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.arg("apply").args(args).current_dir(cwd); + for var in GLOBAL_ARG_ENV_VARS { + cmd.env_remove(var); + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("run socket-patch apply"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +/// Non-error stderr lines: drop the unconditional core API-token warning +/// (both its lead line and its "Got: ... Continuing anyway" continuation) +/// and blank lines, keep everything else. +fn stderr_chatter(stderr: &str) -> Vec { + stderr + .lines() + .filter(|l| { + !l.contains("SOCKET_API_TOKEN") + && !l.contains("Continuing anyway") + && !l.trim().is_empty() + }) + .map(|l| l.to_string()) + .collect() +} + +/// Valid manifest with one npm patch entry and NO blob/diff/package +/// artifact anywhere under `.socket/` — staging has no usable source. +fn write_sourceless_manifest(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + r#"{ "patches": { + "pkg:npm/left-pad@1.3.0": { + "uuid": "11111111-1111-4111-8111-111111111111", + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "index.js": { + "beforeHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "afterHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }}, + "vulnerabilities": {}, "description": "x", + "license": "MIT", "tier": "free" + } + }}"#, + ) + .unwrap(); +} + +/// A guaranteed-unreachable local endpoint: bind an ephemeral port, then +/// release it, so every request fails fast with connection-refused. +fn dead_endpoint() -> String { + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + format!("http://127.0.0.1:{port}") +} + +/// `apply --silent --offline` with a manifest patch that has no local +/// source must keep the offline error diagnostics ("errors only", never +/// "nothing" — exit 1 with no output is undiagnosable in the npm +/// postinstall hook that runs `apply` silently). +#[test] +fn apply_silent_offline_missing_source_keeps_error_output() { + let tmp = tempfile::tempdir().unwrap(); + write_sourceless_manifest(tmp.path()); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--silent", "--offline"]); + assert_eq!(code, 1, "offline + no local source must fail: {stderr}"); + assert!( + stdout.trim().is_empty(), + "silent human mode writes errors to stderr, not stdout: {stdout}" + ); + let chatter = stderr_chatter(&stderr); + assert!( + chatter.iter().any(|l| l.contains("no local source")), + "--silent must keep the offline no-source error (errors only, \ + never nothing); stderr was: {stderr:?}" + ); +} + +/// Online `apply --silent` where every artifact download fails (dead +/// endpoint) must keep the download-failure error output. +#[test] +fn apply_silent_online_download_failure_keeps_error_output() { + let tmp = tempfile::tempdir().unwrap(); + write_sourceless_manifest(tmp.path()); + + let url = dead_endpoint(); + let token = format!("sktsec_{}_api", "x".repeat(44)); + let (code, stdout, stderr) = run_apply( + tmp.path(), + &[ + "--silent", + "--api-url", + &url, + "--proxy-url", + &url, + "--api-token", + &token, + "--org", + "test-org", + ], + ); + assert_eq!(code, 1, "failed downloads + no source must fail: {stderr}"); + assert!( + stdout.trim().is_empty(), + "silent human mode writes errors to stderr, not stdout: {stdout}" + ); + let chatter = stderr_chatter(&stderr); + assert!( + chatter.iter().any(|l| l.contains("could not be downloaded")), + "--silent must keep the download-failure error (errors only, \ + never nothing); stderr was: {stderr:?}" + ); +} + +/// Overshoot guard: under `--json` the envelope is the machine channel — +/// the staging diagnostics must NOT leak to stderr, and the envelope +/// keeps the exact shape `apply_invariants.rs` pins for this path +/// (partialFailure, no top-level error record). +#[test] +fn apply_json_offline_missing_source_keeps_stderr_clean() { + let tmp = tempfile::tempdir().unwrap(); + write_sourceless_manifest(tmp.path()); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--json", "--offline"]); + assert_eq!(code, 1, "offline + no local source must fail: {stderr}"); + let v: serde_json::Value = + serde_json::from_str(&stdout).expect("apply --json must emit valid JSON"); + assert_eq!( + v["status"], "partialFailure", + "the pinned envelope shape for the offline bail, got {v}" + ); + let chatter = stderr_chatter(&stderr); + assert!( + chatter.is_empty(), + "--json suppresses the staging diagnostics on stderr (the \ + envelope is the machine channel); stderr was: {stderr:?}" + ); +} diff --git a/crates/socket-patch-cli/tests/coverage_fix_get_double_json.rs b/crates/socket-patch-cli/tests/coverage_fix_get_double_json.rs new file mode 100644 index 00000000..cdd6a728 --- /dev/null +++ b/crates/socket-patch-cli/tests/coverage_fix_get_double_json.rs @@ -0,0 +1,96 @@ +//! Subprocess regression test: agent-mode `get --json` must +//! print exactly ONE JSON document when the download engine hits a HARD +//! error (here: an unreadable manifest). The engine's fail-closed paths +//! in `download_and_apply_patches` print the `{status: "error"}` +//! envelope themselves via `report_error` and return it; `run()`'s agent +//! path then pretty-printed the SAME envelope again, putting two JSON +//! documents on stdout — violating get's one-document `--json` contract +//! (the vendored search path already guards this with its +//! `result["status"] == "error"` early return). +//! +//! Subprocess (not in-process) because the defect is precisely what the +//! spawned binary PRINTS. Same harness recipe as `get_modes_e2e.rs`. + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/mod.rs"] +mod common; + +const ORG: &str = "test-org"; +const GHSA: &str = "GHSA-dbld-json-once"; +const UUID1: &str = "44444444-4444-4444-8444-444444444444"; +const PURL1: &str = "pkg:npm/double-json-pkg@1.0.0"; + +/// `by-ghsa/{GHSA}` returning ONE free patch, so `select_patches` +/// auto-selects without prompting and the `--json` confirm auto-accepts. +/// No `view/{uuid}` mock is needed: the manifest read fails before any +/// per-patch fetch. +async fn mock_ghsa_single_free(server: &MockServer) { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/by-ghsa/{GHSA}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID1, "purl": PURL1, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "double-json fixture", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; +} + +/// A search-path `get --save-only --json` whose engine run dies on the +/// fail-closed corrupt-manifest read must emit ONE error envelope, not +/// the engine's copy followed by `run()`'s re-print of the same value. +/// `--save-only` keeps the run narrowing-exempt so the flow reaches the +/// engine without needing an installed project. +#[tokio::test] +async fn search_get_json_engine_hard_error_prints_one_document() { + let server = MockServer::start().await; + mock_ghsa_single_free(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + // A manifest that EXISTS but cannot be parsed trips the engine's + // fail-closed read (`Failed to read manifest: ...`). + std::fs::write(socket.join("manifest.json"), "{not json").unwrap(); + + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &[ + "get", + GHSA, + "--save-only", + "--json", + "--api-url", + &server.uri(), + "--api-token", + "fake-token-for-tests", + "--org", + ORG, + ], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + + assert_eq!( + code, 1, + "an unreadable manifest must fail the run.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // `serde_json::from_str` rejects trailing data, so a second envelope + // on the stream fails this parse loudly. + let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("stdout must be exactly one JSON document: {e}\nstdout:\n{stdout}") + }); + assert_eq!(v["status"], "error", "envelope drifted: {v}"); + assert!( + v["error"] + .as_str() + .is_some_and(|m| m.contains("Failed to read manifest")), + "error message drifted: {v}" + ); +} diff --git a/crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs b/crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs new file mode 100644 index 00000000..62e93dcb --- /dev/null +++ b/crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs @@ -0,0 +1,335 @@ +//! Repair's pre-rebuild uuid-dir clearing must never DESTROY an artifact +//! the dispatch then fails to replace. The rebuild loop clears the live +//! dir right before `dispatch_vendor_one` (the backends rebuild on +//! MISSING), but the dispatch itself can still refuse or fail — e.g. the +//! in-hand installed copy is broken in a way no pre-rebuild rung probes — +//! and a failed dispatch replaces nothing: the wired lockfiles are left +//! pointing at a bare ENOENT, and (for pass-1 corrupt candidates) the +//! forensic bytes the NOTE above repair's staging step promises to keep +//! are gone. Gem fixtures modeled on repair_vendor_e2e's. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use sha2::{Digest, Sha256}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +const ORG_SLUG: &str = "test-org"; +const BEFORE: &[u8] = b"before\n"; +const AFTER: &[u8] = b"after\n"; +const AFTER_B64: &str = "YWZ0ZXIK"; + +const GEM_UUID: &str = "22222222-2222-4222-8222-222222222222"; +const GEM_NAME: &str = "padlock"; +const GEM_VERSION: &str = "1.2.0"; +const GEM_PURL: &str = "pkg:gem/padlock@1.2.0"; +const GEM_ENCODED: &str = "pkg%3Agem%2Fpadlock%401.2.0"; +const GEMSPEC_STUB: &[u8] = b"Gem::Specification.new do |s|\n s.name = \"padlock\"\n s.version = \"1.2.0\"\n s.summary = \"repair fixture\"\n s.authors = [\"socket-patch e2e\"]\n s.require_paths = [\"lib\"]\nend\n"; + +fn git_sha256(content: &[u8]) -> String { + let header = format!("blob {}\0", content.len()); + let mut hasher = Sha256::new(); + hasher.update(header.as_bytes()); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +fn gem_copy_rel() -> String { + format!(".socket/vendor/gem/{GEM_UUID}/{GEM_NAME}-{GEM_VERSION}") +} + +/// Hermetic bundler project: exact-pin Gemfile, a lock modeled on real +/// bundler 4.0.15 output, and the installed gem + stub gemspec under the +/// project-local `vendor/bundle` layout the ruby crawler discovers. +fn write_gem_fixture(root: &Path) { + std::fs::write( + root.join("Gemfile"), + format!("source \"https://rubygems.org\"\n\ngem \"{GEM_NAME}\", \"{GEM_VERSION}\"\n"), + ) + .unwrap(); + std::fs::write( + root.join("Gemfile.lock"), + format!( + "GEM\n remote: https://rubygems.org/\n specs:\n {GEM_NAME} ({GEM_VERSION})\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n {GEM_NAME} (= {GEM_VERSION})\n\n\ + BUNDLED WITH\n 4.0.15\n" + ), + ) + .unwrap(); + + let home = root.join("vendor/bundle/ruby/3.4.0"); + let gem_dir = home.join(format!("gems/{GEM_NAME}-{GEM_VERSION}")); + std::fs::create_dir_all(gem_dir.join("lib")).unwrap(); + std::fs::write(gem_dir.join("lib/padlock.rb"), BEFORE).unwrap(); + std::fs::create_dir_all(home.join("specifications")).unwrap(); + std::fs::write( + home.join(format!("specifications/{GEM_NAME}-{GEM_VERSION}.gemspec")), + GEMSPEC_STUB, + ) + .unwrap(); +} + +/// Mount discovery + view for `GEM_UUID` (same shapes as repair_vendor_e2e). +async fn mount_gem_patch_api(mock: &MockServer) { + let before_hash = git_sha256(BEFORE); + let after_hash = git_sha256(AFTER); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": GEM_PURL, + "patches": [{ + "uuid": GEM_UUID, + "purl": GEM_PURL, + "tier": "free", + "cveIds": ["CVE-2026-0002"], + "ghsaIds": [], + "severity": "high", + "title": "gem vendor target" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{GEM_ENCODED}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": GEM_UUID, + "purl": GEM_PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "Gem vendor patch", + "license": "MIT", + "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{GEM_UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": GEM_UUID, + "purl": GEM_PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "lib/padlock.rb": { + "beforeHash": before_hash, + "afterHash": after_hash, + "blobContent": AFTER_B64, + } + }, + "vulnerabilities": { + "GHSA-dddd-eeee-ffff": { + "cves": ["CVE-2026-0002"], + "summary": "gem test vuln", + "severity": "high", + "description": "details" + } + }, + "description": "Gem vendor patch", + "license": "MIT", + "tier": "free", + }))) + .mount(mock) + .await; +} + +/// Serve the after-blob (no-ledger repairs re-download in step 1). +async fn mount_blob(mock: &MockServer) { + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/blob/{}", + git_sha256(AFTER) + ))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(AFTER)) + .mount(mock) + .await; +} + +fn run_cli(root: &Path, mock_uri: &str, argv: &[&str]) -> (i32, String, String) { + let mut full = argv.to_vec(); + full.extend_from_slice(&[ + "--json", + "--api-url", + mock_uri, + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ]); + let out = Command::new(binary()) + .args(&full) + .current_dir(root) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .output() + .expect("run"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn parse_env(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("bad JSON ({e}): {stdout}")) +} + +fn events_of(v: &serde_json::Value) -> Vec { + v["events"].as_array().cloned().unwrap_or_default() +} + +/// `scan --vendor --yes` the gem fixture; returns the vendored copy dir. +fn vendor_gem_project(root: &Path, mock_uri: &str) -> PathBuf { + let (code, stdout, stderr) = run_cli(root, mock_uri, &["scan", "--vendor", "--yes"]); + assert_eq!(code, 0, "gem vendor setup failed: {stdout} {stderr}"); + let copy = root.join(gem_copy_rel()); + assert_eq!( + std::fs::read(copy.join("lib/padlock.rb")).expect("vendored lib"), + AFTER, + "setup must vendor the patched copy" + ); + copy +} + +/// Ledger loss + member-healthy vendored dir → SOFT candidate; the +/// installed copy is still DISCOVERABLE (the crawler only needs the gem +/// dir with `lib/`) but its patched file is gone, so every pre-rebuild +/// soft fallback is bypassed (a rebuild source is "in hand") and the +/// dispatch itself fails the gem backend's fail-closed +/// missing_existing_patch_files pre-check. The failed dispatch replaced +/// nothing: the member-healthy artifact the wired Gemfile/Gemfile.lock +/// still resolve through must SURVIVE — a broken installed copy must +/// never be more destructive than an absent one (which keeps the tree, +/// see repair_vendor_e2e's G1d). RED before the move-aside fix: the uuid +/// dir was deleted up front and `bundle install` ENOENTs on the wired +/// path. +#[tokio::test] +async fn repair_keeps_healthy_soft_artifact_when_rebuild_dispatch_fails() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path()); + let copy = vendor_gem_project(tmp.path(), &mock.uri()); + let gemfile_wired = std::fs::read(tmp.path().join("Gemfile")).unwrap(); + + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + std::fs::remove_file( + tmp.path() + .join(format!( + "vendor/bundle/ruby/3.4.0/gems/{GEM_NAME}-{GEM_VERSION}/lib/padlock.rb" + )), + ) + .unwrap(); + + mount_blob(&mock).await; + let (code, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--download-mode", "file"], + ); + // The rebuild failure stays loud... + assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "failed" && e["purl"] == GEM_PURL), + "envelope={v}" + ); + // ...but the healthy artifact must not have been destroyed. + assert_eq!( + std::fs::read(copy.join("lib/padlock.rb")) + .expect("the vendored artifact must survive the failed rebuild"), + AFTER, + "member-healthy vendored copy intact" + ); + assert_eq!( + std::fs::read(copy.join("padlock.gemspec")).unwrap(), + GEMSPEC_STUB + ); + assert!( + !tmp.path() + .join(format!(".socket/vendor/gem/{GEM_UUID}.pre-rebuild")) + .exists(), + "no set-aside residue after the restore" + ); + // The pre-persisted fingerprint-less entry points at real bytes, and + // the pair is still wired to them. + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert_eq!(state["entries"][GEM_PURL]["uuid"], GEM_UUID, "state={state}"); + assert_eq!( + std::fs::read(tmp.path().join("Gemfile")).unwrap(), + gemfile_wired, + "the wired Gemfile is untouched" + ); +} + +/// The pass-1 corrupt twin of the same root: a ledgered artifact whose +/// unpatched member was tampered (Corrupt via inventory mismatch) queues +/// a rebuild, but the dispatch REFUSES (the installed stub gemspec the +/// gem backend requires is gone). The NOTE above repair's staging step +/// promises the corrupt-but-diagnosable bytes survive every no-rebuild +/// outcome — a refusing dispatch replaced nothing, so deleting the dir +/// up front converts the forensic tamper evidence into a bare ENOENT. +/// RED before the move-aside fix. +#[tokio::test] +async fn repair_keeps_corrupt_forensic_bytes_when_rebuild_dispatch_refuses() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path()); + let copy = vendor_gem_project(tmp.path(), &mock.uri()); + + // Tamper an UNPATCHED member: pass 1 flags the dir Corrupt (the + // recorded fileInventory knows), the patched member still verifies. + const TAMPERED: &[u8] = b"tampered stub\n"; + std::fs::write(copy.join("padlock.gemspec"), TAMPERED).unwrap(); + // The pristine ladder still finds the installed copy (rebuild source + // "in hand"), but the dispatch refuses: the specifications stub the + // gem backend rebuilds the gemspec from is gone. + std::fs::remove_file(tmp.path().join(format!( + "vendor/bundle/ruby/3.4.0/specifications/{GEM_NAME}-{GEM_VERSION}.gemspec" + ))) + .unwrap(); + + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "failed" && e["purl"] == GEM_PURL), + "envelope={v}" + ); + // The corrupt-but-diagnosable bytes survive the refusal. + assert_eq!( + std::fs::read(copy.join("padlock.gemspec")) + .expect("the corrupt artifact must survive a refused rebuild"), + TAMPERED, + "forensic evidence of the tamper preserved" + ); + assert_eq!( + std::fs::read(copy.join("lib/padlock.rb")).unwrap(), + AFTER, + "patched member intact" + ); + assert!( + !tmp.path() + .join(format!(".socket/vendor/gem/{GEM_UUID}.pre-rebuild")) + .exists(), + "no set-aside residue after the restore" + ); +} diff --git a/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_replay.rs b/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_replay.rs new file mode 100644 index 00000000..f1906d54 --- /dev/null +++ b/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_replay.rs @@ -0,0 +1,139 @@ +//! Regression suite for the `--ecosystems`-scoped rollback replay leak: +//! against a records-EMPTY degraded redirect ledger (a record-fetch-failed +//! hosted run persists its edits with no records), `replay_eligible` was +//! vacuously true for a scope spelled ONLY with `--ecosystems` — the flag +//! never fed the `scoped` flip — so `rollback --ecosystems npm` replayed, +//! and dropped from the ledger, leftover hosted edits of OTHER ecosystems +//! it was never asked about. +//! +//! The fixture hand-writes the ledger through the exported +//! `socket_patch_core::patch::redirect` types (real schema, real edit +//! kind) with the matching redirected fragment on disk — the +//! `in_process_rollback_hosted.rs` pattern. +//! +//! `#[serial]`: every command's `run` mirrors env toggles into +//! process-global env vars (`apply_env_toggles`). + +use std::path::Path; + +use serde_json::Value; +use serial_test::serial; +use socket_patch_cli::commands::rollback::{run as rollback_run, RollbackArgs}; +use socket_patch_core::patch::redirect::{save_redirect_state, FileEdit, RedirectState}; + +const PRISTINE_LINE: &str = "requests==2.31.0"; +const WIRED_LINE: &str = "requests @ http://patch.test/patch/pypi/requests/2.31.0/22222222-2222-4222-8222-222222222222/a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1/requests-2.31.0-py3-none-any.whl --hash=sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn requirements_content(line: &str) -> String { + format!("flask==2.0.1\n{line}\n") +} + +/// The leftover requirements-source edit a degraded (record-fetch-failed) +/// hosted run leaves behind: `redirect_requirements_line` has no per-purl +/// revert — only the whole-ledger replay can unwind it. +fn requirements_edit() -> FileEdit { + FileEdit { + path: "requirements.txt".to_string(), + kind: "redirect_requirements_line".to_string(), + action: "rewritten".to_string(), + key: Some("requests".to_string()), + original: Some(Value::String(PRISTINE_LINE.to_string())), + new: Some(Value::String(WIRED_LINE.to_string())), + } +} + +fn ledger_path(root: &Path) -> std::path::PathBuf { + root.join(".socket/vendor/redirect-state.json") +} + +/// requirements.txt still wired + a ledger holding the leftover pypi edit +/// and NO records (the record-fetch-failed shape). No manifest, no vendor +/// ledger — the redirect ledger alone keeps the run off the truly-empty +/// error path. +async fn write_degraded_pypi_fixture(root: &Path) { + std::fs::write( + root.join("requirements.txt"), + requirements_content(WIRED_LINE), + ) + .unwrap(); + let mut state = RedirectState::new(); + state.edits = vec![requirements_edit()]; + save_redirect_state(root, &state) + .await + .expect("write redirect ledger"); +} + +/// In-process wet rollback (`--json --yes --offline --silent`), optionally +/// `--ecosystems`-narrowed. +async fn rollback_in_process(cwd: &Path, ecosystems: Option>) -> i32 { + let args = RollbackArgs { + targets: Vec::new(), + common: socket_patch_cli::args::GlobalArgs { + cwd: cwd.to_path_buf(), + manifest_path: ".socket/manifest.json".to_string(), + ecosystems, + offline: true, + json: true, + yes: true, + silent: true, + ..socket_patch_cli::args::GlobalArgs::default() + }, + one_off: false, + preserve_state: false, + }; + let code = rollback_run(args).await; + // `apply_env_toggles` mirrored `--offline` into the PROCESS env and + // nothing unsets it; scrub so the next in-process run in this + // `#[serial]` process isn't silently forced offline. + std::env::remove_var("SOCKET_OFFLINE"); + code +} + +/// Regression: `rollback --ecosystems npm` over the records-empty degraded +/// ledger must NOT replay (and drop) the leftover PYPI edit. An +/// eco-narrowed run is a scoped run — only an unscoped rollback may claim +/// the whole-ledger replay of leftover edits. +#[tokio::test] +#[serial] +async fn ecosystems_scoped_rollback_leaves_other_ecosystems_leftover_edits() { + let tmp = tempfile::tempdir().unwrap(); + write_degraded_pypi_fixture(tmp.path()).await; + let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); + + let code = rollback_in_process(tmp.path(), Some(vec!["npm".to_string()])).await; + assert_eq!(code, 0, "an npm-scoped run with no npm state is a no-op"); + + assert_eq!( + std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap(), + requirements_content(WIRED_LINE), + "an --ecosystems npm rollback must not unwind the pypi redirect edit" + ); + assert_eq!( + std::fs::read(ledger_path(tmp.path())).unwrap(), + ledger_before, + "the out-of-scope leftover edit must stay in the ledger" + ); +} + +/// Control (the behavior the fix must not break): an UNSCOPED rollback +/// still replays the records-empty ledger's leftover edits and deletes +/// the emptied ledger. +#[tokio::test] +#[serial] +async fn unscoped_rollback_still_replays_leftover_edits() { + let tmp = tempfile::tempdir().unwrap(); + write_degraded_pypi_fixture(tmp.path()).await; + + let code = rollback_in_process(tmp.path(), None).await; + assert_eq!(code, 0, "unscoped replay of the leftover edit must succeed"); + + assert_eq!( + std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap(), + requirements_content(PRISTINE_LINE), + "the unscoped run must unwind the leftover pypi edit" + ); + assert!( + !ledger_path(tmp.path()).exists(), + "the emptied ledger must be deleted" + ); +} diff --git a/crates/socket-patch-cli/tests/coverage_fix_scan_discovery_corrupt_ledger.rs b/crates/socket-patch-cli/tests/coverage_fix_scan_discovery_corrupt_ledger.rs new file mode 100644 index 00000000..ae5fdba7 --- /dev/null +++ b/crates/socket-patch-cli/tests/coverage_fix_scan_discovery_corrupt_ledger.rs @@ -0,0 +1,192 @@ +//! Regression: a corrupt `.socket/vendor/state.json` must not defeat the +//! vendored prune safeguards. With the ledger unreadable, BOTH protection +//! legs used to degrade to empty — `vendored_ledger_supplement` (discovery) +//! silently returned no packages, so the vendored purls never entered +//! `scanned_purls`, and core's `vendored_purl_keys` prune exemption is +//! fail-open by contract — so `scan --prune` deleted a still-vendored +//! package's manifest entry and swept its blobs while the committed +//! artifacts remained. The fix recovers the vendored set from the committed +//! ground truth: manifest entries whose patch uuid owns a live +//! `.socket/vendor//` artifact dir. +//! +//! Modeled on `scan_vendor_e2e.rs` (mock API + real fixture through the +//! built binary). + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use sha2::{Digest, Sha256}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +const ORG_SLUG: &str = "test-org"; +/// The vendored cargo patch: uuid + purl of the entry that must survive. +const UUID: &str = "11111111-1111-4111-8111-111111111111"; +const CARGO_PURL: &str = "pkg:cargo/foo@1.0.0"; +const BEFORE: &[u8] = b"before\n"; +const AFTER: &[u8] = b"after\n"; + +fn git_sha256(content: &[u8]) -> String { + let header = format!("blob {}\0", content.len()); + let mut hasher = Sha256::new(); + hasher.update(header.as_bytes()); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +/// One installed npm package so the crawl finds ≥1 package and scan does +/// not take the zero-package early return (which skips the GC entirely). +fn write_npm_fixture(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "corrupt-ledger-test", "version": "0.0.0" }"#, + ) + .unwrap(); + let pkg = root.join("node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), BEFORE).unwrap(); +} + +/// The committed state of a vendored cargo dependency: manifest entry + +/// referenced blob + the contract-path artifact dir — plus a corrupt +/// vendor ledger (a bad merge-conflict resolution / truncation). +fn seed_vendored_cargo_with_corrupt_ledger(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(AFTER)), AFTER).unwrap(); + let manifest = serde_json::json!({ + "patches": { + CARGO_PURL: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "src/lib.rs": { + "beforeHash": git_sha256(BEFORE), + "afterHash": git_sha256(AFTER), + } + }, + "vulnerabilities": {}, + "description": "vendored cargo patch", + "license": "MIT", + "tier": "free", + } + } + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + + // The committed artifact at the contract path. + let artifact = socket.join(format!("vendor/cargo/{UUID}/foo-1.0.0")); + std::fs::create_dir_all(&artifact).unwrap(); + std::fs::write(artifact.join("Cargo.toml"), b"[package]\nname = \"foo\"\n").unwrap(); + + // Truncated ledger: not valid JSON, so load_state errs (fail-closed). + std::fs::write(socket.join("vendor/state.json"), b"{\"entries\": {").unwrap(); +} + +/// Spawn the built binary in `root` with the ambient `SOCKET_*` surface +/// scrubbed and telemetry killed (same posture as `scan_vendor_e2e.rs`). +fn run_cli(root: &Path, argv: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(argv).current_dir(root); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { + cmd.env_remove(&key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("run"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// A batch endpoint reporting NO available patches for the scanned set. +async fn mount_empty_discovery(mock: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; +} + +/// `scan --prune` on a fresh clone whose vendor ledger is corrupt: the +/// vendored package's manifest entry and blob must survive the GC. Before +/// the fix, discovery's ledger supplement silently returned empty, the +/// prune exemption was also empty, and the entry + blob were deleted while +/// the committed artifacts remained. +#[tokio::test] +async fn corrupt_ledger_scan_prune_keeps_vendored_manifest_entry() { + let mock = MockServer::start().await; + mount_empty_discovery(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_npm_fixture(tmp.path()); + seed_vendored_cargo_with_corrupt_ledger(tmp.path()); + + let uri = mock.uri(); + let (code, stdout, stderr) = run_cli( + tmp.path(), + &[ + "scan", + "--json", + "--prune", + "--api-url", + &uri, + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ], + ); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + + // The GC must not report the vendored entry as pruned… + let pruned = v["gc"]["prunedManifestEntries"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + !pruned.iter().any(|p| p == CARGO_PURL), + "corrupt ledger: the vendored entry must not be pruned; gc={}", + v["gc"] + ); + + // …the manifest entry must survive on disk… + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), + ) + .unwrap(); + assert!( + manifest["patches"][CARGO_PURL].is_object(), + "vendored manifest entry deleted despite committed artifacts; manifest={manifest}" + ); + + // …and its blob must not be swept as an orphan. + assert!( + tmp.path() + .join(".socket/blobs") + .join(git_sha256(AFTER)) + .is_file(), + "vendored entry's blob was swept" + ); +} diff --git a/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs new file mode 100644 index 00000000..9b6dd50b --- /dev/null +++ b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs @@ -0,0 +1,404 @@ +//! Coverage-audit regression: `scan --mode hosted --dry-run` over a VENDORED +//! project must preview the WET run's takeover outcome. +//! +//! Pre-fix, the takeover pre-revert loop's dry-run branch pushed the +//! `redirect_would_revert_vendored` warning ("will revert … then redirect") +//! and `continue`d — skipping the revert but LEAVING the purl in the +//! candidates/overrides handed to the rewriters. The pnpm/berry rewriters +//! then previewed against the still-vendored lock, fail-closed refused its +//! `file:.socket/vendor/…` resolution (`redirect_pnpm_entry_vendored`, +//! "run `vendor --revert` first"), and the envelope reported `redirected: 0` +//! with BOTH contradictory prescriptions — while the same command WITHOUT +//! `--dry-run` reverted first and reported `redirected: 1`. A CI gate keying +//! on the dry-run count concluded the migration would fail when it succeeds. +//! +//! Fixture: a real offline `vendor` run (the `in_process_vendor.rs` harness +//! shapes) produces the vendored lock + `.socket/vendor/state.json` entry; +//! the hosted API is wiremock (`in_process_redirect_pnpm.rs` shapes). + +use std::path::Path; + +use serde_json::{json, Value}; +use serial_test::serial; +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 NAME: &str = "dryrun-vendored-takeover"; +const VERSION: &str = "1.0.0"; +const PURL: &str = "pkg:npm/dryrun-vendored-takeover@1.0.0"; +const UUID: &str = "44444444-4444-4444-8444-444444444444"; +const HOSTED_URL: &str = "http://patch.test/patch/npm/dryrun-vendored-takeover/1.0.0/55555555-5555-4555-8555-555555555555/44444444-4444-4444-8444-444444444444/dryrun-vendored-takeover-1.0.0.tgz"; +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; +const UPSTREAM_SHA512: &str = "sha512-UPSTREAMupstream=="; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + +async fn mock_hosted_api(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "dry-run takeover 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(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-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(json!({ + "results": { + UUID: { + "status": "granted", + "url": HOSTED_URL, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + // `view/{uuid}` — the record the wet run persists into the redirect + // ledger after a confirmed redirect. + let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before_hash, + "afterHash": after_hash, + } + }, + "vulnerabilities": {}, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; +} + +/// The `in_process_redirect_pnpm.rs` project shape: v9 root pnpm lock +/// resolving the package under `packages:`, plus the installed node_modules +/// copy (with the real patch-target file, so the vendor run can pack the +/// artifact). +fn write_pnpm_project(root: &Path) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + std::fs::write( + root.join("pnpm-lock.yaml"), + format!( + "lockfileVersion: '9.0' + +importers: + .: + dependencies: + {NAME}: + specifier: {VERSION} + version: {VERSION} + +packages: + {NAME}@{VERSION}: + resolution: {{integrity: {UPSTREAM_SHA512}}} + +snapshots: + {NAME}@{VERSION}: {{}} +" + ), + ) + .unwrap(); +} + +/// The manifest + staged blob the offline vendor run needs. +fn seed_manifest_and_blob(root: &Path) { + let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + let manifest = json!({ + "patches": { + PURL: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before_hash, + "afterHash": after_hash + } + }, + "vulnerabilities": {}, + "description": "dry-run takeover fixture", + "license": "MIT", + "tier": "free" + } + } + }); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + bytes.push(b'\n'); + std::fs::write(socket.join("manifest.json"), &bytes).unwrap(); + std::fs::write(socket.join("blobs").join(after_hash), PATCHED_INDEX).unwrap(); +} + +/// Run the built binary with ambient `SOCKET_*` scrubbed; `(code, stdout, +/// stderr)`. +fn run_cli(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.args(args).current_dir(cwd); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { + cmd.env_remove(key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("spawn 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(), + ) +} + +/// `vendor --json --offline` through the binary → `(code, envelope)`. +fn vendor_cli(cwd: &Path) -> (i32, Value) { + let (code, stdout, stderr) = run_cli( + cwd, + &["vendor", "--json", "--offline", "--cwd", cwd.to_str().unwrap()], + ); + let env: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("vendor --json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (code, env) +} + +/// `scan --mode hosted --json [--dry-run]` through the binary → `(code, +/// envelope)`. +fn scan_hosted_json(cwd: &Path, api_url: &str, dry_run: bool) -> (i32, Value) { + let mut args = vec![ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + api_url, + "--org", + ORG, + "--api-token", + "fake", + ]; + if dry_run { + args.push("--dry-run"); + } + let (code, stdout, stderr) = run_cli(cwd, &args); + let doc: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("stdout must be the JSON envelope ({e});\nstdout=\n{stdout}\nstderr=\n{stderr}") + }); + (code, doc) +} + +/// Vendor the fixture project for real (offline) so the lock carries the +/// `file:.socket/vendor/…` wiring and `.socket/vendor/state.json` owns the +/// entry — the exact state the takeover pre-revert keys on. +fn vendored_project(root: &Path) { + write_pnpm_project(root); + seed_manifest_and_blob(root); + let (code, env) = vendor_cli(root); + assert_eq!(code, 0, "fixture vendor run must succeed: {env:#}"); + let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.contains(".socket/vendor/"), + "fixture must be vendored:\n{lock}" + ); + let state: Value = serde_json::from_str( + &std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert!( + state["entries"].get(PURL).is_some(), + "fixture vendored ledger must own the purl: {state:#}" + ); +} + +fn warning_codes(doc: &Value) -> Vec<&str> { + doc["redirect"]["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|e| e["code"].as_str()).collect()) + .unwrap_or_default() +} + +/// The dry-run preview must match the wet run's takeover outcome: the +/// vendored purl counts as `redirected` (the wet run reverts its vendored +/// state, then redirects), the `redirect_would_revert_vendored` warning +/// explains the plan, and the contradictory rewriter refusal +/// (`redirect_pnpm_entry_vendored`, "run `vendor --revert` first") never +/// appears — the run itself performs that revert. Nothing lands on disk. +#[tokio::test] +#[serial] +async fn dry_run_over_vendored_project_previews_the_wet_takeover() { + let server = MockServer::start().await; + mock_hosted_api(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendored_project(root); + let vendored_lock = std::fs::read(root.join("pnpm-lock.yaml")).unwrap(); + let vendored_state = std::fs::read(root.join(".socket/vendor/state.json")).unwrap(); + + let (code, doc) = scan_hosted_json(root, &server.uri(), /*dry_run=*/ true); + assert_eq!(code, 0, "dry-run scan --mode hosted must succeed: {doc:#}"); + assert_eq!(doc["redirect"]["dryRun"], true, "envelope: {doc:#}"); + + let codes = warning_codes(&doc); + assert!( + codes.contains(&"redirect_would_revert_vendored"), + "the takeover plan must be announced: {doc:#}" + ); + // THE BUG: the rewriters previewed against the still-vendored lock and + // refused it, contradicting the takeover warning above. + assert!( + !codes.contains(&"redirect_pnpm_entry_vendored"), + "the dry-run must not also tell the user to run `vendor --revert` \ + for a purl this run just promised to revert itself: {doc:#}" + ); + // THE BUG: the preview reported `redirected: 0` for a migration the wet + // run lands (below) — the CI-gate signal this envelope exists for. + assert_eq!( + doc["redirect"]["redirected"], 1, + "the dry-run must preview the wet outcome: {doc:#}" + ); + assert_eq!( + doc["redirect"]["skipped"].as_array().map(Vec::len), + Some(0), + "a revertable vendored purl is not skipped: {doc:#}" + ); + + // Dry-run invariants: nothing on disk moved. + assert_eq!( + std::fs::read(root.join("pnpm-lock.yaml")).unwrap(), + vendored_lock, + "dry-run must leave the vendored lock byte-identical" + ); + assert_eq!( + std::fs::read(root.join(".socket/vendor/state.json")).unwrap(), + vendored_state, + "dry-run must leave the vendored ledger byte-identical" + ); + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "dry-run must not write the redirect ledger" + ); + + // The SAME command without --dry-run: reverts the vendored state, then + // redirects — `redirected: 1`. This is the outcome the preview above + // must agree with. + let (code, wet) = scan_hosted_json(root, &server.uri(), /*dry_run=*/ false); + assert_eq!(code, 0, "wet scan --mode hosted must succeed: {wet:#}"); + assert_eq!( + wet["redirect"]["redirected"], 1, + "the wet takeover must land: {wet:#}" + ); + let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.contains(&format!("tarball: {HOSTED_URL}")), + "the wet run must leave the lock hosted:\n{lock}" + ); +} + +/// Refusal parity: a vendored purl whose revert the wet run would REFUSE +/// (here: a ledger entry whose uuid fails the revert's fail-closed grammar +/// guard) must be refused by the dry-run too — `redirect_vendored_revert_failed` +/// + a `vendored_revert_failed` skip, `redirected: 0` — never previewed as a +/// clean takeover, and never handed to the rewriters for a second, +/// contradictory diagnosis. +#[tokio::test] +#[serial] +async fn dry_run_refuses_unrevertable_vendored_state_like_the_wet_run() { + let server = MockServer::start().await; + mock_hosted_api(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendored_project(root); + // Corrupt the entry's uuid: revert (wet or preview) fail-closes on the + // uuid-dir grammar guard before touching anything. + let state_path = root.join(".socket/vendor/state.json"); + let mut state: Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + state["entries"][PURL]["uuid"] = json!("not-a-uuid"); + std::fs::write(&state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap(); + let vendored_lock = std::fs::read(root.join("pnpm-lock.yaml")).unwrap(); + + let (code, doc) = scan_hosted_json(root, &server.uri(), /*dry_run=*/ true); + assert_eq!(code, 0, "dry-run scan --mode hosted must succeed: {doc:#}"); + + let codes = warning_codes(&doc); + assert!( + codes.contains(&"redirect_vendored_revert_failed"), + "the unrevertable state must be refused in the preview too: {doc:#}" + ); + assert!( + !codes.contains(&"redirect_would_revert_vendored"), + "a refused purl must not also be promised a takeover: {doc:#}" + ); + assert!( + doc["redirect"]["skipped"] + .as_array() + .is_some_and(|s| s.iter().any(|e| e["purl"] == PURL + && e["reason"] == "vendored_revert_failed")), + "the refusal must be accounted as skipped: {doc:#}" + ); + assert_eq!( + doc["redirect"]["redirected"], 0, + "a refused takeover previews as not redirected: {doc:#}" + ); + assert_eq!( + std::fs::read(root.join("pnpm-lock.yaml")).unwrap(), + vendored_lock, + "dry-run must leave the vendored lock byte-identical" + ); +} diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index d10a5446..a8ff8903 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -897,14 +897,29 @@ async fn scan_vendor_resolves_percent_encoded_scoped_purl() { // ───────────────────── prune reconciles vendored state ───────────────────── /// After a dependency is removed and re-locked, `scan --prune` (without -/// `--vendor`) reverts the now-unused vendored entry: lock restored, ledger -/// entry + manifest entry dropped, artifact dir removed. +/// `--vendor`) honors the drift-keep contract, then completes the reclaim +/// once the drift is undone: +/// +/// 1. The wired lock entry VANISHED (an uninstall is one drift flavor — +/// the live lock no longer matches anything the wiring recorded), so +/// the backend revert keeps the artifacts (`RevertOutcome:: +/// kept_artifact`, residual #131) and the GC must keep the ledger and +/// manifest entries too — pre-fix it pruned the ledger, dropped the +/// manifest records, reported the purl in `revertedVendoredEntries`, +/// and the orphan sweep then destroyed the kept artifacts (with the +/// recorded pre-vendor originals, the state a later `git checkout` of +/// the vendored lock still points at). +/// 2. Undoing the drift (restoring the pre-vendor registry lock — the +/// keep warning's documented remediation) converges every recorded +/// fragment, and the same prune then reverts fully: ledger entry + +/// manifest entry dropped, artifact dir removed, lock untouched. #[tokio::test] async fn scan_prune_reverts_unused_vendored_entry() { let mock = MockServer::start().await; mount_patch_api(&mock, UUID).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path()); + let original_lock = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); // A second installed package so the later prune run's crawl is // non-empty (left-pad itself gets removed below). @@ -937,27 +952,72 @@ async fn scan_prune_reverts_unused_vendored_entry() { std::fs::remove_dir_all(tmp.path().join("node_modules/left-pad")).unwrap(); // Plain prune scan (read-only discovery + GC; no --vendor, no --apply). - let out = Command::new(binary()) - .args([ - "scan", - "--json", - "--prune", - "--yes", - "--api-url", - &mock.uri(), - "--api-token", - "fake-token", - "--org", - ORG_SLUG, - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); - let stdout = String::from_utf8_lossy(&out.stdout); - let code = out.status.code().unwrap_or(-1); - assert_eq!(code, 0, "stdout={stdout}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let run_prune = || { + let out = Command::new(binary()) + .args([ + "scan", + "--json", + "--prune", + "--yes", + "--api-url", + &mock.uri(), + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ]) + .current_dir(tmp.path()) + .output() + .expect("run"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let code = out.status.code().unwrap_or(-1); + assert_eq!(code, 0, "stdout={stdout}"); + serde_json::from_str::(stdout.trim()).expect("valid JSON") + }; + + // 1. Drifted (vanished) lock entry: everything is KEPT — nothing may + // be reported reverted, and the artifacts must survive the sweep. + let v = run_prune(); + assert_eq!( + v["gc"]["revertedVendoredEntries"], + serde_json::json!([]), + "a drift-kept entry must not be reported reverted: {v}" + ); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert!( + state["entries"][PURL].is_object(), + "ledger entry must be kept: {state}" + ); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), + ) + .unwrap(); + assert!( + manifest["patches"] + .as_object() + .is_some_and(|m| m.contains_key(PURL)), + "manifest entry must be kept: {manifest}" + ); + assert!( + tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "kept artifacts must survive the orphan sweep" + ); + // The (already left-pad-free) lock stays exactly as the user re-locked + // it — the keep never edits a lock it refused to own. + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock_bytes + ); + // 2. Undo the drift: restore the pre-vendor registry lock, so every + // recorded fragment is converged. The same prune now reclaims fully. + std::fs::write(tmp.path().join("package-lock.json"), &original_lock).unwrap(); + let v = run_prune(); assert_eq!( v["gc"]["revertedVendoredEntries"], serde_json::json!([PURL]), @@ -993,11 +1053,11 @@ async fn scan_prune_reverts_unused_vendored_entry() { .exists(), "artifact dir removed" ); - // The (already left-pad-free) lock stays exactly as the user re-locked - // it — the revert had nothing to restore there. + // The converged revert restores nothing (the lock already equals every + // recorded original), so the restored lock survives byte-for-byte. assert_eq!( std::fs::read(tmp.path().join("package-lock.json")).unwrap(), - lock_bytes + original_lock ); } diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index 297539da..e9aaa50d 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -287,10 +287,18 @@ impl ApiClient { let result = self .post_json::(&path, &body) .await?; - let mut result = result.unwrap_or_else(|| BatchSearchResponse { - packages: Vec::new(), - can_access_paid_patches: false, - }); + // A 404 here is a COLLECTION route miss — an unknown org slug + // (e.g. a typo'd --org / SOCKET_ORG_SLUG) or a server without + // the route. The server expresses "no patches" as 200 with + // empty `packages`, so substituting an empty success would + // mask the misconfiguration as a clean zero-patch scan. + let Some(mut result) = result else { + return Err(ApiError::Other(format!( + "API request failed with status 404: POST {} not found — \ + check the organization slug '{}' (--org / SOCKET_ORG_SLUG)", + path, slug + ))); + }; sort_batch_response(&mut result); return Ok(result); } @@ -2917,3 +2925,76 @@ mod vendor_package_tests { )); } } + +#[cfg(test)] +mod authenticated_batch_tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn auth_client(uri: String, slug: &str) -> ApiClient { + ApiClient::new(ApiClientOptions { + api_url: uri, + api_token: Some("sktsec_token_placeholder_value_api".into()), + use_public_proxy: false, + org_slug: Some(slug.into()), + }) + } + + /// A 404 from the authenticated batch COLLECTION route means the org + /// slug is wrong (or the deployment lacks the route) — the server + /// expresses "no patches" as 200 with empty `packages` — so it must + /// surface as an error. Substituting an empty success would mask a + /// typo'd `--org` / SOCKET_ORG_SLUG as a clean zero-patch scan. + #[tokio::test] + async fn authenticated_batch_404_is_an_error_not_empty_success() { + let server = MockServer::start().await; + // .expect(1): exactly one POST, no retry / per-package fallback. + Mock::given(method("POST")) + .and(path("/v0/orgs/typo-slug/patches/batch")) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + + let err = auth_client(server.uri(), "typo-slug") + .search_patches_batch(None, &["pkg:npm/lodash@4.17.21".to_string()]) + .await + .expect_err("a 404 on the authenticated batch route must not become an empty success"); + let msg = err.to_string(); + assert!( + msg.contains("404") && msg.contains("typo-slug"), + "error must name the status and the org slug: {msg}" + ); + // A wrong org slug must surface, not silently downgrade the scan + // to the public proxy's free-only patches. + assert!( + !is_fallback_candidate(&err), + "batch 404 must not trigger the auth→proxy fallback: {msg}" + ); + } + + /// Guard: the legitimate "no patches" shape (200 + empty packages) + /// stays a success — only the 404 route-miss is an error. + #[tokio::test] + async fn authenticated_batch_200_empty_packages_is_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v0/orgs/acme/patches/batch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [], + "canAccessPaidPatches": true + }))) + .expect(1) + .mount(&server) + .await; + + let result = auth_client(server.uri(), "acme") + .search_patches_batch(None, &["pkg:npm/lodash@4.17.21".to_string()]) + .await + .expect("200 with empty packages is the legitimate no-patches shape"); + assert!(result.packages.is_empty()); + assert!(result.can_access_paid_patches); + } +} diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs index 4454fef2..721446d2 100644 --- a/crates/socket-patch-core/src/package_json/find.rs +++ b/crates/socket-patch-core/src/package_json/find.rs @@ -197,7 +197,8 @@ fn parse_pnpm_workspace_patterns(yaml_content: &str) -> Vec { // The header may carry an inline comment (`packages: # globs`); a `#` // opens a comment only when preceded by whitespace. - let is_packages_header = match trimmed.strip_prefix("packages:") { + let after_key = trimmed.strip_prefix("packages:"); + let is_packages_header = match after_key { Some("") => true, Some(rest) => { rest.starts_with(|c: char| c.is_whitespace()) && rest.trim_start().starts_with('#') @@ -209,6 +210,15 @@ fn parse_pnpm_workspace_patterns(yaml_content: &str) -> Vec { continue; } + // The value may instead be a YAML flow sequence on the header line + // (`packages: ['a/*', "b/*"]`) — pnpm's real YAML parser accepts it + // exactly like the block list. + if let Some(rest) = after_key { + if rest.starts_with(|c: char| c.is_whitespace()) && rest.trim_start().starts_with('[') { + return parse_yaml_flow_sequence(rest.trim_start()); + } + } + if in_packages { if !trimmed.is_empty() && !trimmed.starts_with('-') && !trimmed.starts_with('#') { break; @@ -226,6 +236,43 @@ fn parse_pnpm_workspace_patterns(yaml_content: &str) -> Vec { patterns } +/// Parse a single-line YAML flow sequence (`['a', "b", c]`) into its scalar +/// items. `s` starts at the `[`; anything after the closing `]` (e.g. an +/// inline comment) is ignored. A `,` inside a quoted scalar is part of the +/// value — a brace pattern carries one — not an item separator. +fn parse_yaml_flow_sequence(s: &str) -> Vec { + let mut items = Vec::new(); + let mut current = String::new(); + let mut quote: Option = None; + // Skip the opening `[`. + for c in s.chars().skip(1) { + match quote { + Some(q) => { + current.push(c); + if c == q { + quote = None; + } + } + None => match c { + '\'' | '"' => { + quote = Some(c); + current.push(c); + } + ',' => items.push(std::mem::take(&mut current)), + ']' => break, + _ => current.push(c), + }, + } + } + items.push(current); + + items + .iter() + .map(|item| parse_yaml_list_value(item)) + .filter(|item| !item.is_empty()) + .collect() +} + /// Extract the scalar value of a YAML list item, handling surrounding quotes /// and trailing inline comments (`# ...`). fn parse_yaml_list_value(raw: &str) -> String { @@ -512,9 +559,6 @@ mod tests { } #[test] - #[ignore = "RED: parse_pnpm_workspace does not support the YAML flow-sequence \ - spelling `packages: [a, b]`. The test is correct; the parser fix \ - was not part of this change."] fn test_parse_pnpm_flow_sequence() { // pnpm parses pnpm-workspace.yaml with a real YAML parser, which accepts // a flow sequence (`packages: ['a/*', "b/*"]`) exactly like the block @@ -536,8 +580,6 @@ mod tests { } #[test] - #[ignore = "RED: same missing flow-sequence support as \ - test_parse_pnpm_flow_sequence, for the quoted-comma case."] fn test_parse_pnpm_flow_sequence_keeps_quoted_comma() { // A `,` inside a quoted scalar is part of the value (a brace pattern // carries one), so it must not split the sequence. @@ -805,9 +847,6 @@ mod tests { } #[tokio::test] - #[ignore = "RED: end-to-end consequence of the missing flow-sequence support \ - — a pnpm workspace declared with `packages: [a, b]` has its \ - members silently skipped by discovery."] async fn test_find_pnpm_flow_sequence_members_discovered() { // End-to-end symptom of the flow-sequence gap: the inline // `packages: [...]` spelling yielded no patterns, so a real pnpm diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 54d7497e..23a5bb46 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -518,6 +518,7 @@ fn rewrite_pypi_requirements( continue; }; let target = canonicalize_pypi_name(&dep.name); + let mut matched_any = false; for raw in lines.iter_mut() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') || line.starts_with('-') { @@ -529,6 +530,7 @@ fn rewrite_pypi_requirements( if canonicalize_pypi_name(&caps[1]) != target { continue; } + matched_any = true; // pip-compile --generate-hashes emits backslash continuations // (`foo==1.2 \` + indented `--hash=…` lines). Rewriting only the // first physical line would orphan the old hash lines and — with @@ -574,6 +576,20 @@ fn rewrite_pypi_requirements( changed = true; } } + // Parity with the npm/pnpm/berry/uv rewriters: a granted dep no line + // accounted for — omitted (a transitive dep the file never pins), or + // spelled in a form the name matcher cannot parse (a PEP 508 extras + // bracket) — must be SAID, not silently dropped from the redirected + // count. A found-but-refused line (continuation) already warned above. + if !matched_any { + result.warnings.push(RewriteWarning { + code: "redirect_requirements_entry_not_found".into(), + detail: format!( + "no requirements.txt entry for {}@{}", + dep.name, dep.version + ), + }); + } } if changed { result @@ -2981,7 +2997,12 @@ fn default_nuget_config() -> String { const NUGET_ORG_KEY: &str = "nuget.org"; const NUGET_ORG_URL: &str = "https://api.nuget.org/v3/index.json"; -fn add_nuget_source(config: &str, reg: &str, index_url: &str, pkg_id: &str) -> String { +/// `None` when an insert found no anchor (no `` form and no +/// `` root, or no close tag for a from-scratch mapping): the +/// caller must skip the dep fail-closed — writing the mapping without its +/// source (or recording the edit at all) routes the patched id to a source +/// that was never defined while the ledger claims the redirect landed. +fn add_nuget_source(config: &str, reg: &str, index_url: &str, pkg_id: &str) -> Option { // Capture the pre-existing packageSource keys BEFORE the Socket source is // added — the fallback below fans a `*` mapping out to them. let mut pre_existing_keys = nuget_package_source_keys(config); @@ -2994,14 +3015,18 @@ fn add_nuget_source(config: &str, reg: &str, index_url: &str, pkg_id: &str) -> S // nuget.org source so the catch-all has a real target (unless the config // already has one). Only relevant when we are about to CREATE the mapping. let creating_mapping = !out.contains(""); - let seed_nuget_org = - creating_mapping && pre_existing_keys.is_empty() && !config.contains(NUGET_ORG_KEY); + // "Already has one" is decided by the parsed keys ALONE: + // a whole-file "nuget.org" probe is satisfied by text that defines no + // source (a defaultPushSource URL, a entry, a + // comment), and suppressing the seed on it leaves the from-scratch + // mapping socket-only — NU1100 for every other package. + let seed_nuget_org = creating_mapping && pre_existing_keys.is_empty(); if seed_nuget_org { - out = insert_nuget_source(&out, NUGET_ORG_KEY, NUGET_ORG_URL); + out = insert_nuget_source(&out, NUGET_ORG_KEY, NUGET_ORG_URL)?; pre_existing_keys.push(NUGET_ORG_KEY.to_string()); } - out = insert_nuget_source(&out, reg, index_url); + out = insert_nuget_source(&out, reg, index_url)?; let socket_mapping = format!( " \n \n " @@ -3038,21 +3063,25 @@ fn add_nuget_source(config: &str, reg: &str, index_url: &str, pkg_id: &str) -> S format!("{socket_mapping}\n{fallback_mappings}") }; let map_block = format!(" \n{inner}\n "); - out = out.replacen( - "", - &format!("{map_block}\n"), - 1, - ); - } - out + // The close tag may carry whitespace (`` is valid + // XML); a literal replacen would silently drop the mapping. + let close_re = Regex::new(r"") + .expect("static configuration close-tag regex is valid"); + let m = close_re.find(&out)?; + let at = m.start(); + out = format!("{}{map_block}\n{}", &out[..at], &out[at..]); + } + Some(out) } /// Insert an `` source under ``, -/// creating the element (right after ``) when absent. A -/// self-closing `` (any whitespace before `/>`) is expanded -/// in place into an open/close pair rather than left dangling beside a -/// duplicate element. -fn insert_nuget_source(config: &str, key: &str, url: &str) -> String { +/// creating the element (right after the `` root open tag, +/// whatever whitespace or attributes it carries) when absent. A self-closing +/// `` (any whitespace before `/>`) is expanded in place +/// into an open/close pair rather than left dangling beside a duplicate +/// element. `None` when no anchor exists at all — the caller must treat the +/// insert as failed rather than proceed on unchanged text. +fn insert_nuget_source(config: &str, key: &str, url: &str) -> Option { let source_line = format!(" "); // A self-closing element carries no children, so expand it to an open/close // pair holding the new source. Matched before the open-tag check because a @@ -3067,19 +3096,26 @@ fn insert_nuget_source(config: &str, key: &str, url: &str) -> String { "\n{source_line}\n " )); out.push_str(&config[m.end()..]); - out + Some(out) } else if config.contains("") { - config.replacen( + Some(config.replacen( "", &format!("\n{source_line}"), 1, - ) + )) } else { - config.replacen( - "", - &format!("\n \n{source_line}\n "), - 1, - ) + // The root open tag may carry whitespace or attributes + // (``, ``) — all valid XML a + // literal `` match would silently miss, leaving the + // source undefined while the mapping still lands. + let open_re = Regex::new(r"]*)?>") + .expect("static configuration open-tag regex is valid"); + let end = open_re.find(config)?.end(); + Some(format!( + "{}\n \n{source_line}\n {}", + &config[..end], + &config[end..] + )) } } @@ -3097,7 +3133,12 @@ fn nuget_package_source_keys(config: &str) -> Vec { .as_str() }) .unwrap_or(""); - Regex::new(r#"]` keeps the match inside one + // element. + Regex::new(r#"]*?key\s*=\s*"([^"]+)""#) .expect("static add-key regex is valid") .captures_iter(scope) .map(|c| c[1].to_string()) @@ -3156,7 +3197,23 @@ fn rewrite_nuget( .unwrap_or_else(|| dep.name.to_lowercase()); if !config.contains(&format!("key=\"{reg}\"")) { - config = add_nuget_source(&config, ®, &ov.index_url, &dep.name); + // A failed insert skips the WHOLE dep (no edit record, no lock + // re-pin): a mapping without its source routes the patched id to + // a source that was never defined, and a lock pinned at the + // patched contentHash over an upstream fetch fails NU1403 — both + // while the ledger would claim the redirect landed. + let Some(updated) = add_nuget_source(&config, ®, &ov.index_url, &dep.name) else { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_config_unwritable".into(), + detail: format!( + "nuget.config has no element to wire {} into; \ + not redirected", + dep.name + ), + }); + continue; + }; + config = updated; config_changed = true; result.edits.push(FileEdit { path: "nuget.config".into(), @@ -5625,6 +5682,140 @@ mod tests { )); } + /// The nuget.org seed must not be suppressed by "nuget.org" TEXT outside + /// the `` element — a `defaultPushSource` URL, a + /// `` entry, or a comment is not a package + /// source. Suppressing the seed there leaves the from-scratch mapping + /// socket-only, and a mapping is exclusive: every other package NU1100s. + #[test] + fn nuget_seed_not_suppressed_by_nugetorg_text_outside_sources() { + for extra in [ + // The push URL mentions nuget.org but defines no source. + " \n \n \n", + // So does a comment. + " \n", + ] { + let mut files = BTreeMap::new(); + files.insert( + "nuget.config".to_string(), + format!( + "\n\n \n \n{extra}\n" + ), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + let out = r.files.get("nuget.config").expect("config rewritten"); + assert!( + out.contains( + "" + ), + "nuget.org source seeded despite unrelated mention: {out}" + ); + assert!( + out.contains( + " \n \n " + ), + "catch-all present (socket-only mapping NU1100s everything): {out}" + ); + } + } + + /// An `` keyed nuget.org that the strict scan used to miss (XML + /// allows whitespace around `=` and any attribute order) is a REAL + /// source: it must be harvested as the catch-all target — not + /// double-added by the seed, and not left out of the `*` fan-out. + #[test] + fn nuget_whitespace_variant_add_is_harvested_not_reseeded() { + let mut files = BTreeMap::new(); + files.insert( + "nuget.config".to_string(), + "\n\n \n \n \n\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + let out = r.files.get("nuget.config").expect("config rewritten"); + assert!( + !out.contains("\n \n " + ), + "the existing source takes the catch-all: {out}" + ); + } + + /// A root open tag that isn't the literal `` — trailing + /// whitespace or attributes, both valid XML NuGet parses fine — must + /// still receive the source insert. The literal `replacen` used to no-op + /// silently while the mapping (anchored on the close tag) still landed, + /// routing the patched id to a source that was never defined. + #[test] + fn nuget_config_root_tag_with_whitespace_still_wired() { + for open_tag in ["", ""] { + let mut files = BTreeMap::new(); + files.insert( + "nuget.config".to_string(), + format!( + "\n{open_tag}\n\n" + ), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + let out = r.files.get("nuget.config").expect("config rewritten"); + assert!( + out.contains(""), + "socket mapping present: {out}" + ); + } + } + + /// When NO anchor exists for the source insert, the dep must be skipped + /// fail-closed with a warning: no config write, no recorded edit whose + /// `new` claims the source landed, and no lock re-pin (a patched + /// contentHash over an upstream fetch fails restore with NU1403). + #[test] + fn nuget_config_without_configuration_root_skips_dep_with_warning() { + let mut files = BTreeMap::new(); + files.insert( + "nuget.config".to_string(), + "\n\n".to_string(), + ); + files.insert( + "packages.lock.json".to_string(), + r#"{ + "version": 1, + "dependencies": { + "net8.0": { + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[13.0.3, )", + "resolved": "13.0.3", + "contentHash": "ORIGINALHASH==" + } + } + } +} +"# + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "an unwritable config must not record a half-write: files={:?} edits={:?}", + r.files.keys(), + r.edits + ); + assert!( + warning_codes(&r).contains(&"redirect_nuget_config_unwritable"), + "the failed insert must be SAID: {:?}", + r.warnings + ); + } + /// A pre-existing `` already covers the other /// sources — the rewriter must append ONLY the Socket mapping and add NO /// catch-all (injecting `*` entries would loosen the project's own @@ -5690,6 +5881,82 @@ mod tests { ); } + /// A granted pypi dep whose requirements.txt line the name matcher cannot + /// parse (a PEP 508 extras bracket terminates the name run before any + /// terminator alternative) — or that the file omits entirely — must be + /// SAID with an entry-not-found warning, matching npm/pnpm/yarn/berry/ + /// bun/uv/cargo/composer, not silently dropped from the redirected count. + #[test] + fn requirements_unmatched_dep_warns_entry_not_found() { + let mut files = BTreeMap::new(); + files.insert( + "requirements.txt".to_string(), + "requests[security]==2.28.1\n".to_string(), + ); + let overrides = vec![pypi_override( + "requests", + "2.28.1", + "http://patch.test/requests-2.28.1-py3-none-any.whl", + &"c".repeat(64), + )]; + let r = rewrite_registry_redirect(&files, &overrides); + // The extras spelling itself is a recorded TS-parity residual (the + // line matching needs a coordinated TS+Rust fix) — the line stays. + assert!( + r.files.is_empty() && r.edits.is_empty(), + "extras line must not be rewritten: {:?}", + r.files + ); + assert!( + warning_codes(&r).contains(&"redirect_requirements_entry_not_found"), + "the un-wired dep must be SAID, not silent: {:?}", + r.warnings + ); + } + + /// The not-found warning fires ONLY for a dep no line accounted for: a + /// rewritten line and a continuation-refused line (which carries its own + /// warning) both count as found. + #[test] + fn requirements_matched_or_refused_dep_gets_no_not_found_warning() { + let overrides = vec![pypi_override( + "requests", + "2.28.1", + "http://patch.test/requests-2.28.1-py3-none-any.whl", + &"c".repeat(64), + )]; + // Plain match: rewritten, no not-found. + let mut files = BTreeMap::new(); + files.insert( + "requirements.txt".to_string(), + "requests==2.28.1\n".to_string(), + ); + let r = rewrite_registry_redirect(&files, &overrides); + assert!(!r.edits.is_empty(), "plain pin rewritten"); + assert!( + !warning_codes(&r).contains(&"redirect_requirements_entry_not_found"), + "a rewritten dep is not not-found: {:?}", + r.warnings + ); + // Continuation refusal: found-but-refused must not ALSO say not-found. + let mut files = BTreeMap::new(); + files.insert( + "requirements.txt".to_string(), + "requests==2.28.1 \\\n --hash=sha256:OLDOLDOLD\n".to_string(), + ); + let r = rewrite_registry_redirect(&files, &overrides); + assert!( + warning_codes(&r).contains(&"redirect_requirements_continuation"), + "{:?}", + r.warnings + ); + assert!( + !warning_codes(&r).contains(&"redirect_requirements_entry_not_found"), + "a refused-with-cause dep is not not-found: {:?}", + r.warnings + ); + } + fn berry_override(name: &str, version: &str, url: &str, checksum: &str) -> DepOverride { DepOverride { integrity: Integrity { diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 8aa0a3b6..16b2d866 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -281,8 +281,12 @@ fn remove_fragment_once(content: &str, fragment: &str) -> String { } else { pos }; - // The removed line's own newline goes with it. - if content[end..].starts_with('\n') { + // The removed line's own newline goes with it — but only when the + // whole line is removed: a fragment spliced out from behind a + // non-whitespace prefix (the user commented the line out) leaves the + // prefix as its own line, and eating the newline would join that + // prefix onto the FOLLOWING line, commenting it out too. + if start == line_start && content[end..].starts_with('\n') { end += 1; } if end >= content.len() { @@ -569,6 +573,18 @@ pub async fn revert_remaining_redirect_edits( // only the line the redirect owns, and say so. (_, Some(c)) => { if c.contains(PNPM_TRUST_LINE) { + if c.matches(PNPM_TRUST_LINE).count() > 1 { + refuse( + format!( + "{}: the `{PNPM_TRUST_LINE}` line appears more \ + than once — ambiguous, refusing to guess", + edit.path + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } staged.insert( edit.path.clone(), Some(remove_fragment_once(&c, PNPM_TRUST_LINE)), @@ -992,6 +1008,38 @@ mod tests { ); } + #[tokio::test] + async fn commented_out_added_fragment_removal_keeps_the_following_line() { + // The user disabled the redirect by commenting the directive out. + // Mid-line removal must not eat the line's newline — doing so + // joins the surviving comment prefix onto the NEXT line and + // comments out the `require` directive. + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "go.mod", + "module m\n// replace x v1.0.0 => gopatch.socket.dev/x v1\nrequire y v1.0.0\n", + ) + .await; + let mut state = state_with( + vec![edit( + "go.mod", + "redirect_golang_replace", + "added", + None, + Some("replace x v1.0.0 => gopatch.socket.dev/x v1"), + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + read(dir.path(), "go.mod").await, + "module m\n// \nrequire y v1.0.0\n", + "the require directive must survive on its own line" + ); + } + #[tokio::test] async fn anchor_shaped_original_never_reads_as_already_reverted() { // The Cargo.toml insert variant records the always-present table @@ -1237,6 +1285,71 @@ mod tests { ); } + #[tokio::test] + async fn duplicated_trust_line_refuses_instead_of_removing_the_wrong_copy() { + // A commented-out copy of the trust line above the live one: + // removing the FIRST occurrence would strip the comment's text and + // leave the LIVE line active while claiming full revert. Must + // refuse like the ReplaceFragment / RemoveAddedFragment ambiguity + // guards. + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "pnpm-workspace.yaml", + "packages:\n - '.'\n# trustLockfile: true — added by socket\ntrustLockfile: true\n", + ) + .await; + let mut state = state_with( + vec![FileEdit { + path: "pnpm-workspace.yaml".into(), + kind: "redirect_pnpm_workspace_trust".into(), + action: "added".into(), + key: Some("trustLockfile".into()), + original: None, + new: Some(json!("true")), + }], + &[], + ); + let before = read(dir.path(), "pnpm-workspace.yaml").await; + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1, "{out:?}"); + assert_eq!(out.refusals[0].group, "pnpm"); + assert!(out.refusals[0].reason.contains("more than once")); + assert_eq!(read(dir.path(), "pnpm-workspace.yaml").await, before); + assert_eq!(state.edits.len(), 1, "the edit must survive for a retry"); + } + + #[tokio::test] + async fn commented_out_trust_line_removal_keeps_the_following_line() { + // Single (commented) occurrence: removal proceeds, but must not + // eat the newline and comment out the key on the next line. + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "pnpm-workspace.yaml", + "packages:\n - '.'\n# trustLockfile: true\nshamefullyHoist: true\n", + ) + .await; + let mut state = state_with( + vec![FileEdit { + path: "pnpm-workspace.yaml".into(), + kind: "redirect_pnpm_workspace_trust".into(), + action: "added".into(), + key: Some("trustLockfile".into()), + original: None, + new: Some(json!("true")), + }], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + read(dir.path(), "pnpm-workspace.yaml").await, + "packages:\n - '.'\n# \nshamefullyHoist: true\n", + "the following key must survive on its own line" + ); + } + // ---------- dry-run ---------- #[tokio::test] diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index a7e344a8..a178ceab 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -455,17 +455,30 @@ pub async fn revert_npm_redirect_purl( && match entry.get("version").and_then(Value::as_str) { Some(v) => v == version, // Version field gone (hand-edited lock): fall - // back to the recorded URLs, erring toward - // claiming — the replay itself fails closed - // on any value mismatch. - None => edit_references_version(e, &version), + // back to the recorded URLs, which must name + // this exact package AND version — version + // alone would claim a same-version alias + // collision (`npm i @npm:other`, name + // field stripped too) whose live values ARE + // its edit's `new` values, so the replay + // would NOT fail closed and the sibling would + // be silently un-hosted. + None => edit_references_package(e, &name, &version), } } // Entry (or the whole lock) gone: keep the fail-closed // "no longer exists" refusal for edits attributable to - // this purl by key path + recorded URLs; a sibling - // version's edit is not ours to claim. - None => key_name == name && edit_references_version(e, &version), + // this purl — by key path + recorded URLs, or (an alias + // install OF this package keys its entry by the ALIAS, + // so the key path exonerates nothing) by recorded URLs + // naming this exact package. Leaving the alias edit + // unclaimed would drop the record while stranding it — + // half a takeover. A sibling purl's edit is still not + // ours to claim. + None => { + (key_name == name && edit_references_version(e, &version)) + || edit_references_package(e, &name, &version) + } } } "redirect_bun_lock_package" => { @@ -588,6 +601,37 @@ fn edit_references_version(edit: &FileEdit, version: &str) -> bool { }) } +/// Does one of this edit's recorded `resolved` URLs reference BOTH `name` +/// and `version`? +/// +/// Name-discriminated twin of [`edit_references_version`], for the claims +/// where the lock key path cannot vouch for the name (an alias install keys +/// its entry by the alias, and `npm i @npm:other` keys ANOTHER package +/// by this name's path). Both the hosted artifact URL +/// (`…/npm///…/-.tgz`) and the registry +/// tarball URL (`…//-/-.tgz`) embed the name as a +/// `//` path segment or in the tarball basename — where a scoped +/// name's `@scope/` prefix is dropped (`…/@scope/x/-/x-1.0.0.tgz`), so the +/// basename probe uses the bare name. Both discriminators must hold in the +/// SAME URL, so a sibling purl of a different name never matches even at an +/// identical version. +fn edit_references_package(edit: &FileEdit, name: &str, version: &str) -> bool { + let path_seg = format!("/{version}/"); + let tarball = format!("-{version}.tgz"); + let name_seg = format!("/{name}/"); + let bare = name.rsplit('/').next().unwrap_or(name); + let name_tarball = format!("/{bare}-{version}.tgz"); + [&edit.new, &edit.original].into_iter().any(|v| { + v.as_ref() + .and_then(|o| o.get("resolved")) + .and_then(Value::as_str) + .is_some_and(|s| { + (s.contains(&path_seg) || s.contains(&tarball)) + && (s.contains(&name_seg) || s.contains(&name_tarball)) + }) + }) +} + /// Replay one recorded package-lock JSON edit (`redirect_npm_lock_entry` / /// `redirect_npm_lock_dep`) through the staged view. async fn revert_npm_json_edit( @@ -1704,6 +1748,228 @@ mod tests { assert_eq!(state.edits.len(), edits_before, "ledger keeps the edits"); } + /// `npm i mylp@npm:left-pad` records an edit keyed by the ALIAS lock path + /// (`node_modules/mylp`); after `npm uninstall mylp` regenerates the lock + /// without that entry, the takeover must still attribute the edit to this + /// purl (via its recorded URLs — the key path says "mylp") and refuse + /// fail-closed exactly like the path-keyed vanished entry above — never + /// report success with the alias edit stranded in the ledger behind a + /// dropped record (half a takeover). + #[tokio::test] + async fn npm_vanished_alias_keyed_entry_fails_closed_not_half_takeover() { + let lock = serde_json::json!({ + "name": "app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-pristine==" + }, + // Alias install OF the target package: the rewriter matches + // it via the `name` field and keys its edit by this path. + "node_modules/mylp": { + "name": "left-pad", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-pristine==" + }, + }, + }); + let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); + let (tmp, mut state) = npm_redirected_fixture("package-lock.json", &pristine).await; + let root = tmp.path(); + assert_eq!( + state.edits.len(), + 2, + "path-keyed + alias-keyed edits: {:?}", + state.edits + ); + // `npm uninstall mylp` regenerated the lock: the alias entry is gone, + // the surviving entry keeps the hosted `resolved`. + let mut on_disk: Value = serde_json::from_str( + &tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(), + ) + .unwrap(); + on_disk + .get_mut("packages") + .and_then(Value::as_object_mut) + .unwrap() + .remove("node_modules/mylp") + .expect("fixture alias entry present"); + let on_disk_text = serde_json::to_string_pretty(&on_disk).unwrap(); + tokio::fs::write(root.join("package-lock.json"), &on_disk_text) + .await + .unwrap(); + let edits_before = state.edits.len(); + + let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect_err("vanished alias-keyed entry must refuse, not strand its edit"); + assert!(err.contains("no longer exists"), "{err}"); + assert!(!state.records.is_empty(), "ledger keeps the record"); + assert_eq!(state.edits.len(), edits_before, "ledger keeps the edits"); + assert_eq!( + tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(), + on_disk_text, + "nothing reached disk on refusal" + ); + } + + /// `npm i left-pad@npm:other` keys package `other` under + /// `node_modules/left-pad`; a hand edit strips BOTH the `name` and + /// `version` fields from that live entry. Taking over left-pad must not + /// claim `other`'s edit through a version-only URL fallback — the entry's + /// live values ARE that edit's `new` values, so the replay would NOT fail + /// closed: `other` would be silently un-hosted and its edit dropped while + /// its record survives edit-less. + #[tokio::test] + async fn npm_version_gone_fallback_does_not_claim_alias_collision_sibling() { + let other_purl = "pkg:npm/other@1.3.0"; + let lock = serde_json::json!({ + "name": "app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + // Alias of ANOTHER package onto this key path — same version + // on purpose. + "node_modules/left-pad": { + "name": "other", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/other/-/other-1.3.0.tgz", + "integrity": "sha512-pristine-other==" + }, + // The real target package, nested. + "node_modules/b/node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-pristine-1.3.0==" + }, + }, + }); + let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); + let (tmp, mut state) = npm_redirected_fixture_multi( + "package-lock.json", + &pristine, + &[ + (NPM_PURL, npm_dep()), + (other_purl, npm_dep_for("other", "1.3.0")), + ], + ) + .await; + let root = tmp.path(); + assert_eq!(state.edits.len(), 2, "{:?}", state.edits); + let other_url = npm_dep_for("other", "1.3.0").artifact_url.clone(); + // Hand edit / merge artifact: strip the alias entry's name+version. + let mut on_disk: Value = serde_json::from_str( + &tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(), + ) + .unwrap(); + let entry = on_disk + .get_mut("packages") + .and_then(|p| p.get_mut("node_modules/left-pad")) + .and_then(Value::as_object_mut) + .unwrap(); + entry.remove("name").expect("fixture name field present"); + entry.remove("version").expect("fixture version field present"); + tokio::fs::write( + root.join("package-lock.json"), + serde_json::to_string_pretty(&on_disk).unwrap(), + ) + .await + .unwrap(); + + revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("takeover of left-pad succeeds without touching `other`"); + + let lock = tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(); + assert!(!lock.contains(NPM_URL), "left-pad un-hosted: {lock}"); + assert!( + lock.contains(&other_url), + "`other` (aliased onto node_modules/left-pad, fields stripped) \ + still hosted-wired: {lock}" + ); + assert!( + state.records.contains_key(other_purl) && !state.records.contains_key(NPM_PURL), + "{:?}", + state.records.keys() + ); + assert_eq!( + state.edits.len(), + 1, + "other keeps its edit: {:?}", + state.edits + ); + } + + /// Narrowing guard for the version-gone fallback: with only the `version` + /// field hand-stripped from the target's own live entry, the recorded + /// URLs name this exact package+version, so the takeover still claims and + /// reverts it rather than stranding the edit. + #[tokio::test] + async fn npm_version_stripped_target_entry_is_still_claimed_via_recorded_urls() { + let lock = serde_json::json!({ + "name": "app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-pristine==" + }, + }, + }); + let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); + let (tmp, mut state) = npm_redirected_fixture("package-lock.json", &pristine).await; + let root = tmp.path(); + let mut on_disk: Value = serde_json::from_str( + &tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(), + ) + .unwrap(); + on_disk + .get_mut("packages") + .and_then(|p| p.get_mut("node_modules/left-pad")) + .and_then(Value::as_object_mut) + .unwrap() + .remove("version") + .expect("fixture version field present"); + tokio::fs::write( + root.join("package-lock.json"), + serde_json::to_string_pretty(&on_disk).unwrap(), + ) + .await + .unwrap(); + + revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("revert succeeds"); + let lock = tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(); + assert!(!lock.contains(NPM_URL), "left-pad un-hosted: {lock}"); + assert!(state.records.is_empty(), "record dropped"); + assert!(state.edits.is_empty(), "edits dropped"); + } + #[tokio::test] async fn npm_refuses_on_drifted_lock_fail_closed() { let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index eef14181..3736ed65 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -318,7 +318,7 @@ pub(crate) async fn revert_bun_opts( // advertises a revert the wet run refuses. Skipped under // `keep_artifact`: the refusal exists only to protect the deletion, // which a preserve-state revert never performs. - if entry.wiring.is_empty() { + if !keep_artifact && entry.wiring.is_empty() { if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( project_root, &entry.uuid, @@ -1588,6 +1588,52 @@ mod tests { ); } + /// `--preserve-state` (`keep_artifact`) with empty wiring: the + /// deletion-protecting refusal above must be SKIPPED (the fn doc and + /// composer's reference implementation both promise it — a + /// preserve-state revert deletes nothing), so the revert completes as + /// a successful no-op with lock and artifact intact. Dry-run preview + /// included: it must never advertise a refusal the wet preserve-state + /// run does not hit. + #[tokio::test] + async fn empty_wiring_preserve_state_revert_skips_the_deletion_refusal() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + let tgz_path = fx.root().join(fx.rel_tgz()); + let lock_vendored = fx.read_lock().await; + + for dry_run in [true, false] { + let outcome = revert_bun_opts( + &entry, + fx.root(), + RevertOpts { + dry_run, + keep_artifact: true, + }, + ) + .await; + assert!( + outcome.success, + "dry_run={dry_run}: preserve-state deletes nothing, so the \ + deletion guard must not fire: {:?}", + outcome.error + ); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!( + !outcome.kept_artifact, + "preserve-state is not a drift-keep" + ); + assert!(tgz_path.exists(), "artifact kept"); + assert_eq!( + fx.read_lock().await, + lock_vendored, + "empty wiring replays nothing" + ); + } + } + #[tokio::test] async fn revert_refuses_tampered_uuid_fail_closed() { let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; diff --git a/crates/socket-patch-core/src/vendor/cargo_config.rs b/crates/socket-patch-core/src/vendor/cargo_config.rs index 1c0add15..1fc019a3 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -30,10 +30,9 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio::fs; -use toml_edit::{DocumentMut, InlineTable, Item, Table, Value}; +use toml_edit::{DocumentMut, InlineTable, Item, Table, TableLike, Value}; use crate::utils::fs::atomic_write_bytes_preserving_mode; -use crate::utils::toml_edit_ext::ensure_table; /// Project-relative root of the vendor backend's committed crate copies. An /// entry whose `path` is under this prefix is socket-owned. @@ -267,6 +266,30 @@ fn entry_path(item: &Item) -> Option<&str> { .and_then(Item::as_str) } +/// `parent[key]` as a mutable table-like view, creating a (header) table if +/// absent. Like `toml_edit_ext::ensure_table` but tolerant of an existing +/// inline-table value — `[patch]` + `crates-io = { … }` is valid TOML that +/// cargo honors identically to `[patch.crates-io]` (a hand edit or another +/// tool re-serializing this user-owned file produces it), and refusing it +/// would strand the socket-owned entries inside. Errors on a non-table item. +fn ensure_table_like<'a>( + parent: &'a mut dyn TableLike, + key: &str, + implicit: bool, +) -> Result<&'a mut dyn TableLike, String> { + if !parent.contains_key(key) { + let mut t = Table::new(); + t.set_implicit(implicit); + // An inline-table parent converts this to an inline value on insert, + // preserving the user's inline style. + parent.insert(key, Item::Table(t)); + } + parent + .get_mut(key) + .and_then(Item::as_table_like_mut) + .ok_or_else(|| format!("`{key}` is not a table")) +} + fn upsert_patch_entry(content: &str, name: &str, rel_path: &str) -> Result, String> { let mut doc = content .parse::() @@ -275,8 +298,8 @@ fn upsert_patch_entry(content: &str, name: &str, rel_path: &str) -> Result Result, Strin .map_err(|e| format!("Invalid .cargo/config.toml: {e}"))?; let mut removed = false; - if let Some(patch) = doc.get_mut("patch").and_then(Item::as_table_mut) { + // Table-like views (as in `entry_path`): the inline `crates-io = { … }` + // form is honored by cargo, and a remove blind to it would leave the + // entry dangling after the vendor copy it points at is deleted. + if let Some(patch) = doc.get_mut("patch").and_then(Item::as_table_like_mut) { let mut crates_io_empty = false; - if let Some(crates_io) = patch.get_mut("crates-io").and_then(Item::as_table_mut) { + if let Some(crates_io) = patch.get_mut("crates-io").and_then(Item::as_table_like_mut) { if matches!(crates_io.get(name).and_then(entry_path), Some(p) if path_is_socket_owned(p)) { crates_io.remove(name); @@ -321,8 +347,8 @@ fn remove_patch_entry(content: &str, name: &str) -> Result, Strin } if doc .get("patch") - .and_then(Item::as_table) - .map(Table::is_empty) + .and_then(Item::as_table_like) + .map(|t| t.is_empty()) .unwrap_or(false) { doc.as_table_mut().remove("patch"); @@ -338,9 +364,9 @@ fn parse_patch_entries(content: &str) -> HashMap { }; let crates_io = doc .get("patch") - .and_then(Item::as_table) + .and_then(Item::as_table_like) .and_then(|t| t.get("crates-io")) - .and_then(Item::as_table); + .and_then(Item::as_table_like); if let Some(tbl) = crates_io { for (name, item) in tbl.iter() { let path = entry_path(item).map(str::to_string); @@ -496,6 +522,37 @@ mod tests { ); } + /// COVERAGE 2026-09: `crates-io` written as an INLINE table — + /// `[patch]` + `crates-io = { cfg-if = { path = "…" } }` — is valid TOML + /// that cargo honors identically to `[patch.crates-io]` (a hand edit or + /// another tool re-serializing this user-owned file produces it). The + /// path prefix is the entire ownership signal, so a socket-owned entry + /// in this form must refresh in place, not error via `ensure_table`. + #[test] + fn test_upsert_refreshes_inline_crates_io_form() { + let old = format!("{CARGO_VENDOR_DIR}/11111111-2222-3333-4444-555555555555/cfg-if-1.0.4"); + let toml = format!("[patch]\ncrates-io = {{ cfg-if = {{ path = \"{old}\" }} }}\n"); + let want = vendor_path("cfg-if", "1.0.4"); + let out = upsert_patch_entry(&toml, "cfg-if", &want) + .expect("inline-form owned entry must refresh, not error") + .expect("stale path means the file changes"); + let doc = parse(&out); + assert_eq!( + entry_path(&doc["patch"]["crates-io"]["cfg-if"]), + Some(want.as_str()) + ); + // Idempotent thereafter. + assert!(upsert_patch_entry(&out, "cfg-if", &want).unwrap().is_none()); + } + + /// COVERAGE 2026-09: …and a USER-authored entry in the inline form is + /// still refused, never silently overwritten. + #[test] + fn test_upsert_refuses_user_authored_inline_form() { + let toml = "[patch]\ncrates-io = { cfg-if = { path = \"../my-fork\" } }\n"; + assert!(upsert_patch_entry(toml, "cfg-if", &vendor_path("cfg-if", "1.0.4")).is_err()); + } + #[test] fn test_upsert_takes_over_legacy_redirect_entry() { // An entry left by the retired redirect backend is socket-owned → @@ -553,6 +610,57 @@ mod tests { assert!(remove_patch_entry(toml, "cfg-if").unwrap().is_none()); } + /// COVERAGE 2026-09: removal twin of the inline-form blindness. A + /// socket-owned entry inside `crates-io = { … }` must be removed on + /// rollback — a silent no-op here means revert_cargo_vendor_opts still + /// deletes the `.socket/vendor/cargo//` copy, leaving a dangling + /// `[patch]` entry that breaks the next `cargo build`. + #[test] + fn test_remove_inline_crates_io_form_socket_entry() { + let toml = format!( + "[patch]\ncrates-io = {{ cfg-if = {{ path = \"{}\" }} }}\n", + vendor_path("cfg-if", "1.0.4") + ); + let out = remove_patch_entry(&toml, "cfg-if") + .unwrap() + .expect("socket-owned inline-form entry must be removed, not no-op'd"); + assert!(!out.contains("cfg-if")); + assert!(!out.contains("[patch"), "emptied [patch] pruned: {out}"); + + // The fully-inline `patch = { crates-io = { … } }` form as well. + let toml = format!( + "patch = {{ crates-io = {{ cfg-if = {{ path = \"{}\" }} }} }}\n", + vendor_path("cfg-if", "1.0.4") + ); + let out = remove_patch_entry(&toml, "cfg-if") + .unwrap() + .expect("fully-inline patch form entry must be removed"); + assert!(!out.contains("cfg-if")); + assert!(!out.contains("patch"), "emptied inline patch pruned: {out}"); + } + + /// COVERAGE 2026-09: sibling user entries sharing the inline table + /// survive the removal. + #[test] + fn test_remove_inline_crates_io_form_keeps_user_entry() { + let toml = format!( + "[patch]\ncrates-io = {{ cfg-if = {{ path = \"{}\" }}, other = {{ git = \"https://example.com/o.git\" }} }}\n", + vendor_path("cfg-if", "1.0.4") + ); + let out = remove_patch_entry(&toml, "cfg-if").unwrap().unwrap(); + let doc = parse(&out); + assert!(doc["patch"]["crates-io"].get("cfg-if").is_none()); + assert!(doc["patch"]["crates-io"].get("other").is_some()); + } + + /// COVERAGE 2026-09: the ownership guard holds through the inline form — + /// a user-authored same-name entry stays a no-op. + #[test] + fn test_remove_inline_form_user_entry_is_noop() { + let toml = "[patch]\ncrates-io = { cfg-if = { path = \"../my-fork\" } }\n"; + assert!(remove_patch_entry(toml, "cfg-if").unwrap().is_none()); + } + #[test] fn test_remove_absent_is_noop() { assert!(remove_patch_entry("[build]\njobs = 2\n", "cfg-if") @@ -586,6 +694,34 @@ mod tests { assert!(entries["mine"].socket_owned); } + /// COVERAGE 2026-09: read twin of the inline-form blindness — an unread + /// entry makes verify / pre-flight report the vendor copy unwired (so + /// GC-reclaimable) while cargo still resolves through it. + #[test] + fn test_parse_entries_handles_inline_crates_io_form() { + let toml = format!( + "[patch]\ncrates-io = {{ mine = {{ path = \"{}\" }}, yours = {{ git = \"https://example.com/y.git\" }} }}\n", + vendor_path("mine", "1.0.0") + ); + let entries = parse_patch_entries(&toml); + assert!( + entries.get("mine").is_some_and(|e| e.socket_owned), + "inline-table crates-io form must be readable: {entries:?}" + ); + assert!(entries.get("yours").is_some_and(|e| !e.socket_owned)); + + // The fully-inline `patch = { crates-io = { … } }` form as well. + let toml = format!( + "patch = {{ crates-io = {{ mine = {{ path = \"{}\" }} }} }}\n", + vendor_path("mine", "1.0.0") + ); + let entries = parse_patch_entries(&toml); + assert!( + entries.get("mine").is_some_and(|e| e.socket_owned), + "fully-inline patch form must be readable: {entries:?}" + ); + } + #[test] fn test_parse_malformed_is_empty() { assert!(parse_patch_entries("this is = = not toml [[[").is_empty()); diff --git a/crates/socket-patch-core/src/vendor/common.rs b/crates/socket-patch-core/src/vendor/common.rs index 1ac0a46c..a26bb475 100644 --- a/crates/socket-patch-core/src/vendor/common.rs +++ b/crates/socket-patch-core/src/vendor/common.rs @@ -444,13 +444,24 @@ pub(crate) async fn revert_lock_fragment_splice( let original_text = rec.original.as_ref().and_then(Value::as_str); match super::toml_surgery::replace_fragment(&lock_text, new_text, original_text) { Some(t) => lock_text = t, - None => warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!( - "{lock_file} fragment for {:?} changed since vendoring; left untouched", - rec.key - ), - )), + None => { + // ALREADY CONVERGED (the LIVENESS CONTRACT, vendor/mod.rs): + // the lock already carries the recorded pre-vendor original + // — an earlier partial revert or a relock regeneration + // already restored the unit. Not drift: stay silent so the + // drift-skip keep gate can converge instead of keeping the + // artifact dir and ledger entry forever. + if original_text.is_some_and(|orig| lock_text.contains(orig)) { + continue; + } + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "{lock_file} fragment for {:?} changed since vendoring; left untouched", + rec.key + ), + )); + } } } @@ -582,6 +593,59 @@ mod tests { ); } + /// LIVENESS CONTRACT (vendor/mod.rs): a fragment whose lock already + /// carries the recorded pre-vendor original — a relock regenerated the + /// unit, or an earlier partial revert restored it — is CONVERGED, not + /// drifted: re-classifying it would make the pypi drift-keep gate + /// retain the artifact dir and ledger entry forever, with remediation + /// advice that can never be satisfied. + #[tokio::test] + async fn revert_lock_fragment_splice_converged_fragment_is_silent_no_op() { + let dir = tempfile::tempdir().unwrap(); + let lock = dir.path().join("poetry.lock"); + tokio::fs::write(&lock, "alpha\nOLD-FRAGMENT\nomega\n") + .await + .unwrap(); + + let mut entry: VendorEntry = serde_json::from_value(serde_json::json!({ + "ecosystem": "pypi", + "basePurl": "pkg:pypi/six@1.16.0", + "uuid": "u", + "artifact": {"path": ".socket/vendor/pypi/u/x.whl"}, + "wiring": [], + })) + .unwrap(); + entry.wiring = vec![record( + "poetry.lock", + "poetry_lock_package", + WiringAction::Rewritten, + "six", + Some("OLD-FRAGMENT".into()), + "NEW-FRAGMENT".into(), + )]; + + let outcome = revert_lock_fragment_splice( + &entry, + dir.path(), + false, + "poetry.lock", + "poetry_lock_package", + "poetry", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "converged fragments must not read as drift: {:?}", + outcome.warnings + ); + assert_eq!( + tokio::fs::read_to_string(&lock).await.unwrap(), + "alpha\nOLD-FRAGMENT\nomega\n", + "nothing to restore" + ); + } + /// The lock file is user-owned: reverting the splice must not reset its /// permission bits (the `package_json/update.rs` mode-reset bug, same /// class — see `atomic_write_bytes_preserving_mode`). diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 45970f27..76b1bccc 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -211,6 +211,7 @@ pub async fn vendor_composer( record, sources, force, + false, // live-wired: never unwind the uuid dir on failure &pkg, version, &mut warnings, @@ -276,6 +277,7 @@ pub async fn vendor_composer( record, sources, force, + true, // fresh vendor: nothing pre-existing worth keeping &pkg, version, &mut warnings, @@ -293,6 +295,7 @@ pub async fn vendor_composer( let Some(original_obj) = original_entry.as_object() else { // find_lock_entry only matches objects; defensive. let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_dirs(©_dir).await; result.success = false; result.error = Some("composer.lock entry is not a JSON object".to_string()); return done(result, None, warnings); @@ -316,6 +319,7 @@ pub async fn vendor_composer( }; if let Err(e) = write_result { let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_dirs(©_dir).await; result.success = false; result.error = Some(format!("failed to write composer.lock: {e}")); return done(result, None, warnings); @@ -507,12 +511,109 @@ pub async fn revert_composer_opts( // ── helpers ────────────────────────────────────────────────────────────────── -/// Copy the installed package into `copy_dir` and run the hardened apply -/// pipeline against it (vendor auto-force policy — see -/// [`super::force_apply_staged`]). On apply failure the whole uuid dir is -/// removed — a partial copy under `.socket/vendor/` would be misjudged by -/// verify/sweep — and the failed [`ApplyResult`] is the `Err` for the caller -/// to bubble (composer.lock is only ever edited after this succeeds). +fn swap_sibling_for(copy_dir: &Path, suffix: &str) -> std::path::PathBuf { + let name = copy_dir + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "copy".to_string()); + match copy_dir.parent() { + Some(parent) => parent.join(format!("{name}{suffix}")), + None => copy_dir.join(suffix), + } +} + +/// The staging sibling for a copy dir: +/// `//@.socket-stage`. (Re)builds are +/// materialised here and swapped into place only on success, so a failure can +/// never destroy a pre-existing (possibly live-wired) copy. +fn stage_dir_for(copy_dir: &Path) -> std::path::PathBuf { + swap_sibling_for(copy_dir, ".socket-stage") +} + +/// The backup sibling the old copy is parked at mid-swap: +/// `//@.socket-old`. +fn backup_dir_for(copy_dir: &Path) -> std::path::PathBuf { + swap_sibling_for(copy_dir, ".socket-old") +} + +/// Swap a fully-built stage into place without a destructive window: park the +/// old copy (if any) at `.socket-old` with a same-dir rename, rename the +/// stage over the now-vacant copy path, and only then delete the backup. +/// Every step is a single atomic rename — no step can leave less recoverable +/// state than it started with (see the cargo twin for the full rationale). +async fn swap_stage_into_place(stage: &Path, copy_dir: &Path) -> std::io::Result<()> { + let backup = backup_dir_for(copy_dir); + // A stale backup (crash mid-swap on an earlier run) would make the + // park rename fail; `remove_tree` is a no-op when it is absent. + remove_tree(&backup).await?; + let had_old = match tokio::fs::rename(copy_dir, &backup).await { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(e), + }; + match tokio::fs::rename(stage, copy_dir).await { + Ok(()) => { + if had_old { + let _ = remove_tree(&backup).await; + } + Ok(()) + } + Err(e) => { + if had_old { + let _ = tokio::fs::rename(&backup, copy_dir).await; + } + Err(e) + } + } +} + +/// Best-effort removal of the EMPTY dir levels a failed run may have created +/// above the copy — `//`, `/`, `.socket/vendor/composer/` +/// and `.socket/vendor/` — so a hard failure leaves no husk for sweep to +/// enumerate as a vendored unit (or for the user to commit). `remove_dir` +/// refuses non-empty dirs, so live copies, markers, and other patches' vendor +/// dirs always survive. `copy_dir` may be the copy or its stage sibling +/// (same parent); pruning starts at its parent. +async fn prune_empty_vendor_dirs(copy_dir: &Path) { + let mut level = copy_dir.parent(); + for _ in 0..4 { + let Some(dir) = level else { return }; + match tokio::fs::remove_dir(dir).await { + Ok(()) => {} + // Already unwound wholesale (`remove_tree(uuid_dir)`): keep + // pruning the parent levels this run created. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + // Non-empty (a live copy or marker) or otherwise busy: stop. + Err(_) => return, + } + level = dir.parent(); + } +} + +/// Failure cleanup for a staged (re)build: always remove the stage, then +/// either unwind the whole `/` dir (`unwind_uuid_dir` — a fresh vendor +/// with no pre-existing state worth keeping) or leave existing state (a +/// live-wired copy and its marker) untouched; either way prune any +/// empty-husk dirs left behind. +async fn cleanup_failed_stage(stage: &Path, uuid_dir: &Path, unwind_uuid_dir: bool) { + let _ = remove_tree(stage).await; + if unwind_uuid_dir { + let _ = remove_tree(uuid_dir).await; + } + prune_empty_vendor_dirs(stage).await; +} + +/// Copy the installed package into a STAGE sibling of `copy_dir`, run the +/// hardened apply pipeline against it (vendor auto-force policy — see +/// [`super::force_apply_staged`]), and swap the stage into `copy_dir` only on +/// success. A failed (re)build therefore never destroys a pre-existing copy: +/// with `unwind_uuid_dir` (a fresh vendor — nothing pre-existing to keep) the +/// whole uuid dir is removed, without it (a live-wired rebuild, where +/// composer.lock keeps pointing at the copy) the previous copy and marker are +/// left exactly as they were; either way no partial copy or empty `/` +/// husk — which verify/sweep would misjudge — survives, and the failed +/// [`ApplyResult`] is the `Err` for the caller to bubble (composer.lock is +/// only ever edited after this succeeds). #[allow(clippy::too_many_arguments)] async fn copy_and_patch( purl: &str, @@ -522,11 +623,15 @@ async fn copy_and_patch( record: &PatchRecord, sources: &PatchSources<'_>, force: bool, + unwind_uuid_dir: bool, pkg: &str, version: &str, warnings: &mut Vec, ) -> Result { - if let Err(e) = fresh_copy(installed_dir, copy_dir, None).await { + let stage = stage_dir_for(copy_dir); + // `fresh_copy` removes + recreates the stage itself. + if let Err(e) = fresh_copy(installed_dir, &stage, None).await { + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; return Err(synthesized_result( purl, copy_dir, @@ -536,13 +641,18 @@ async fn copy_and_patch( )); } let mut result = super::force_apply_staged( - purl, copy_dir, record, sources, false, force, pkg, version, warnings, + purl, &stage, record, sources, false, force, pkg, version, warnings, ) .await; result.package_path = copy_dir.display().to_string(); if !result.success { - // Don't leave a half-built copy under `.socket/vendor/`. - let _ = remove_tree(uuid_dir).await; + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; + return Err(result); + } + if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; + result.success = false; + result.error = Some(format!("failed to move the rebuilt copy into place: {e}")); return Err(result); } Ok(result) @@ -592,16 +702,22 @@ async fn composer_service_copy( }; match fetch_verified_archive(cfg, &record.uuid).await { ServiceArtifact::Ready(archive) => { - let _ = remove_tree(copy_dir).await; - if let Err(e) = tokio::fs::create_dir_all(copy_dir).await { + // Extract into a STAGE sibling and swap it into the copy dir only + // once fully verified — a failure then leaves any pre-existing + // (possibly live-wired) copy and its marker untouched and no husk + // behind. + let stage = stage_dir_for(copy_dir); + let _ = remove_tree(&stage).await; + if let Err(e) = tokio::fs::create_dir_all(&stage).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; return hard( "vendor_prebuilt_write_failed", - format!("cannot create {}: {e}", copy_dir.display()), + format!("cannot create {}: {e}", stage.display()), ); } // composer dist zips carry a single variable top-level dir. - if let Err(e) = extract_zip(&archive.bytes, copy_dir, /*strip_first=*/ true) { - let _ = remove_tree(uuid_dir).await; + if let Err(e) = extract_zip(&archive.bytes, &stage, /*strip_first=*/ true) { + cleanup_failed_stage(&stage, uuid_dir, false).await; return hard( "vendor_prebuilt_extract_failed", format!("cannot extract the prebuilt dist zip: {e}"), @@ -619,8 +735,8 @@ async fn composer_service_copy( // `record.files` and shipped a copy missing its patched files // (exit 0, empty copy_dir on disk). Fail closed here and let // the `auto` source fall back to the local build. - if !copy_matches_after_hashes(copy_dir, &record.files).await { - let _ = remove_tree(copy_dir).await; + if !copy_matches_after_hashes(&stage, &record.files).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; return miss( warnings, "vendor_prebuilt_layout_mismatch", @@ -631,6 +747,13 @@ async fn composer_service_copy( ), ); } + if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; + return hard( + "vendor_prebuilt_write_failed", + format!("cannot move the extracted dist into place: {e}"), + ); + } warnings.push(VendorWarning::new( "vendor_prebuilt_downloaded", format!( @@ -1409,6 +1532,89 @@ mod tests { ); } + /// A failed FRESH copy must leave no `.socket/vendor` husk: `fresh_copy` + /// creates the full `//@` destination chain + /// BEFORE walking the source, so a copy failure (unreadable installed + /// package, ENOSPC mid-copy) would otherwise strand an empty uuid dir + /// that sweep enumerates as a vendored unit with no ledger entry — a + /// phantom orphan the user commits. Same contract as the cargo twin's + /// `cleanup_failed_stage`. + #[tokio::test] + async fn test_failed_fresh_copy_leaves_no_vendor_husk() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, _installed, record) = fixture(&lock).await; + let root = dir.path(); + let before = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + + // A missing installed dir makes the copy's source walk fail after + // the destination chain was created (unit-level stand-in for the + // mid-copy ENOSPC / EACCES / concurrent-delete failures). + let missing = root.join("missing"); + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &missing, &record, PURL, false).await); + assert!(!result.success); + assert!(entry.is_none()); + assert!( + !root.join(".socket/vendor").exists(), + "a failed copy must not strand a uuid-dir husk under .socket/vendor" + ); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + before, + "lock untouched on failure" + ); + } + + /// Wired lock + drifted copy + a FAILING local rebuild: the + /// rebuild-artifact-only path keeps composer.lock untouched by design, so + /// a failure must NOT delete the uuid dir the lock still points at — that + /// strands the project (`composer install` dies with "Source path … is + /// not found", precisely the state revert refuses to create). Like the + /// cargo twin, the rebuild is staged: the previous + /// (drifted-but-installable) copy and the marker survive exactly as they + /// were. + #[tokio::test] + async fn test_failed_rebuild_keeps_wired_artifact() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + + let (r1, e1, _) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(r1.success, "{:?}", r1.error); + assert!(e1.is_some()); + let lock_bytes = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + + // Drift the committed copy so the rerun takes the rebuild path… + let drifted = root.join(copy_rel()).join("src/LoggerInterface.php"); + tokio::fs::write(&drifted, b"/-`. Same +/// directory as the copy → every swap step is a real rename, never a +/// cross-device copy. The suffixes can never collide with a copy dir: this +/// backend creates exactly one `-` leaf per uuid dir, from +/// validated plain gem tokens (see the mirrored cargo.rs machinery). +fn swap_sibling_for(copy_dir: &Path, suffix: &str) -> std::path::PathBuf { + let name = copy_dir + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "copy".to_string()); + match copy_dir.parent() { + Some(parent) => parent.join(format!("{name}{suffix}")), + None => copy_dir.join(suffix), + } +} + +/// The staging sibling for a copy dir: `/-.socket-stage`. +/// (Re)builds are materialised here and swapped into place only on success, so +/// a failure can never destroy a pre-existing (possibly live-wired) copy. +fn stage_dir_for(copy_dir: &Path) -> std::path::PathBuf { + swap_sibling_for(copy_dir, ".socket-stage") +} + +/// The backup sibling the old copy is parked at mid-swap: +/// `/-.socket-old`. +fn backup_dir_for(copy_dir: &Path) -> std::path::PathBuf { + swap_sibling_for(copy_dir, ".socket-old") +} + +/// Swap a fully-built stage into place without a destructive window: park the +/// old copy (if any) at `.socket-old` with a same-dir rename, rename the +/// stage over the now-vacant copy path, and only then delete the backup. Every +/// step is a single atomic rename — unlike a remove-then-rename swap (where a +/// partial `remove_dir_all`, realistic under Windows file locks, strands a +/// half-deleted copy) no step can leave less recoverable state than it started +/// with. If the stage rename fails the backup is renamed straight back; should +/// even that restore fail (an external process racing the uuid dir), the old +/// copy still exists intact at `.socket-old` instead of being destroyed. +async fn swap_stage_into_place(stage: &Path, copy_dir: &Path) -> std::io::Result<()> { + let backup = backup_dir_for(copy_dir); + // A stale backup (crash mid-swap on an earlier run) would make the + // park rename fail; `remove_tree` is a no-op when it is absent. + remove_tree(&backup).await?; + let had_old = match tokio::fs::rename(copy_dir, &backup).await { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(e), + }; + match tokio::fs::rename(stage, copy_dir).await { + Ok(()) => { + if had_old { + let _ = remove_tree(&backup).await; + } + Ok(()) + } + Err(e) => { + if had_old { + let _ = tokio::fs::rename(&backup, copy_dir).await; + } + Err(e) + } + } +} + +/// Best-effort removal of an EMPTY `/` dir plus the empty +/// `.socket/vendor/gem/` and `.socket/vendor/` levels a failed run may have +/// created, so a hard failure leaves no husk for the user to commit. +/// `remove_dir` refuses non-empty dirs, so live copies, markers, and other +/// gems' vendor dirs always survive. +async fn prune_empty_vendor_dirs(uuid_dir: &Path) { + // The uuid level may already be gone (the unwind paths `remove_tree` it + // before pruning): NotFound must continue to the parent levels this run + // created, or they survive as committable husks. Any other error (i.e. + // non-empty: a live copy or marker) still stops the prune. + match tokio::fs::remove_dir(uuid_dir).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return, + } + let Some(eco_dir) = uuid_dir.parent() else { + return; + }; + if tokio::fs::remove_dir(eco_dir).await.is_err() { + return; + } + if let Some(vendor_dir) = eco_dir.parent() { + let _ = tokio::fs::remove_dir(vendor_dir).await; + } +} + +/// Failure cleanup for a staged (re)build: always remove the stage, then +/// either unwind the whole `/` dir (`unwind_uuid_dir` — a fresh vendor +/// with no pre-existing state worth keeping) or leave existing state +/// untouched — a live-wired rebuild must never delete the copy the Gemfile +/// `path:` and the lock's PATH `remote:` still point at; either way prune any +/// empty-husk dirs left behind. +async fn cleanup_failed_stage(stage: &Path, uuid_dir: &Path, unwind_uuid_dir: bool) { + let _ = remove_tree(stage).await; + if unwind_uuid_dir { + let _ = remove_tree(uuid_dir).await; + } + prune_empty_vendor_dirs(uuid_dir).await; +} + /// The path-source stub gemspec served as the gem's SECOND artifact, alongside /// the `.gem` (mirrors npm's `yarn-berry-zip`). The converter generates it /// because a `.gem` only carries the gemspec as YAML in `metadata.gz`, not the @@ -725,6 +834,7 @@ async fn gem_service_copy( name: &str, copy_dir: &Path, uuid_dir: &Path, + unwind_uuid_dir: bool, warnings: &mut Vec, ) -> GemServiceCopy { let Some(cfg) = service else { @@ -889,25 +999,29 @@ async fn gem_service_copy( ); } - // Extract the patched `.gem`'s data.tar.gz into a clean copy dir, then add - // the stub as `.gemspec` (a `.gem`'s data.tar.gz never carries one — - // the gemspec lives in metadata.gz). - let _ = remove_tree(copy_dir).await; - if let Err(e) = tokio::fs::create_dir_all(copy_dir).await { + // Extract the patched `.gem`'s data.tar.gz into a STAGE sibling, add the + // stub as `.gemspec` (a `.gem`'s data.tar.gz never carries one — + // the gemspec lives in metadata.gz), and swap it into the copy dir only + // once fully verified — a failure then leaves any pre-existing (possibly + // live-wired) copy untouched and no husk behind. + let stage = stage_dir_for(copy_dir); + let _ = remove_tree(&stage).await; + if let Err(e) = tokio::fs::create_dir_all(&stage).await { + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; return hard( "vendor_prebuilt_write_failed", - format!("cannot create {}: {e}", copy_dir.display()), + format!("cannot create {}: {e}", stage.display()), ); } - if let Err(e) = extract_gem_data(&archive.bytes, copy_dir) { - let _ = remove_tree(uuid_dir).await; + if let Err(e) = extract_gem_data(&archive.bytes, &stage) { + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; return hard( "vendor_prebuilt_extract_failed", format!("cannot extract the prebuilt .gem: {e}"), ); } - if let Err(e) = tokio::fs::write(copy_dir.join(format!("{name}.gemspec")), &stub).await { - let _ = remove_tree(uuid_dir).await; + if let Err(e) = tokio::fs::write(stage.join(format!("{name}.gemspec")), &stub).await { + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; return hard( "vendor_prebuilt_write_failed", format!("cannot write the stub gemspec into the vendored dir: {e}"), @@ -920,8 +1034,8 @@ async fn gem_service_copy( // stub gemspec we just wrote is not in record.files, so it is not part // of this check.) Fail closed → `auto` falls back to the local build. // (Mirrors composer_lock.rs.) - if !copy_matches_after_hashes(copy_dir, &record.files).await { - let _ = remove_tree(uuid_dir).await; + if !copy_matches_after_hashes(&stage, &record.files).await { + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; return miss( warnings, "vendor_prebuilt_layout_mismatch", @@ -933,6 +1047,13 @@ async fn gem_service_copy( false, ); } + if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; + return hard( + "vendor_prebuilt_write_failed", + format!("cannot move the extracted .gem into place: {e}"), + ); + } warnings.push(VendorWarning::new( "vendor_prebuilt_downloaded", format!( @@ -948,8 +1069,15 @@ async fn gem_service_copy( /// as the fallback. Returns the verify [`ApplyResult`] (a synthesized /// `AlreadyPatched` on the service path), or a terminal [`VendorOutcome`] to /// bubble. A non-fatal copy/stub/patch failure is surfaced as an UN-successful -/// `ApplyResult` (the caller returns it as a `Done` with no ledger entry); this -/// helper cleans up its own partial copy in that case. +/// `ApplyResult` (the caller returns it as a `Done` with no ledger entry). +/// +/// Either build is staged (see [`swap_stage_into_place`]) and swapped into +/// `copy_dir` only on success, so a failure never destroys a pre-existing +/// copy: with `unwind_uuid_dir` (a fresh vendor — nothing pre-existing to +/// keep) the whole uuid dir is removed on failure, without it (the wired +/// hot-path rebuild, where the Gemfile `path:` and the lock's PATH `remote:` +/// still point at the copy) the previous copy, marker, and wiring are left +/// exactly as they were. #[allow(clippy::too_many_arguments)] async fn materialise_patched_copy( purl: &str, @@ -962,10 +1090,21 @@ async fn materialise_patched_copy( record: &PatchRecord, sources: &PatchSources<'_>, force: bool, + unwind_uuid_dir: bool, service: Option<&VendorServiceConfig>, warnings: &mut Vec, ) -> Result> { - match gem_service_copy(service, record, name, copy_dir, uuid_dir, warnings).await { + match gem_service_copy( + service, + record, + name, + copy_dir, + uuid_dir, + unwind_uuid_dir, + warnings, + ) + .await + { GemServiceCopy::Used => { // The service `.gem` is the patched package; trust its verified // integrity (every file reads as AlreadyPatched). @@ -1029,7 +1168,10 @@ async fn materialise_patched_copy( ), ))); } - if let Err(e) = fresh_copy(installed_dir, copy_dir, None).await { + let stage = stage_dir_for(copy_dir); + // `fresh_copy` removes + recreates the stage itself. + if let Err(e) = fresh_copy(installed_dir, &stage, None).await { + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; return Ok(synthesized_result( purl, copy_dir, @@ -1038,12 +1180,12 @@ async fn materialise_patched_copy( Some(format!("failed to copy installed gem: {e}")), )); } - // The vendored dir is freshly created and not yet referenced by + // The stage is freshly created and not yet referenced by // anything, so a plain write suffices for the gemspec. if let Err(e) = - tokio::fs::write(copy_dir.join(format!("{name}.gemspec")), spec_text).await + tokio::fs::write(stage.join(format!("{name}.gemspec")), spec_text).await { - let _ = remove_tree(uuid_dir).await; + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; return Ok(synthesized_result( purl, copy_dir, @@ -1055,13 +1197,22 @@ async fn materialise_patched_copy( )); } let mut result = super::force_apply_staged( - purl, copy_dir, record, sources, false, force, name, version, warnings, + purl, &stage, record, sources, false, force, name, version, warnings, ) .await; result.package_path = copy_dir.display().to_string(); if !result.success { - // Don't leave a half-built copy; neither project file was touched. - let _ = remove_tree(uuid_dir).await; + // Don't leave a half-built stage; neither project file was + // touched, and any pre-existing copy is still in place. + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; + return Ok(result); + } + if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; + result.success = false; + result.error = + Some(format!("failed to move the rebuilt copy into place: {e}")); + return Ok(result); } Ok(result) } @@ -1079,9 +1230,9 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) revert_gem_opts(entry, project_root, RevertOpts::new(dry_run)).await } -/// [`revert_gem`] with full [`RevertOpts`]: `keep_artifact` skips the -/// artifact deletion — and the unwired refusal that exists only to protect -/// it — while the wiring restore runs unchanged. +/// [`revert_gem`] with full [`RevertOpts`]: `keep_artifact` skips ONLY the +/// artifact deletion; the wiring restore — and the empty-wiring refusal, +/// which applies under `keep_artifact` too — runs unchanged. pub async fn revert_gem_opts( entry: &VendorEntry, project_root: &Path, @@ -1110,8 +1261,10 @@ pub async fn revert_gem_opts( // the removed dir and the next `bundle install` hard-fails. Refuse // loudly with the manual cleanup steps instead. (Every entry // `vendor_gem` records carries at least the Gemfile + lock records.) - // Skipped under `keep_artifact`: the refusal exists only to protect the - // deletion, which a preserve-state revert never performs. + // NOT skipped under `keep_artifact` (PR #231 review hardening): a + // preserve-state revert that cannot restore the wiring must not report + // the system restored while the pair edit still wires the vendored dir + // in — the patch would silently stay applied. if entry.wiring.is_empty() { let name = parse_gem_purl(&entry.base_purl) .map(|(n, _)| n) @@ -5086,6 +5239,150 @@ mod tests { ); } + /// AUDIT B1 (cargo twin, PR #194): a failed hot-path artifact rebuild must + /// never destroy the live-wired vendored copy. Drift the committed copy + /// (bad merge / hand edit — still buildable: the path source exists and + /// the stub is valid), then re-run with the patch content unavailable + /// (empty blobs dir — the offline shape: a drifted file harvests no + /// blob): the rebuild fails, but the previous — drifted yet buildable — + /// copy, the marker, the Gemfile, and the lock must all be left exactly + /// as they were, never a deleted uuid dir under a still-pointing pair + /// edit. + #[tokio::test] + async fn failed_rebuild_preserves_live_wired_copy() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + let (r1, e1, _) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(r1.success, "{:?}", r1.error); + assert!(e1.is_some()); + let gemfile_wired = tokio::fs::read(root.join(GEMFILE)).await.unwrap(); + let lock_wired = tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(); + + tokio::fs::write(copy_lib(&root), b"drifted but buildable\n") + .await + .unwrap(); + + let empty = root.join(".socket/empty-blobs"); + tokio::fs::create_dir_all(&empty).await.unwrap(); + let (r2, e2, _) = + unwrap_done(run_vendor(&root, &empty, &installed, &record, false).await); + assert!(!r2.success, "rebuild must fail without patch content"); + assert!(e2.is_none()); + + // The live-wired state is untouched: copy, marker, Gemfile, lock. + assert_eq!( + tokio::fs::read(copy_lib(&root)).await.unwrap(), + b"drifted but buildable\n", + "the previous committed copy must survive a failed rebuild" + ); + assert!( + root.join(format!(".socket/vendor/gem/{UUID}/{VENDOR_MARKER_FILE}")) + .exists(), + "marker must survive" + ); + assert_eq!( + tokio::fs::read(root.join(GEMFILE)).await.unwrap(), + gemfile_wired, + "Gemfile untouched" + ); + assert_eq!( + tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(), + lock_wired, + "lock untouched" + ); + // And the failed rebuild's swap siblings never leak into the uuid dir. + let uuid_dir = root.join(format!(".socket/vendor/gem/{UUID}")); + let mut rd = tokio::fs::read_dir(&uuid_dir).await.unwrap(); + while let Some(e) = rd.next_entry().await.unwrap() { + let n = e.file_name().to_string_lossy().into_owned(); + assert!(!n.contains("socket-stage"), "stage litter: {n}"); + assert!(!n.contains("socket-old"), "backup litter: {n}"); + } + } + + /// The swap itself must never leave less recoverable state than it + /// started with. Force the stage rename to fail (stage absent — the same + /// io::Error surface as a Windows file lock) with a live copy in place: + /// the old copy must be restored byte-identical, with no backup parked + /// beside it. + #[tokio::test] + async fn swap_failure_restores_previous_copy() { + let dir = tempfile::tempdir().unwrap(); + let copy = dir.path().join("rack-3.2.6"); + tokio::fs::create_dir_all(copy.join("lib")).await.unwrap(); + tokio::fs::write(copy.join("lib/rack.rb"), b"live\n") + .await + .unwrap(); + + let stage = stage_dir_for(©); + assert!( + swap_stage_into_place(&stage, ©).await.is_err(), + "swapping a missing stage must fail" + ); + assert_eq!( + tokio::fs::read(copy.join("lib/rack.rb")).await.unwrap(), + b"live\n", + "the previous copy must be restored after a failed swap" + ); + assert!(!backup_dir_for(©).exists(), "no parked backup litter"); + } + + /// Same destroy class, service leg: a wired-but-stale hot-path rebuild + /// whose served `.gem` fails to extract (garbage bytes behind a correct + /// SRI) hard-fails — and must leave the drifted-but-present copy and the + /// live pair edit exactly as they were, not delete the uuid dir the + /// Gemfile `path:` still points at. + #[tokio::test] + async fn failed_service_rebuild_preserves_live_wired_copy() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + let (r1, _, _) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(r1.success, "{:?}", r1.error); + let gemfile_wired = tokio::fs::read(root.join(GEMFILE)).await.unwrap(); + let lock_wired = tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(); + tokio::fs::write(copy_lib(&root), b"drifted but buildable\n") + .await + .unwrap(); + + let garbage = b"not a gem archive".to_vec(); + let sri = sri_sha512(&garbage); + let stub_sri = sri_sha512(SERVICE_STUB); + let server = wiremock::MockServer::start().await; + mount_gem_granted(&server, &garbage, &sri, Some((SERVICE_STUB, &stub_sri))).await; + let sources = PatchSources::blobs_only(&blobs); + + let outcome = vendor_gem( + PURL, + &installed, + &root, + &record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + Some(&gem_service_cfg( + &server.uri(), + VendorSource::Service, + false, + )), + ) + .await; + let (code, _) = unwrap_refused(outcome); + assert_eq!(code, "vendor_prebuilt_extract_failed"); + + assert_eq!( + tokio::fs::read(copy_lib(&root)).await.unwrap(), + b"drifted but buildable\n", + "the previous committed copy must survive a failed service rebuild" + ); + assert_eq!( + tokio::fs::read(root.join(GEMFILE)).await.unwrap(), + gemfile_wired + ); + assert_eq!( + tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(), + lock_wired + ); + } + /// `auto` + a not-built service status falls back to the local build. #[tokio::test] async fn service_unavailable_auto_falls_back_to_build() { @@ -5429,6 +5726,54 @@ mod tests { ); } + /// The empty-wiring refusal applies under `keep_artifact` + /// (`--preserve-state`) TOO — PR #231's review hardening (5ceba4a3) + /// deliberately removed the `&& !keep_artifact` gate: a preserve-state + /// rollback that cannot restore the wiring must not report the system + /// restored while the pair edit still wires the vendored dir in (the + /// patch would silently stay applied). Pins that decision. + #[tokio::test] + async fn preserve_state_revert_refuses_empty_wiring_entry() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_PINNED, LOCK_PINNED).await; + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + let mut entry = entry.expect("wired entry"); + entry.wiring = Vec::new(); + + let gemfile_before = tokio::fs::read(root.join(GEMFILE)).await.unwrap(); + let lock_before = tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(); + for dry_run in [true, false] { + let outcome = revert_gem_opts( + &entry, + &root, + RevertOpts { + dry_run, + keep_artifact: true, + }, + ) + .await; + assert!( + !outcome.success, + "dry_run={dry_run}: must refuse under keep_artifact too" + ); + let err = outcome.error.expect("error detail"); + assert!(err.contains("vendor_wiring_unknown"), "{err}"); + } + assert!( + root.join(copy_rel()).join("lib/rack.rb").is_file(), + "the artifact must NOT be deleted" + ); + assert_eq!( + tokio::fs::read(root.join(GEMFILE)).await.unwrap(), + gemfile_before + ); + assert_eq!( + tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(), + lock_before + ); + } + #[cfg(unix)] fn mkfifo(path: &Path) { use std::os::unix::ffi::OsStrExt; diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index 5b606b2a..64817fd9 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -135,6 +135,22 @@ pub async fn vendor_go_module( return refusal; } + // Hot path (mirrors cargo.rs / composer_lock.rs): already wired to this + // uuid with the committed copy intact → touch nothing and never consult + // `pristine_src` — a pruned/partial module-cache copy must not fail a + // healthy re-run. The engine's `redirect_in_sync` would answer the same, + // but only after the `!force` missing-target pre-check below consulted + // the pristine source; returning here keeps that pre-check scoped to + // runs that actually rebuild from it. Dry runs keep the engine's + // read-only verify as their preview. + if copy_was_ok && !dry_run { + return done( + already_patched_result(purl, ©_dir, &record.files), + None, + warnings, + ); + } + // Acquire the patched module: prefer the prebuilt module zip from the patch // service (download → verify → extract → wire the `replace`, no pristine // source needed); else let the engine copy the pristine source, patch it, @@ -976,6 +992,48 @@ mod tests { ); } + /// A healthy wired re-run must never consult the pristine module cache: + /// a pruned/partial cache copy (a beforeHash target file absent) must not + /// fail a project whose vendored state is fully intact — the in-sync hot + /// path answers already-patched without touching `pristine_src`, exactly + /// as the cargo and composer backends do. + #[tokio::test] + async fn test_wired_intact_copy_rerun_survives_pruned_pristine() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + + let copy = root.join(copy_rel()).join("bar.go"); + let gomod = root.join("go.mod"); + let copy1 = tokio::fs::read(©).await.unwrap(); + let mod1 = tokio::fs::read(&gomod).await.unwrap(); + + // The module cache lost a patch-target file after the first run. + tokio::fs::remove_file(pristine.join("bar.go")) + .await + .unwrap(); + + let (result, entry, warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!( + result.success, + "an in-sync re-run must not fail on a pruned pristine: {:?}", + result.error + ); + assert!(entry.is_none(), "no re-recorded entry"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!( + tokio::fs::read(©).await.unwrap(), + copy1, + "copy untouched" + ); + assert_eq!( + tokio::fs::read(&gomod).await.unwrap(), + mod1, + "go.mod byte-stable" + ); + } + #[tokio::test] async fn test_dry_run_writes_nothing() { let (dir, blobs, pristine, record) = fixture().await; diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 1c50c98f..eb1a3998 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -467,8 +467,22 @@ pub async fn harvest_artifact_blobs( if file.is_dir() || file.size() > MAX_FILE_BYTES { continue; } + // SECURITY: `file.size()` above is only the archive-DECLARED + // uncompressed size; the entry reader is bounded solely by the + // COMPRESSED size, so a zip bomb can declare a tiny size (past + // the gate) yet decompress far beyond the cap. Bound the + // decompressed read itself and drop any entry that overflows, + // before its bytes are all in memory. let mut content = Vec::with_capacity(file.size() as usize); - if file.read_to_end(&mut content).is_err() { + if file + .by_ref() + .take(MAX_FILE_BYTES + 1) + .read_to_end(&mut content) + .is_err() + { + continue; + } + if content.len() as u64 > MAX_FILE_BYTES { continue; } let h = compute_git_sha256_from_bytes(&content); @@ -1161,6 +1175,91 @@ mod harvest_tests { "dir-shaped artifact must yield its afterHash blob" ); } + + fn write_zip(path: &Path, entry_name: &str, content: &[u8]) { + use std::io::Write as _; + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + writer + .start_file( + entry_name, + zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated), + ) + .unwrap(); + writer.write_all(content).unwrap(); + let bytes = writer.finish().unwrap().into_inner(); + std::fs::write(path, &bytes).unwrap(); + } + + /// Like [`write_zip`], but afterwards forges the entry's DECLARED + /// uncompressed size (in both the local file header at offset 22 and the + /// central-directory header's field at +24) down to a small value. The + /// deflate stream is untouched, so it still decompresses to `content` — + /// this is the zip-bomb shape: a tiny declared size hiding a large + /// payload. + fn write_forged_undersized_zip(path: &Path, entry_name: &str, content: &[u8]) { + write_zip(path, entry_name, content); + let mut bytes = std::fs::read(path).unwrap(); + let forged: u32 = 10; + assert_eq!(&bytes[0..4], b"PK\x03\x04", "local file header"); + bytes[22..26].copy_from_slice(&forged.to_le_bytes()); + let cd = bytes + .windows(4) + .position(|w| w == b"PK\x01\x02") + .expect("central directory header"); + bytes[cd + 24..cd + 28].copy_from_slice(&forged.to_le_bytes()); + std::fs::write(path, &bytes).unwrap(); + } + + #[tokio::test] + async fn honest_zip_artifact_yields_its_after_blob() { + let tmp = tempfile::tempdir().unwrap(); + let purl = "pkg:pypi/lib@1.0.0"; + let rel = format!(".socket/vendor/pypi/{UUID}/lib-1.0.0-py3-none-any.whl"); + write_zip(&tmp.path().join(&rel), "lib/__init__.py", PATCHED); + write_ledger(tmp.path(), purl, UUID, &rel); + + let (k, r) = record(purl, UUID, "lib/__init__.py", PATCHED); + let patches = HashMap::from([(k, r)]); + let mem = harvest_artifact_blobs(tmp.path(), &patches).await; + let hash = compute_git_sha256_from_bytes(PATCHED); + assert_eq!( + mem.get(&hash).map(|b| b.as_slice()), + Some(PATCHED), + "an in-bounds zip entry must still yield its afterHash blob" + ); + } + + /// A zip entry whose header DECLARES a tiny uncompressed size but whose + /// stream decompresses past the per-file cap (a zip bomb) must contribute + /// nothing. The metadata gate trusts `file.size()` (the declared value), + /// but the entry reader is bounded only by the COMPRESSED size, so the + /// decompressed read itself must enforce the cap — otherwise the bomb is + /// inflated fully into memory before any cap or CRC check, OOM-killing the + /// harvest. + #[tokio::test] + async fn oversized_zip_entry_declaring_small_size_contributes_nothing() { + // Mirror of the private per-file cap in `harvest_artifact_blobs`. + const MAX_FILE_BYTES: usize = 64 * 1024 * 1024; + let tmp = tempfile::tempdir().unwrap(); + let purl = "pkg:pypi/lib@1.0.0"; + let rel = format!(".socket/vendor/pypi/{UUID}/lib-1.0.0-py3-none-any.whl"); + // Decompresses to just past the cap; a run of one byte compresses to a + // few KiB, so the artifact itself stays well under the artifact cap. + let content = vec![0x41u8; MAX_FILE_BYTES + 4096]; + write_forged_undersized_zip(&tmp.path().join(&rel), "lib/__init__.py", &content); + write_ledger(tmp.path(), purl, UUID, &rel); + + let (k, r) = record(purl, UUID, "lib/__init__.py", &content); + let patches = HashMap::from([(k, r)]); + let mem = harvest_artifact_blobs(tmp.path(), &patches).await; + assert!( + mem.is_empty(), + "a zip entry decompressing past the per-file cap must be dropped, \ + not harvested — the declared size must not gate an unbounded read" + ); + } } #[cfg(test)] diff --git a/crates/socket-patch-core/src/vendor/npm_common.rs b/crates/socket-patch-core/src/vendor/npm_common.rs index 4432725c..1a029a26 100644 --- a/crates/socket-patch-core/src/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/vendor/npm_common.rs @@ -252,14 +252,27 @@ pub(super) async fn stage_patch_pack( } // ── Pack the deterministic tarball ────────────────────────────────── + // An Err past this point must unwind the uuid dir the pack is about to + // create inside the project (the `Err` contract above: "Nothing inside + // the project was written") — but never one that already existed (a + // same-uuid re-vendor's dir may still be referenced by live wiring). + let uuid_dir_preexisted = tokio::fs::metadata(project_root.join(&coords.uuid_dir_rel)) + .await + .is_ok(); let (rel_tgz, dest) = prepare_tgz_dest(purl, project_root, &coords).await?; let packed = match pack_deterministic(&stage, &dest).await { Ok(p) => p, Err(e) => { - return Err(Box::new(done_failure( - purl, - format!("cannot pack the vendored tarball: {e}"), - ))) + return Err(Box::new( + done_failure_unstage( + purl, + format!("cannot pack the vendored tarball: {e}"), + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await, + )) } }; @@ -271,7 +284,18 @@ pub(super) async fn stage_patch_pack( { match read_staged_package_json(&stage).await { Ok(pkg) => Some(pkg), - Err(e) => return Err(Box::new(done_failure(purl, e))), + Err(e) => { + return Err(Box::new( + done_failure_unstage( + purl, + e, + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await, + )) + } } } else { None @@ -425,12 +449,25 @@ async fn staged_pack_from_service_bytes( ))); } + // An Err past this point must unwind the uuid dir the write is about to + // create inside the project (the caller's `Err` contract: "Nothing + // inside the project was written") — but never one that already existed + // (a same-uuid re-vendor's dir may still be referenced by live wiring). + let uuid_dir_preexisted = tokio::fs::metadata(project_root.join(&coords.uuid_dir_rel)) + .await + .is_ok(); let (rel_tgz, dest) = prepare_tgz_dest(purl, project_root, coords).await?; if let Err(e) = atomic_write_bytes(&dest, bytes).await { - return Err(Box::new(done_failure( - purl, - format!("cannot write the vendored tarball: {e}"), - ))); + return Err(Box::new( + done_failure_unstage( + purl, + format!("cannot write the vendored tarball: {e}"), + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await, + )); } let staged_pkg_json = if record @@ -440,7 +477,18 @@ async fn staged_pack_from_service_bytes( { match read_package_json_from_vendored_tgz(&dest).await { Ok(pkg) => Some(pkg), - Err(e) => return Err(Box::new(done_failure(purl, e))), + Err(e) => { + return Err(Box::new( + done_failure_unstage( + purl, + e, + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await, + )) + } } } else { None @@ -758,6 +806,109 @@ mod tests { assert!(!declares_bundled_deps(&serde_json::json!({})), "absent"); } + /// An Err AFTER `prepare_tgz_dest` must unwind the uuid dir the pack + /// created inside the project — the module contract ("a refusal or + /// failure in this pipeline leaves the project byte-untouched", and the + /// `Err` arm's "Nothing inside the project was written") — instead of + /// stranding an unledgered, committable husk no `--revert` entry tracks + /// (the vendor/cargo.rs failed-vendor-husk class). Reachable shape: the + /// patch rewrites package/package.json to content that is not valid + /// JSON (apply is afterHash-gated only, never a JSON parse), so + /// `read_staged_package_json` errs after the tarball fully packed. + #[tokio::test] + async fn err_after_pack_unstages_the_uuid_dir() { + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::patch::apply::PatchSources; + + const ORIG_PKG: &[u8] = b"{\"name\":\"left-pad\",\"version\":\"1.3.0\"}\n"; + const BAD_PKG: &[u8] = b"module.exports = 'not json';\n"; + + async fn build_fixture() -> (tempfile::TempDir, PatchRecord) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let installed = root.join("node_modules/left-pad"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write(installed.join("package.json"), ORIG_PKG) + .await + .unwrap(); + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + let after_hash = compute_git_sha256_from_bytes(BAD_PKG); + tokio::fs::write(blobs.join(&after_hash), BAD_PKG) + .await + .unwrap(); + let mut record = record_with_uuid(UUID); + record.files.clear(); + record.files.insert( + "package/package.json".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(ORIG_PKG), + after_hash, + }, + ); + (tmp, record) + } + + async fn run(root: &Path, record: &PatchRecord) -> Box { + let blobs = root.join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + let mut warnings = Vec::new(); + match stage_patch_pack( + "pkg:npm/left-pad@1.3.0", + &root.join("node_modules/left-pad"), + root, + record, + &sources, + false, + false, + &mut warnings, + None, + ) + .await + { + Err(outcome) => outcome, + Ok(_) => panic!("expected the post-pack package.json parse to Err"), + } + } + + // Fresh vendor: the post-pack failure must leave no husk at all. + let (tmp, record) = build_fixture().await; + match *run(tmp.path(), &record).await { + VendorOutcome::Done { result, entry, .. } => { + assert!(!result.success); + assert!( + result + .error + .as_deref() + .unwrap_or("") + .contains("not parseable JSON"), + "fails on the post-pack package.json parse: {:?}", + result.error + ); + assert!(entry.is_none()); + } + other => panic!("expected Done failure, got {other:?}"), + } + assert!( + !tmp.path().join(".socket/vendor").exists(), + "no orphaned uuid-dir husk may remain after a post-pack failure" + ); + + // Same-uuid re-vendor: a PRE-EXISTING uuid dir is never unstaged + // (live wiring may still reference it). + let (tmp, record) = build_fixture().await; + let uuid_dir = tmp.path().join(format!(".socket/vendor/npm/{UUID}")); + tokio::fs::create_dir_all(&uuid_dir).await.unwrap(); + match *run(tmp.path(), &record).await { + VendorOutcome::Done { result, .. } => assert!(!result.success), + other => panic!("expected Done failure, got {other:?}"), + } + assert!( + uuid_dir.exists(), + "a pre-existing uuid dir survives the failure" + ); + } + #[tokio::test] async fn done_failure_shape_matches_contract() { let outcome = done_failure("pkg:npm/x@1.0.0", "boom".to_string()); diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 37e34012..e1152d1f 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -24,7 +24,9 @@ use crate::patch::copy_tree::remove_tree; use crate::utils::fs::atomic_write_bytes_preserving_mode; use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; -use super::npm_common::{done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack}; +use super::npm_common::{ + done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, +}; use super::path::parse_vendor_path; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, @@ -196,6 +198,12 @@ pub async fn vendor_npm( // ── 4–7. Stage → patch → pack (shared flavor-agnostic pipeline: // tempdir stage outside the project, nested node_modules prune, // bundled-deps refusal, hardened apply, deterministic pack) ──── + // A wiring failure past this point must unwind the uuid dir staging is + // about to create — but never one that already existed (a same-uuid + // re-vendor's dir may still be referenced by live wiring). + let uuid_dir_preexisted = tokio::fs::metadata(project_root.join(&uuid_dir_rel)) + .await + .is_ok(); let (staged, result) = match stage_patch_pack( purl, installed_dir, @@ -238,10 +246,14 @@ pub async fn vendor_npm( let mut recomputed_deps = false; { let Some(packages) = lock.get_mut("packages").and_then(Value::as_object_mut) else { - return done_failure( + return done_failure_unstage( purl, "lock `packages` object vanished mid-rewrite".to_string(), - ); + project_root, + &uuid_dir_rel, + uuid_dir_preexisted, + ) + .await; }; for m in &matches { let Some(live) = packages.get_mut(&m.key).and_then(Value::as_object_mut) else { @@ -324,10 +336,26 @@ pub async fn vendor_npm( let indent = detect_indent(&String::from_utf8_lossy(&lock_bytes)); let out = match serialize_json(&lock, &indent) { Ok(out) => out, - Err(e) => return done_failure(purl, format!("cannot serialize {lock_name}: {e}")), + Err(e) => { + return done_failure_unstage( + purl, + format!("cannot serialize {lock_name}: {e}"), + project_root, + &uuid_dir_rel, + uuid_dir_preexisted, + ) + .await + } }; if let Err(e) = atomic_write_bytes_preserving_mode(&project_root.join(&lock_name), &out).await { - return done_failure(purl, format!("cannot write {lock_name}: {e}")); + return done_failure_unstage( + purl, + format!("cannot write {lock_name}: {e}"), + project_root, + &uuid_dir_rel, + uuid_dir_preexisted, + ) + .await; } // ── 9. Marker + ledger entry ───────────────────────────────────────── @@ -1364,6 +1392,65 @@ mod tests { ); } + /// A wiring failure AFTER `stage_patch_pack` packed the tarball into the + /// project must unstage the uuid dir ([`done_failure_unstage`]) — plain + /// `done_failure` leaves an untracked artifact husk with NO ledger entry, + /// breaking the "a failure leaves the project byte-untouched" contract + /// (yarn classic/berry + pnpm-legacy precedent). Repro: the project root + /// turns read-only after install (the .socket subtree keeps its own + /// writable mode, so packing succeeds and only step 8's lock write fails + /// with EACCES). + #[cfg(unix)] + #[tokio::test] + async fn post_pack_lock_write_failure_unstages_the_artifact_husk() { + use std::os::unix::fs::PermissionsExt; + + async fn chmod(path: &Path, mode: u32) { + tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .await + .unwrap(); + } + + let fx = fixture().await; + chmod(fx.root(), 0o555).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + chmod(fx.root(), 0o755).await; + assert!(!result.success, "the lock-write failure is reported"); + assert!( + result + .error + .as_deref() + .unwrap_or("") + .contains("cannot write package-lock.json"), + "{:?}", + result.error + ); + assert!(entry.is_none(), "no ledger entry for a failed wiring"); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "no orphaned artifact husk may remain (nothing tracks it)" + ); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "lock byte-untouched on failure" + ); + + // A same-uuid re-vendor's PRE-EXISTING dir must survive the failure + // (live wiring may still reference it). + let fx2 = fixture().await; + let uuid_dir = fx2.root().join(format!(".socket/vendor/npm/{UUID}")); + tokio::fs::create_dir_all(&uuid_dir).await.unwrap(); + chmod(fx2.root(), 0o555).await; + let (result, entry, _) = expect_done(fx2.vendor(false).await); + chmod(fx2.root(), 0o755).await; + assert!(!result.success); + assert!(entry.is_none()); + assert!(uuid_dir.exists(), "a pre-existing uuid dir is never unstaged"); + } + /// `vendor --force` keeps its missing-file tolerance (strict superset /// of the auto-force policy). #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 3dc6ad55..bf53e160 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -313,6 +313,7 @@ pub async fn vendor_nuget( sources, force, service, + /*config_wired=*/ true, &mut warnings, ) .await @@ -394,6 +395,7 @@ pub async fn vendor_nuget( sources, force, service, + /*config_wired=*/ false, &mut warnings, ) .await @@ -670,7 +672,12 @@ pub async fn revert_nuget_opts( /// rebuild otherwise (extract the cached pristine nupkg → force-apply → re-zip /// deterministically). Returns `(bytes, ApplyResult)`, or a terminal /// [`VendorOutcome`] to bubble. On a non-fatal rebuild failure the returned -/// `ApplyResult.success` is false and the partial uuid dir is cleaned up. +/// `ApplyResult.success` is false and the partial uuid dir is cleaned up — +/// UNLESS `config_wired`: on the wired hot path nuget.config already routes +/// the patched id exclusively at this dir, so removing it (the marker AND a +/// previously servable committed nupkg) would leave the wired config pointing +/// at nothing and brick every cold restore (the module-doc invariant stated +/// for the lock re-pin failure). #[allow(clippy::too_many_arguments)] async fn materialise_patched_nupkg( purl: &str, @@ -683,12 +690,15 @@ async fn materialise_patched_nupkg( sources: &PatchSources<'_>, force: bool, service: Option<&VendorServiceConfig>, + config_wired: bool, warnings: &mut Vec, ) -> Result<(Vec, ApplyResult), Box> { match service_archive_copy(service, &record.uuid, name, ".nupkg", warnings).await { ServiceCopy::Used(bytes) => { if let Err(e) = write_nupkg(uuid_dir, nupkg_path, &bytes).await { - let _ = remove_tree(uuid_dir).await; + if !config_wired { + let _ = remove_tree(uuid_dir).await; + } return Err(Box::new(refused("vendor_prebuilt_write_failed", e))); } Ok(( @@ -708,6 +718,7 @@ async fn materialise_patched_nupkg( record, sources, force, + config_wired, warnings, ) .await @@ -719,7 +730,8 @@ async fn materialise_patched_nupkg( /// extract it to a private stage, force-apply the patch, and re-zip /// deterministically. The `.signature.p7s` part is dropped (see the module /// doc). Returns `(bytes, ApplyResult)`; a failure surfaces as an un-successful -/// `ApplyResult` (partial uuid dir cleaned up), or a refusal to bubble. +/// `ApplyResult` (partial uuid dir cleaned up — unless `config_wired`, see +/// [`materialise_patched_nupkg`]), or a refusal to bubble. #[allow(clippy::too_many_arguments)] async fn local_rebuild( purl: &str, @@ -731,6 +743,7 @@ async fn local_rebuild( record: &PatchRecord, sources: &PatchSources<'_>, force: bool, + config_wired: bool, warnings: &mut Vec, ) -> Result<(Vec, ApplyResult), Box> { let Some(src_nupkg) = locate_cached_nupkg(installed_dir).await else { @@ -817,7 +830,12 @@ async fn local_rebuild( }; if let Err(e) = write_nupkg(uuid_dir, nupkg_path, &nupkg_bytes).await { - let _ = remove_tree(uuid_dir).await; + // atomic_write_bytes cleans up its own stage file, so an existing + // committed nupkg in the dir is intact; on the wired hot path it (and + // the marker) must stay — the config still routes restores here. + if !config_wired { + let _ = remove_tree(uuid_dir).await; + } return Ok((Vec::new(), failed_result(purl, nupkg_path, e))); } Ok((nupkg_bytes, result)) @@ -929,12 +947,15 @@ fn build_config_edit( // The pre-existing sources the catch-all fans `*` out to. When the // config has NONE and we are creating a mapping from scratch, a // socket-only mapping would NU1100 every other package, so seed the - // implicit default nuget.org source (unless already present) and map - // `*` to it. Mirrors redirect::add_nuget_source. + // implicit default nuget.org source and map `*` to it. "Already + // present" is decided by the parsed source keys ALONE: a whole-file + // "nuget.org" probe is satisfied by text that defines no source (a + // defaultPushSource URL, a entry), and + // suppressing the seed on it recreates the exact socket-only + // mapping the seed exists to prevent. Mirrors + // redirect::add_nuget_source. let mut catch_all_keys = parse_config_source_keys(&visible); - let seed_nuget_org = creating_mapping - && catch_all_keys.is_empty() - && !visible.contains(NUGET_ORG_SOURCE_KEY); + let seed_nuget_org = creating_mapping && catch_all_keys.is_empty(); let source_add = format!(" \n"); let org_add = format!( @@ -1076,12 +1097,23 @@ fn parse_config_source_keys(text: &str) -> Vec { } /// The value of `="..."` inside an element's attribute text, if present. +/// Tolerates whitespace around `=` (`key = "nuget.org"` is valid XML NuGet +/// parses): a real source the scan misses would read as "no sources", +/// triggering a duplicate nuget.org seed and leaving the missed source out of +/// the catch-all fan-out. fn attr_value(elem: &str, attr: &str) -> Option { - let needle = format!("{attr}=\""); - let at = elem.find(&needle)?; - let after = &elem[at + needle.len()..]; - let close = after.find('"')?; - Some(after[..close].to_string()) + let mut rest = elem; + loop { + let at = rest.find(attr)?; + let after = rest[at + attr.len()..].trim_start(); + if let Some(eq) = after.strip_prefix('=') { + if let Some(quoted) = eq.trim_start().strip_prefix('"') { + let close = quoted.find('"')?; + return Some(quoted[..close].to_string()); + } + } + rest = &rest[at + attr.len()..]; + } } /// The `[start, end)` byte span of a self-closing `` element @@ -1620,6 +1652,75 @@ mod tests { ); } + #[test] + fn seed_not_suppressed_by_nugetorg_text_outside_sources() { + // "nuget.org" appearing OUTSIDE — a defaultPushSource + // URL, a entry — defines no package source. + // With zero catch-all keys the seed must still run: a socket-only + // from-scratch mapping is exclusive and NU1100s every other package. + for extra in [ + " \n \n \n", + " \n \n \n", + ] { + let orig = format!( + "\n\n \n{extra}\n" + ); + let edit = build_config_edit( + Some(&orig), + &source_key(), + &format!(".socket/vendor/nuget/{UUID}"), + "Newtonsoft.Json", + ) + .unwrap(); + let t = &edit.new_text; + assert!( + t.contains( + "" + ), + "nuget.org seeded despite unrelated mention: {t}" + ); + assert!( + t.contains( + " \n \n " + ), + "catch-all targets the seeded nuget.org: {t}" + ); + } + } + + #[test] + fn unparsed_nugetorg_add_takes_catch_all_not_duplicated() { + // XML allows whitespace around `=`: `` is a + // REAL source NuGet parses. It must be harvested as the catch-all + // target — not double-added by the seed, and not left out of the `*` + // fan-out (a source with no mapping entry is unusable once a mapping + // exists). + let orig = "\n\ + \n\ + \x20 \n\ + \x20 \n\ + \x20 \n\ + \n"; + let edit = build_config_edit( + Some(orig), + &source_key(), + &format!(".socket/vendor/nuget/{UUID}"), + "Newtonsoft.Json", + ) + .unwrap(); + let t = &edit.new_text; + assert!( + !t.contains("\n \n " + ), + "the existing source takes the catch-all: {t}" + ); + } + // ── packages.lock.json surgery ───────────────────────────────────────── fn lock_json(content_hash: &str) -> String { @@ -2499,6 +2600,54 @@ mod tests { ); } + #[cfg(unix)] + #[tokio::test] + async fn hot_path_nupkg_write_failure_keeps_wired_feed_dir() { + use std::os::unix::fs::PermissionsExt as _; + let (dir, blobs, installed, record) = fixture(true, None).await; + let root = dir.path(); + let (r1, _e, _w) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r1.success); + + // The committed nupkg rots (stale bytes) — the rerun takes the wired + // hot-path rebuild leg. A read-only uuid dir then blocks the rebuilt + // nupkg's atomic stage file. Skip when modes are ignored (root). + let stale = b"stale-but-previously-servable".to_vec(); + tokio::fs::write(root.join(copy_rel()), &stale) + .await + .unwrap(); + let uuid_dir = root.join(format!(".socket/vendor/nuget/{UUID}")); + tokio::fs::set_permissions(&uuid_dir, std::fs::Permissions::from_mode(0o555)) + .await + .unwrap(); + if std::fs::write(uuid_dir.join(".probe"), b"x").is_ok() { + let _ = std::fs::remove_file(uuid_dir.join(".probe")); + tokio::fs::set_permissions(&uuid_dir, std::fs::Permissions::from_mode(0o755)) + .await + .unwrap(); + return; + } + let outcome = run_vendor(root, &blobs, &installed, &record, false).await; + let _ = tokio::fs::set_permissions(&uuid_dir, std::fs::Permissions::from_mode(0o755)).await; + + let (r2, _e2, _w2) = unwrap_done(outcome); + assert!(!r2.success, "the failed rebuild write must be reported"); + // nuget.config (from run 1) still routes the patched id EXCLUSIVELY at + // this dir: deleting it on the wired path would leave the mapping + // pointing at nothing — every cold restore of the patched package + // hard-fails until a re-vendor or revert (module-doc invariant, same + // as the lock re-pin failure above). + assert!( + uuid_dir.exists(), + "the wired feed dir must survive a rebuild write failure" + ); + assert_eq!( + tokio::fs::read(root.join(copy_rel())).await.unwrap(), + stale, + "the previously committed nupkg must survive the failed write" + ); + } + // ── tamper-able wiring `file` regression ─────────────────────────────── #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index 57bd51aa..ae594e3b 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -65,7 +65,7 @@ use crate::utils::fs::atomic_write_bytes_preserving_mode; use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; use super::npm_common::{ - done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, + done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, }; use super::path::parse_vendor_path; use super::pnpm_lock::{ @@ -432,6 +432,12 @@ pub async fn vendor_pnpm_legacy( } // ── 4. Stage → patch → pack ─────────────────────────────────────────── + // A wiring failure past this point must unwind the uuid dir staging is + // about to create — but never one that already existed (a same-uuid + // re-vendor's dir may still be referenced by live wiring). + let uuid_dir_preexisted = tokio::fs::metadata(project_root.join(&coords.uuid_dir_rel)) + .await + .is_ok(); let (staged, result) = match stage_patch_pack( purl, installed_dir, @@ -476,13 +482,31 @@ pub async fn vendor_pnpm_legacy( let (pkg_changed, created_pnpm_table, created_overrides_table) = match apply_pkg_override(&mut pkg, &effective_key, &spec, &mut wiring) { Ok(out) => out, - Err(e) => return done_failure(purl, e), + Err(e) => { + return done_failure_unstage( + purl, + e, + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await + } }; let mut lock_changed = false; match edit_overrides(&mut lines, &ctx, &mut wiring) { Ok(changed) => lock_changed |= changed, - Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + Err(e) => { + return done_failure_unstage( + purl, + format!("{PNPM_LOCK} surgery failed: {e}"), + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await + } } let root_edit = match grammar { PnpmLockGrammar::V54 => edit_root_deps_v54(&mut lines, &ctx, &mut wiring), @@ -493,18 +517,45 @@ pub async fn vendor_pnpm_legacy( lock_changed |= changed; hit } - Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + Err(e) => { + return done_failure_unstage( + purl, + format!("{PNPM_LOCK} surgery failed: {e}"), + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await + } }; if grammar == PnpmLockGrammar::V54 && root_dep_hit { match edit_specifier_v54(&mut lines, &ctx, &mut wiring) { Ok(changed) => lock_changed |= changed, - Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + Err(e) => { + return done_failure_unstage( + purl, + format!("{PNPM_LOCK} surgery failed: {e}"), + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await + } } } for edit in [edit_packages, edit_pkg_dep_refs] { match edit(&mut lines, &ctx, &mut wiring) { Ok(changed) => lock_changed |= changed, - Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + Err(e) => { + return done_failure_unstage( + purl, + format!("{PNPM_LOCK} surgery failed: {e}"), + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await + } } } @@ -539,7 +590,16 @@ pub async fn vendor_pnpm_legacy( let pkg_indent = detect_indent(&String::from_utf8_lossy(&pkg_bytes)); let new_pkg_bytes = match serialize_json(&pkg, &pkg_indent) { Ok(bytes) => bytes, - Err(e) => return done_failure(purl, format!("cannot serialize {PACKAGE_JSON}: {e}")), + Err(e) => { + return done_failure_unstage( + purl, + format!("cannot serialize {PACKAGE_JSON}: {e}"), + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await + } }; let lock_out = lines.join("\n"); if let Err(e) = commit_surfaces( @@ -553,7 +613,14 @@ pub async fn vendor_pnpm_legacy( ) .await { - return done_failure(purl, e); + return done_failure_unstage( + purl, + e, + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await; } // ── 7. Marker + ledger entry ────────────────────────────────────────── @@ -1219,7 +1286,7 @@ pub async fn revert_pnpm_legacy_opts( // (see [`super::pnpm_lock::guard_unwired_revert`]). Skipped under // `keep_artifact`: the refusal exists only to protect the deletion, // which a preserve-state revert never performs. - if entry.wiring.is_empty() { + if entry.wiring.is_empty() && !keep_artifact { let in_use = pnpm_legacy_entry_in_use(entry, project_root).await; if let Some(blocked) = guard_unwired_revert(project_root, in_use, &uuid_dir_rel).await { return blocked; @@ -1452,6 +1519,13 @@ fn revert_value_line( if k != dep { continue; } + // ALREADY CONVERGED: the live value already equals the recorded + // pre-vendor original — an earlier partial revert (or the user, by + // hand) already restored it. Not drift: stay silent so the + // drift-skip keep gate can converge. + if rec.original.as_ref().and_then(Value::as_str) == Some(rest.as_str()) { + return; + } let ours = Some(rest.as_str()) == rec.new.as_ref().and_then(Value::as_str) || parse_vendor_path(&rest).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { @@ -1508,9 +1582,20 @@ fn revert_root_dep_pair( continue; } let (spec_f, ver_f, _) = dep_field_lines(lines, k + 1, end, 4); - let (Some((si, _)), Some((vi, live_ver))) = (spec_f, ver_f) else { + let (Some((si, live_spec)), Some((vi, live_ver))) = (spec_f, ver_f) else { break; }; + // ALREADY CONVERGED: both fields already equal the recorded + // pre-vendor original — an earlier partial revert (or the user, by + // hand) already restored this record. Not drift: stay silent so + // the drift-skip keep gate can converge. + if let Some(original) = rec.original.as_ref() { + if original.get("specifier").and_then(Value::as_str) == Some(live_spec.as_str()) + && original.get("version").and_then(Value::as_str) == Some(live_ver.as_str()) + { + return; + } + } let new_ver = rec .new .as_ref() @@ -1616,6 +1701,20 @@ fn revert_package_block( *dirty = true; return; } + // ALREADY CONVERGED: an earlier partial revert restored this record — + // the restore rekeys the block back to its pre-vendor key, so the + // recorded `file:` key no longer matches while the original block is + // live verbatim. Not drift: stay silent so the drift-skip keep gate can + // converge instead of keeping the artifacts forever. + if let Some(orig) = rec.original.as_ref().and_then(value_lines) { + let mut j = start + 1; + while let Some(block) = next_block(lines, j, end) { + if lines[block.header..block.end] == orig[..] { + return; + } + j = block.end; + } + } warnings.push(drifted(format!( "packages entry `{new_key}` no longer exists; nothing to restore" ))); @@ -1663,6 +1762,12 @@ fn revert_pkg_dep_ref( if d != dep { continue; } + // ALREADY CONVERGED: the live ref already equals the recorded + // pre-vendor original — an earlier partial revert (or the + // user, by hand) already restored it. Not drift. + if rec.original.as_ref().and_then(Value::as_str) == Some(rest.as_str()) { + return; + } let ours = Some(rest.as_str()) == rec.new.as_ref().and_then(Value::as_str) || parse_vendor_path(&rest).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { @@ -2858,6 +2963,127 @@ packages: assert_eq!(fx.read(PNPM_LOCK).await, crlf, "the drifted lock is left alone"); } + /// LIVENESS CONTRACT ([`RevertOutcome::drift_skipped`]): a pair already + /// back at its pre-vendor state (a `git checkout`, or an earlier partial + /// revert) is CONVERGED, not drifted — revert must no-op silently and + /// still remove the artifact, exactly like the v9 backend, instead of + /// keeping artifact + ledger entry forever behind an "undo the drift" + /// remediation the reverted state can never satisfy. Both grammars + /// (covers all four legacy-only revert helpers). + #[tokio::test] + async fn converged_pair_revert_is_silent_and_removes_the_artifact() { + for (before_lock, tag) in [(T7_BEFORE_LOCK, "5.4"), (T8_BEFORE_LOCK, "6.0")] { + let fx = fixture_with(T_BEFORE_PKG, before_lock).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + // The pair reaches its pre-vendor state while the ledger entry + // survives (`git checkout pnpm-lock.yaml package.json`). + tokio::fs::write(fx.root().join(PACKAGE_JSON), T_BEFORE_PKG) + .await + .unwrap(); + tokio::fs::write(fx.root().join(PNPM_LOCK), before_lock) + .await + .unwrap(); + + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(outcome.success, "{tag}: {:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "{tag}: converged records are silent no-ops, not drift: {:?}", + outcome.warnings + ); + assert!(!outcome.kept_artifact, "{tag}"); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "{tag}: the artifact dir is removed" + ); + assert_eq!(fx.read(PACKAGE_JSON).await, T_BEFORE_PKG, "{tag}"); + assert_eq!(fx.read(PNPM_LOCK).await, before_lock, "{tag}"); + } + } + + /// A wiring failure AFTER `stage_patch_pack` packed the tarball into the + /// project must unstage the uuid dir ([`done_failure_unstage`]) — plain + /// `done_failure` leaves an untracked artifact husk with NO ledger entry, + /// breaking the "a failure leaves the project byte-untouched" contract + /// (yarn classic/berry precedent). Reachable shape: a non-object `pnpm` + /// value passes classify's pre-flight (`Value::get` on a string is None) + /// and errs in `apply_pkg_override`, after the pack. + #[tokio::test] + async fn post_pack_wiring_failure_unstages_the_artifact_husk() { + let pkg_bad_pnpm = r#"{ + "name": "legacy-spike2", + "version": "0.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer", + "left-pad": "1.3.0", + "left-pad-old": "npm:left-pad@1.2.0" + }, + "pnpm": "not-an-object" +} +"#; + let fx = fixture_with(pkg_bad_pnpm, T7_BEFORE_LOCK).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(!result.success, "the wiring failure is reported"); + assert!(entry.is_none(), "no ledger entry for a failed wiring"); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "no orphaned artifact husk may remain (nothing tracks it)" + ); + assert_eq!(fx.read(PACKAGE_JSON).await, pkg_bad_pnpm); + assert_eq!(fx.read(PNPM_LOCK).await, T7_BEFORE_LOCK); + + // A same-uuid re-vendor's PRE-EXISTING dir must survive the failure + // (live wiring may still reference it). + let fx2 = fixture_with(pkg_bad_pnpm, T7_BEFORE_LOCK).await; + let uuid_dir = fx2.root().join(format!(".socket/vendor/npm/{UUID}")); + tokio::fs::create_dir_all(&uuid_dir).await.unwrap(); + let (result, entry, _) = expect_done(fx2.vendor(false).await); + assert!(!result.success); + assert!(entry.is_none()); + assert!( + uuid_dir.exists(), + "a pre-existing uuid dir is never unstaged" + ); + } + + /// `keep_artifact` (rollback/remove --preserve-state) never deletes the + /// artifact, so the empty-wiring refusal that exists only to protect the + /// deletion must be SKIPPED (its own documented contract): a + /// repair-reconstructed entry over a still-wired lock preserve-state + /// reverts as a clean no-op instead of a hard per-entry failure. + #[tokio::test] + async fn empty_wiring_preserve_state_revert_skips_the_deletion_guard() { + let fx = fixture_with(T_BEFORE_PKG, T7_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + entry.pnpm = None; + let lock_wired = fx.read(PNPM_LOCK).await; + + let opts = RevertOpts { + dry_run: false, + keep_artifact: true, + }; + let outcome = revert_pnpm_legacy_opts(&entry, fx.root(), opts).await; + assert!( + outcome.success, + "a preserve-state revert performs no deletion, so nothing needs guarding: {:?} {:?}", + outcome.error, outcome.warnings + ); + assert!( + fx.root().join(fx.rel_tgz()).exists(), + "the artifact stays (preserve-state)" + ); + assert_eq!(fx.read(PNPM_LOCK).await, lock_wired, "lock untouched"); + } + #[cfg(unix)] fn mkfifo(path: &Path) { use std::os::unix::ffi::OsStrExt; diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 94abff96..6a135a34 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -394,6 +394,25 @@ pub async fn vendor_pypi( } } + // The in-sync probes key only on the patch uuid in the wired path, so + // the lockfile still pins the FIRST vendor's exact wheel path + sha256. + // An artifact-only rebuild is safe only when it reproduces those exact + // bytes; the ledger entry recorded at wiring time carries that pin. + // (Callers with no readable ledger entry keep the unguarded rebuild — + // the local build is deterministic for locally-vendored projects.) + let expected_pin: Option<(String, String)> = if in_sync { + match super::state::load_state(project_root).await { + Ok(state) => state + .entries + .into_values() + .find(|e| e.ecosystem == "pypi" && e.uuid == record.uuid) + .map(|e| (e.artifact.path, e.artifact.sha256)), + Err(_) => None, + } + } else { + None + }; + // Acquire the patched wheel: prefer the prebuilt service artifact (which // skips needing the package installed), else build it locally. A refusal / // hard fail bubbles as a terminal outcome. @@ -416,6 +435,7 @@ pub async fn vendor_pypi( dry_run, force, service, + expected_pin.as_ref(), &mut warnings, ) .await @@ -460,6 +480,25 @@ pub async fn vendor_pypi( } if in_sync { + // The wiring still pins the first vendor's wheel path + sha256; a + // rebuilt artifact that does not reproduce them would break every + // subsequent hash-checked install (`pip --require-hashes`, + // `uv sync`, …) the moment vendor reports success. Sweep the + // mismatched wheel back out and fail loudly instead. + if let Some((pin_path, pin_sha)) = &expected_pin { + if *pin_path != rel_wheel || *pin_sha != artifact.sha256_hex { + let _ = tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await; + let mut result = result; + result.success = false; + result.error = Some(format!( + "the rebuilt wheel ({rel_wheel}, sha256 {}) does not match the wheel the \ + lockfile still pins ({pin_path}, sha256 {pin_sha}); run `socket-patch \ + vendor --revert` for {base} and re-vendor to re-wire the lockfile", + artifact.sha256_hex + )); + return done(result, None, warnings); + } + } // Artifact rebuilt; wiring untouched, ledger entry stays with the // first run (the only copy of the pre-vendor originals). warnings.push(VendorWarning::new( @@ -630,6 +669,22 @@ pub async fn revert_pypi_opts( if !outcome.success || dry_run { return outcome; } + // LOSSINESS GUARD (residual #131 — the RevertOutcome contract every + // npm-family backend honors): when any wiring record was left alone + // ("drifted; left untouched"), the lockfile may still resolve through + // the uuid dir, and the ledger entry holds the only recorded pre-vendor + // originals. Keep both (the caller keeps the entry when `kept_artifact` + // is set) instead of deleting evidence out from under a lock the flavor + // revert just refused to touch. + if outcome.drift_skipped() { + // Display-only path: with a non-canonical uuid nothing below would + // have been deleted anyway, but the drift-keep must still be + // surfaced so the ledger entry survives. + let uuid_dir_rel = vendor_uuid_dir_rel("pypi", &entry.uuid) + .unwrap_or_else(|| format!(".socket/vendor/pypi/{:?}", entry.uuid)); + outcome.keep_artifact(&uuid_dir_rel); + return outcome; + } // `--preserve-state` (`keep_artifact`): the wiring restore above already // ran; the artifact dir stays behind (and the caller keeps the ledger // entry), so only the deletion is skipped. @@ -690,6 +745,7 @@ async fn acquire_patched_wheel( dry_run: bool, force: bool, service: Option<&VendorServiceConfig>, + expected_pin: Option<&(String, String)>, warnings: &mut Vec, ) -> Result { if let Some(refusal) = service_offline_conflict(service) { @@ -699,8 +755,16 @@ async fn acquire_patched_wheel( // A dry run previews the local build; the service is only consulted for // a real vendor. if cfg.service_enabled() && !dry_run { - match try_pypi_service_wheel(base, uuid_dir_rel, project_root, record, cfg, warnings) - .await + match try_pypi_service_wheel( + base, + uuid_dir_rel, + project_root, + record, + cfg, + expected_pin, + warnings, + ) + .await { PypiServiceWheel::Used(acq) => return Ok(*acq), PypiServiceWheel::HardFail(outcome) => return Err(*outcome), @@ -768,6 +832,7 @@ async fn try_pypi_service_wheel( project_root: &Path, record: &PatchRecord, cfg: &VendorServiceConfig, + expected_pin: Option<&(String, String)>, warnings: &mut Vec, ) -> PypiServiceWheel { // A terminal `service`-mode refusal (boxed — the enum's other variants are @@ -799,6 +864,26 @@ async fn try_pypi_service_wheel( ); }; let rel_wheel = format!("{uuid_dir_rel}/{wheel_name}"); + let sha256_hex = hex::encode(Sha256::digest(&archive.bytes)); + // In-sync rebuild: the lockfile still pins the first vendor's + // wheel path + sha256, and a prebuilt wheel that differs would + // break every subsequent hash-checked install the moment vendor + // reports success. Checked BEFORE writing, so a mismatch leaves + // no poisoned artifact behind (`auto` falls back to the + // deterministic local build, which reproduces a local pin). + if let Some((pin_path, pin_sha)) = expected_pin { + if *pin_path != rel_wheel || *pin_sha != sha256_hex { + return miss( + warnings, + "vendor_prebuilt_pin_mismatch", + format!( + "the prebuilt wheel ({rel_wheel}, sha256 {sha256_hex}) does not \ + match the wheel the lockfile still pins ({pin_path}, sha256 \ + {pin_sha})" + ), + ); + } + } let dest = project_root.join(uuid_dir_rel).join(&wheel_name); if let Some(parent) = dest.parent() { if let Err(e) = tokio::fs::create_dir_all(parent).await { @@ -828,7 +913,7 @@ async fn try_pypi_service_wheel( result: already_patched_result(base, &dest, &record.files), artifact: Some(WheelArtifact { file_name: wheel_name.clone(), - sha256_hex: hex::encode(Sha256::digest(&archive.bytes)), + sha256_hex, size: archive.bytes.len() as u64, }), wheel_name, @@ -1932,6 +2017,548 @@ wheels = [ ); } + /// Write `entry` into the on-disk ledger the CLI would have persisted + /// after a real vendor run (state.json keyed by base purl). + async fn save_ledger_entry(root: &Path, entry: &VendorEntry) { + use crate::vendor::state::{save_state, VendorState}; + let mut state = VendorState::new(); + state.entries.insert(entry.base_purl.clone(), entry.clone()); + save_state(root, &state).await.unwrap(); + } + + /// BUG GUARD (in-sync rebuild × service): the in-sync probes key only on + /// the patch uuid, so the lockfile still pins the FIRST vendor's exact + /// wheel sha256. A service-built wheel with different bytes must not + /// silently replace the missing artifact — under `auto` the rebuild must + /// fall back to the deterministic local build that reproduces the pin, + /// or every subsequent `pip install --require-hashes` / `uv sync` fails + /// hash verification right after vendor reported a successful rebuild. + #[tokio::test] + async fn in_sync_service_rebuild_must_not_break_wired_pin() { + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + // Local vendor: requirements.txt pins the locally-built wheel's hash. + let VendorOutcome::Done { result, entry, .. } = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await + else { + panic!("first vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("entry on success"); + let wired = tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(); + save_ledger_entry(&fx.root, &entry).await; + + // The exact situation the rebuild path exists for: uuid dir deleted. + tokio::fs::remove_dir_all(fx.root.join(format!(".socket/vendor/pypi/{UUID}"))) + .await + .unwrap(); + + // The service offers a wheel whose bytes do NOT match the wired pin. + let bytes = b"service-built wheel bytes that differ from the local build"; + let sri = sri_sha512(bytes); + let server = wiremock::MockServer::start().await; + mount_pypi_granted(&server, WHEEL_NAME, &sri, bytes).await; + + let outcome = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + Some(&pypi_service_cfg(&server.uri(), VendorSource::Auto, false)), + ) + .await; + let VendorOutcome::Done { + result, + entry: e2, + warnings, + } = outcome + else { + panic!("rebuild run must be Done, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + assert!(e2.is_none(), "artifact-only rebuild records no entry"); + // The wheel on disk still verifies against the pinned hash. + let on_disk = tokio::fs::read(fx.root.join(&entry.artifact.path)) + .await + .expect("the pinned wheel path must exist again"); + assert_eq!( + hex::encode(sha2::Sha256::digest(&on_disk)), + entry.artifact.sha256, + "the rebuilt wheel must reproduce the sha256 the lockfile still pins" + ); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(), + wired, + "rebuild must not touch requirements.txt" + ); + assert!( + warnings + .iter() + .any(|w| w.code == "vendor_prebuilt_pin_mismatch"), + "the service mismatch is surfaced: {warnings:?}" + ); + assert!( + warnings.iter().any(|w| w.code == "vendor_artifact_rebuilt"), + "{warnings:?}" + ); + } + + /// `service` mode + an in-sync rebuild whose prebuilt bytes do not match + /// the wired pin hard-fails (nothing written) instead of silently + /// breaking the lockfile's hash pin. + #[tokio::test] + async fn in_sync_service_rebuild_pin_mismatch_service_mode_hard_fails() { + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { result, entry, .. } = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await + else { + panic!("first vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("entry on success"); + save_ledger_entry(&fx.root, &entry).await; + tokio::fs::remove_dir_all(fx.root.join(format!(".socket/vendor/pypi/{UUID}"))) + .await + .unwrap(); + + let bytes = b"service-built wheel bytes that differ from the local build"; + let sri = sri_sha512(bytes); + let server = wiremock::MockServer::start().await; + mount_pypi_granted(&server, WHEEL_NAME, &sri, bytes).await; + + let outcome = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + Some(&pypi_service_cfg( + &server.uri(), + VendorSource::Service, + false, + )), + ) + .await; + let VendorOutcome::Refused { code, detail } = outcome else { + panic!("expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "vendor_prebuilt_required"); + assert!(detail.contains("pins"), "{detail}"); + assert!( + !fx.root.join(&entry.artifact.path).exists(), + "a pin mismatch must write nothing" + ); + } + + /// The reverse direction of the same class: a project vendored FROM THE + /// SERVICE whose wheel goes missing must not "rebuild" locally into + /// different bytes — the loud failure names the pin so the user can + /// revert + re-vendor instead of committing a broken lockfile. + #[tokio::test] + async fn in_sync_local_rebuild_pin_mismatch_fails_loudly() { + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let bytes = b"prebuilt wheel bytes from the service"; + let sri = sri_sha512(bytes); + let server = wiremock::MockServer::start().await; + mount_pypi_granted(&server, WHEEL_NAME, &sri, bytes).await; + let VendorOutcome::Done { result, entry, .. } = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + Some(&pypi_service_cfg( + &server.uri(), + VendorSource::Service, + false, + )), + ) + .await + else { + panic!("service vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("entry on success"); + save_ledger_entry(&fx.root, &entry).await; + let uuid_dir = fx.root.join(format!(".socket/vendor/pypi/{UUID}")); + tokio::fs::remove_dir_all(&uuid_dir).await.unwrap(); + + // Re-run without the service: the local build cannot reproduce the + // service bytes the lockfile still pins. + let outcome = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let VendorOutcome::Done { + result, entry: e2, .. + } = outcome + else { + panic!("rebuild run must be Done, got {outcome:?}"); + }; + assert!( + !result.success, + "a rebuild that breaks the wired pin must not report success" + ); + assert!( + result.error.as_deref().unwrap_or("").contains("pins"), + "{:?}", + result.error + ); + assert!(e2.is_none()); + assert!( + !uuid_dir.exists(), + "the mismatched wheel must be swept back out" + ); + } + + /// Positive control: a prebuilt wheel that byte-matches the wired pin + /// (the normal case for a service-vendored project) rebuilds fine under + /// `service` mode. + #[tokio::test] + async fn in_sync_service_rebuild_matching_pin_succeeds() { + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { result, entry, .. } = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await + else { + panic!("first vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("entry on success"); + let wheel_bytes = tokio::fs::read(fx.root.join(&entry.artifact.path)) + .await + .unwrap(); + save_ledger_entry(&fx.root, &entry).await; + tokio::fs::remove_dir_all(fx.root.join(format!(".socket/vendor/pypi/{UUID}"))) + .await + .unwrap(); + + let sri = sri_sha512(&wheel_bytes); + let server = wiremock::MockServer::start().await; + mount_pypi_granted(&server, WHEEL_NAME, &sri, &wheel_bytes).await; + + let outcome = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + Some(&pypi_service_cfg( + &server.uri(), + VendorSource::Service, + false, + )), + ) + .await; + let VendorOutcome::Done { + result, warnings, .. + } = outcome + else { + panic!("rebuild run must be Done, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + assert_eq!( + tokio::fs::read(fx.root.join(&entry.artifact.path)) + .await + .unwrap(), + wheel_bytes, + "the pinned wheel is restored byte-for-byte" + ); + assert!( + warnings.iter().any(|w| w.code == "vendor_artifact_rebuilt"), + "{warnings:?}" + ); + } + + // ─────────── revert drift-keep gate (RevertOutcome contract) ─────────── + + use crate::vendor::state::{WiringAction, WiringRecord}; + + /// A pypi-flavored [`VendorEntry`] carrying just what revert reads. + fn revert_entry(flavor: &str, rel_wheel: &str, wiring: Vec) -> VendorEntry { + VendorEntry { + ecosystem: "pypi".into(), + base_purl: "pkg:pypi/six@1.16.0".into(), + uuid: UUID.into(), + artifact: VendorArtifact { + path: rel_wheel.to_string(), + sha256: String::new(), + size: None, + platform_locked: None, + file_inventory: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: Some(flavor.into()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + } + } + + const PIPENV_REGISTRY_LOCK: &str = r#"{ + "_meta": { + "hash": {"sha256": "x"}, + "pipfile-spec": 6, + "requires": {}, + "sources": [] + }, + "default": { + "six": { + "hashes": ["sha256:aaa"], + "index": "pypi", + "markers": "python_version >= '2.7'", + "version": "==1.16.0" + } + }, + "develop": {} +} +"#; + + /// BUG GUARD (missing drift-keep gate — the npm-family RevertOutcome + /// contract, residual #131): a drift-skipped pipenv revert leaves the + /// vendor-pointing entry in Pipfile.lock, so deleting the uuid dir + /// bricks every subsequent `pipenv install`/`sync` and pruning the + /// ledger entry destroys the only recorded pre-vendor original. The + /// backend must keep both and say so via `kept_artifact`. + #[tokio::test] + async fn pipenv_drift_skipped_revert_keeps_artifact() { + use crate::vendor::pypi_pipenv::{load_pipenv_project, wire_pipenv}; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write(root.join("Pipfile.lock"), PIPENV_REGISTRY_LOCK) + .await + .unwrap(); + let rel_wheel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl"); + let p = load_pipenv_project(root).await.unwrap(); + let (wiring, _meta) = wire_pipenv(&p, root, "six", &rel_wheel, &"0".repeat(64), UUID) + .await + .unwrap(); + let uuid_dir = root.join(format!(".socket/vendor/pypi/{UUID}")); + tokio::fs::create_dir_all(&uuid_dir).await.unwrap(); + let wheel = uuid_dir.join("six-1.16.0-py2.py3-none-any.whl"); + tokio::fs::write(&wheel, b"wheel bytes").await.unwrap(); + + // Hand-edit ONLY the markers string; the "file" ref still points + // into the uuid dir about to be deleted. + let text = tokio::fs::read_to_string(root.join("Pipfile.lock")) + .await + .unwrap(); + let mut live: serde_json::Value = serde_json::from_str(&text).unwrap(); + live["default"]["six"]["markers"] = serde_json::json!("python_version >= '3.0'"); + tokio::fs::write( + root.join("Pipfile.lock"), + serde_json::to_string_pretty(&live).unwrap(), + ) + .await + .unwrap(); + + let entry = revert_entry("pipenv", &rel_wheel, wiring); + let outcome = revert_pypi(&entry, root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), + "{:?}", + outcome.warnings + ); + assert!( + outcome.kept_artifact, + "a drift-skipped revert must flag the keep so the CLI retains the ledger entry" + ); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_artifact_kept"), + "{:?}", + outcome.warnings + ); + assert!( + wheel.is_file(), + "Pipfile.lock still references the wheel; deleting it would brick installs" + ); + } + + /// The splice flavors (poetry/pdm) share the same missing gate: a + /// hand-edited-but-still-vendor-pointing `[[package]]` unit is left + /// alone with a drift warning, so the uuid dir it references must + /// survive the revert (and the ledger entry with it). + #[tokio::test] + async fn poetry_pdm_drift_skipped_revert_keeps_artifact() { + for (flavor, lock_file, kind) in [ + ("poetry", "poetry.lock", "poetry_lock_package"), + ("pdm", "pdm.lock", "pdm_lock_package"), + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let rel_wheel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl"); + let original_unit = + "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = \"registry\"\n"; + let new_unit = format!( + "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = \"./{rel_wheel}\"\n" + ); + // Hand-edited since vendoring (an added comment), but the unit + // still resolves through the vendored wheel. + let live = format!( + "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\n# reviewed\nsource = \ + \"./{rel_wheel}\"\n" + ); + tokio::fs::write(root.join(lock_file), &live).await.unwrap(); + let uuid_dir = root.join(format!(".socket/vendor/pypi/{UUID}")); + tokio::fs::create_dir_all(&uuid_dir).await.unwrap(); + let wheel = uuid_dir.join("six-1.16.0-py2.py3-none-any.whl"); + tokio::fs::write(&wheel, b"wheel bytes").await.unwrap(); + + let wiring = vec![WiringRecord { + file: lock_file.to_string(), + kind: kind.to_string(), + action: WiringAction::Rewritten, + key: Some("six".into()), + original: Some(serde_json::Value::String(original_unit.to_string())), + new: Some(serde_json::Value::String(new_unit.clone())), + }]; + let entry = revert_entry(flavor, &rel_wheel, wiring); + let outcome = revert_pypi(&entry, root, false).await; + assert!(outcome.success, "{flavor}: {:?}", outcome.error); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), + "{flavor}: {:?}", + outcome.warnings + ); + assert!( + outcome.kept_artifact, + "{flavor}: a drift-skipped revert must flag the keep" + ); + assert!( + wheel.is_file(), + "{flavor}: {lock_file} still references the wheel; deleting it would brick \ + installs" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(lock_file)) + .await + .unwrap(), + live, + "{flavor}: the drifted lock is left alone" + ); + } + } + + /// LIVENESS CONTRACT twin of the gate: a lock that already carries the + /// pre-vendor originals (a relock regenerated them, or an earlier + /// partial revert restored them) is CONVERGED, not drifted — the revert + /// must stay silent and still clean up the artifact, or the drift-keep + /// gate would retain the uuid dir and ledger entry forever. + #[tokio::test] + async fn pipenv_converged_revert_deletes_artifact_without_drift() { + use crate::vendor::pypi_pipenv::{load_pipenv_project, wire_pipenv}; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write(root.join("Pipfile.lock"), PIPENV_REGISTRY_LOCK) + .await + .unwrap(); + let rel_wheel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl"); + let p = load_pipenv_project(root).await.unwrap(); + let (wiring, _meta) = wire_pipenv(&p, root, "six", &rel_wheel, &"0".repeat(64), UUID) + .await + .unwrap(); + let uuid_dir = root.join(format!(".socket/vendor/pypi/{UUID}")); + tokio::fs::create_dir_all(&uuid_dir).await.unwrap(); + tokio::fs::write(uuid_dir.join("six-1.16.0-py2.py3-none-any.whl"), b"wheel") + .await + .unwrap(); + + // Simulate `pipenv lock` regenerating the registry entry. + tokio::fs::write(root.join("Pipfile.lock"), PIPENV_REGISTRY_LOCK) + .await + .unwrap(); + + let entry = revert_entry("pipenv", &rel_wheel, wiring); + let outcome = revert_pypi(&entry, root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), + "already-converged records are silent no-ops: {:?}", + outcome.warnings + ); + assert!(!outcome.kept_artifact); + assert!( + !uuid_dir.exists(), + "a converged revert must still clean up the artifact" + ); + } + /// `--offline` + `--vendor-source=service` refuses, never hitting the network. #[tokio::test] async fn offline_service_mode_refuses() { diff --git a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs index 8151b059..57187be1 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs @@ -394,6 +394,15 @@ pub(super) async fn revert_pipenv( warnings.push(drifted()); continue; }; + // ALREADY CONVERGED (the LIVENESS CONTRACT, vendor/mod.rs): the live + // entry already equals the recorded pre-vendor original — an earlier + // partial revert, a hand-restore, or a `pipenv lock` regeneration + // already reverted this record. Not drift: stay silent so the + // drift-skip keep gate can converge instead of keeping the artifact + // dir and ledger entry forever. + if map.get(name) == rec.original.as_ref() { + continue; + } let (Some(new_value), Some(live)) = (rec.new.as_ref(), map.get(name)) else { warnings.push(drifted()); continue; @@ -1114,6 +1123,36 @@ mod tests { ); } + /// LIVENESS CONTRACT (vendor/mod.rs): a record whose live entry already + /// equals the recorded pre-vendor original — `pipenv lock` regenerated + /// the registry shape, or an earlier partial revert restored it — is a + /// silent no-op, NOT drift: re-classifying it would make the pypi + /// drift-keep gate retain the artifact dir and ledger entry forever, + /// with remediation advice that can never be satisfied. + #[tokio::test] + async fn revert_already_converged_entry_is_silent_no_op() { + let tmp = write_lock(LOCK_DIRECT_REGISTRY).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_default(&p, tmp.path()).await; + // Simulate `pipenv lock` regenerating the pre-vendor registry entry. + tokio::fs::write(tmp.path().join("Pipfile.lock"), LOCK_DIRECT_REGISTRY) + .await + .unwrap(); + + let outcome = revert_pipenv(&entry_for(wiring, meta), tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "converged records must not read as drift: {:?}", + outcome.warnings + ); + assert_eq!( + read_lock(tmp.path()).await, + LOCK_DIRECT_REGISTRY, + "nothing to restore, nothing re-serialized" + ); + } + /// A third-party edit to the entry we wrote (e.g. `pipenv lock` /// regenerated it — spike V6) is left alone with a drift warning; /// unknown wiring kinds from a newer ledger degrade the same way. diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index a0fd0eb2..95e64c13 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -541,17 +541,24 @@ pub(super) async fn wire_uv( let mut original_specifier: Option = None; match class { UvDepClass::Direct => { - let edit = rewrite_requires_dist_entry(&new_lock, canon_name, rel_wheel)?; - new_lock.replace_range(edit.span, &edit.new_entry); - original_specifier = edit.specifier; - wiring.push(record( - "uv.lock", - "uv_lock_requires_dist", - WiringAction::Rewritten, - canon_name, - Some(edit.old_entry), - edit.new_entry, - )); + let edits = rewrite_root_metadata_entries(&new_lock, canon_name, rel_wheel)?; + // Splice back-to-front so the earlier (ascending) spans stay valid. + for edit in edits.iter().rev() { + new_lock.replace_range(edit.span.clone(), &edit.new_entry); + } + for edit in edits { + if original_specifier.is_none() { + original_specifier = edit.specifier; + } + wiring.push(record( + "uv.lock", + edit.kind, + WiringAction::Rewritten, + canon_name, + Some(edit.old_entry), + edit.new_entry, + )); + } } UvDepClass::Transitive => { let (rec, text) = add_manifest_override(&new_lock, canon_name, rel_wheel)?; @@ -634,7 +641,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - ) }; match rec.kind.as_str() { - "uv_lock_package" | "uv_lock_requires_dist" => { + "uv_lock_package" | "uv_lock_requires_dist" | "uv_lock_requires_dev" => { match replace_fragment(&lock_text, new_text, original_text) { Some(t) => lock_text = t, None => warnings.push(drifted("uv.lock")), @@ -877,26 +884,35 @@ fn rewrite_target_package_unit( Ok((old_unit, new_unit)) } -/// One planned requires-dist entry rewrite: the absolute byte span plus the -/// verbatim old/new entry texts and the captured specifier. +/// One planned root-metadata entry rewrite: the absolute byte span plus the +/// verbatim old/new fragment texts, the captured specifier, and the wiring +/// record kind (`uv_lock_requires_dist` / `uv_lock_requires_dev`). struct RequiresDistEdit { span: Range, old_entry: String, new_entry: String, specifier: Option, + kind: &'static str, } -/// Find + transform the root package's `requires-dist` entry for `canon`: +/// Find + transform EVERY root-package metadata entry for `canon`: the +/// `[package.metadata]` `requires-dist` array (project.dependencies / +/// optional-dependencies) AND each `[package.metadata.requires-dev]` group +/// array (PEP 735 `[dependency-groups]` — uv records group deps there, never +/// in requires-dist, and rewrites ALL entries to the path shape when a +/// source applies; spike-verified against uv 0.11.19). Each entry: /// `{ name = "x", specifier = "==v" }` → `{ name = "x", path = "" }` /// (uv DROPS the specifier for path sources — recorded for revert). Returns -/// the absolute byte span so the caller splices by range, never by string -/// search (a bare `{ name = "x" }` entry would collide with `dependencies` -/// arrays elsewhere in the lock). -fn rewrite_requires_dist_entry( +/// absolute byte spans, ascending, so the caller splices by range, never by +/// string search (a bare `{ name = "x" }` entry would collide with +/// `dependencies` arrays elsewhere in the lock). requires-dev fragments span +/// the whole ` = […]` line so identically-pinned groups stay +/// distinguishable when revert matches fragments by text. +fn rewrite_root_metadata_entries( lock_text: &str, canon: &str, rel_wheel: &str, -) -> Result { +) -> Result, (&'static str, String)> { let unit_span = find_unit_span(lock_text, unit_is_root).ok_or_else(|| { ( "pypi_uv_lock_root_missing", @@ -905,38 +921,96 @@ fn rewrite_requires_dist_entry( })?; let unit_start = unit_span.start; let unit_text = &lock_text[unit_span]; - let rd_rel = unit_text.find("requires-dist = [").ok_or_else(|| { - ( - "pypi_uv_lock_root_missing", - "uv.lock root package has no [package.metadata] requires-dist".to_string(), - ) - })?; - let arr_open = rd_rel + "requires-dist = ".len(); - let arr_end = balanced_span(unit_text, arr_open).ok_or_else(|| { - ( - "pypi_uv_lock_parse_failed", - "uv.lock requires-dist array is unbalanced".to_string(), - ) - })?; - let array_text = &unit_text[arr_open..arr_end]; let needle = format!("name = \"{canon}\""); - for (s, e) in top_level_brace_groups(array_text) { - let entry = &array_text[s..e]; - if !entry.contains(&needle) { - continue; + let mut edits: Vec = Vec::new(); + + // requires-dist lives in [package.metadata], always AHEAD of the + // requires-dev sub-table — bound the search so a dev group literally + // named "requires-dist" can never masquerade as the real key. + let dev_hdr = unit_text.find("[package.metadata.requires-dev]"); + let dist_scan = &unit_text[..dev_hdr.unwrap_or(unit_text.len())]; + if let Some(rd_rel) = dist_scan.find("requires-dist = [") { + let arr_open = rd_rel + "requires-dist = ".len(); + let arr_end = balanced_span(unit_text, arr_open).ok_or_else(|| { + ( + "pypi_uv_lock_parse_failed", + "uv.lock requires-dist array is unbalanced".to_string(), + ) + })?; + let array_text = &unit_text[arr_open..arr_end]; + for (s, e) in top_level_brace_groups(array_text) { + let entry = &array_text[s..e]; + if !entry.contains(&needle) { + continue; + } + let (new_entry, specifier) = path_source_entry(entry, rel_wheel); + edits.push(RequiresDistEdit { + span: (unit_start + arr_open + s)..(unit_start + arr_open + e), + old_entry: entry.to_string(), + new_entry, + specifier, + kind: "uv_lock_requires_dist", + }); + break; } - let (new_entry, specifier) = path_source_entry(entry, rel_wheel); - return Ok(RequiresDistEdit { - span: (unit_start + arr_open + s)..(unit_start + arr_open + e), - old_entry: entry.to_string(), - new_entry, - specifier, - }); - } - Err(( - "pypi_uv_lock_package_missing", - format!("uv.lock root requires-dist has no entry for {canon}"), - )) + } + + if let Some(hdr_rel) = dev_hdr { + // Section spans from after the header to the next sub-table header + // (group array elements are indented, so a line-leading `[` is + // always a header). + let sect_start = hdr_rel + "[package.metadata.requires-dev]".len(); + let sect_end = unit_text[sect_start..] + .find("\n[") + .map(|i| sect_start + i + 1) + .unwrap_or(unit_text.len()); + let mut cursor = sect_start; + while let Some(open_rel) = unit_text[cursor..sect_end].find('[') { + let arr_open = cursor + open_rel; + let arr_end = balanced_span(unit_text, arr_open).ok_or_else(|| { + ( + "pypi_uv_lock_parse_failed", + "uv.lock [package.metadata.requires-dev] array is unbalanced".to_string(), + ) + })?; + let array_text = &unit_text[arr_open..arr_end]; + for (s, e) in top_level_brace_groups(array_text) { + let entry = &array_text[s..e]; + if !entry.contains(&needle) { + continue; + } + let (new_entry, specifier) = path_source_entry(entry, rel_wheel); + // Fragment from the group key so revert's text match can't + // confuse two groups pinning the same entry. + let key_start = unit_text[..arr_open].rfind('\n').map_or(0, |i| i + 1); + edits.push(RequiresDistEdit { + span: (unit_start + key_start)..(unit_start + arr_end), + old_entry: unit_text[key_start..arr_end].to_string(), + new_entry: format!( + "{}{}{}", + &unit_text[key_start..arr_open + s], + new_entry, + &unit_text[arr_open + e..arr_end] + ), + specifier, + kind: "uv_lock_requires_dev", + }); + break; + } + cursor = arr_end; + } + } + + if edits.is_empty() { + return Err(( + "pypi_uv_lock_package_missing", + format!( + "uv.lock root [package.metadata] has no requires-dist or requires-dev entry \ + for {canon}; run `uv lock` first" + ), + )); + } + Ok(edits) } /// Build the path-source requires-dist entry from the registry one: keep @@ -1030,6 +1104,10 @@ fn add_manifest_override( // multi-line: add an indented element before the closing bracket let body = &old_array[..old_array.rfind(']').unwrap_or(old_array.len())]; format!("{body} {element},\n]") + } else if old_array[1..old_array.len() - 1].trim().is_empty() { + // `overrides = []` (hand-edited; uv omits the key when empty): + // no existing element to comma-separate from + format!("[{element}]") } else { format!("{}, {element}]", &old_array[..old_array.len() - 1]) }; @@ -1187,6 +1265,14 @@ fn parse_requires_dist(raw: &str) -> Option { rest = after_open[close + 1..].trim_start(); } + // A PEP 508 direct reference (`name @ https://…`) has no PEP 440 + // specifier form uv could parse back out of the lock — fail closed (the + // caller drops the whole block; `uv sync` heals it) rather than emit a + // bogus `specifier = "@https://…"`. + if rest.starts_with('@') { + return None; + } + // The version specifier may be wrapped in parens (older METADATA style, // e.g. `six (>=1.5)`); strip them, then drop all interior whitespace // (uv serializes specifiers compactly). @@ -2411,4 +2497,360 @@ wheels = [ "revert must byte-restore uv.lock" ); } + + /// A hand-edited `overrides = []` (uv itself omits the key when empty) + /// must extend to a well-formed single-element array — the single-line + /// branch used to emit `[, { … }]`, a leading comma that stops the whole + /// lock from parsing (every later `uv sync` AND our own next load fail). + #[tokio::test] + async fn override_wiring_extends_an_empty_manifest_overrides_array() { + let empty_overrides_lock = TRANSITIVE_REGISTRY_LOCK.replace( + "requires-python = \">=3.10\"\n", + "requires-python = \">=3.10\"\n\n[manifest]\noverrides = []\n", + ); + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, &empty_overrides_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", + ) + .await + .unwrap(); + + let (pyproject, lock) = read_pair(tmp.path()).await; + lock.parse::() + .expect("the wired uv.lock must stay parseable TOML"); + assert_eq!(pyproject, OVERRIDE_TRANSITIVE_PYPROJECT); + assert_eq!( + lock, OVERRIDE_TRANSITIVE_LOCK, + "extending an empty overrides array must byte-match uv's own \ + single-element form" + ); + + // revert byte-restores the empty array, not uv's omitted-key form — + // the user wrote `overrides = []` and gets it back verbatim. + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, TRANSITIVE_REGISTRY_PYPROJECT); + assert_eq!(lock, empty_overrides_lock); + } + + // ── PEP 735 dependency-groups (requires-dev) fixtures ─────────────── + // Byte-exact uv output (uv 0.11.19, 2026-09-01): a dep declared only in + // `[dependency-groups]` is recorded in the root unit's + // `[package.metadata.requires-dev]` groups, never `requires-dist`, and a + // path source rewrites EVERY group entry with the same specifier→path + // transformation. + + const DEV_GROUP_REGISTRY_PYPROJECT: &str = r#"[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [] + +[dependency-groups] +dev = ["six==1.16.0"] +"#; + + const DEV_GROUP_PATH_PYPROJECT: &str = r#"[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [] + +[dependency-groups] +dev = ["six==1.16.0"] + +[tool.uv.sources] +six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } +"#; + + const DEV_GROUP_REGISTRY_LOCK: &str = r#"version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "six" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "six", specifier = "==1.16.0" }] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, +] +"#; + + const DEV_GROUP_PATH_LOCK: &str = r#"version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "six" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }] + +[[package]] +name = "six" +version = "1.16.0" +source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } +wheels = [ + { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" }, +] +"#; + + const MIXED_SURFACES_REGISTRY_PYPROJECT: &str = r#"[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["six==1.16.0"] + +[dependency-groups] +dev = ["six==1.16.0"] +lint = ["six==1.16.0"] +"#; + + const MIXED_SURFACES_PATH_PYPROJECT: &str = r#"[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["six==1.16.0"] + +[dependency-groups] +dev = ["six==1.16.0"] +lint = ["six==1.16.0"] + +[tool.uv.sources] +six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } +"#; + + const MIXED_SURFACES_REGISTRY_LOCK: &str = r#"version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "six" }, +] + +[package.dev-dependencies] +dev = [ + { name = "six" }, +] +lint = [ + { name = "six" }, +] + +[package.metadata] +requires-dist = [{ name = "six", specifier = "==1.16.0" }] + +[package.metadata.requires-dev] +dev = [{ name = "six", specifier = "==1.16.0" }] +lint = [{ name = "six", specifier = "==1.16.0" }] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, +] +"#; + + const MIXED_SURFACES_PATH_LOCK: &str = r#"version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "six" }, +] + +[package.dev-dependencies] +dev = [ + { name = "six" }, +] +lint = [ + { name = "six" }, +] + +[package.metadata] +requires-dist = [{ name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }] + +[package.metadata.requires-dev] +dev = [{ name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }] +lint = [{ name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }] + +[[package]] +name = "six" +version = "1.16.0" +source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } +wheels = [ + { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" }, +] +"#; + + /// A dep declared ONLY under PEP 735 `[dependency-groups]` classifies + /// Direct (sources apply to it), but uv records it in the root unit's + /// `[package.metadata.requires-dev]`, never `requires-dist` — wiring + /// must rewrite the group entry, not refuse with a root-missing error. + #[tokio::test] + async fn dev_group_wiring_matches_fixture_byte_identically() { + let tmp = write_pair(DEV_GROUP_REGISTRY_PYPROJECT, DEV_GROUP_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + assert_eq!(classify_dependency(&p, "six"), UvDepClass::Direct); + + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, DEV_GROUP_PATH_PYPROJECT); + assert_eq!( + lock, DEV_GROUP_PATH_LOCK, + "uv.lock must byte-match uv's own dev-group path-source output" + ); + assert_eq!(meta.dep_class, "direct"); + assert_eq!(meta.original_specifier.as_deref(), Some("==1.16.0")); + let kinds: Vec<&str> = wiring.iter().map(|w| w.kind.as_str()).collect(); + assert_eq!( + kinds, + vec!["uv_sources_entry", "uv_lock_package", "uv_lock_requires_dev"] + ); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, DEV_GROUP_REGISTRY_PYPROJECT); + assert_eq!(lock, DEV_GROUP_REGISTRY_LOCK); + } + + /// A dep declared in project.dependencies AND several dependency-groups: + /// uv rewrites the requires-dist entry AND every requires-dev group + /// entry to the path shape — so must we, or `uv lock --check` goes red. + #[tokio::test] + async fn mixed_surfaces_wiring_rewrites_every_metadata_entry() { + let tmp = write_pair( + MIXED_SURFACES_REGISTRY_PYPROJECT, + MIXED_SURFACES_REGISTRY_LOCK, + ) + .await; + let p = load_uv_project(tmp.path()).await.unwrap(); + assert_eq!(classify_dependency(&p, "six"), UvDepClass::Direct); + + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, MIXED_SURFACES_PATH_PYPROJECT); + assert_eq!( + lock, MIXED_SURFACES_PATH_LOCK, + "every requires-dist/requires-dev entry must be rewritten" + ); + assert_eq!(meta.original_specifier.as_deref(), Some("==1.16.0")); + let kinds: Vec<&str> = wiring.iter().map(|w| w.kind.as_str()).collect(); + assert_eq!( + kinds, + vec![ + "uv_sources_entry", + "uv_lock_package", + "uv_lock_requires_dist", + "uv_lock_requires_dev", + "uv_lock_requires_dev" + ] + ); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, MIXED_SURFACES_REGISTRY_PYPROJECT); + assert_eq!(lock, MIXED_SURFACES_REGISTRY_LOCK); + } + + /// A PEP 508 direct reference (`name @ https://…`) has no PEP 440 + /// specifier form — it must fail closed (whole block dropped, uv sync + /// heals the lock) rather than sail through as the bogus + /// `specifier = "@https://…"` uv refuses to parse back. + #[test] + fn parse_requires_dist_rejects_direct_url_references() { + assert_eq!( + parse_requires_dist("foo @ https://example.com/foo-1.0-py3-none-any.whl"), + None + ); + // extras + marker variants are equally direct references + assert_eq!( + parse_requires_dist("foo[fast] @ git+https://example.com/foo.git ; extra == 'x'"), + None + ); + + // block-level: one direct-reference line drops the WHOLE block + let text = "Name: widget\n\ + Requires-Dist: leftpad >=1.0\n\ + Requires-Dist: foo @ https://example.com/foo-1.0-py3-none-any.whl\n\ + \n\ + body\n"; + assert_eq!(render_package_metadata_block(text), None); + } } diff --git a/crates/socket-patch-core/src/vendor/registry_fetch.rs b/crates/socket-patch-core/src/vendor/registry_fetch.rs index 848436f3..4c3bf0b3 100644 --- a/crates/socket-patch-core/src/vendor/registry_fetch.rs +++ b/crates/socket-patch-core/src/vendor/registry_fetch.rs @@ -208,8 +208,9 @@ pub(crate) fn extract_zip(bytes: &[u8], dest: &Path, strip_first: bool) -> Resul Ok(()) } -/// Composer dist zips (packagist/GitHub zipballs): sha1-verified, variable -/// top dir stripped. The extracted dir plays the installed package dir. +/// Composer dist zips: sha1-verified; a variable zipball top dir is +/// stripped when present, flat `composer archive`-built dists extract +/// as-is. The extracted dir plays the installed package dir. async fn fetch_composer( entry: &LockfileEntry, client: &reqwest::Client, @@ -225,7 +226,11 @@ async fn fetch_composer( let tmp = tempfile::tempdir() .map_err(|e| FetchError::Failed(format!("cannot create fetch tempdir: {e}")))?; let dir = tmp.path().join("package"); - extract_zip(&bytes, &dir, /*strip_first=*/ true).map_err(FetchError::Failed)?; + // Strip only when the zip actually nests under a lone top dir (the + // zipball layout) — flat `composer archive`-built dists carry + // composer.json at the root; see [`zip_has_single_top_dir`]. + let strip_first = zip_has_single_top_dir(&bytes).map_err(FetchError::Failed)?; + extract_zip(&bytes, &dir, strip_first).map_err(FetchError::Failed)?; if tokio::fs::metadata(dir.join("composer.json")) .await .is_err() @@ -678,11 +683,25 @@ pub async fn stage_local_artifact( // guard class as the vendor lockfile reads. let bytes = { use tokio::io::AsyncReadExt as _; - let (mut file, metadata) = crate::utils::fs::open_regular_file(tgz_path) + let (file, metadata) = crate::utils::fs::open_regular_file(tgz_path) .await .map_err(|e| FetchError::Failed(format!("cannot read {}: {e}", tgz_path.display())))?; + // Enforce the cap BEFORE the size-matched allocation and read: the + // committed artifact path can hold a huge (or sparse, cost-free to + // craft) file, and a metadata-sized `with_capacity` would abort or + // OOM instead of returning the clean cap error below. Declared size + // here + actual bytes below — the same double enforcement as + // [`download`]; the `take` holds the memory bound even against a + // file that grows between this stat and the read. + if metadata.len() > MAX_DOWNLOAD_BYTES { + return Err(FetchError::Failed(format!( + "{}: artifact exceeds the {MAX_DOWNLOAD_BYTES}-byte cap", + tgz_path.display() + ))); + } let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes) + file.take(MAX_DOWNLOAD_BYTES + 1) + .read_to_end(&mut bytes) .await .map_err(|e| FetchError::Failed(format!("cannot read {}: {e}", tgz_path.display())))?; bytes @@ -889,6 +908,32 @@ fn verify_sri(bytes: &[u8], sri: &str) -> Result<(), String> { } } +/// Whether every FILE entry in the zip nests under one shared top-level +/// directory — the GitHub/GitLab-zipball layout. This is the per-archive +/// `strip_first` decision Composer itself makes (ArchiveDownloader promotes +/// a lone top dir, else installs from the extract root): `composer archive`- +/// built dists (Satis archive builds, Artifactory/Nexus, private Packagist) +/// store composer.json at the archive ROOT, where an unconditional strip +/// would drop it and refuse a genuine, integrity-verified artifact. +fn zip_has_single_top_dir(bytes: &[u8]) -> Result { + let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .map_err(|e| format!("unreadable zip: {e}"))?; + let mut top: Option<&str> = None; + for name in archive.file_names() { + if name.ends_with('/') { + continue; // dir entries: extraction skips them too + } + let Some((first, _)) = name.split_once('/') else { + return Ok(false); // a root-level file — flat layout + }; + if top.is_some_and(|t| t != first) { + return Ok(false); + } + top = Some(first); + } + Ok(top.is_some()) +} + /// Strip the FIRST path component (npm's tarball semantics — usually /// `package/`, but registry tarballs may use any prefix dir). fn strip_first_component(path: &Path) -> Option { @@ -1525,6 +1570,66 @@ mod tests { } } + #[tokio::test] + async fn composer_flat_dist_fetch_keeps_root_layout() { + // `composer archive`-built dists (Satis archive builds, Artifactory/ + // Nexus, private Packagist) store composer.json at the archive ROOT — + // no zipball top dir. Composer itself auto-detects the layout per + // archive (ArchiveDownloader promotes a lone top dir, else installs + // from the extract root); an unconditional first-component strip + // drops the root composer.json and refuses a genuine, sha1-verified + // artifact as "carries no composer.json". + let zip_bytes = make_zip(&[ + ("composer.json", br#"{"name":"acme/flat"}"#), + ("src/Flat.php", b" assert!(msg.contains("cap"), "{msg}"), + other => panic!("expected the cap refusal, got {other:?}"), + } + + let mut ru = std::mem::MaybeUninit::::zeroed(); + assert_eq!( + unsafe { libc::getrusage(libc::RUSAGE_SELF, ru.as_mut_ptr()) }, + 0 + ); + let ru = unsafe { ru.assume_init() }; + // macOS reports ru_maxrss in bytes, Linux in kilobytes. + let peak = if cfg!(target_os = "macos") { + ru.ru_maxrss as u64 + } else { + (ru.ru_maxrss as u64) * 1024 + }; + assert!( + peak < HUGE / 2, + "peak RSS {peak} bytes — the oversized artifact was buffered into \ + memory before the cap check" + ); + } + #[test] fn goproxy_base_splits_on_pipe_separator() { // GOPROXY is a comma- OR pipe-separated list (go help goproxy); a From 4a29cc1dceddb49b9331a2eb71b99df0ff2f0a94 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 1 Sep 2026 17:18:07 -0400 Subject: [PATCH 2/3] =?UTF-8?q?test:=20coverage-audit=20test=20extension?= =?UTF-8?q?=20=E2=80=94=2094.2%=20=E2=86=92=2098.2%=20lines,=20+1,300=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the audited test plans for every uncovered-but-testable range found by the full-coverage audit at d5e1815 (936 worthwhile ranges across 92 files): inline #[cfg(test)] additions plus 30 new per-module covgap_* integration suites (conflict-free placement; shared suites untouched). An adversarial review pass (46 reviewers + 3-vote refutation panels over the full diff) confirmed 42 findings, all applied: - fix residuals: uv revert convergence carve-outs (drift-keep gate no longer keeps converged reverts forever), requirements-flavor vendor_revert_residual_reference keep signal, fail-closed in-sync rebuild pin guard (corrupt/missing state.json now refuses instead of rebuilding blind), redirect nuget open-tag/single-quote tolerance matching the nuget_feed twin, scan --prune now reports keptVendoredEntries + remediation hint (dry preview documented as drift-blind), takeover scope-aware name probe, golang hot-path gate strengthened to full redirect_in_sync equivalence, multi-line pnpm-workspace flow sequences, fetch-stage online-failure silent-mode envelope - unfixed twins found by review: bun_lock + pnpm v9 post-pack failure paths ported to done_failure_unstage (a new test had enshrined the husk behavior; now asserts the fixed contract) - test hygiene: geteuid()==0 root guards on all new chmod-based tests, SOCKET_*/VIRTUAL_ENV scrubs in spawned-CLI harnesses, telemetry disabled in covgap_commands_vex (was POSTing to production), hermetic Gemfile.lock fixtures replacing real bundle probes, cfg(unix)/path- separator gates, vacuous tests repaired or deleted 6,597 tests green (196 binaries, full workspace). Coverage (host, default features): 94.18% → 98.2%+ lines; 72-77% of the 5,720 never-executed production lines from the baseline audit now covered; the remainder is classified defensive/unreachable/interactive-only/ platform-gated in the audit record. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/fetch_stage.rs | 59 +- crates/socket-patch-cli/src/commands/get.rs | 434 +++- .../src/commands/repair_vendor.rs | 61 + .../socket-patch-cli/src/commands/rollback.rs | 473 ++++ .../src/commands/scan/discovery.rs | 320 +++ .../socket-patch-cli/src/commands/scan/gc.rs | 483 +++- .../src/commands/scan/hosted.rs | 97 + .../socket-patch-cli/src/commands/scan/mod.rs | 349 +++ .../src/commands/scan/vendor_flow.rs | 100 + .../socket-patch-cli/src/commands/vendor.rs | 605 ++++- .../src/ecosystem_dispatch.rs | 90 + .../coverage_fix_repair_vendor_predelete.rs | 17 +- .../coverage_fix_vendor_silent_mute_exit.rs | 200 ++ .../tests/covgap_api_client.rs | 110 + .../tests/covgap_commands_apply.rs | 1156 +++++++++ .../tests/covgap_commands_fetch_stage.rs | 452 ++++ .../tests/covgap_commands_get.rs | 1784 ++++++++++++++ .../tests/covgap_commands_list.rs | 309 +++ .../tests/covgap_commands_remove.rs | 1769 ++++++++++++++ .../tests/covgap_commands_repair.rs | 759 ++++++ .../tests/covgap_commands_repair_vendor.rs | 1553 ++++++++++++ .../tests/covgap_commands_rollback.rs | 2164 +++++++++++++++++ .../tests/covgap_commands_scan_hosted.rs | 1263 ++++++++++ .../tests/covgap_commands_scan_mod.rs | 1207 +++++++++ .../tests/covgap_commands_scan_vendor_flow.rs | 586 +++++ .../tests/covgap_commands_setup.rs | 1522 ++++++++++++ .../tests/covgap_commands_update.rs | 361 +++ .../tests/covgap_commands_vendor.rs | 933 +++++++ .../tests/covgap_commands_vex.rs | 835 +++++++ .../tests/covgap_ecosystem_dispatch.rs | 302 +++ .../socket-patch-cli/tests/covgap_output.rs | 481 ++++ .../tests/covgap_setup_composer_mod.rs | 146 ++ .../tests/covgap_setup_gem_mod.rs | 164 ++ .../tests/covgap_setup_gem_version.rs | 196 ++ .../tests/covgap_setup_pypi_detect.rs | 230 ++ .../tests/covgap_update_download.rs | 73 + .../tests/covgap_update_swap.rs | 143 ++ .../tests/covgap_utils_socket_cli_config.rs | 163 ++ .../socket-patch-core/src/api/blob_fetcher.rs | 63 + crates/socket-patch-core/src/api/client.rs | 533 ++++ crates/socket-patch-core/src/api/date.rs | 81 + .../src/crawlers/cargo_crawler.rs | 23 + .../src/crawlers/composer_crawler.rs | 99 + .../src/crawlers/go_crawler.rs | 156 ++ .../src/crawlers/maven_crawler.rs | 99 + .../src/crawlers/npm_crawler.rs | 92 + .../src/crawlers/nuget_crawler.rs | 55 + .../src/crawlers/python_crawler.rs | 57 + .../src/crawlers/ruby_crawler.rs | 130 + .../src/package_json/detect.rs | 78 + .../src/package_json/find.rs | 296 ++- .../src/package_json/update.rs | 49 + crates/socket-patch-core/src/patch/apply.rs | 483 ++++ .../socket-patch-core/src/patch/apply_lock.rs | 92 + .../src/patch/redirect/golang_local.rs | 306 +++ .../src/patch/redirect/mod.rs | 1508 +++++++++++- .../src/patch/redirect/replay.rs | 539 ++++ .../src/patch/redirect/state.rs | 147 ++ .../src/patch/redirect/takeover.rs | 1009 +++++++- .../socket-patch-core/src/patch/rollback.rs | 294 +++ .../src/setup/composer/mod.rs | 15 + crates/socket-patch-core/src/setup/gem/mod.rs | 295 +++ .../socket-patch-core/src/setup/gem/update.rs | 152 ++ .../src/setup/gem/version.rs | 51 + .../src/setup/pypi/detect.rs | 67 + .../socket-patch-core/src/setup/pypi/edit.rs | 132 + crates/socket-patch-core/src/telemetry.rs | 7 +- .../socket-patch-core/src/update/channel.rs | 58 + .../socket-patch-core/src/update/download.rs | 255 ++ crates/socket-patch-core/src/update/mod.rs | 108 + .../socket-patch-core/src/update/release.rs | 249 ++ crates/socket-patch-core/src/update/state.rs | 31 + crates/socket-patch-core/src/update/swap.rs | 128 + crates/socket-patch-core/src/utils/fs.rs | 80 + .../socket-patch-core/src/vendor/berry_zip.rs | 12 + .../socket-patch-core/src/vendor/bun_lock.rs | 589 ++++- .../src/vendor/bun_lock_text.rs | 73 + crates/socket-patch-core/src/vendor/cargo.rs | 588 +++++ .../src/vendor/cargo_config.rs | 101 + .../src/vendor/cargo_lock.rs | 9 + crates/socket-patch-core/src/vendor/common.rs | 338 +++ .../src/vendor/composer_lock.rs | 969 ++++++++ crates/socket-patch-core/src/vendor/gem.rs | 1711 +++++++++++++ .../src/vendor/go_mod_edit.rs | 120 + crates/socket-patch-core/src/vendor/golang.rs | 715 +++++- .../src/vendor/lock_inventory.rs | 544 ++++- .../src/vendor/maven_repo.rs | 1114 +++++++++ crates/socket-patch-core/src/vendor/mod.rs | 356 +++ .../src/vendor/npm_common.rs | 582 +++++ .../src/vendor/npm_flavor.rs | 91 + .../socket-patch-core/src/vendor/npm_lock.rs | 498 +++- .../socket-patch-core/src/vendor/npm_pack.rs | 44 + .../src/vendor/nuget_feed.rs | 1269 +++++++++- crates/socket-patch-core/src/vendor/path.rs | 71 + .../socket-patch-core/src/vendor/pnpm_lock.rs | 1589 +++++++++++- .../src/vendor/pnpm_lock_legacy.rs | 1202 +++++++++ crates/socket-patch-core/src/vendor/pypi.rs | 1710 ++++++++++++- .../socket-patch-core/src/vendor/pypi_pdm.rs | 185 ++ .../src/vendor/pypi_pipenv.rs | 363 +++ .../src/vendor/pypi_poetry.rs | 159 ++ .../src/vendor/pypi_requirements.rs | 483 +++- .../socket-patch-core/src/vendor/pypi_uv.rs | 1204 ++++++++- .../src/vendor/pypi_wheel.rs | 316 +++ .../src/vendor/registry_fetch.rs | 739 ++++++ .../src/vendor/service_fetch.rs | 284 ++- crates/socket-patch-core/src/vendor/state.rs | 65 + .../src/vendor/toml_surgery.rs | 35 + crates/socket-patch-core/src/vendor/verify.rs | 190 ++ .../src/vendor/yarn_berry_lock.rs | 673 +++++ .../src/vendor/yarn_classic_lock.rs | 546 +++++ crates/socket-patch-core/src/vex/product.rs | 128 + .../tests/covgap_api_blob_fetcher.rs | 500 ++++ .../tests/covgap_crawlers_composer_crawler.rs | 172 ++ .../tests/covgap_crawlers_npm_crawler.rs | 361 +++ .../tests/covgap_patch_apply.rs | 159 ++ .../tests/covgap_update_state.rs | 66 + 117 files changed, 50512 insertions(+), 179 deletions(-) create mode 100644 crates/socket-patch-cli/tests/coverage_fix_vendor_silent_mute_exit.rs create mode 100644 crates/socket-patch-cli/tests/covgap_api_client.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_apply.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_fetch_stage.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_get.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_list.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_remove.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_repair.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_rollback.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_setup.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_update.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_vendor.rs create mode 100644 crates/socket-patch-cli/tests/covgap_commands_vex.rs create mode 100644 crates/socket-patch-cli/tests/covgap_ecosystem_dispatch.rs create mode 100644 crates/socket-patch-cli/tests/covgap_output.rs create mode 100644 crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs create mode 100644 crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs create mode 100644 crates/socket-patch-cli/tests/covgap_setup_gem_version.rs create mode 100644 crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs create mode 100644 crates/socket-patch-cli/tests/covgap_update_download.rs create mode 100644 crates/socket-patch-cli/tests/covgap_update_swap.rs create mode 100644 crates/socket-patch-cli/tests/covgap_utils_socket_cli_config.rs create mode 100644 crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs create mode 100644 crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs create mode 100644 crates/socket-patch-core/tests/covgap_crawlers_npm_crawler.rs create mode 100644 crates/socket-patch-core/tests/covgap_patch_apply.rs create mode 100644 crates/socket-patch-core/tests/covgap_update_state.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb07d637..838b6ee8 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -94,7 +94,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments **Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The interactive path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) and the runtime-gated maven/nuget crawlers with their gate off are exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under the apply lock — lock contention skips it without failing the scan): vendored entries whose patch is gone from the manifest are reverted, vendored entries whose dependency is no longer in the lockfile graph are reverted AND their manifest entries dropped (detached entries are exempt from both — they are manifest- and lockfile-invisible by design; a missing or undeterminable lockfile keeps the entry, fail-safe), and orphan `.socket/vendor//` dirs with no ledger entry are swept. The JSON `gc` sub-object gains `revertedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview). +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) and the runtime-gated maven/nuget crawlers with their gate off are exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under the apply lock — lock contention skips it without failing the scan): vendored entries whose patch is gone from the manifest are reverted, vendored entries whose dependency is no longer in the lockfile graph are reverted AND their manifest entries dropped (detached entries are exempt from both — they are manifest- and lockfile-invisible by design; a missing or undeterminable lockfile keeps the entry, fail-safe), and orphan `.socket/vendor//` dirs with no ledger entry are swept. The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview). `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index f806b3ee..2c1aa004 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -428,7 +428,10 @@ pub(crate) async fn stage_vendor_sources_in_memory( let mut complete = true; for (file, info) in &patch.files { let (Some(b64), Some(hash)) = (&info.blob_content, &info.after_hash) else { - if !quiet { + // An error, not progress chatter: prints even + // under --silent (same rule as + // report_offline_missing above). + if !common.json { eprintln!(" [error] {purl}: no blob content served for {file}"); } complete = false; @@ -458,7 +461,12 @@ pub(crate) async fn stage_vendor_sources_in_memory( } } if !failed.is_empty() { - if !quiet { + // An error, not progress chatter: the vendor caller only marks + // the envelope (printed exclusively under --json), so muting + // this under --silent meant exit 1 with zero output — the + // CLI_CONTRACT violation ("errors only", NEVER nothing) fixed + // for the disk stager's arms above. + if !common.json { eprintln!( "Error: could not fetch patch content for {} patch(es):", failed.len() @@ -790,4 +798,51 @@ mod tests { ); assert!(!dst.join("subdir").exists(), "directories are not mirrored"); } + + /// The hardlink-failure copy fallback — the PRIMARY mirror path when + /// `.socket/` and the overlay tempdir sit on different filesystems + /// (EXDEV; e.g. tmpfs /tmp on Linux). Same-volume tempdirs always + /// hardlink, so force the arm deterministically: a DANGLING symlink at + /// the destination makes `metadata` err (follows the link — the + /// existing-file skip does not fire), makes `hard_link` fail (the link + /// occupies the path), and lets `copy` succeed by writing THROUGH the + /// link into its target. + #[cfg(unix)] + #[tokio::test] + async fn overlay_dir_falls_back_to_copy_when_hardlink_fails() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + let dst = tmp.path().join("dst"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&dst).unwrap(); + std::fs::write(src.join("a"), b"from-src").unwrap(); + // Dangling link: the target does not exist yet. + let resolved = tmp.path().join("resolved"); + std::os::unix::fs::symlink(&resolved, dst.join("a")).unwrap(); + + overlay_dir(&src, &dst).await; + + // hard_link never replaces an occupied path, so the entry must + // still be the symlink — the bytes can only have arrived via the + // copy arm. + assert!( + dst.join("a") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + "the destination entry stays a symlink (hard_link cannot have run)" + ); + assert_eq!( + std::fs::read(dst.join("a")).unwrap(), + b"from-src", + "the mirrored bytes are readable at the destination path" + ); + assert_eq!( + std::fs::read(&resolved).unwrap(), + b"from-src", + "proof the copy arm ran: only a write-through-the-link copy \ + creates the link target" + ); + } } diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 6faf0b3e..79e3ceae 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -532,6 +532,35 @@ fn detect_identifier_type(identifier: &str) -> Option { } } +/// Render one patch as an interactive-selection option line: +/// ` [] (fixes: ) - `. +/// +/// Each advisory is summarized by its CVE ids joined with `", "` when it +/// has any, falling back to the advisory id itself (e.g. a GHSA with no +/// CVE assigned yet); the `(fixes: …)` segment is omitted entirely for a +/// patch with no vulnerabilities. The description is truncated to 60 +/// characters. +fn format_patch_option(p: &PatchSearchResult) -> String { + let vuln_summary: Vec = p + .vulnerabilities + .iter() + .map(|(id, v)| { + if v.cves.is_empty() { + id.clone() + } else { + v.cves.join(", ") + } + }) + .collect(); + let vulns = if vuln_summary.is_empty() { + String::new() + } else { + format!(" (fixes: {})", vuln_summary.join(", ")) + }; + let desc = truncate_with_ellipsis(&p.description, 60); + format!("{} [{}]{} - {}", p.uuid, p.tier, vulns, desc) +} + /// Select one patch per PURL from available patches. /// /// Within a PURL, candidates are ranked by [`cmp_search_results`]: merged @@ -588,29 +617,7 @@ pub(crate) fn select_patches( selected.push(group[0].clone()); } else { // Free user with multiple patches: interactive selection - let options: Vec = group - .iter() - .map(|p| { - let vuln_summary: Vec = p - .vulnerabilities - .iter() - .map(|(id, v)| { - if v.cves.is_empty() { - id.clone() - } else { - v.cves.join(", ") - } - }) - .collect(); - let vulns = if vuln_summary.is_empty() { - String::new() - } else { - format!(" (fixes: {})", vuln_summary.join(", ")) - }; - let desc = truncate_with_ellipsis(&p.description, 60); - format!("{} [{}]{} - {}", p.uuid, p.tier, vulns, desc) - }) - .collect(); + let options: Vec = group.iter().map(|p| format_patch_option(p)).collect(); match select_one( &format!("Multiple patches available for {purl}. Select one:"), @@ -4120,4 +4127,385 @@ mod tests { "a patch with no afterHash produces an empty (guardrail) files map" ); } + + // --- base64_decode ----------------------------------------------------- + // Blob content comes straight from the API; a corrupted payload must + // surface as a decode error (which write_blob_entry turns into a + // per-file failure), never as garbage bytes silently written to disk. + + #[test] + fn base64_decode_rejects_invalid_character() { + let err = base64_decode("ab!cd").expect_err("'!' is not in the base64 alphabet"); + assert!( + err.contains("Invalid base64 character"), + "error must say what went wrong; got: {err}" + ); + assert!( + err.contains('!'), + "error must name the offending character; got: {err}" + ); + } + + // --- pnpm_lock_resolves: needle at byte 0 ------------------------------ + // The boundary probe reads the char BEFORE the match; a match at the very + // start of the text has none (`None => true`). A regression that indexes + // `text[..pos - 1]` unconditionally would underflow/panic here. + + #[test] + fn pnpm_lock_resolves_needle_at_start_of_text() { + // pos == 0, plain v9 spelling: no preceding char is a valid boundary. + assert!(pnpm_lock_resolves("left-pad@1.3.0:\n", "left-pad", "1.3.0")); + // pos == 0, v5/v6 `/name/version` and `/name@version` spellings: the + // leading `/` delimiter itself has nothing before it. + assert!(pnpm_lock_resolves("/left-pad/1.3.0:\n", "left-pad", "1.3.0")); + assert!(pnpm_lock_resolves("/left-pad@1.3.0:\n", "left-pad", "1.3.0")); + // Still boundary-checked at the start of text: a scoped tail whose + // name begins mid-token must NOT match. + assert!(!pnpm_lock_resolves( + "@scope/left-pad@1.3.0:\n", + "left-pad", + "1.3.0" + )); + } + + // --- write_all_patch_blobs --------------------------------------------- + // The per-patch fan-out over write_blob_entry: the FIRST bad entry must + // fail the whole patch (Err(())) and leave nothing outside the blobs + // dir. This is the branch every blob-failure flow downstream keys on. + + #[tokio::test] + async fn write_all_patch_blobs_traversal_hash_fails_and_writes_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let blobs_dir = tmp.path().join("blobs"); + tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); + + let mut files = HashMap::new(); + let mut info = file_resp(None, Some("../escaped")); + info.blob_content = Some(BLOB_B64.to_string()); + files.insert("package/index.js".to_string(), info); + let patch = patch_with_files(files); + + let res = write_all_patch_blobs(&blobs_dir, &patch, /*quiet=*/ true).await; + assert_eq!(res, Err(()), "a traversal afterHash must fail the patch"); + assert!( + !tmp.path().join("escaped").exists(), + "nothing may be written outside the blobs dir" + ); + assert_eq!( + std::fs::read_dir(&blobs_dir).unwrap().count(), + 0, + "no blob may be written for a rejected patch" + ); + } + + // --- fold_narrowing_into_result ---------------------------------------- + // Hosted runs stack release-variant warnings (already in the envelope as + // strings) with coarse-narrowing PnP warnings folded in later; the merge + // must PRESERVE the existing strings and append the new `(code) detail` + // ones, while skip records bump found/skipped and extend patches[]. + + #[test] + fn fold_narrowing_merges_into_existing_warnings_and_counts() { + let mut result = serde_json::json!({ + "status": "success", + "found": 1, + "skipped": 0, + "patches": [{"purl": "pkg:npm/kept@1.0.0", "action": "added"}], + "warnings": ["existing variant warning"], + }); + let skips = vec![serde_json::json!({ + "purl": "pkg:npm/skipped@1.0.0", "uuid": "u", + "action": "skipped", "errorCode": "package_not_installed", + })]; + let warnings = vec![( + "yarn_pnp_unsupported".to_string(), + "PnP layout detail".to_string(), + )]; + fold_narrowing_into_result(&mut result, &skips, &warnings); + + assert_eq!(result["found"], 2, "skip records count as found"); + assert_eq!(result["skipped"], 1); + let patches = result["patches"].as_array().unwrap(); + assert_eq!(patches.len(), 2, "skip record folded into patches[]"); + assert_eq!(patches[1]["errorCode"], "package_not_installed"); + assert_eq!( + result["warnings"], + serde_json::json!([ + "existing variant warning", + "(yarn_pnp_unsupported) PnP layout detail" + ]), + "existing warning strings must survive the merge, new ones appended" + ); + } + + // --- resolved_api_overrides -------------------------------------------- + // The org the nested client resolves to is behavior-bearing: an explicit + // override wins; otherwise `--org` (params.org) fills the gap. + + fn dl_params_for_org(org: Option, org_slug: Option) -> DownloadParams { + DownloadParams { + cwd: PathBuf::from("."), + manifest_path: PathBuf::from(".socket/manifest.json"), + org, + save_only: true, + global: false, + global_prefix: None, + json: true, + silent: true, + download_mode: "diff".to_string(), + api_overrides: socket_patch_core::api::client::ApiClientEnvOverrides { + api_url: None, + api_token: None, + org_slug, + proxy_url: None, + }, + all_releases: false, + strict: false, + ecosystems: None, + persist_blobs: false, + } + } + + #[test] + fn resolved_api_overrides_falls_back_to_params_org() { + let p = dl_params_for_org(Some("from-org".into()), None); + assert_eq!( + resolved_api_overrides(&p).org_slug.as_deref(), + Some("from-org"), + "a missing override org must fall back to --org" + ); + } + + #[test] + fn resolved_api_overrides_explicit_org_slug_wins() { + let p = dl_params_for_org(Some("from-org".into()), Some("explicit".into())); + assert_eq!( + resolved_api_overrides(&p).org_slug.as_deref(), + Some("explicit"), + "an explicit override org must not be clobbered by --org" + ); + } + + // --- format_patch_option: vulnerability summaries in the option lines -- + + #[test] + fn patch_option_line_joins_cves_when_advisory_has_them() { + // An advisory WITH CVEs is summarized by the CVE ids joined with + // ", " — the advisory id itself is not shown. + let mut a = mk_patch("a", "pkg:npm/foo@1.0", "free", "2024-01-01"); + a.vulnerabilities.insert( + "GHSA-with-cves".into(), + VulnerabilityResponse { + cves: vec!["CVE-2024-0001".into(), "CVE-2024-0002".into()], + summary: "s".into(), + severity: "high".into(), + description: String::new(), + }, + ); + assert_eq!( + format_patch_option(&a), + "a [free] (fixes: CVE-2024-0001, CVE-2024-0002) - desc-a" + ); + } + + #[test] + fn patch_option_line_falls_back_to_advisory_id_without_cves() { + // An advisory WITHOUT CVEs (e.g. a GHSA with no CVE assigned yet) + // falls back to the advisory id. + let mut b = mk_patch("b", "pkg:npm/foo@1.0", "free", "2024-06-01"); + b.vulnerabilities.insert( + "GHSA-no-cves".into(), + VulnerabilityResponse { + cves: vec![], + summary: "s".into(), + severity: "low".into(), + description: String::new(), + }, + ); + assert_eq!( + format_patch_option(&b), + "b [free] (fixes: GHSA-no-cves) - desc-b" + ); + } + + #[test] + fn patch_option_line_omits_fixes_segment_without_vulnerabilities() { + let c = mk_patch("c", "pkg:npm/foo@1.0", "paid", "2024-06-01"); + assert_eq!(format_patch_option(&c), "c [paid] - desc-c"); + } + + // --- download_patch_records (detached download phase) ------------------ + // pub(crate), so its branches are pinned here. wiremock is a dev-dep and + // available to unit tests. Every override field is set explicitly so no + // ambient SOCKET_* env can steer the client; the env guard below scrubs + // the two vars the client constructor still consults for gaps. + + struct EnvVarGuard { + saved: Vec<(&'static str, Option)>, + } + + impl EnvVarGuard { + fn scrub(keys: &[&'static str]) -> Self { + let saved = keys + .iter() + .map(|k| { + let old = std::env::var(k).ok(); + std::env::remove_var(k); + (*k, old) + }) + .collect(); + Self { saved } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + for (k, v) in &self.saved { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + } + } + + fn detached_params(root: &Path, server_url: String) -> DownloadParams { + DownloadParams { + cwd: root.to_path_buf(), + manifest_path: root.join(".socket/manifest.json"), + org: Some("test-org".to_string()), + save_only: true, + global: false, + global_prefix: None, + json: true, + silent: true, + download_mode: "diff".to_string(), + api_overrides: socket_patch_core::api::client::ApiClientEnvOverrides { + api_url: Some(server_url), + api_token: Some("fake".to_string()), + org_slug: Some("test-org".to_string()), + proxy_url: None, + }, + all_releases: false, + strict: false, + ecosystems: None, + // The vendor-detached posture this fn exists for. + persist_blobs: false, + } + } + + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_no_applicable_files_is_failed_and_unrecorded() { + use wiremock::matchers::{method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; + let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let purl = "pkg:npm/covgap-no-after@1.0.0"; + // Every file lacks an afterHash -> files_for_manifest is empty -> + // the no-applicable-files guardrail must count a failure, return + // no record, and never claim the purl was downloaded. + Mock::given(method("GET")) + .and(wm_path(format!("/v0/orgs/test-org/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { "beforeHash": "e".repeat(64), "afterHash": null } + }, + "vulnerabilities": {}, "description": "d", "license": "MIT", "tier": "free", + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + let (code, json, records) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 1, "guardrail failure must exit 1; json={json}"); + assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!(json["downloaded"], 0, "json={json}"); + assert!(records.is_empty(), "no record may be handed to the vendor step"); + assert_eq!(json["patches"][0]["action"], "failed", "json={json}"); + assert_eq!( + json["patches"][0]["error"], "patch has no applicable files", + "json={json}" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_view_404_is_fetch_miss() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + // No view mock mounted: wiremock answers 404, which the API client + // maps to Ok(None) — the "could not fetch details" fetch-miss arm. + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let uuid = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + let purl = "pkg:npm/covgap-missing-view@1.0.0"; + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + + let (code, json, records) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 1, "a fetch miss must exit 1; json={json}"); + assert_eq!(json["failed"], 1, "json={json}"); + assert!(records.is_empty()); + assert_eq!(json["patches"][0]["action"], "failed", "json={json}"); + assert_eq!( + json["patches"][0]["error"], "could not fetch details", + "json={json}" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_uninstalled_variant_base_warns_and_keeps_all() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + // Two qualified PyPI variants sharing an UNINSTALLED base: release + // narrowing must keep both (with the not-installed warning), and the + // warnings key must ride the detached envelope. Views stay unmounted + // (404) so both then fail — proving both were kept for the loop. + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let base = "pkg:pypi/covgap-sixish@1.0.0"; + let selected = vec![ + mk_patch( + "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + &format!("{base}?artifact_id=wheel"), + "free", + "2024-01-01", + ), + mk_patch( + "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + &format!("{base}?artifact_id=sdist"), + "free", + "2024-01-01", + ), + ]; + + let (code, json, records) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 1, "json={json}"); + assert_eq!(json["found"], 2, "both variants must be kept; json={json}"); + assert_eq!(json["failed"], 2, "json={json}"); + assert!(records.is_empty()); + let warnings = json["warnings"] + .as_array() + .unwrap_or_else(|| panic!("keep-all fallback must surface warnings; json={json}")); + assert!( + warnings + .iter() + .any(|w| w.as_str().unwrap_or_default().contains("not installed locally")), + "warning must explain the keep-all fallback; json={json}" + ); + } } diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 009d70ba..8f6f987e 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -1446,6 +1446,67 @@ mod tests { ); } + /// The scanner's false-positive guard: a `.socket` mention that is NOT + /// a parseable vendored-artifact path (the committed manifest, a + /// non-uuid path segment) must never be reported as a vendor reference + /// — `parse_vendor_path`'s reject branch is what keeps `repair` from + /// reconstructing ledger entries out of ordinary `.socket/` mentions. + #[tokio::test] + async fn scan_ignores_non_vendor_socket_mentions() { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write( + tmp.path().join("package.json"), + r#"{ + "name": "t", + "socketManifest": ".socket/manifest.json", + "notAVendorPath": ".socket/vendor/npm/not-a-uuid/x.tgz" +}"#, + ) + .await + .unwrap(); + let refs = scan_vendor_references(tmp.path()).await; + assert!( + refs.is_empty(), + "non-vendor .socket mentions must be rejected: {refs:?}" + ); + } + + /// A lock that is PRESENT but does not reference the uuid must not + /// claim the entry: the probe falls through past pnpm-lock.yaml and + /// yarn.lock to the lock that actually carries the reference. + #[tokio::test] + async fn detect_reference_flavor_falls_through_present_unreferencing_locks() { + let uuid = "11111111-1111-4111-8111-111111111111"; + let mention = format!("resolved: file:.socket/vendor/npm/{uuid}/left-pad-1.3.0.tgz\n"); + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write( + tmp.path().join("pnpm-lock.yaml"), + "lockfileVersion: '9.0'\n", + ) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("yarn.lock"), "# yarn lockfile v1\n") + .await + .unwrap(); + tokio::fs::write(tmp.path().join("package-lock.json"), &mention) + .await + .unwrap(); + assert_eq!( + detect_reference_flavor(tmp.path(), "npm", uuid).await, + Some("package-lock".to_string()), + "present-but-unreferencing locks must fall through to the referencing one" + ); + } + + /// The empty-component rejects: a purl with no name or no version can + /// never drive a registry fetch — `npm_coords` must return `None`, not + /// empty coordinates. + #[test] + fn npm_coords_rejects_empty_name_or_version() { + assert_eq!(npm_coords("pkg:npm/@1.2.3"), None, "empty name"); + assert_eq!(npm_coords("pkg:npm/left-pad@"), None, "empty version"); + } + /// The reconstruction stamps [`VendorEntry::flavor`] from whichever /// lockfile carries the vendored reference, so `vendor --revert` routes /// to the backend whose unwired-revert guard probes the RIGHT lockfile. diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index e111c234..17a544b9 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -3544,4 +3544,477 @@ mod tests { "the no-op must be reported as already original, got {results:?}" ); } + + // --- Coverage-gap fills (2026-09 audit) -------------------------------- + + /// Exhaustive pin of the status vocabulary the JSON `filesVerified` + /// entries and the `--verbose` per-file labels are built from: every + /// `VerifyRollbackStatus` variant maps to its stable snake_case string. + #[test] + fn verify_rollback_status_str_covers_every_variant() { + assert_eq!( + verify_rollback_status_str(&VerifyRollbackStatus::Ready), + "ready" + ); + assert_eq!( + verify_rollback_status_str(&VerifyRollbackStatus::AlreadyOriginal), + "already_original" + ); + assert_eq!( + verify_rollback_status_str(&VerifyRollbackStatus::HashMismatch), + "hash_mismatch" + ); + assert_eq!( + verify_rollback_status_str(&VerifyRollbackStatus::NotFound), + "not_found" + ); + assert_eq!( + verify_rollback_status_str(&VerifyRollbackStatus::MissingBlob), + "missing_blob" + ); + } + + /// The local-go redirect rollback's FAILURE arm: when the `go.mod` edit + /// fails (here: `go.mod` is a directory, so the read errors), the result + /// flips to failure, clears the pre-populated `files_rolled_back` (the + /// JSON `rolledBack` count is derived from it, and nothing was rolled + /// back), and carries the error. + #[tokio::test] + async fn try_rollback_local_go_reports_failure_when_go_mod_unreadable() { + const PURL: &str = "pkg:golang/github.com/foo/bar@v1.4.2"; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // go.mod as a DIRECTORY: `drop_replace_entry`'s go.mod read fails. + std::fs::create_dir(root.join("go.mod")).unwrap(); + + let patch = record_with_file("uuid-go", "errors.go", "go_before"); + let common = crate::args::GlobalArgs { + cwd: root.to_path_buf(), + ..crate::args::GlobalArgs::default() + }; + let result = try_rollback_local_go(PURL, root, &patch, &common) + .await + .expect("go PURL in local mode must be handled by the go backend"); + assert!( + !result.success, + "an unreadable go.mod must fail the redirect rollback, got {result:?}" + ); + assert!( + result.files_rolled_back.is_empty(), + "a failed redirect rollback must not claim files were rolled \ + back, got {:?}", + result.files_rolled_back + ); + assert!( + result.error.is_some(), + "the failure must carry the underlying error" + ); + } + + /// The undiscovered-redirect fallback's FAILURE leg: a manifest-only + /// local-go redirect (no module-cache copy for the crawler to find) + /// whose `go.mod` edit fails must surface as a real failed result — + /// `success: false` with the error set — not silently vanish. + #[tokio::test] + async fn undiscovered_local_go_redirect_failure_reports_error() { + use socket_patch_core::vendor::go_mod_edit::{ensure_replace_entry, GO_PATCHES_DIR}; + + const MODULE: &str = "github.com/socket-patch-test/never-cached"; + const VERSION: &str = "v1.4.2"; + const PURL: &str = "pkg:golang/github.com/socket-patch-test/never-cached@v1.4.2"; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write( + root.join("go.mod"), + format!("module myproj\n\ngo 1.21\n\nrequire {MODULE} {VERSION}\n"), + ) + .await + .unwrap(); + assert!( + ensure_replace_entry(root, MODULE, VERSION, GO_PATCHES_DIR, false) + .await + .unwrap() + ); + let copy_dir = root + .join(GO_PATCHES_DIR) + .join(format!("{MODULE}@{VERSION}")); + tokio::fs::create_dir_all(©_dir).await.unwrap(); + tokio::fs::write(copy_dir.join("errors.go"), b"// patched\n") + .await + .unwrap(); + + let mut patches = HashMap::new(); + patches.insert( + PURL.to_string(), + record_with_file("uuid-go", "errors.go", "go_before"), + ); + let manifest = PatchManifest { + patches, + setup: None, + }; + let socket = root.join(".socket"); + tokio::fs::create_dir_all(&socket).await.unwrap(); + let manifest_path = socket.join("manifest.json"); + tokio::fs::write(&manifest_path, serde_json::to_string(&manifest).unwrap()) + .await + .unwrap(); + + // Now break the redirect removal: replace go.mod with a DIRECTORY. + tokio::fs::remove_file(root.join("go.mod")).await.unwrap(); + tokio::fs::create_dir(root.join("go.mod")).await.unwrap(); + + let common = crate::args::GlobalArgs { + cwd: root.to_path_buf(), + offline: true, + ..crate::args::GlobalArgs::default() + }; + let (success, results, _vendored, _not_installed) = rollback_patches( + &common, + &manifest_path, + None, + false, // dry_run + true, // silent + Some(vec!["golang".to_string()]), + ) + .await + .expect("rollback must not error at the boundary"); + + assert!( + !success, + "a failed undiscovered-redirect rollback must flip success, got {results:?}" + ); + assert_eq!(results.len(), 1, "got {results:?}"); + assert!(!results[0].success, "got {results:?}"); + assert!( + results[0].error.is_some(), + "the failure must carry the go.mod error, got {results:?}" + ); + assert!( + results[0].files_rolled_back.is_empty(), + "nothing was rolled back, got {results:?}" + ); + assert!( + copy_dir.exists(), + "the go.mod edit failed first, so the patched copy must survive" + ); + } + + /// The `remove`-delegation contract for a MISSING manifest: the + /// Identifier selection keeps the legacy hard error. Pins the CURRENT + /// wording — `read_manifest` reports NotFound as `Ok(None)`, which the + /// Identifier arm maps to the legacy "Invalid manifest" string (the + /// message predates the missing/corrupt split). + #[tokio::test] + async fn rollback_patches_missing_manifest_is_identifier_error() { + let tmp = tempfile::tempdir().unwrap(); + let common = crate::args::GlobalArgs { + cwd: tmp.path().to_path_buf(), + offline: true, + ..crate::args::GlobalArgs::default() + }; + let err = rollback_patches( + &common, + &tmp.path().join(".socket/manifest.json"), + Some("pkg:npm/x@1.0.0"), + false, + true, + None, + ) + .await + .expect_err("a missing manifest is an error for the Identifier selection"); + assert_eq!(err, "Invalid manifest"); + } + + /// The Identifier selection's no-match error names the identifier. + #[tokio::test] + async fn rollback_patches_unmatched_identifier_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + tokio::fs::create_dir_all(&socket).await.unwrap(); + let mut patches = HashMap::new(); + patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo")); + let manifest = PatchManifest { + patches, + setup: None, + }; + let manifest_path = socket.join("manifest.json"); + tokio::fs::write(&manifest_path, serde_json::to_string(&manifest).unwrap()) + .await + .unwrap(); + + let common = crate::args::GlobalArgs { + cwd: tmp.path().to_path_buf(), + offline: true, + ..crate::args::GlobalArgs::default() + }; + let err = rollback_patches( + &common, + &manifest_path, + Some("pkg:npm/nope@9.9"), + false, + true, + None, + ) + .await + .expect_err("an identifier matching nothing must be an error"); + assert_eq!(err, "No patch found matching identifier: pkg:npm/nope@9.9"); + } + + /// An EMPTY manifest with no identifier is a quiet success for the + /// delegation (the announce print runs; `remove` then has nothing to + /// drop): `Ok` with success and every list empty. + #[tokio::test] + async fn rollback_patches_empty_manifest_is_quiet_success() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + tokio::fs::create_dir_all(&socket).await.unwrap(); + let manifest_path = socket.join("manifest.json"); + tokio::fs::write(&manifest_path, b"{\"patches\": {}}\n") + .await + .unwrap(); + + let common = crate::args::GlobalArgs { + cwd: tmp.path().to_path_buf(), + offline: true, + ..crate::args::GlobalArgs::default() + }; + // silent=false so the "No patches found in manifest" announce path + // actually executes (its output is not capturable here; the + // contract under test is the quiet Ok). + let (success, results, vendored_skipped, not_installed) = + rollback_patches(&common, &manifest_path, None, false, false, None) + .await + .expect("an empty manifest is not an error"); + assert!(success); + assert!(results.is_empty(), "got {results:?}"); + assert!(vendored_skipped.is_empty()); + assert!(not_installed.is_empty()); + } + + /// A package gated by ONE absent before-blob must name ONLY that blob's + /// file in the synthesized offline abort: a sibling file in the SAME + /// patch whose blob IS staged never rides into the failure rows (the + /// per-file gate skip for present blobs). + #[tokio::test] + async fn offline_gate_names_only_the_absent_blob_file() { + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + + let index_before: &[u8] = b"index original\n"; + let index_after: &[u8] = b"index patched\n"; + let lib_before: &[u8] = b"lib original\n"; + let lib_after: &[u8] = b"lib patched\n"; + let index_before_hash = compute_git_sha256_from_bytes(index_before); + let index_after_hash = compute_git_sha256_from_bytes(index_after); + let lib_before_hash = compute_git_sha256_from_bytes(lib_before); + let lib_after_hash = compute_git_sha256_from_bytes(lib_after); + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let socket = root.join(".socket"); + let blobs = socket.join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + + // Installed package with BOTH files at their PATCHED bytes (so + // both would genuinely read their before-blob on restore). + tokio::fs::write( + root.join("package.json"), + r#"{ "name": "gate-two-file-root", "version": "0.0.0" }"#, + ) + .await + .unwrap(); + let pkg_dir = root.join("node_modules").join("gatepkg"); + tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); + tokio::fs::write( + pkg_dir.join("package.json"), + r#"{ "name": "gatepkg", "version": "1.0.0" }"#, + ) + .await + .unwrap(); + tokio::fs::write(pkg_dir.join("index.js"), index_after) + .await + .unwrap(); + tokio::fs::write(pkg_dir.join("lib.js"), lib_after) + .await + .unwrap(); + + // One record, two file rows. + let mut rec = make_record("uuid-gate"); + rec.files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: index_before_hash.clone(), + after_hash: index_after_hash.clone(), + }, + ); + rec.files.insert( + "package/lib.js".to_string(), + PatchFileInfo { + before_hash: lib_before_hash.clone(), + after_hash: lib_after_hash.clone(), + }, + ); + let mut patches = HashMap::new(); + patches.insert("pkg:npm/gatepkg@1.0.0".to_string(), rec); + let manifest = PatchManifest { + patches, + setup: None, + }; + let manifest_path = socket.join("manifest.json"); + tokio::fs::write(&manifest_path, serde_json::to_string(&manifest).unwrap()) + .await + .unwrap(); + + // Stage ONLY index's before-blob; lib's is deliberately absent. + tokio::fs::write(blobs.join(&index_before_hash), index_before) + .await + .unwrap(); + + let common = crate::args::GlobalArgs { + cwd: root.to_path_buf(), + offline: true, + ..crate::args::GlobalArgs::default() + }; + let (success, results, _vendored, _not_installed) = + rollback_patches(&common, &manifest_path, None, false, true, None) + .await + .expect("rollback must not error"); + assert!(!success, "the absent lib before-blob must abort offline"); + assert_eq!(results.len(), 1, "got {results:?}"); + let r = &results[0]; + assert_eq!(r.package_key, "pkg:npm/gatepkg@1.0.0"); + assert!(!r.success); + assert_eq!( + r.files_verified.len(), + 1, + "the staged index blob must NOT ride into the abort, got {results:?}" + ); + let f = &r.files_verified[0]; + assert_eq!(f.file, "package/lib.js"); + assert_eq!(f.status, VerifyRollbackStatus::MissingBlob); + assert_eq!(f.target_hash.as_deref(), Some(lib_before_hash.as_str())); + assert!( + r.error + .as_deref() + .is_some_and(|e| e.contains("package/lib.js")), + "the abort error names the blocking file, got {results:?}" + ); + } + + /// A dry run over a patch that CREATES a file (empty `beforeHash` + /// sentinel) must succeed: the throwaway blob stage skips the sentinel + /// (there is no blob "" to stage) and leaves no litter behind. + #[tokio::test] + async fn dry_run_tolerates_created_by_patch_sentinel_rows() { + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + + let index_before: &[u8] = b"sentinel index original\n"; + let index_after: &[u8] = b"sentinel index patched\n"; + let created: &[u8] = b"file created by the patch\n"; + let index_before_hash = compute_git_sha256_from_bytes(index_before); + let index_after_hash = compute_git_sha256_from_bytes(index_after); + let created_hash = compute_git_sha256_from_bytes(created); + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let socket = root.join(".socket"); + let blobs = socket.join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write( + root.join("package.json"), + r#"{ "name": "sentinel-root", "version": "0.0.0" }"#, + ) + .await + .unwrap(); + let pkg_dir = root.join("node_modules").join("sentinelpkg"); + tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); + tokio::fs::write( + pkg_dir.join("package.json"), + r#"{ "name": "sentinelpkg", "version": "1.0.0" }"#, + ) + .await + .unwrap(); + tokio::fs::write(pkg_dir.join("index.js"), index_after) + .await + .unwrap(); + tokio::fs::write(pkg_dir.join("created.js"), created) + .await + .unwrap(); + + let mut rec = make_record("uuid-sentinel"); + rec.files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: index_before_hash.clone(), + after_hash: index_after_hash, + }, + ); + rec.files.insert( + "package/created.js".to_string(), + PatchFileInfo { + before_hash: String::new(), // created-by-patch sentinel + after_hash: created_hash, + }, + ); + let mut patches = HashMap::new(); + patches.insert("pkg:npm/sentinelpkg@1.0.0".to_string(), rec); + let manifest = PatchManifest { + patches, + setup: None, + }; + let manifest_path = socket.join("manifest.json"); + tokio::fs::write(&manifest_path, serde_json::to_string(&manifest).unwrap()) + .await + .unwrap(); + tokio::fs::write(blobs.join(&index_before_hash), index_before) + .await + .unwrap(); + + let common = crate::args::GlobalArgs { + cwd: root.to_path_buf(), + offline: true, + ..crate::args::GlobalArgs::default() + }; + let (success, results, _vendored, _not_installed) = rollback_patches( + &common, + &manifest_path, + None, + true, // dry_run + true, // silent + None, + ) + .await + .expect("dry run must not error"); + assert!( + success, + "a created-by-patch row must not fail the dry run, got {results:?}" + ); + assert_eq!(results.len(), 1, "got {results:?}"); + assert!(results[0].success, "got {results:?}"); + + // No `.socket-stage-*` litter, and the real blobs dir is untouched + // (exactly the one staged before-blob — no phantom "" blob). + let mut socket_entries: Vec = std::fs::read_dir(&socket) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + socket_entries.sort(); + assert!( + socket_entries + .iter() + .all(|n| !n.starts_with(".socket-stage")), + "dry-run must clean up its blob stage, found {socket_entries:?}" + ); + let blob_entries: Vec = std::fs::read_dir(&blobs) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + blob_entries, + vec![index_before_hash], + "the committable blobs dir must be untouched by a dry run" + ); + } } diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index dfe71cf6..dd084a6f 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -1002,6 +1002,12 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); seed_manifest_entry(tmp.path(), "pkg:cargo/foo@1.0.0", "../../escape"); seed_corrupt_ledger(tmp.path()); + // Seed the dir the traversal uuid actually resolves to if a bypassed + // guard builds `.socket/vendor/cargo/../../escape` (-> `.socket/ + // escape`), so the probe finds an EXISTING dir and fabricates the + // purl — plus the literal spelling for a bypass that keeps the uuid + // as a lone path component. + std::fs::create_dir_all(tmp.path().join(".socket/escape")).unwrap(); std::fs::create_dir_all(tmp.path().join(".socket/vendor/cargo/escape")).unwrap(); assert!(supplement_in(tmp.path(), &[]).await.is_empty()); @@ -1092,6 +1098,320 @@ mod tests { ); } + // ---- unsupported_layout_warnings ----------------------------------- + + #[test] + fn unsupported_layout_warnings_forwards_unknown_codes_verbatim() { + use socket_patch_core::vendor::lock_inventory::UnsupportedNpmLayout; + + // Forward-compat contract: a refusal code this match doesn't know + // yet must surface verbatim — code AND the probe's own detail — + // rather than being swallowed back into silence. + let unknown = UnsupportedNpmLayout { + code: "vendor_future_layout_unsupported", + detail: "probe detail text".to_string(), + }; + assert_eq!( + unsupported_layout_warnings(std::slice::from_ref(&unknown)), + vec![( + "vendor_future_layout_unsupported".to_string(), + "probe detail text".to_string(), + )], + ); + + // Contrast: a KNOWN code is rewritten — renamed to apply's refusal + // errorCode and scan-phrased, not the probe's vendor-phrased text. + let known = UnsupportedNpmLayout { + code: "vendor_yarn_berry_unsupported", + detail: "probe detail text".to_string(), + }; + let rewritten = unsupported_layout_warnings(std::slice::from_ref(&known)); + assert_eq!(rewritten.len(), 1); + assert_eq!(rewritten[0].0, "yarn_pnp_unsupported"); + assert_ne!(rewritten[0].1, "probe detail text"); + } + + // ---- candidate_supersedes (merge-coverage rung) ---------------------- + // Production publishes no merged patches yet, so this rung has never run + // outside these tests; these pin its polarity for the day one ships. + + /// A batch-shaped patch with explicit advisory lists and NO publish + /// date, so only the severity and merge-coverage rungs can decide. + fn info_with_advisories( + uuid: &str, + severity: Option<&str>, + ghsas: &[&str], + cves: &[&str], + ) -> BatchPatchInfo { + BatchPatchInfo { + uuid: uuid.to_string(), + purl: "pkg:npm/foo@1.0".to_string(), + tier: "free".to_string(), + cve_ids: cves.iter().map(|s| (*s).to_string()).collect(), + ghsa_ids: ghsas.iter().map(|s| (*s).to_string()).collect(), + severity: severity.map(str::to_string), + title: String::new(), + published_at: None, + } + } + + #[test] + fn candidate_supersedes_on_broader_ghsa_merge_coverage() { + // Same severity, no dates: only the advisory count separates them. + // A patch folding in MORE GHSAs is broader and genuinely supersedes. + let merged = info_with_advisories( + "uuid-merged", + Some("high"), + &["GHSA-1111-1111-1111", "GHSA-2222-2222-2222"], + &[], + ); + let single = + info_with_advisories("uuid-single", Some("high"), &["GHSA-3333-3333-3333"], &[]); + assert!( + candidate_supersedes(&merged, &single), + "broader merge coverage is a genuine supersede" + ); + // Swapped: a NARROWER candidate never supersedes. Only reachable by + // direct call — via detect_updates a lower-coverage candidate can + // never win `min_by` — but the polarity of the `>` at the coverage + // return must be pinned somewhere. + assert!( + !candidate_supersedes(&single, &merged), + "narrower coverage must never supersede" + ); + } + + #[test] + fn candidate_supersedes_cve_aliases_do_not_inflate_ghsa_coverage() { + // Both sides name a GHSA, so the CVE lists are aliases and must not + // count: 1 == 1 advisory, no date on either side -> not a supersede + // in either direction (falls through coverage to the strict-date + // rung, which requires two REAL dates). + let candidate = info_with_advisories( + "uuid-cand", + Some("high"), + &["GHSA-xxxx-xxxx-xxxx"], + &["CVE-2026-1", "CVE-2026-2"], + ); + let applied = info_with_advisories( + "uuid-appl", + Some("high"), + &["GHSA-yyyy-yyyy-yyyy"], + &["CVE-2026-3"], + ); + assert!(!candidate_supersedes(&candidate, &applied)); + assert!(!candidate_supersedes(&applied, &candidate)); + } + + #[test] + fn detect_updates_flags_merged_patch_superseding_applied_single() { + // End-to-end through detect_updates: the manifest holds the + // single-advisory patch; the batch offers it alongside a merged + // sibling (2 GHSAs, same severity, no dates). The merged patch wins + // the ranking on coverage AND genuinely supersedes — the module doc + // promises this works the day production ships a merged patch. + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-single")]); + let pkgs = vec![BatchPackagePatches { + purl: "pkg:npm/foo@1.0".to_string(), + patches: vec![ + info_with_advisories("uuid-single", Some("high"), &["GHSA-3333-3333-3333"], &[]), + info_with_advisories( + "uuid-merged", + Some("high"), + &["GHSA-1111-1111-1111", "GHSA-2222-2222-2222"], + &[], + ), + ], + }]; + let updates = detect_updates(Some(&m), &pkgs); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].old_uuid, "uuid-single"); + assert_eq!(updates[0].new_uuid, "uuid-merged"); + } + + // ---- preverify_vendor_baselines -------------------------------------- + // The HashMismatch positive path is covered end-to-end by + // tests/scan_vendor_e2e.rs; these pin the three SKIP shapes: the two + // pre-fetch skips (lockfile-only, no crawled counterpart) and the + // per-file new-file skip after the fetch. + + fn search_result(uuid: &str, purl: &str) -> PatchSearchResult { + PatchSearchResult { + uuid: uuid.to_string(), + purl: purl.to_string(), + published_at: String::new(), + description: String::new(), + license: String::new(), + tier: "free".to_string(), + vulnerabilities: std::collections::HashMap::new(), + } + } + + fn crawled_pkg( + name: &str, + purl: &str, + path: std::path::PathBuf, + ) -> socket_patch_core::crawlers::types::CrawledPackage { + socket_patch_core::crawlers::types::CrawledPackage { + name: name.to_string(), + version: "1.0.0".to_string(), + namespace: None, + purl: purl.to_string(), + path, + } + } + + fn api_client_for(uri: &str) -> socket_patch_core::api::client::ApiClient { + socket_patch_core::api::client::ApiClient::new( + socket_patch_core::api::client::ApiClientOptions { + api_url: uri.to_string(), + api_token: None, + use_public_proxy: true, + org_slug: None, + }, + ) + } + + #[tokio::test] + async fn preverify_skips_lockfile_only_and_uncrawled_patches_without_fetching() { + // A server with NO mounted mocks: any fetch would still degrade to + // "skip" (404 -> Ok(None)), so the real assertion is the request + // log — both skips fire BEFORE the detail fetch. + let mock = wiremock::MockServer::start().await; + let client = api_client_for(&mock.uri()); + + let selected = vec![ + // (a) lockfile-only: no installed bytes to compare. The patch + // purl is API-encoded; the lockfile-only set holds the + // crawler's literal spelling — the normalize bridge must match + // them. + search_result("uuid-lockonly", "pkg:npm/%40scope/lockonly@1.0.0"), + // (b) no crawled counterpart at all. + search_result("uuid-ghost", "pkg:npm/ghost@1.0.0"), + ]; + let crawled = vec![ + // The lockonly purl HAS a crawled counterpart — production + // passes `filtered_crawled`, which CONTAINS the fabricated + // lockfile-only supplement entries — so the lockfile-only guard + // is the deciding branch: were it (or its normalize bridge) + // broken, the find below would succeed and the detail fetch + // would fire, tripping the request-log assertion. + crawled_pkg( + "lockonly", + "pkg:npm/@scope/lockonly@1.0.0", + std::path::PathBuf::from("/nonexistent"), + ), + crawled_pkg( + "other", + "pkg:npm/other@1.0.0", + std::path::PathBuf::from("/nonexistent"), + ), + ]; + let lockfile_only: HashSet = + std::iter::once("pkg:npm/@scope/lockonly@1.0.0".to_string()).collect(); + + let mismatched = + preverify_vendor_baselines(&client, None, &selected, &crawled, &lockfile_only).await; + assert!(mismatched.is_empty()); + assert!( + mock.received_requests().await.unwrap().is_empty(), + "both skip shapes must decide before any detail fetch" + ); + } + + /// Mount `GET /patch/view/` (the public-proxy detail route) with + /// the given `files` map; every other `PatchResponse` field is filler. + async fn mount_patch_view(mock: &wiremock::MockServer, uuid: &str, files: serde_json::Value) { + use wiremock::matchers::{method, path as wm_path}; + wiremock::Mock::given(method("GET")) + .and(wm_path(format!("/patch/view/{uuid}"))) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ + "uuid": uuid, + "purl": "pkg:npm/newfile@1.0.0", + "publishedAt": "2026-01-01T00:00:00Z", + "files": files, + "vulnerabilities": {}, + "description": "", + "license": "MIT", + "tier": "free", + }), + )) + .mount(mock) + .await; + } + + #[tokio::test] + async fn preverify_ignores_new_file_entries_with_no_baseline() { + // A fetched detail file with NO beforeHash is a new file: there is + // no baseline to compare, so it must not flag a mismatch — even + // though nothing exists at its would-be path. (This is the live + // wire shape: new-file patch entries omit beforeHash entirely.) + let mock = wiremock::MockServer::start().await; + mount_patch_view( + &mock, + "u3", + serde_json::json!({ + "package/added.js": { "afterHash": "a".repeat(64) } + }), + ) + .await; + let client = api_client_for(&mock.uri()); + + let tmp = tempfile::tempdir().unwrap(); + let pkg_dir = tmp.path().join("node_modules/newfile"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + let crawled = vec![crawled_pkg("newfile", "pkg:npm/newfile@1.0.0", pkg_dir)]; + let selected = vec![search_result("u3", "pkg:npm/newfile@1.0.0")]; + + let mismatched = + preverify_vendor_baselines(&client, None, &selected, &crawled, &HashSet::new()).await; + assert!( + mismatched.is_empty(), + "a new-file-only patch never annotates a baseline mismatch" + ); + // Unlike the pre-fetch skips, this one DID fetch the detail. + assert_eq!(mock.received_requests().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn preverify_new_file_skip_is_per_file_not_per_patch() { + // One patch, two files: a baseline-less new file AND a real + // beforeHash entry whose installed bytes differ. The new-file skip + // is a per-file `continue`, so the sibling mismatch must still + // flag the patch uuid. + let mock = wiremock::MockServer::start().await; + mount_patch_view( + &mock, + "u4", + serde_json::json!({ + "package/added.js": { "afterHash": "a".repeat(64) }, + "package/index.js": { + "beforeHash": "b".repeat(64), + "afterHash": "c".repeat(64), + }, + }), + ) + .await; + let client = api_client_for(&mock.uri()); + + let tmp = tempfile::tempdir().unwrap(); + let pkg_dir = tmp.path().join("node_modules/newfile"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + // Installed bytes hash to neither beforeHash nor afterHash. + std::fs::write(pkg_dir.join("index.js"), b"installed bytes\n").unwrap(); + let crawled = vec![crawled_pkg("newfile", "pkg:npm/newfile@1.0.0", pkg_dir)]; + let selected = vec![search_result("u4", "pkg:npm/newfile@1.0.0")]; + + let mismatched = + preverify_vendor_baselines(&client, None, &selected, &crawled, &HashSet::new()).await; + assert_eq!( + mismatched, + std::iter::once("u4".to_string()).collect::>(), + "the new-file skip must not swallow a sibling file's mismatch" + ); + } + #[test] fn collect_vuln_ids_dedups_across_patches() { // The same CVE appears on two patches of one package; it must be diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index e75aff9a..6b43953e 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -28,6 +28,16 @@ pub(super) struct GcSummary { /// their patch is gone from the manifest or their dependency left the /// lockfile graph — see `vendor::run_vendor_gc`. Sorted. vendored_reverted: Vec, + /// Vendored entries the wet pass drift-kept + /// (`RevertOutcome::kept_artifact`): a revert was due, but the lock + /// entries drifted since vendoring, so artifacts, ledger entry, and + /// manifest records were all retained — nothing reclaimed until the + /// user undoes the drift and re-runs `vendor --revert`. Sorted. + /// Always empty in preview mode (drift is only detected by a wet + /// wiring replay), so the preview still lists such entries in + /// `vendored_reverted` — this field is what lets the apply output + /// explain the difference. + vendored_kept: Vec, /// Orphan `.socket/vendor//` dirs swept (or sweepable). vendor_orphan_dirs: usize, /// `true` when `--no-prune` was set; the sub-object only carries the @@ -48,6 +58,8 @@ impl GcSummary { .chain(v.unused_reverted) .collect(); self.vendored_reverted.sort(); + self.vendored_kept = v.kept; + self.vendored_kept.sort(); self.vendor_orphan_dirs = v.orphan_dirs; } @@ -62,6 +74,7 @@ impl GcSummary { "removedDiffArchives": self.diffs.blobs_removed, "removedPackageArchives": self.packages.blobs_removed, "revertedVendoredEntries": self.vendored_reverted, + "keptVendoredEntries": self.vendored_kept, "removedVendorOrphanDirs": self.vendor_orphan_dirs, "bytesFreed": self.total_bytes(), }) @@ -246,23 +259,39 @@ pub(super) async fn gc_json( } } -/// Human-readable one-liner for the vendored-state half of a GC pass; +/// Human-readable line(s) for the vendored-state half of a GC pass; /// prints nothing when that half did nothing. pub(super) fn print_gc_vendored_line(gc: &GcSummary) { - if gc.vendored_reverted.is_empty() && gc.vendor_orphan_dirs == 0 { - return; - } - println!( - "GC: reverted {} vendored entr{}; swept {} orphan vendor dir{}.", - gc.vendored_reverted.len(), - if gc.vendored_reverted.len() == 1 { - "y" - } else { - "ies" - }, - gc.vendor_orphan_dirs, - if gc.vendor_orphan_dirs == 1 { "" } else { "s" }, - ); + if !gc.vendored_reverted.is_empty() || gc.vendor_orphan_dirs > 0 { + println!( + "GC: reverted {} vendored entr{}; swept {} orphan vendor dir{}.", + gc.vendored_reverted.len(), + if gc.vendored_reverted.len() == 1 { + "y" + } else { + "ies" + }, + gc.vendor_orphan_dirs, + if gc.vendor_orphan_dirs == 1 { "" } else { "s" }, + ); + } + // Drift-keeps are the one GC outcome that silently contradicts the + // `--dry-run` preview (which cannot see drift and lists the entry as + // revertable), so they always earn the same remediation hint every + // other drift-keep caller prints. + if !gc.vendored_kept.is_empty() { + println!( + "GC: kept {} drifted vendored entr{}: lock entries were re-resolved since \ + vendoring, so their artifacts and manifest/ledger entries were retained — undo \ + the drift and re-run `vendor --revert` to finish.", + gc.vendored_kept.len(), + if gc.vendored_kept.len() == 1 { + "y" + } else { + "ies" + }, + ); + } } /// PURL strings present in the manifest but absent from `scanned_purls`. @@ -707,4 +736,428 @@ mod tests { "wet run must prune the entry" ); } + + // ---- missing/corrupt manifest fail-safe --------------------------------- + // An unreadable manifest must abort the GC pass, NOT be treated as an + // empty referenced-set: the cleanup helpers derive "still referenced" + // from the manifest they're handed, so proceeding with an empty one + // would sweep EVERY blob in `.socket/blobs` — including ones a healthy + // manifest (restored from git, say) still references. + + /// Tempdir with a `.socket/blobs/` blob planted but NO manifest + /// written; returns `(manifest_path, socket_dir, blob_path)`. + fn seed_blob_without_manifest( + tmp: &std::path::Path, + blob_hash: &str, + ) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { + let socket_dir = tmp.join(".socket"); + let blobs_dir = socket_dir.join("blobs"); + std::fs::create_dir_all(&blobs_dir).unwrap(); + let blob_path = blobs_dir.join(blob_hash); + std::fs::write(&blob_path, vec![0u8; 64]).unwrap(); + let manifest_path = socket_dir.join("manifest.json"); + (manifest_path, socket_dir, blob_path) + } + + #[tokio::test] + async fn run_apply_gc_deletes_nothing_when_manifest_missing() { + let tmp = tempfile::tempdir().unwrap(); + let (manifest_path, socket_dir, blob_path) = + seed_blob_without_manifest(tmp.path(), &"e".repeat(64)); + + let gc = run_apply_gc( + &gc_common(tmp.path()), + &manifest_path, + &socket_dir, + &scanned(&[]), + &no_vendored(), + ) + .await; + + assert!( + gc.pruned.is_empty(), + "nothing to prune from a missing manifest; pruned {:?}", + gc.pruned + ); + assert_eq!( + gc.blobs.blobs_removed, 0, + "a missing manifest must NOT read as an empty referenced-set" + ); + assert_eq!(gc.total_bytes(), 0, "no bytes may be freed"); + assert!( + blob_path.exists(), + "the blob must survive a GC pass with no manifest to consult" + ); + assert!( + !manifest_path.exists(), + "the aborted pass must not conjure a manifest file" + ); + } + + #[tokio::test] + async fn run_apply_gc_deletes_nothing_when_manifest_corrupt() { + let tmp = tempfile::tempdir().unwrap(); + let (manifest_path, socket_dir, blob_path) = + seed_blob_without_manifest(tmp.path(), &"e".repeat(64)); + std::fs::write(&manifest_path, "{ not json").unwrap(); + + let gc = run_apply_gc( + &gc_common(tmp.path()), + &manifest_path, + &socket_dir, + &scanned(&[]), + &no_vendored(), + ) + .await; + + assert!( + gc.pruned.is_empty(), + "nothing to prune from a corrupt manifest; pruned {:?}", + gc.pruned + ); + assert_eq!( + gc.blobs.blobs_removed, 0, + "a corrupt manifest must NOT read as an empty referenced-set" + ); + assert_eq!(gc.total_bytes(), 0, "no bytes may be freed"); + assert!( + blob_path.exists(), + "the blob must survive a GC pass with an unreadable manifest" + ); + assert_eq!( + std::fs::read_to_string(&manifest_path).unwrap(), + "{ not json", + "the aborted pass must not rewrite the corrupt manifest" + ); + } + + #[tokio::test] + async fn preview_apply_gc_reports_zero_and_mutates_nothing_when_manifest_missing_or_corrupt() { + // Missing manifest. + let tmp = tempfile::tempdir().unwrap(); + let (manifest_path, socket_dir, blob_path) = + seed_blob_without_manifest(tmp.path(), &"e".repeat(64)); + let gc = preview_apply_gc( + &gc_common(tmp.path()), + &manifest_path, + &socket_dir, + &scanned(&[]), + &no_vendored(), + ) + .await; + assert!(gc.pruned.is_empty(), "pruned {:?}", gc.pruned); + assert_eq!( + gc.blobs.blobs_removed, 0, + "preview of a missing manifest must report zero orphans, \ + not the whole blob store" + ); + assert_eq!(gc.total_bytes(), 0); + assert!(blob_path.exists(), "preview must not delete the blob"); + assert!( + !manifest_path.exists(), + "preview must not create a manifest file" + ); + // The serialized degenerate preview is the normal all-zero shape, + // not the `skipped` one. + let json = gc.to_preview_json(); + assert_eq!(json["prunableManifestEntries"], serde_json::json!([])); + assert_eq!(json["orphanBlobs"], serde_json::json!(0)); + assert_eq!(json["bytesReclaimable"], serde_json::json!(0)); + + // Corrupt manifest, fresh tempdir. + let tmp = tempfile::tempdir().unwrap(); + let (manifest_path, socket_dir, blob_path) = + seed_blob_without_manifest(tmp.path(), &"e".repeat(64)); + std::fs::write(&manifest_path, "{ not json").unwrap(); + let gc = preview_apply_gc( + &gc_common(tmp.path()), + &manifest_path, + &socket_dir, + &scanned(&[]), + &no_vendored(), + ) + .await; + assert!(gc.pruned.is_empty(), "pruned {:?}", gc.pruned); + assert_eq!( + gc.blobs.blobs_removed, 0, + "preview of a corrupt manifest must report zero orphans" + ); + assert_eq!(gc.total_bytes(), 0); + assert!(blob_path.exists(), "preview must not delete the blob"); + assert_eq!( + std::fs::read_to_string(&manifest_path).unwrap(), + "{ not json", + "preview must not rewrite the corrupt manifest" + ); + } + + // ---- lockfile-unused vendored entry in the preview ---------------------- + + #[tokio::test] + async fn preview_counts_blobs_of_lockfile_unused_vendored_entry() { + // A vendored entry whose dependency left the lockfile graph: the wet + // pass reverts it AND drops its manifest entry, so its blob is freed + // in the same run. The preview must mirror that — drop the entry's + // manifest keys in memory before the orphan sweep — or `--dry-run` + // under-reports orphanBlobs/bytesReclaimable vs the real `--prune`. + // Note the vendored exemption set deliberately contains the purl: + // detect_prunable exempts it (gc.pruned stays empty), so ONLY the + // vendor-gc mirror loop can surface the blob as reclaimable. + const PURL: &str = "pkg:npm/gone@1.0.0"; + const UUID: &str = "11111111-1111-4111-8111-111111111111"; + + let tmp = tempfile::tempdir().unwrap(); + let (manifest_path, socket_dir, blob_path) = + seed_manifest_with_blob(tmp.path(), PURL, &"d".repeat(64)); + // A lockfile that parses but carries no `.socket/vendor/npm//` + // reference: the in-use probe answers Some(false) — unused. + std::fs::write( + tmp.path().join("package-lock.json"), + "{\"lockfileVersion\":3,\"packages\":{}}", + ) + .unwrap(); + // The ledger: one npm package-lock entry keyed by the manifest purl. + let mut state = socket_patch_core::vendor::VendorState::default(); + state.entries.insert( + PURL.to_string(), + socket_patch_core::vendor::VendorEntry { + ecosystem: "npm".into(), + base_purl: PURL.into(), + uuid: UUID.into(), + artifact: socket_patch_core::vendor::state::VendorArtifact { + path: format!(".socket/vendor/npm/{UUID}/gone-1.0.0.tgz"), + sha256: String::new(), + size: None, + platform_locked: None, + file_inventory: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: Some("package-lock".into()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + }, + ); + socket_patch_core::vendor::save_state(tmp.path(), &state) + .await + .unwrap(); + let state_before = + std::fs::read(tmp.path().join(".socket/vendor/state.json")).unwrap(); + + let vendored: HashSet = [PURL.to_string()].into_iter().collect(); + let gc = preview_apply_gc( + &gc_common(tmp.path()), + &manifest_path, + &socket_dir, + &scanned(&[]), + &vendored, + ) + .await; + + assert_eq!( + gc.vendored_reverted, + vec![PURL.to_string()], + "the lockfile-unused entry must be listed as revertable" + ); + assert!( + gc.pruned.is_empty(), + "the vendored exemption keeps it out of the prunable set (it is \ + reclaimed via the vendor GC, not detect_prunable); got {:?}", + gc.pruned + ); + assert_eq!( + gc.blobs.blobs_removed, 1, + "the preview must count the unused entry's blob as an orphan — \ + its manifest keys are dropped in memory before the sweep, \ + mirroring what the wet run frees" + ); + assert!( + gc.total_bytes() > 0, + "bytesReclaimable must include the unused entry's blob" + ); + assert_eq!(gc.vendor_orphan_dirs, 0, "no orphan uuid dirs on disk"); + // Preview is non-mutating: blob, manifest entry, and ledger intact. + assert!(blob_path.exists(), "preview must not delete the blob"); + let m = read_manifest(&manifest_path).await.unwrap().unwrap(); + assert!( + m.patches.contains_key(PURL), + "preview must not prune the on-disk manifest entry" + ); + assert_eq!( + std::fs::read(tmp.path().join(".socket/vendor/state.json")).unwrap(), + state_before, + "preview must not rewrite the vendor ledger" + ); + } + + // ---- drift-kept vendored entry in the wet pass --------------------------- + + /// A vendored entry whose lock fragment DRIFTED since vendoring (fork + /// re-resolve): the in-use probe calls it unused, but the wet revert + /// refuses to touch the drifted lock and keeps artifacts, ledger entry + /// and manifest record. The preview cannot see drift and lists the + /// entry as revertable, so the wet `scan --prune` reclaims nothing — + /// pre-fix, with zero explanation (the kept purl was counted nowhere + /// and both call sites dropped the backend's vendor_artifact_kept + /// warning). The keep must surface as `keptVendoredEntries` in the + /// apply JSON. + #[tokio::test] + async fn apply_gc_reports_drift_kept_vendored_entry() { + use socket_patch_core::vendor::state::{WiringAction, WiringRecord}; + + const PURL: &str = "pkg:npm/gone@1.0.0"; + const UUID: &str = "11111111-1111-4111-8111-111111111111"; + + let tmp = tempfile::tempdir().unwrap(); + let (manifest_path, socket_dir, blob_path) = + seed_manifest_with_blob(tmp.path(), PURL, &"e".repeat(64)); + // The drifted lock: the recorded key resolves to a third-party + // fork — neither our vendored fragment nor the recorded + // pre-vendor original (and no `.socket/vendor/npm//` + // mention, so the in-use probe answers Some(false) — unused). + std::fs::write( + tmp.path().join("package-lock.json"), + serde_json::to_vec(&serde_json::json!({ + "lockfileVersion": 3, + "packages": { + "node_modules/gone": { + "version": "1.0.0", + "resolved": "https://example.com/their-fork.tgz", + } + } + })) + .unwrap(), + ) + .unwrap(); + // The artifact the keep must preserve. + let uuid_dir = tmp.path().join(format!(".socket/vendor/npm/{UUID}")); + std::fs::create_dir_all(&uuid_dir).unwrap(); + std::fs::write(uuid_dir.join("gone-1.0.0.tgz"), b"tgz").unwrap(); + // The ledger: one wired package-lock entry, so the revert can + // classify the fork fragment as third-party drift. + let mut state = socket_patch_core::vendor::VendorState::default(); + state.entries.insert( + PURL.to_string(), + socket_patch_core::vendor::VendorEntry { + ecosystem: "npm".into(), + base_purl: PURL.into(), + uuid: UUID.into(), + artifact: socket_patch_core::vendor::state::VendorArtifact { + path: format!(".socket/vendor/npm/{UUID}/gone-1.0.0.tgz"), + sha256: String::new(), + size: None, + platform_locked: None, + file_inventory: None, + }, + wiring: vec![WiringRecord { + file: "package-lock.json".into(), + kind: "npm_lock_entry".into(), + action: WiringAction::Rewritten, + key: Some("node_modules/gone".into()), + original: Some(serde_json::json!({ + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gone/-/gone-1.0.0.tgz", + })), + new: Some(serde_json::json!({ + "version": "1.0.0", + "resolved": + format!("file:.socket/vendor/npm/{UUID}/gone-1.0.0.tgz"), + })), + }], + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: Some("package-lock".into()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + }, + ); + socket_patch_core::vendor::save_state(tmp.path(), &state) + .await + .unwrap(); + + let vendored: HashSet = [PURL.to_string()].into_iter().collect(); + let gc = run_apply_gc( + &gc_common(tmp.path()), + &manifest_path, + &socket_dir, + &scanned(&[]), + &vendored, + ) + .await; + + assert!( + gc.vendored_reverted.is_empty(), + "a drift-kept entry must not be reported reverted: {:?}", + gc.vendored_reverted + ); + assert_eq!( + gc.vendored_kept, + vec![PURL.to_string()], + "the keep must be counted — the only signal that the entry the \ + preview listed as revertable was deliberately not reclaimed" + ); + assert_eq!( + gc.to_apply_json()["keptVendoredEntries"], + serde_json::json!([PURL]), + "scan --prune --json must carry the keep" + ); + // Nothing reclaimed: manifest record, blob, ledger entry, and + // artifacts all survive (the drift-keep contract). + assert_eq!(gc.blobs.blobs_removed, 0, "kept entry's blob is not swept"); + assert!(blob_path.exists()); + let m = read_manifest(&manifest_path).await.unwrap().unwrap(); + assert!( + m.patches.contains_key(PURL), + "the kept entry's manifest record must survive" + ); + assert!( + socket_patch_core::vendor::load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL), + "the kept entry's ledger record must survive" + ); + assert!(uuid_dir.exists(), "kept artifacts must survive the sweep"); + } + + /// The `keptVendoredEntries` plumbing in isolation: absorbed sorted, + /// serialized on the apply shape, absent from the preview shape (a + /// read-only preview cannot detect drift, so emitting a constant `[]` + /// would claim a check that never ran). + #[test] + fn gc_json_shapes_carry_drift_keeps_only_on_apply() { + let mut gc = GcSummary::default(); + gc.absorb_vendor_gc(crate::commands::vendor::VendorGcSummary { + kept: vec!["pkg:npm/b@1.0.0".into(), "pkg:npm/a@1.0.0".into()], + ..Default::default() + }); + assert_eq!( + gc.vendored_kept, + vec!["pkg:npm/a@1.0.0".to_string(), "pkg:npm/b@1.0.0".to_string()], + "absorb must sort, like every other purl list" + ); + let apply = gc.to_apply_json(); + assert_eq!( + apply["keptVendoredEntries"], + serde_json::json!(["pkg:npm/a@1.0.0", "pkg:npm/b@1.0.0"]) + ); + assert_eq!(apply["revertedVendoredEntries"], serde_json::json!([])); + let preview = gc.to_preview_json(); + assert!( + preview.get("keptVendoredEntries").is_none(), + "preview must not claim a drift check it cannot run: {preview}" + ); + } } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 00938f19..55bd8950 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -3013,6 +3013,103 @@ mod tests { assert!(out.warnings.is_empty()); } + /// Committed `vendor/cache` fold, UNKNOWN-sha arm (`_ => false`): when + /// the run carries NO artifact sha for the gem (empty shas map — e.g. a + /// reference served without a gem checksum), a committed archive beside + /// a stale install must STILL be folded into the delete list. Removal is + /// safe either way (`bundle install` refetches), so "unknown" must never + /// downgrade to "proven patched" and leave the archive to silently + /// reinstate the stale bytes. + #[tokio::test] + async fn gem_stale_probe_folds_committed_cache_with_unknown_artifact_sha() { + let tmp = tempfile::tempdir().unwrap(); + materialize_gem(tmp.path(), GEM_UPSTREAM); + let committed = tmp + .path() + .join("vendor") + .join("cache") + .join(format!("{GEM_LEAF}.gem")); + std::fs::create_dir_all(committed.parent().unwrap()).unwrap(); + std::fs::write(&committed, b"upstream archive bytes").unwrap(); + + // `probe()` passes an EMPTY gem_artifact_shas map: the + // (None, Some(_)) pair must take the fold-anyway arm. + let out = probe(tmp.path(), &one_confirmed(), &one_record()).await; + assert_eq!( + out.warnings.len(), + 1, + "one stale install, one warning (cache folded, not standalone): {:?}", + out.warnings + ); + let detail = detail_of(&out.warnings[0]); + assert!( + detail.contains(&committed.display().to_string()), + "the committed archive must join the delete list even with no \ + known artifact sha: {detail}" + ); + assert_eq!( + out.stale_purls, + std::collections::BTreeSet::from([GEM_PURL.to_string()]) + ); + // Read-only contract: the archive itself is never deleted. + assert!(committed.is_file(), "the probe prescribes, never deletes"); + } + + /// Standalone cache pass 3, UNREADABLE-archive arm: a committed archive + /// whose bytes cannot be read (chmod 000) yields NO positive evidence, + /// so the probe must stay silent instead of guessing staleness from the + /// differing expected sha — the never-warn-without-positive-evidence + /// contract, archive flavor. + #[cfg(unix)] + #[tokio::test] + async fn gem_stale_probe_never_judges_unreadable_committed_archive() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + // NO installed gem dir (fresh-checkout shape) so pass 3 is the only + // judgment path. + let committed = tmp + .path() + .join("vendor") + .join("cache") + .join(format!("{GEM_LEAF}.gem")); + std::fs::create_dir_all(committed.parent().unwrap()).unwrap(); + std::fs::write(&committed, b"upstream archive bytes").unwrap(); + std::fs::set_permissions(&committed, std::fs::Permissions::from_mode(0o000)).unwrap(); + // Root ignores mode bits: detect it while the chmod is in force so + // the assertion below matches what the probe could actually read. + let readable_despite_chmod = std::fs::File::open(&committed).is_ok(); + + let mut shas = std::collections::BTreeMap::new(); + shas.insert( + ("stale-unit".to_string(), "1.0.0".to_string()), + "0".repeat(64), // differs from the archive bytes' sha + ); + let out = gem_stale_install_warnings( + tmp.path(), + false, + None, + &one_confirmed(), + &one_record(), + &std::collections::BTreeMap::new(), + &shas, + ) + .await; + std::fs::set_permissions(&committed, std::fs::Permissions::from_mode(0o644)).unwrap(); + + if readable_despite_chmod { + // Running as root: the archive WAS readable and its sha differs, + // so the ordinary stale-cache warning is the correct outcome. + assert_eq!(out.warnings.len(), 1, "root fallback: readable + stale"); + } else { + assert!( + out.warnings.is_empty(), + "an unreadable archive is never staleness evidence: {:?}", + out.warnings + ); + assert!(out.stale_purls.is_empty()); + } + } + /// The standalone cache-flavor warning's load-bearing wording. #[test] fn gem_stale_cache_warning_names_archive_and_remedy() { diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index bbc6f206..aa48ef6c 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -4005,4 +4005,353 @@ mod tests { vec!["pkg:npm/minimist@1.2.2".to_string()] ); } + + // ---- takeover detection degradation: corrupt / probe-less ledgers ------ + + #[tokio::test] + async fn corrupt_vendor_state_json_degrades_to_no_overlap() { + // A hand-corrupted (or torn mid-write) `.socket/vendor/state.json` + // must classify like a missing one: this path only feeds takeover + // WARNINGS, and the vendored write paths hard-error on corruption + // themselves. A valid redirect ledger alone must not produce a + // spurious overlap. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + let dir = root.join(".socket/vendor"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("state.json"), "not-json {{{") + .await + .unwrap(); + + assert!( + overlapping_ledger_purls(root).await.is_empty(), + "a corrupt vendor ledger must degrade to no-overlap" + ); + assert_eq!( + classify_overlap_takeover(root).await, + OverlapTakeover::default(), + "no overlap ⇒ no directional classification" + ); + } + + #[tokio::test] + async fn cargo_overlap_with_no_lock_to_probe_stays_silent() { + // Both ledgers claim the cargo purl but there is NO Cargo.lock (a + // fresh checkout / deleted lock). `classify_cargo_overlap`'s probe + // returns NoLockfile, which proves neither direction — the `_` arm + // must classify silent rather than guess. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &[CARGO_PURL]).await; + write_cargo_vendor_ledger(root).await; + + // The raw overlap fires (both ledgers name the purl)… + assert_eq!( + overlapping_ledger_purls(root).await, + vec![CARGO_PURL.to_string()], + "the overlap itself must be detected" + ); + // …but with no lock to prove a direction, both buckets stay empty. + assert_eq!( + classify_overlap_takeover(root).await, + OverlapTakeover::default(), + "no Cargo.lock ⇒ neither direction proven ⇒ silent" + ); + } + + // ---- hostile-ledger tamper guards (path traversal) ---------------------- + // The ledgers are committed files an attacker can edit: a recorded + // lockfile name must never make the wiring probes READ outside the + // project root. + + #[tokio::test] + async fn hosted_wiring_text_proof_never_reads_outside_the_project() { + // The escaping file EXISTS and carries the record uuid — it would + // prove hosted wiring were it read. The `../` guard must skip it. + let outer = tempfile::tempdir().unwrap(); + let root = outer.path().join("proj"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write( + outer.path().join("escape.lock"), + format!("resolved https://patch.socket.dev/x/{TAKEOVER_UUID}/m.tgz\n"), + ) + .await + .unwrap(); + + assert!( + !hosted_wiring_live( + &root, + "pkg:npm/minimist@1.2.2", + Some(TAKEOVER_UUID), + &["../escape.lock"], + &[], + ) + .await, + "a '../'-escaping ledger path must never be read" + ); + + // Positive control: the SAME content inside the project proves the + // wiring — so the negative above is the guard, not a missing file. + tokio::fs::write( + root.join("inside.lock"), + format!("resolved https://patch.socket.dev/x/{TAKEOVER_UUID}/m.tgz\n"), + ) + .await + .unwrap(); + assert!( + hosted_wiring_live( + &root, + "pkg:npm/minimist@1.2.2", + Some(TAKEOVER_UUID), + &["inside.lock"], + &[], + ) + .await, + "the identical in-project file must prove hosted wiring" + ); + } + + #[tokio::test] + async fn vendored_wiring_probe_never_reads_outside_the_project() { + let marker = socket_patch_core::vendor::path::vendor_uuid_dir_rel("npm", TAKEOVER_UUID) + .expect("npm has a vendor dir mapping"); + let entry_json = |wiring_file: &str| { + serde_json::json!({ + "ecosystem": "npm", + "basePurl": "pkg:npm/minimist@1.2.2", + "uuid": TAKEOVER_UUID, + "artifact": { + "path": format!("{marker}/minimist-1.2.2.tgz"), + }, + "wiring": [{ + "file": wiring_file, + "kind": "npm_lock_entry", + "action": "rewritten", + }], + }) + }; + + let outer = tempfile::tempdir().unwrap(); + let root = outer.path().join("proj"); + tokio::fs::create_dir_all(&root).await.unwrap(); + // The escaping file EXISTS and contains the vendored marker. + tokio::fs::write( + outer.path().join("escape.lock"), + format!("resolved file:{marker}/minimist-1.2.2.tgz\n"), + ) + .await + .unwrap(); + + let escaping: socket_patch_core::vendor::VendorEntry = + serde_json::from_value(entry_json("../escape.lock")).unwrap(); + assert!( + !vendored_wiring_live(&root, &escaping).await, + "a '../'-escaping wiring file must never be read" + ); + + // Positive control: same content, in-project name ⇒ proven live. + tokio::fs::write( + root.join("inside.lock"), + format!("resolved file:{marker}/minimist-1.2.2.tgz\n"), + ) + .await + .unwrap(); + let in_project: socket_patch_core::vendor::VendorEntry = + serde_json::from_value(entry_json("inside.lock")).unwrap(); + assert!( + vendored_wiring_live(&root, &in_project).await, + "the identical in-project wiring file must prove vendored wiring" + ); + } + + // ---- note_vendor_supersedes_redirect: warning + npm auto-reconcile ------ + // The vendored flows' takeover advisory. Detection is pinned above; + // these pin the post-detection body: the reconciled/manual/dry-run + // partitions, the ledger mutation, and the fires-once contract. + + const NPM_TAKEOVER_PURL: &str = "pkg:npm/minimist@1.2.2"; + + fn vendor_env() -> crate::json_envelope::Envelope { + crate::json_envelope::Envelope::new(crate::json_envelope::Command::Vendor) + } + + /// `GlobalArgs` for the advisory: `json` keeps the stderr print quiet + /// (the envelope `warnings[]` is what the tests read). + fn takeover_common() -> GlobalArgs { + GlobalArgs { + json: true, + ..GlobalArgs::default() + } + } + + /// The WET npm takeover: redirect ledger records the purl (with a + /// version-exact keyed edit `drop_superseded_purl` can claim), the + /// vendored ledger is wired, and the LIVE lock points at the committed + /// vendored artifact. + async fn write_wet_npm_takeover(root: &Path) { + write_redirect_ledger_with_edits( + root, + &[NPM_TAKEOVER_PURL], + vec![redirect_edit("package-lock.json", "minimist@1.2.2")], + ) + .await; + write_vendor_ledger_wired(root, &[NPM_TAKEOVER_PURL]).await; + write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; + } + + #[tokio::test] + async fn vendored_takeover_wet_npm_run_reconciles_the_ledger_once() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_wet_npm_takeover(root).await; + + let mut env = vendor_env(); + note_vendor_supersedes_redirect(&mut env, root, &takeover_common()).await; + + assert_eq!(env.warnings.len(), 1, "exactly one warning: {:?}", env.warnings); + assert_eq!(env.warnings[0].code, VENDOR_SUPERSEDES_REDIRECT); + assert!( + env.warnings[0].detail.contains("reconciled automatically"), + "a wet npm run must report the past-tense reconciled detail: {}", + env.warnings[0].detail + ); + assert!( + env.warnings[0].detail.contains(NPM_TAKEOVER_PURL), + "the warning must name the package: {}", + env.warnings[0].detail + ); + + // Both halves dropped; the emptied ledger is deleted outright. + assert!( + load_ledger(root).await.is_none(), + "an emptied redirect ledger must be deleted" + ); + + // Fires once: the reconciled project no longer overlaps. + let mut env2 = vendor_env(); + note_vendor_supersedes_redirect(&mut env2, root, &takeover_common()).await; + assert!( + env2.warnings.is_empty(), + "a reconciled takeover must not re-warn: {:?}", + env2.warnings + ); + } + + #[tokio::test] + async fn vendored_takeover_dry_run_warns_manual_and_leaves_the_ledger() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_wet_npm_takeover(root).await; + let ledger_path = root.join(".socket/vendor/redirect-state.json"); + let before = tokio::fs::read(&ledger_path).await.unwrap(); + + let mut env = vendor_env(); + let common = GlobalArgs { + dry_run: true, + ..takeover_common() + }; + note_vendor_supersedes_redirect(&mut env, root, &common).await; + + assert_eq!(env.warnings.len(), 1, "{:?}", env.warnings); + assert_eq!(env.warnings[0].code, VENDOR_SUPERSEDES_REDIRECT); + // A dry run hands out the MANUAL remediation (never the past-tense + // reconciled text — nothing was mutated). + assert!( + env.warnings[0].detail.contains("clean up by hand"), + "dry-run must carry the manual advisory: {}", + env.warnings[0].detail + ); + assert!( + !env.warnings[0].detail.contains("reconciled automatically"), + "dry-run must not claim a reconciliation: {}", + env.warnings[0].detail + ); + let after = tokio::fs::read(&ledger_path).await.unwrap(); + assert_eq!(before, after, "a dry run must leave the ledger byte-identical"); + } + + #[tokio::test] + async fn degraded_ledger_reconcile_matches_nothing_and_falls_back_to_manual() { + // The degraded record-fetch-failed ledger: records EMPTY, one + // version-blind path-keyed edit. The overlap fallback flags it, but + // `drop_superseded_purl` (fail-closed: no record uuid to anchor on, + // key not version-exact) drops nothing — the warning must hand out + // the manual remediation, never claim a reconciliation. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger_with_edits( + root, + &[], + vec![redirect_edit("package-lock.json", "node_modules/minimist")], + ) + .await; + write_vendor_ledger_wired(root, &[NPM_TAKEOVER_PURL]).await; + write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; + let ledger_path = root.join(".socket/vendor/redirect-state.json"); + let before = tokio::fs::read(&ledger_path).await.unwrap(); + + let mut env = vendor_env(); + note_vendor_supersedes_redirect(&mut env, root, &takeover_common()).await; + + assert_eq!(env.warnings.len(), 1, "{:?}", env.warnings); + assert_eq!(env.warnings[0].code, VENDOR_SUPERSEDES_REDIRECT); + assert_eq!( + env.warnings[0].detail, + mode_takeover_detail(&[NPM_TAKEOVER_PURL.to_string()], false), + "an Ok(false) reconcile must fall back to the manual detail verbatim" + ); + let after = tokio::fs::read(&ledger_path).await.unwrap(); + assert_eq!( + before, after, + "a no-op reconcile must leave the degraded ledger byte-identical" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn reconcile_persist_failure_fails_closed_with_manual_advice() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_wet_npm_takeover(root).await; + let vendor_dir = root.join(".socket/vendor"); + let ledger_path = vendor_dir.join("redirect-state.json"); + let before = tokio::fs::read(&ledger_path).await.unwrap(); + + std::fs::set_permissions(&vendor_dir, std::fs::Permissions::from_mode(0o555)).unwrap(); + // Root ignores mode bits; skip there (CI containers sometimes run as root). + if std::fs::File::create(vendor_dir.join("probe")).is_ok() { + let _ = std::fs::remove_file(vendor_dir.join("probe")); + let _ = + std::fs::set_permissions(&vendor_dir, std::fs::Permissions::from_mode(0o755)); + eprintln!("skipping: running as root, 0555 does not block writes"); + return; + } + + let mut env = vendor_env(); + note_vendor_supersedes_redirect(&mut env, root, &takeover_common()).await; + + // Restore BEFORE asserting so a failure never leaks an undeletable + // tempdir. + std::fs::set_permissions(&vendor_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!(env.warnings.len(), 1, "{:?}", env.warnings); + assert_eq!(env.warnings[0].code, VENDOR_SUPERSEDES_REDIRECT); + assert!( + env.warnings[0].detail.contains("Automatic reconciliation failed"), + "the persist failure must be surfaced inside the warning: {}", + env.warnings[0].detail + ); + assert!( + env.warnings[0] + .detail + .starts_with(&mode_takeover_detail(&[NPM_TAKEOVER_PURL.to_string()], false)), + "the failure text must ride on the full manual remediation: {}", + env.warnings[0].detail + ); + // Fail closed: the atomic writer left the ledger fully pre-drop. + let after = tokio::fs::read(&ledger_path).await.unwrap(); + assert_eq!(before, after, "a failed persist must leave the ledger untouched"); + } } 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 bfae517b..c9f9c337 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -757,3 +757,103 @@ mod service_config_tests { assert!(cfg.offline); } } + +#[cfg(test)] +mod fold_vendored_skips_tests { + use super::fold_vendored_skips_into_apply; + + /// A pre-rendered vendored-skip record, shaped exactly like + /// [`super::partition_skipped_selected`]'s output. + fn record(purl: &str) -> serde_json::Value { + serde_json::json!({ + "purl": purl, + "uuid": "11111111-1111-4111-8111-111111111111", + "action": "skipped", + "errorCode": "vendored", + }) + } + + /// The count-consistency contract: every pre-download vendored skip + /// was "found" by discovery and "skipped" here, so both counters bump + /// by the record count, the records land appended after the download + /// phase's own entries, and every other counter is left alone. + #[test] + fn fold_bumps_found_and_skipped_and_appends_records() { + let mut apply_obj = serde_json::json!({ + "status": "partialFailure", + "found": 2, + "downloaded": 1, + "skipped": 1, + "failed": 1, + "applied": 1, + "patches": [{ "purl": "pkg:npm/a@1.0.0" }], + }); + let records = [record("pkg:npm/b@1.0.0"), record("pkg:npm/c@1.0.0")]; + + fold_vendored_skips_into_apply(&mut apply_obj, &records); + + let obj = apply_obj.as_object().expect("still an object"); + assert!( + !obj.contains_key("status"), + "the inner status is scan's to recompute: {apply_obj}" + ); + assert_eq!(apply_obj["found"], 4, "{apply_obj}"); + assert_eq!(apply_obj["skipped"], 3, "{apply_obj}"); + assert_eq!(apply_obj["downloaded"], 1, "untouched: {apply_obj}"); + assert_eq!(apply_obj["failed"], 1, "untouched: {apply_obj}"); + assert_eq!(apply_obj["applied"], 1, "untouched: {apply_obj}"); + let patches = apply_obj["patches"].as_array().expect("patches array"); + assert_eq!(patches.len(), 3, "{apply_obj}"); + assert_eq!(patches[0]["purl"], "pkg:npm/a@1.0.0", "{apply_obj}"); + assert_eq!(patches[1], records[0], "appended in order: {apply_obj}"); + assert_eq!(patches[2], records[1], "appended in order: {apply_obj}"); + } + + /// Missing counters default to zero before the bump (the + /// `unwrap_or(0)` fallback) — the keys are CREATED, not skipped, so a + /// minimal download report still ends up count-consistent. + #[test] + fn fold_missing_counts_default_to_zero() { + let mut apply_obj = serde_json::json!({ "patches": [] }); + let records = [record("pkg:npm/b@1.0.0")]; + + fold_vendored_skips_into_apply(&mut apply_obj, &records); + + assert_eq!(apply_obj["found"], 1, "{apply_obj}"); + assert_eq!(apply_obj["skipped"], 1, "{apply_obj}"); + let patches = apply_obj["patches"].as_array().expect("patches array"); + assert_eq!(patches.len(), 1, "{apply_obj}"); + assert_eq!(patches[0], records[0], "{apply_obj}"); + } + + /// A non-object report (defensive arm) is left byte-identical — no + /// panic, no partial mutation. + #[test] + fn fold_non_object_report_is_a_noop() { + let mut apply_obj = serde_json::json!("nope"); + fold_vendored_skips_into_apply(&mut apply_obj, &[record("pkg:npm/b@1.0.0")]); + assert_eq!(apply_obj, serde_json::json!("nope")); + } + + /// With zero records the fold only strips the inner `status`: counts + /// and patches stay exactly as the download phase reported them. + #[test] + fn fold_empty_records_only_strips_status() { + let mut apply_obj = serde_json::json!({ + "status": "success", + "found": 2, + "skipped": 1, + "patches": [{ "purl": "pkg:npm/a@1.0.0" }], + }); + fold_vendored_skips_into_apply(&mut apply_obj, &[]); + assert_eq!( + apply_obj, + serde_json::json!({ + "found": 2, + "skipped": 1, + "patches": [{ "purl": "pkg:npm/a@1.0.0" }], + }), + "only the status may change on the zero-record fold" + ); + } +} diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 1f397084..becde7d7 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1588,6 +1588,12 @@ pub(crate) struct VendorGcSummary { /// (b) entries whose package left the lockfile dependency graph — /// reverted, and their manifest entries dropped. pub unused_reverted: Vec, + /// Entries a wet revert drift-kept ([`RevertOutcome::kept_artifact`]): + /// the backend left the drifted lock alone, so artifacts, ledger entry + /// and (in (b)) manifest records were all retained — nothing reclaimed. + /// Always empty on dry runs: backends detect drift only during a wet + /// wiring replay, so the preview still lists such entries as revertable. + pub kept: Vec, /// (c) orphan uuid dirs (no owning ledger entry) swept. pub orphan_dirs: usize, /// Entries that could not be reverted (kept in the ledger), plus any @@ -1607,8 +1613,12 @@ pub(crate) struct VendorGcSummary { /// /// A drift-skipped revert ([`RevertOutcome::kept_artifact`]) keeps the /// ledger entry — and, in (b), the purl's manifest records — exactly like -/// every other `dispatch_revert_one` caller; the kept purl is counted -/// nowhere (nothing was reclaimed). +/// every other `dispatch_revert_one` caller; the kept purl is reported in +/// [`VendorGcSummary::kept`] so `scan --prune` can explain the entry it +/// did not reclaim instead of silently no-oping on what its own preview +/// listed as revertable. Wet-only: a dry [`dispatch_revert_one`] returns +/// before the wiring replay that detects drift, so the dry lists still +/// carry such an entry as revertable. /// /// Detached entries are exempt from BOTH (a) (never manifest-tracked) and /// (b) (lockfile-invisible by design — the probe would always call them @@ -1674,7 +1684,8 @@ pub(crate) async fn run_vendor_gc( // entry must survive too (the RevertOutcome contract every // other caller honors) — which also shields the uuid dir // from the (c) orphan sweep. Nothing was reclaimed, so the - // purl is counted nowhere. + // purl is reported as kept, never as reverted. + out.kept.push(purl); } else { state.entries.remove(&purl); out.dropped_reverted.push(purl); @@ -1713,6 +1724,7 @@ pub(crate) async fn run_vendor_gc( // purl's manifest records must survive too: pruning them would // make the next `vendor` reconcile re-revert an entry whose // backing record is gone (the `remove` caller's rationale). + out.kept.push(purl); continue; } state.entries.remove(&purl); @@ -2034,6 +2046,71 @@ mod variant_probe_tests { ); } } + + /// A variant record consisting ONLY of new files (every `beforeHash` + /// empty) has no representative to probe: `representative_file` returns + /// `None`, and `variant_matches_installed(None)` must ADMIT the variant + /// (the same pinned contract as apply's variant loop) — a new file can + /// neither identify nor disqualify a variant, so the record proceeds to + /// the backend instead of being silently dropped as not-installed. + #[tokio::test] + async fn all_new_file_variant_record_is_admitted() { + let tmp = tempfile::tempdir().unwrap(); + let site = tmp.path().join("site-packages"); + tokio::fs::create_dir_all(site.join("foo-1.0.0.dist-info")) + .await + .unwrap(); + tokio::fs::write( + site.join("foo-1.0.0.dist-info").join("METADATA"), + "Name: foo\nVersion: 1.0.0\n", + ) + .await + .unwrap(); + tokio::fs::create_dir_all(site.join("foo")).await.unwrap(); + tokio::fs::write(site.join("foo").join("__init__.py"), b"print('hi')\n") + .await + .unwrap(); + let after = compute_git_sha256_from_bytes(b"patched\n"); + + let common = GlobalArgs { + cwd: tmp.path().to_path_buf(), + global_prefix: Some(site.clone()), + ecosystems: Some(vec!["pypi".to_string()]), + dry_run: true, + offline: true, + json: true, + silent: true, + ..GlobalArgs::default() + }; + let sources = PatchSources { + blobs_path: tmp.path(), + packages_path: None, + diffs_path: None, + mem_blobs: None, + }; + + let mut records: HashMap = HashMap::new(); + records.insert( + WHEEL.to_string(), + record(&[("brand_new_file.py", "", &after)]), + ); + let mut env = Envelope::new(Command::Vendor); + vendor_records(&common, &records, &sources, false, false, &mut env, None).await; + + assert!( + env.events.iter().any(|e| e.purl.as_deref() == Some(WHEEL)), + "an all-new-files variant must pass the probe (representative None \ + admits) and reach the backend; events: {:?}", + env.events + ); + assert!( + !env.events + .iter() + .any(|e| e.error_code.as_deref() == Some("package_not_installed")), + "the admitted variant must not be misclassified as not installed: {:?}", + env.events + ); + } } #[cfg(test)] @@ -2479,6 +2556,13 @@ mod gc_tests { "a drift-kept entry must not be reported reverted: {out:?}" ); assert!(out.failed.is_empty(), "a keep is not a failure: {out:?}"); + assert_eq!( + out.kept, + vec![PURL.to_string()], + "the drift-keep must be COUNTED — scan --prune's only signal \ + that the entry its preview listed was deliberately not \ + reclaimed: {out:?}" + ); assert!( load_state(tmp.path()) .await @@ -2519,6 +2603,13 @@ mod gc_tests { "a drift-kept entry must not be reported reverted: {out:?}" ); assert!(out.failed.is_empty(), "a keep is not a failure: {out:?}"); + assert_eq!( + out.kept, + vec![PURL.to_string()], + "the drift-keep must be COUNTED — scan --prune's only signal \ + that the entry its preview listed was deliberately not \ + reclaimed: {out:?}" + ); assert!( load_state(tmp.path()) .await @@ -2540,6 +2631,27 @@ mod gc_tests { ); } + /// The preview half of the drift-keep contract: backends detect drift + /// only during a wet wiring replay (a dry [`dispatch_revert_one`] + /// returns before it), so the read-only preview still lists a drifted + /// entry as revertable and `kept` stays empty. The wet run's `kept` + /// report — and the `keptVendoredEntries` / hint `scan --prune` builds + /// on it — is what explains the difference when the wet run then + /// reclaims nothing. + #[tokio::test] + async fn vendor_gc_dry_run_cannot_see_drift_and_reports_nothing_kept() { + let (tmp, common, manifest_path) = wired_gc_fixture(fork_fragment()).await; + let dry = run_vendor_gc(&common, &manifest_path, true).await; + assert_eq!(dry.unused_reverted, vec![PURL.to_string()], "{dry:?}"); + assert!(dry.kept.is_empty(), "{dry:?}"); + // Read-only: the ledger entry is untouched. + assert!(load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL)); + } + /// KEEP-GATE LIVENESS (mirrors in_process_vendor.rs's /// `revert_completes_when_lock_already_matches_the_original`): a wired /// entry whose lock fragment already equals the recorded pre-vendor @@ -2552,6 +2664,11 @@ mod gc_tests { let out = run_vendor_gc(&common, &manifest_path, false).await; assert_eq!(out.unused_reverted, vec![PURL.to_string()], "{out:?}"); assert!(out.failed.is_empty(), "{out:?}"); + assert!( + out.kept.is_empty(), + "a converged entry reverts cleanly — it must not be reported \ + as a drift-keep: {out:?}" + ); assert!(load_state(tmp.path()).await.unwrap().entries.is_empty()); assert!( !tmp.path() @@ -2588,4 +2705,486 @@ mod gc_tests { .join(format!(".socket/vendor/npm/{UUID}")) .exists()); } + + /// Wet GC under apply-lock contention: the run records the single skip + /// marker and reclaims NOTHING (the scan-must-not-fail contract), while + /// a dry-run preview with the same lock held still lists (dry runs are + /// read-only and lock-free). + #[tokio::test] + async fn vendor_gc_lock_contention_skips_without_reverting() { + let (tmp, common, manifest_path) = gc_fixture(false).await; + // Both passes WOULD reclaim: patch dropped + dependency gone. + write_manifest(&manifest_path, &PatchManifest::new()) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("package-lock.json"), "{\"packages\":{}}") + .await + .unwrap(); + + let _held = socket_patch_core::patch::apply_lock::acquire( + &tmp.path().join(".socket"), + Duration::ZERO, + ) + .expect("test holds the apply lock first"); + + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert_eq!( + out.failed, + vec!["vendor GC skipped: another socket-patch run holds the apply lock".to_string()], + "{out:?}" + ); + assert!(out.dropped_reverted.is_empty(), "{out:?}"); + assert!(out.unused_reverted.is_empty(), "{out:?}"); + assert_eq!(out.orphan_dirs, 0, "{out:?}"); + assert!( + load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL), + "a contended GC must not touch the ledger" + ); + assert!( + tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "a contended GC must not touch artifacts" + ); + + let dry = run_vendor_gc(&common, &manifest_path, true).await; + assert_eq!( + dry.dropped_reverted, + vec![PURL.to_string()], + "the lock-free dry preview still lists: {dry:?}" + ); + assert!(dry.failed.is_empty(), "{dry:?}"); + } + + /// (a) revert FAILURE accounting: a ledger entry whose ecosystem has no + /// revert backend (a tampered/hand-edited state.json) lands in + /// `out.failed`, is KEPT in the ledger, and is excluded from pass (b) + /// (no double count). + #[tokio::test] + async fn vendor_gc_failed_dropped_revert_keeps_entry() { + let (tmp, common, manifest_path) = gc_fixture(false).await; + write_manifest(&manifest_path, &PatchManifest::new()) + .await + .unwrap(); + let mut state = load_state(tmp.path()).await.unwrap(); + state.entries.get_mut(PURL).unwrap().ecosystem = "frobnicate".into(); + save_state(tmp.path(), &state).await.unwrap(); + + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert_eq!(out.failed, vec![PURL.to_string()], "{out:?}"); + assert!(out.dropped_reverted.is_empty(), "{out:?}"); + assert!( + out.unused_reverted.is_empty(), + "an (a)-handled purl must not also be tried by (b): {out:?}" + ); + assert!( + load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL), + "a failed revert must keep the ledger entry" + ); + assert!( + tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the still-wired artifact dir survives the orphan sweep" + ); + } + + /// (b) revert FAILURE accounting: the in-use probe says the dependency + /// left the lock graph (the lock never mentions the tampered uuid's + /// dir), the revert refuses fail-closed on the non-canonical uuid, and + /// BOTH the ledger entry and the purl's manifest record are kept. + #[tokio::test] + async fn vendor_gc_failed_unused_revert_keeps_entry_and_manifest() { + let (tmp, common, manifest_path) = gc_fixture(false).await; + let mut state = load_state(tmp.path()).await.unwrap(); + state.entries.get_mut(PURL).unwrap().uuid = "deadbeef".into(); + save_state(tmp.path(), &state).await.unwrap(); + + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert_eq!(out.failed, vec![PURL.to_string()], "{out:?}"); + assert!(out.unused_reverted.is_empty(), "{out:?}"); + assert!(out.dropped_reverted.is_empty(), "{out:?}"); + assert!( + load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL), + "a failed (b) revert must keep the ledger entry" + ); + let manifest = read_manifest(&manifest_path).await.unwrap().unwrap(); + assert!( + manifest.patches.contains_key(PURL), + "a failed (b) revert must not drop the manifest record" + ); + } + + /// `--ecosystems` scoping gates BOTH GC passes ([`ecosystem_in_scope`]'s + /// `Some(list)` branch): a cargo-scoped run must not revert an npm entry + /// as a cross-ecosystem side effect, while the matching scope reclaims + /// it normally. + #[tokio::test] + async fn vendor_gc_respects_ecosystems_scope() { + let (tmp, mut common, manifest_path) = gc_fixture(false).await; + // Both passes WOULD reclaim the npm entry were it in scope. + write_manifest(&manifest_path, &PatchManifest::new()) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("package-lock.json"), "{\"packages\":{}}") + .await + .unwrap(); + + common.ecosystems = Some(vec!["cargo".to_string()]); + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert!( + out.dropped_reverted.is_empty() + && out.unused_reverted.is_empty() + && out.failed.is_empty(), + "an out-of-scope entry is untouchable: {out:?}" + ); + assert!( + load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL), + "cargo scope must keep the npm ledger entry" + ); + assert!( + tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "cargo scope must keep the npm artifacts" + ); + + common.ecosystems = Some(vec!["npm".to_string()]); + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert_eq!( + out.dropped_reverted, + vec![PURL.to_string()], + "the matching scope reclaims: {out:?}" + ); + assert!(load_state(tmp.path()).await.unwrap().entries.is_empty()); + } +} + +#[cfg(test)] +mod scope_and_hint_tests { + use super::*; + + /// [`flavor_install_command`] drives the human reinstall hints: every + /// npm-family flavor must name its own package manager's install, and + /// flavors with no consuming install step stay silent. + #[test] + fn flavor_install_command_maps_every_flavor() { + assert_eq!(flavor_install_command("package-lock"), Some("npm install")); + assert_eq!(flavor_install_command("yarn-classic"), Some("yarn install")); + assert_eq!(flavor_install_command("yarn-berry"), Some("yarn install")); + assert_eq!(flavor_install_command("pnpm"), Some("pnpm install")); + assert_eq!(flavor_install_command("pnpm-legacy"), Some("pnpm install")); + assert_eq!(flavor_install_command("bun"), Some("bun install")); + assert_eq!(flavor_install_command("cargo"), None); + assert_eq!(flavor_install_command(""), None); + } + + fn with_scope(list: Option<&[&str]>) -> GlobalArgs { + GlobalArgs { + ecosystems: list.map(|l| l.iter().map(|s| s.to_string()).collect()), + ..GlobalArgs::default() + } + } + + /// The `Some(list)` branch of [`ecosystem_in_scope`]: exact match, + /// case-insensitivity, and the `go` → `golang` alias; `None` means + /// everything is in scope. + #[test] + fn ecosystem_in_scope_honors_list_alias_and_case() { + let unscoped = with_scope(None); + assert!(ecosystem_in_scope(&unscoped, "npm")); + assert!(ecosystem_in_scope(&unscoped, "cargo")); + + let npm_only = with_scope(Some(&["npm"])); + assert!(ecosystem_in_scope(&npm_only, "npm")); + assert!(!ecosystem_in_scope(&npm_only, "cargo")); + assert!(!ecosystem_in_scope(&npm_only, "golang")); + + let upper = with_scope(Some(&["NPM"])); + assert!( + ecosystem_in_scope(&upper, "npm"), + "scope matching is case-insensitive" + ); + + let go_alias = with_scope(Some(&["go"])); + assert!( + ecosystem_in_scope(&go_alias, "golang"), + "`go` must alias the golang ecosystem" + ); + assert!(!ecosystem_in_scope(&go_alias, "npm")); + } +} + +#[cfg(test)] +mod revert_dispatch_tests { + use super::*; + use socket_patch_core::vendor::state::VendorArtifact; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + fn entry_for(eco: &str, base_purl: &str) -> VendorEntry { + VendorEntry { + ecosystem: eco.into(), + base_purl: base_purl.into(), + uuid: UUID.into(), + artifact: VendorArtifact { + path: format!(".socket/vendor/{eco}/{UUID}/artifact"), + sha256: String::new(), + size: None, + platform_locked: None, + file_inventory: 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 nuget and maven revert arms must route to their real backends — + /// whatever those backends decide about an empty project, the outcome + /// must never be the unknown-ecosystem fall-through refusal. + #[tokio::test] + async fn nuget_and_maven_reverts_route_to_real_backends() { + for (eco, purl) in [ + ("nuget", "pkg:nuget/Newtonsoft.Json@13.0.1"), + ( + "maven", + "pkg:maven/org.apache.logging.log4j/log4j-core@2.17.0", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + let outcome = dispatch_revert_one(&entry_for(eco, purl), tmp.path(), true).await; + if let Some(error) = &outcome.error { + assert!( + !error.contains("no vendor backend for ecosystem"), + "`{eco}` must route to its backend, not the unknown-ecosystem arm: {error}" + ); + } + } + } + + /// An unknown ecosystem string (a tampered/hand-edited state.json entry) + /// fails CLOSED with a diagnostic naming the ecosystem — never guessed + /// into some other backend, never a silent success. + #[tokio::test] + async fn unknown_ecosystem_revert_fails_closed() { + let tmp = tempfile::tempdir().unwrap(); + let outcome = dispatch_revert_one( + &entry_for("frobnicate", "pkg:frobnicate/x@1.0.0"), + tmp.path(), + false, + ) + .await; + assert!(!outcome.success, "unknown ecosystem must fail the revert"); + let error = outcome.error.expect("failure carries a diagnostic"); + assert!( + error.contains("no vendor backend for ecosystem `frobnicate`"), + "{error}" + ); + } + + /// [`dispatch_in_use_one`]'s fail-safe arm: every ecosystem without an + /// in-use probe (everything but npm/cargo) reports `None` — "cannot + /// determine" — which all callers must treat as KEEP. + #[tokio::test] + async fn in_use_probe_is_none_for_unprobed_ecosystems() { + let tmp = tempfile::tempdir().unwrap(); + for (eco, purl) in [ + ("gem", "pkg:gem/rails@6.0.3"), + ("pypi", "pkg:pypi/foo@1.0.0"), + ("frobnicate", "pkg:frobnicate/x@1.0.0"), + ] { + assert_eq!( + dispatch_in_use_one(&entry_for(eco, purl), tmp.path()).await, + None, + "`{eco}` has no in-use probe — must report undeterminable (keep)" + ); + } + } +} + +#[cfg(test)] +mod persist_tests { + use super::*; + use socket_patch_core::vendor::state::VendorArtifact; + + const UUID_A: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const UUID_B: &str = "1a2b3c4d-5e6f-4a1b-8c2d-9e0f1a2b3c4d"; + const UUID_C: &str = "2b3c4d5e-6f7a-4b2c-9d3e-0f1a2b3c4d5e"; + const PURL_ONE: &str = "pkg:npm/left-pad@1.3.0"; + const PURL_TWO: &str = "pkg:npm/right-pad@1.0.0"; + + fn npm_entry(base_purl: &str, uuid: &str) -> VendorEntry { + VendorEntry { + ecosystem: "npm".into(), + base_purl: base_purl.into(), + uuid: uuid.into(), + artifact: VendorArtifact { + path: format!(".socket/vendor/npm/{uuid}/pkg.tgz"), + sha256: String::new(), + size: None, + platform_locked: None, + file_inventory: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: Some("package-lock".into()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + } + } + + fn empty_record() -> PatchRecord { + PatchRecord { + uuid: UUID_A.to_string(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + } + } + + async fn mk_uuid_dir(root: &Path, uuid: &str) { + let dir = root.join(format!(".socket/vendor/npm/{uuid}")); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("pkg.tgz"), b"tgz").await.unwrap(); + } + + /// The stale-uuid sweep's filter-false KEEP: on a re-vendor under a new + /// patch uuid, the previous uuid's dir must be kept when another ledger + /// entry (a variant sibling) still shares the same `(eco, uuid)` — + /// deleting it would destroy the sibling's live artifact. Once nothing + /// shares the uuid, the same sweep removes the stale dir and records + /// the `vendor_stale_artifact_removed` event. + #[tokio::test] + async fn stale_uuid_sweep_keeps_dir_still_shared_with_a_sibling() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + mk_uuid_dir(root, UUID_A).await; + let common = GlobalArgs { + cwd: root.to_path_buf(), + json: true, + silent: true, + ..GlobalArgs::default() + }; + let record = empty_record(); + + let mut state = VendorState::default(); + state + .entries + .insert(PURL_ONE.to_string(), npm_entry(PURL_ONE, UUID_A)); + state + .entries + .insert(PURL_TWO.to_string(), npm_entry(PURL_TWO, UUID_A)); + + // Re-vendor PURL_ONE under UUID_B: UUID_A is still owned by the + // sibling entry, so its dir must survive and no removal is recorded. + let mut env = Envelope::new(Command::Vendor); + let has_errors = persist_vendor_entry( + &common, + &mut env, + &mut state, + PURL_ONE, + npm_entry(PURL_ONE, UUID_B), + false, + &record, + ) + .await; + assert!(!has_errors, "save must succeed: {:?}", env.events); + assert!( + root.join(format!(".socket/vendor/npm/{UUID_A}")).exists(), + "a uuid dir still shared with a sibling entry must be KEPT" + ); + assert!( + !env.events + .iter() + .any(|e| e.error_code.as_deref() == Some("vendor_stale_artifact_removed")), + "no removal may be recorded for a kept dir: {:?}", + env.events + ); + + // Drop the sibling; re-vendor PURL_ONE again under UUID_C. UUID_B is + // now unshared — the sweep removes it and records the event. + state.entries.remove(PURL_TWO); + mk_uuid_dir(root, UUID_B).await; + let mut env = Envelope::new(Command::Vendor); + let has_errors = persist_vendor_entry( + &common, + &mut env, + &mut state, + PURL_ONE, + npm_entry(PURL_ONE, UUID_C), + false, + &record, + ) + .await; + assert!(!has_errors, "save must succeed: {:?}", env.events); + assert!( + !root.join(format!(".socket/vendor/npm/{UUID_B}")).exists(), + "an unshared stale uuid dir is removed on re-vendor" + ); + assert!( + env.events + .iter() + .any(|e| e.error_code.as_deref() == Some("vendor_stale_artifact_removed")), + "the removal is recorded: {:?}", + env.events + ); + assert!( + root.join(format!(".socket/vendor/npm/{UUID_A}")).exists(), + "the sweep only reclaims the REPLACED entry's dir, never unrelated ones" + ); + } +} + +#[cfg(test)] +mod pristine_fetch_tests { + use super::*; + + /// No lockfile entry AND no ledger entry: the pristine-source ladder + /// reports `NoSource` (the calm `package_not_installed` path) BEFORE any + /// network I/O — nothing else can name a verifiable source. + #[tokio::test] + async fn no_lock_and_no_ledger_is_no_source() { + let tmp = tempfile::tempdir().unwrap(); + let client = registry_fetch::build_registry_client(); + let out = + fetch_pristine_package(tmp.path(), &[], &client, "pkg:npm/left-pad@1.3.0", None).await; + assert!( + matches!(out, PristineFetch::NoSource), + "expected NoSource for a purl with no lock and no ledger entry" + ); + } } diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index c4bcd8e9..2e2423eb 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -1268,6 +1268,96 @@ mod tests { } } + /// Deno is the ONE dispatch branch no other test drives end-to-end + /// (lcov: every other ecosystem's `scan_ecosystem!` invocation has + /// executed, deno's never has). Stage the JSR cache layout + /// `/@///` and resolve a `pkg:jsr/` PURL + /// through the full dispatch — partition → `get_jsr_cache_paths` + /// (returns `global_prefix` verbatim) → `find_by_purls` → merge. + /// `silent = false` also executes the "Using Deno JSR cache at:" + /// banner branch for the deno invocation. + #[tokio::test] + async fn dispatch_find_deno_global_prefix_resolves_jsr_purl() { + let tmp = tempfile::tempdir().unwrap(); + // JSR cache layout: /@scope/name/version/ (scope keeps '@'). + let pkg_dir = tmp.path().join("@std").join("path").join("0.220.0"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + std::fs::write(pkg_dir.join("mod.ts"), b"export default 1;").unwrap(); + + let purl = "pkg:jsr/@std/path@0.220.0".to_string(); + let partitioned = partition_purls(std::slice::from_ref(&purl), None); + // `pkg:jsr/` is the one PURL type whose token differs from its + // cli_name — it must partition to Ecosystem::Deno, not vanish. + assert_eq!(partitioned.len(), 1); + assert_eq!(partitioned.get(&Ecosystem::Deno), Some(&vec![purl.clone()])); + + let options = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: false, + global_prefix: Some(tmp.path().to_path_buf()), + }; + + let out = find_packages_for_purls(&partitioned, &options, false).await; + assert_eq!( + out.get(&purl), + Some(&pkg_dir), + "deno dispatch must resolve the jsr PURL to its cache dir" + ); + + // Deno is wired to `merge_first_wins` on the ROLLBACK path too (it + // has no release variants), so the same verbatim key must resolve. + // A refactor routing deno through `merge_qualified` would drop the + // key (the crawler echoes the verbatim input PURL, and rollback's + // qualified fan-out only re-keys stripped bases) — caught here. + let rb = find_packages_for_rollback(&partitioned, &options, false).await; + assert_eq!( + rb.get(&purl), + Some(&pkg_dir), + "deno rollback dispatch must keep the verbatim jsr key" + ); + } + + /// The `!silent` banner branch — "Using ", + 1, + ); + assert_ne!(edited, wired); + tokio::fs::write(root.join("nuget.config"), &edited) + .await + .unwrap(); + let lock = tokio::fs::read(root.join(PACKAGES_LOCK)).await.unwrap(); + + let outcome = revert_nuget(&entry, root, true).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(), + edited, + "dry-run excise must not modify the config" + ); + assert_eq!( + tokio::fs::read(root.join(PACKAGES_LOCK)).await.unwrap(), + lock + ); + assert!(root.join(format!(".socket/vendor/nuget/{UUID}")).exists()); + } + + // ── covgap 2026-09: local-rebuild failure shapes ──────────────────────── + + /// A cached .nupkg that is not a zip cannot be staged: failed result, and + /// no project file (or artifact dir) is written after the failure. + #[tokio::test] + async fn corrupt_cached_nupkg_fails_before_any_wiring() { + let (dir, blobs, installed, record) = fixture(true, None).await; + let root = dir.path(); + tokio::fs::write( + installed.join("newtonsoft.json.13.0.3.nupkg"), + b"not a zip archive", + ) + .await + .unwrap(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(!result.success); + assert!(entry.is_none()); + assert!( + result + .error + .as_deref() + .unwrap_or("") + .contains("cannot extract"), + "{:?}", + result.error + ); + assert!( + !root.join("nuget.config").exists(), + "no config after a failed rebuild" + ); + assert!( + !root.join(".socket").exists(), + "no artifact dir after a failed extract" + ); + } + + /// An empty blob store fails the force-apply: failed result, no wiring. + #[tokio::test] + async fn missing_blob_apply_failure_writes_no_config() { + let (dir, _blobs, installed, record) = fixture(true, None).await; + let root = dir.path(); + let empty = tempfile::tempdir().unwrap(); + let sources = PatchSources::blobs_only(empty.path()); + let outcome = vendor_nuget( + PURL, &installed, root, &record, &sources, "t", false, false, None, + ) + .await; + let (result, entry, _w) = unwrap_done(outcome); + assert!( + !result.success, + "a missing after-hash blob must fail the apply" + ); + assert!(result.error.is_some()); + assert!(entry.is_none()); + assert!( + !root.join("nuget.config").exists(), + "no config after a failed apply" + ); + } + + /// A regular FILE squatting the uuid dir path: create_dir_all fails, the + /// vendor reports it, and no config is written. (The squatting file itself + /// survives — remove_tree removes trees, not files: pinned as the current + /// husk behavior.) + #[tokio::test] + async fn squatted_uuid_path_fails_and_writes_no_config() { + let (dir, blobs, installed, record) = fixture(true, None).await; + let root = dir.path(); + let parent = root.join(".socket/vendor/nuget"); + tokio::fs::create_dir_all(&parent).await.unwrap(); + tokio::fs::write(parent.join(UUID), b"squatter").await.unwrap(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(!result.success); + assert!(entry.is_none()); + assert!( + result + .error + .as_deref() + .unwrap_or("") + .contains("cannot create"), + "{:?}", + result.error + ); + assert!( + !root.join("nuget.config").exists(), + "no config after a failed artifact write" + ); + assert!( + parent.join(UUID).is_file(), + "the squatting file survives the cleanup attempt" + ); + } + + // ── covgap 2026-09: service prebuilt (Tier A) arms ────────────────────── + + #[tokio::test] + async fn service_prebuilt_used_writes_served_bytes_verbatim() { + use crate::api::client::{ApiClient, ApiClientOptions}; + use crate::vendor::npm_pack::PackedTarball; + use crate::vendor::VendorSource; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let (dir, blobs, installed, record) = fixture(true, None).await; + let root = dir.path(); + let server = MockServer::start().await; + let served = make_nupkg(PATCHED); + let sri = PackedTarball::from_bytes(&served).integrity; + let serve_path = + "/patch/nuget/newtonsoft.json/13.0.3/tok/uuid/newtonsoft.json.13.0.3.nupkg"; + let serve_url = format!("{}{serve_path}", server.uri()); + Mock::given(method("POST")) + .and(path("/v0/orgs/acme/patches/package")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { UUID: { + "status": "granted", + "url": serve_url, + "artifacts": [{ "kind": "tarball", "url": serve_url, + "integrity": { "sha512": sri } }] + }} + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(serve_path)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(served.clone())) + .mount(&server) + .await; + let cfg = VendorServiceConfig { + source: VendorSource::Service, + client: Some(ApiClient::new(ApiClientOptions { + api_url: server.uri(), + api_token: Some("sktsec_placeholder_value_for_tests_api".into()), + use_public_proxy: false, + org_slug: Some("acme".into()), + })), + use_public_proxy: false, + vendor_url: None, + patch_server_url: None, + offline: false, + }; + + let sources = PatchSources::blobs_only(&blobs); + let outcome = vendor_nuget( + PURL, + &installed, + root, + &record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + Some(&cfg), + ) + .await; + let (result, entry, warnings) = unwrap_done(outcome); + assert!(result.success, "{:?}", result.error); + assert!( + warnings + .iter() + .any(|w| w.code == "vendor_prebuilt_downloaded"), + "{warnings:?}" + ); + // Tier A: the served bytes ARE the artifact, written VERBATIM — the + // signature part is still present (service bytes are trusted, never + // re-zipped locally). + let got = tokio::fs::read(root.join(copy_rel())).await.unwrap(); + assert_eq!(got, served); + assert!( + read_nupkg_entry(&got, ".signature.p7s").is_some(), + "verbatim copy keeps the signature part" + ); + // Wiring ran normally over the downloaded artifact. + let cfg_text = tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(); + assert!(cfg_text.contains(&source_key())); + let lock = tokio::fs::read_to_string(root.join(PACKAGES_LOCK)) + .await + .unwrap(); + assert!( + lock.contains(&content_hash(&served)), + "lock pinned at the served bytes" + ); + assert_eq!(entry.expect("ledger entry").wiring.len(), 3); + } + + /// A terminal service miss under `--vendor-source=service` is a hard + /// refusal — never a quiet local rebuild — and leaves no partial state. + #[tokio::test] + async fn service_prebuilt_miss_hard_fails_under_service_source() { + use crate::api::client::{ApiClient, ApiClientOptions}; + use crate::vendor::VendorSource; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let (dir, blobs, installed, record) = fixture(true, None).await; + let root = dir.path(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v0/orgs/acme/patches/package")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { UUID: { "status": "not_found" } } + }))) + .mount(&server) + .await; + let cfg = VendorServiceConfig { + source: VendorSource::Service, + client: Some(ApiClient::new(ApiClientOptions { + api_url: server.uri(), + api_token: Some("sktsec_placeholder_value_for_tests_api".into()), + use_public_proxy: false, + org_slug: Some("acme".into()), + })), + use_public_proxy: false, + vendor_url: None, + patch_server_url: None, + offline: false, + }; + + let sources = PatchSources::blobs_only(&blobs); + let outcome = vendor_nuget( + PURL, + &installed, + root, + &record, + &sources, + "t", + false, + false, + Some(&cfg), + ) + .await; + let (code, detail) = unwrap_refused(outcome); + assert_eq!(code, "vendor_prebuilt_required"); + assert!(detail.contains("unavailable"), "{detail}"); + assert!( + !root.join(".socket").exists(), + "a hard service miss writes nothing" + ); + assert!(!root.join("nuget.config").exists()); + } + + // ── covgap 2026-09: edit_lock / lock_pinned pure edges ────────────────── + + #[test] + fn edit_lock_absent_shapes_are_nothing_to_pin() { + // No dependencies object at all. + assert!( + edit_lock(r#"{"version":1}"#, "Newtonsoft.Json", "13.0.3", "N==") + .unwrap() + .is_none() + ); + // A framework whose value is not an object is skipped. + let odd = r#"{"version":1,"dependencies":{"net8.0":42}}"#; + assert!(edit_lock(odd, "Newtonsoft.Json", "13.0.3", "N==") + .unwrap() + .is_none()); + // A matching resolved entry WITHOUT a contentHash: nothing to rewrite. + let unhashed = serde_json::to_string_pretty(&json!({ + "version": 1, + "dependencies": { + "net8.0": { + "Newtonsoft.Json": { "type": "Direct", "resolved": "13.0.3" } + } + } + })) + .unwrap(); + assert!(edit_lock(&unhashed, "Newtonsoft.Json", "13.0.3", "N==") + .unwrap() + .is_none()); + } + + #[test] + fn edit_lock_conflicting_hashes_fail_closed() { + let lock = serde_json::to_string_pretty(&json!({ + "version": 1, + "dependencies": { + "net8.0": { + "Newtonsoft.Json": { "resolved": "13.0.3", "contentHash": "AAA==" } + }, + "net6.0": { + "Newtonsoft.Json": { "resolved": "13.0.3", "contentHash": "BBB==" } + } + } + })) + .unwrap(); + let err = edit_lock(&lock, "Newtonsoft.Json", "13.0.3", "N==") + .err() + .expect("a self-contradictory lock must fail closed"); + assert!(err.contains("conflicting contentHash"), "{err}"); + } + + #[test] + fn lock_pinned_edge_shapes_report_unpinned() { + // No dependencies object. + assert!(!lock_pinned("{}", "Newtonsoft.Json", "13.0.3", "H==")); + // Non-object framework value. + assert!(!lock_pinned( + r#"{"dependencies":{"net8.0":42}}"#, + "Newtonsoft.Json", + "13.0.3", + "H==" + )); + // Same id at a DIFFERENT resolved version: no match, not pinned. + let lock = lock_json("H=="); + assert!(!lock_pinned(&lock, "Newtonsoft.Json", "12.0.0", "H==")); + } + + // ── covgap 2026-09: revert_lock_record pure edges ─────────────────────── + + fn lock_wiring(original: Option<&str>, new: Option<&str>) -> WiringRecord { + WiringRecord { + file: PACKAGES_LOCK.to_string(), + kind: LOCK_WIRING_KIND.to_string(), + action: WiringAction::Rewritten, + key: Some("Newtonsoft.Json".to_string()), + original: original.map(|s| Value::String(s.to_string())), + new: new.map(|s| Value::String(s.to_string())), + } + } + + #[tokio::test] + async fn revert_lock_record_missing_fields_is_drift() { + let dir = tempfile::tempdir().unwrap(); + let lock_path = dir.path().join(PACKAGES_LOCK); + let text = lock_json("OURS=="); + tokio::fs::write(&lock_path, &text).await.unwrap(); + // original missing → drift, and the file is never touched. + let w = lock_wiring(None, Some("OURS==")); + assert!(!revert_lock_record(&lock_path, &w, false).await.unwrap()); + assert_eq!( + tokio::fs::read_to_string(&lock_path).await.unwrap(), + text + ); + // new missing → same drift. + let w = lock_wiring(Some("OLD=="), None); + assert!(!revert_lock_record(&lock_path, &w, false).await.unwrap()); + assert_eq!( + tokio::fs::read_to_string(&lock_path).await.unwrap(), + text + ); + } + + #[tokio::test] + async fn revert_lock_record_identity_pin_needs_no_file() { + // orig == ours: nothing to undo — proven by pointing at a lock path + // that does not even exist (any I/O would error). + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("no-such-lock.json"); + let w = lock_wiring(Some("SAME=="), Some("SAME==")); + assert!(revert_lock_record(&missing, &w, false).await.unwrap()); + assert!(!missing.exists()); + } + + #[tokio::test] + async fn revert_lock_record_prior_restore_vs_drift() { + let dir = tempfile::tempdir().unwrap(); + let lock_path = dir.path().join(PACKAGES_LOCK); + let w = lock_wiring(Some("OLD=="), Some("NEW==")); + // Ours gone but the original is present: a prior revert already + // restored it (shared hash across framework entries) — done. + let restored = lock_json("OLD=="); + tokio::fs::write(&lock_path, &restored).await.unwrap(); + assert!(revert_lock_record(&lock_path, &w, false).await.unwrap()); + assert_eq!( + tokio::fs::read_to_string(&lock_path).await.unwrap(), + restored + ); + // Neither value present: drift, file untouched. + let foreign = lock_json("SOMETHINGELSE=="); + tokio::fs::write(&lock_path, &foreign).await.unwrap(); + assert!(!revert_lock_record(&lock_path, &w, false).await.unwrap()); + assert_eq!( + tokio::fs::read_to_string(&lock_path).await.unwrap(), + foreign + ); + // A missing lock file is drift too, not an error. + tokio::fs::remove_file(&lock_path).await.unwrap(); + assert!(!revert_lock_record(&lock_path, &w, false).await.unwrap()); + } + + // ── covgap 2026-09: unwind of a CREATED config ────────────────────────── + + /// When vendor CREATED nuget.config and the lock edit then fails, the + /// unwind must DELETE the created config (the None arm), not restore it. + #[tokio::test] + async fn failed_lock_edit_unwind_deletes_created_config() { + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + tokio::fs::write(root.join(PACKAGES_LOCK), b"{ not json") + .await + .unwrap(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(!result.success, "unparseable lock fails the vendor"); + assert!(entry.is_none()); + assert!( + result + .error + .as_deref() + .unwrap_or("") + .contains("unparseable"), + "{:?}", + result.error + ); + assert!( + !root.join("nuget.config").exists(), + "the config vendor just created must be unwound (deleted)" + ); + assert!( + !root.join(format!(".socket/vendor/nuget/{UUID}")).exists(), + "partial uuid dir removed" + ); + } + + // ── covgap 2026-09: comment blanking + key scan edges ─────────────────── + + #[test] + fn blank_comments_unterminated_blanks_through_eof() { + let input = "keep \n\n" + ); + tokio::fs::write(root.join("nuget.config"), &live) + .await + .unwrap(); + let w = WiringRecord { + file: "nuget.config".to_string(), + kind: CONFIG_SOURCE_WIRING_KIND.to_string(), + action: WiringAction::Rewritten, + key: Some(key), + original: Some(Value::String( + "\n".to_string(), + )), + new: Some(Value::String( + "what vendor wrote (the live file has drifted since)".to_string(), + )), + }; + + // A read-only project root blocks the excise rewrite's atomic stage + // file. Skip when the environment ignores modes (running as root). + tokio::fs::set_permissions(root, std::fs::Permissions::from_mode(0o555)) + .await + .unwrap(); + if std::fs::write(root.join(".probe"), b"x").is_ok() { + let _ = std::fs::remove_file(root.join(".probe")); + tokio::fs::set_permissions(root, std::fs::Permissions::from_mode(0o755)) + .await + .unwrap(); + return; // modes ignored (running as root) — nothing to test + } + let res = revert_config_record(root, &uuid_dir_rel, &w, false).await; + tokio::fs::set_permissions(root, std::fs::Permissions::from_mode(0o755)) + .await + .unwrap(); + + let err = res.expect_err("the excise write failure must surface"); + assert!( + err.contains("failed to excise the vendored source from"), + "{err}" + ); + assert!(err.contains("nuget.config"), "{err}"); + assert_eq!( + tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(), + live, + "the drifted live config is untouched after the failed write" + ); + } } diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index aa9773ee..3043161d 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -6709,4 +6709,553 @@ snapshots: assert!(fx.root().join(fx.rel_tgz()).exists(), "artifact untouched"); } } + + // ── coverage mop-up 2026-09: remaining vendor/revert edges ───────────── + + /// SECURITY: the coordinates guard is the FIRST gate in `vendor_pnpm` — + /// a purl the npm grammar cannot parse refuses fail-closed before any + /// file is even read, let alone written. + #[tokio::test] + async fn unparseable_purl_refuses_unsafe_coordinates_before_any_write() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let blobs = fx.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + let outcome = vendor_pnpm( + "pkg:npm/left-pad", // no @version — the npm purl grammar refuses + &fx.installed(), + fx.root(), + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let detail = expect_refused(outcome, "unsafe_coordinates"); + assert!(detail.contains("cannot parse"), "{detail}"); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG, "pkg untouched"); + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK, "lock untouched"); + assert!(!ws_exists(&fx).await, "no workspace file scaffolded"); + } + + /// A staging failure (the installed dir is gone between crawl and + /// vendor) is a LOUD Done failure from the shared stage→patch→pack + /// pipeline, with the project byte-untouched and no uuid dir created. + #[tokio::test] + async fn missing_installed_dir_is_a_loud_staging_failure() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + tokio::fs::remove_dir_all(fx.installed()).await.unwrap(); + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(!result.success, "staging failure must fail the run"); + assert!( + result + .error + .as_deref() + .is_some_and(|e| e.contains("cannot stage a copy of the installed package")), + "{:?}", + result.error + ); + assert!(entry.is_none()); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG, "pkg untouched"); + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK, "lock untouched"); + assert!(!uuid_dir(&fx).exists(), "no artifact dir left behind"); + } + + /// The vendor marker is informational: a write failure (here: a + /// directory squatting on the marker path inside the uuid dir) degrades + /// to a `vendor_marker_write_failed` warning while the vendoring itself + /// succeeds with the full wiring in place. + #[tokio::test] + async fn marker_write_failure_degrades_to_a_warning() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let marker_path = uuid_dir(&fx).join(crate::vendor::state::VENDOR_MARKER_FILE); + tokio::fs::create_dir_all(&marker_path).await.unwrap(); + tokio::fs::write(marker_path.join("occupied"), b"x") + .await + .unwrap(); + + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_some(), "the ledger entry is still recorded"); + assert_warning( + &warnings, + "vendor_marker_write_failed", + "informational vendor marker", + ); + assert_eq!( + fx.read(PACKAGE_JSON).await, + P1_AFTER_PKG, + "wiring landed despite the marker failure" + ); + } + + /// A lock carrying only a `snapshots:` section (no `packages:`) still + /// resolves the in-use probe: the missing section is skipped, not + /// treated as "cannot determine". + #[tokio::test] + async fn in_use_probe_scans_snapshots_when_packages_section_is_absent() { + let (fx, entry) = reconstructed_fixture().await; + let lock = format!( + "lockfileVersion: '9.0'\n\nsnapshots:\n\n left-pad@file:{}: {{}}\n", + fx.rel_tgz() + ); + tokio::fs::write(fx.root().join(PNPM_LOCK), &lock) + .await + .unwrap(); + assert_eq!( + pnpm_entry_in_use(&entry, fx.root()).await, + Some(true), + "the snapshots-only lock still resolves through the artifact" + ); + } + + /// Third-party keys added under a vendor-created `pnpm` table since + /// vendoring keep the table alive on revert: only the emptied + /// `overrides` sub-table is pruned. + #[tokio::test] + async fn revert_keeps_a_created_pnpm_table_that_gained_third_party_keys() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + let mut doc: Value = serde_json::from_str(&fx.read(PACKAGE_JSON).await).unwrap(); + doc["pnpm"]["peerDependencyRules"] = serde_json::json!({"allowAny": ["left-pad"]}); + tokio::fs::write( + fx.root().join(PACKAGE_JSON), + serde_json::to_string_pretty(&doc).unwrap(), + ) + .await + .unwrap(); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let after: Value = serde_json::from_str(&fx.read(PACKAGE_JSON).await).unwrap(); + let pnpm_tbl = after + .get("pnpm") + .and_then(Value::as_object) + .expect("pnpm table kept alive by the third-party key"); + assert!( + pnpm_tbl.get("overrides").is_none(), + "the emptied created overrides table is still pruned: {pnpm_tbl:?}" + ); + assert!(pnpm_tbl.contains_key("peerDependencyRules")); + } + + /// A revert whose lock write fails (read-only project root) fails + /// LOUDLY, and the failed atomic write leaves the vendored lock intact + /// — never a half-restored pair. + #[cfg(unix)] + #[tokio::test] + async fn revert_lock_write_failure_fails_loudly_and_keeps_the_artifact() { + use std::os::unix::fs::PermissionsExt; + if unsafe { libc::geteuid() } == 0 { + return; // root bypasses permission bits; nothing to test + } + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let lock_after = fx.read(PNPM_LOCK).await; + + tokio::fs::set_permissions(fx.root(), std::fs::Permissions::from_mode(0o555)) + .await + .unwrap(); + let outcome = revert_pnpm(&entry, fx.root(), false).await; + tokio::fs::set_permissions(fx.root(), std::fs::Permissions::from_mode(0o755)) + .await + .unwrap(); + + assert!(!outcome.success, "lock write failure must fail the revert"); + assert!( + outcome + .error + .as_deref() + .is_some_and(|e| e.contains(&format!("cannot write {PNPM_LOCK}"))), + "{:?}", + outcome.error + ); + assert_eq!( + fx.read(PNPM_LOCK).await, + lock_after, + "failed atomic write leaves the vendored lock intact" + ); + assert!(fx.root().join(fx.rel_tgz()).exists(), "artifact survives"); + } + + /// Same failure class one write later: an entry whose wiring touches + /// ONLY package.json (so the lock is never written) fails loudly when + /// the package.json write fails. + #[cfg(unix)] + #[tokio::test] + async fn revert_pkg_write_failure_fails_loudly() { + use std::os::unix::fs::PermissionsExt; + if unsafe { libc::geteuid() } == 0 { + return; // root bypasses permission bits; nothing to test + } + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + // Keep ONLY the package.json record: the first (and only) surface + // write the revert attempts is then package.json itself. + entry.wiring.retain(|r| r.file == PACKAGE_JSON); + assert_eq!(entry.wiring.len(), 1, "{:?}", entry.wiring); + let pkg_after = fx.read(PACKAGE_JSON).await; + + tokio::fs::set_permissions(fx.root(), std::fs::Permissions::from_mode(0o555)) + .await + .unwrap(); + let outcome = revert_pnpm(&entry, fx.root(), false).await; + tokio::fs::set_permissions(fx.root(), std::fs::Permissions::from_mode(0o755)) + .await + .unwrap(); + + assert!(!outcome.success, "pkg write failure must fail the revert"); + assert!( + outcome + .error + .as_deref() + .is_some_and(|e| e.contains(&format!("cannot write {PACKAGE_JSON}"))), + "{:?}", + outcome.error + ); + assert_eq!( + fx.read(PACKAGE_JSON).await, + pkg_after, + "failed atomic write leaves the vendored pkg intact" + ); + } + + /// An artifact-dir removal failure (read-only `.socket/vendor/npm` + /// parent) fails the revert loudly — but only AFTER the wiring restore, + /// which has already run and stays restored. + #[cfg(unix)] + #[tokio::test] + async fn revert_artifact_removal_failure_fails_loudly_after_restoring_wiring() { + use std::os::unix::fs::PermissionsExt; + if unsafe { libc::geteuid() } == 0 { + return; // root bypasses permission bits; nothing to test + } + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let vendor_parent = fx.root().join(".socket/vendor/npm"); + + tokio::fs::set_permissions(&vendor_parent, std::fs::Permissions::from_mode(0o555)) + .await + .unwrap(); + let outcome = revert_pnpm(&entry, fx.root(), false).await; + tokio::fs::set_permissions(&vendor_parent, std::fs::Permissions::from_mode(0o755)) + .await + .unwrap(); + + assert!(!outcome.success, "removal failure must fail the revert"); + assert!( + outcome + .error + .as_deref() + .is_some_and(|e| e.contains("cannot remove")), + "{:?}", + outcome.error + ); + assert_eq!( + fx.read(PACKAGE_JSON).await, + P1_BEFORE_PKG, + "wiring restore ran before the removal attempt" + ); + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK); + } + + /// `--preserve-state` (`keep_artifact`): the wiring restore runs in + /// full — pair byte-restored, the created workspace file deleted — but + /// the artifact dir stays, and `kept_artifact` stays false (that flag + /// is reserved for drift-keeps). + #[tokio::test] + async fn preserve_state_revert_restores_wiring_but_keeps_the_artifact() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + let outcome = revert_pnpm_opts( + &entry, + fx.root(), + RevertOpts { + dry_run: false, + keep_artifact: true, + }, + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!( + !outcome.kept_artifact, + "preserve-state never counts as a drift-keep" + ); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK); + assert!( + !ws_exists(&fx).await, + "the created workspace file is wiring, not artifact — still removed" + ); + assert!(fx.root().join(fx.rel_tgz()).exists(), "artifact kept"); + } + + /// A created-then-user-rewritten workspace file whose `overrides:` + /// section is gone entirely is ALREADY CONVERGED for an Added override + /// (the reverted state is "no override") — no drift warning, the user's + /// file survives, and the artifact removal proceeds. + #[tokio::test] + async fn ws_rewritten_without_overrides_section_is_converged_for_added() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let user_ws = "packages:\n - '.'\n"; + write_ws(&fx, user_ws).await; + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + fx.read(PNPM_WORKSPACE).await, + user_ws, + "the user's rewritten file survives untouched" + ); + assert!( + !uuid_dir(&fx).exists(), + "converged (not drift): the artifact removal proceeds" + ); + } + + /// The empty-section pruner is a no-op when there is no `overrides:` + /// section at all (nothing to prune, nothing disturbed). + #[test] + fn remove_empty_ws_overrides_section_is_a_noop_without_a_section() { + let mut lines = split_lines("packages:\n - '.'\n"); + let before = lines.clone(); + remove_empty_ws_overrides_section(&mut lines); + assert_eq!(lines, before); + } + + /// The pre-flight refusal scan tolerates importer entries the surgery + /// never targets: an inline-valued line at dep indent and a dep entry + /// with no `version:` field are both skipped, and the plain registry + /// target still passes. + #[test] + fn refusal_scan_skips_inline_and_fieldless_importer_entries() { + let lines = split_lines( + "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n \ + inline-extra: 1.0.0\n fieldless:\n specifier: ^2.0.0\n \ + left-pad:\n specifier: 1.3.0\n version: 1.3.0\n", + ); + assert_eq!(check_rewritable_refs(&lines, "left-pad", "1.3.0"), Ok(())); + } + + /// A dep entry carrying a field that is neither `specifier` nor + /// `version` (hand-edited or future-pnpm) is skipped, not misparsed: + /// both known fields are still found and the cursor lands past the + /// whole field run. + #[test] + fn dep_field_lines_ignores_unknown_fields() { + let lines = split_lines( + " specifier: ^1.0.0\n engines: whatever\n version: 1.0.0\n other:", + ); + let (spec, ver, f) = dep_field_lines(&lines, 0, lines.len()); + assert_eq!(spec, Some((0, "^1.0.0".to_string()))); + assert_eq!(ver, Some((2, "1.0.0".to_string()))); + assert_eq!(f, 3, "cursor stops at the first non-field line"); + } + + /// The importer/snapshot edits are no-ops on a lock without their + /// sections (a deps-free lock has no importer dep entries, and a lock + /// without `snapshots:` has nothing to rekey or re-reference). + #[test] + fn lock_edits_are_noops_without_their_sections() { + let rel_tgz = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + let spec = format!("file:{rel_tgz}"); + let ctx = EditCtx { + name: "left-pad", + version: "1.3.0", + rel_tgz: &rel_tgz, + spec: &spec, + integrity: SPIKE_INTEGRITY, + override_key: "left-pad@1.3.0", + }; + let mut lines = split_lines("lockfileVersion: '9.0'\n\nsettings:\n autoInstallPeers: true\n"); + let before = lines.clone(); + let mut wiring = Vec::new(); + assert_eq!(edit_importers(&mut lines, &ctx, &mut wiring), Ok(false)); + assert_eq!(edit_snapshot_rekey(&mut lines, &ctx, &mut wiring), Ok(false)); + assert_eq!(edit_snapshot_refs(&mut lines, &ctx, &mut wiring), Ok(false)); + assert_eq!(lines, before, "no-ops leave every byte alone"); + assert!(wiring.is_empty(), "{wiring:?}"); + + // A snapshots section with only foreign entries: scanned, untouched. + let mut lines = split_lines("lockfileVersion: '9.0'\n\nsnapshots:\n\n other@1.0.0: {}\n"); + let before = lines.clone(); + assert_eq!(edit_snapshot_rekey(&mut lines, &ctx, &mut wiring), Ok(false)); + assert_eq!(lines, before); + assert!(wiring.is_empty(), "{wiring:?}"); + + // The packages rewrite is fail-LOUD instead: pre-flight proved the + // entry exists, so scanning past every block means it vanished. + let mut lines = split_lines( + "lockfileVersion: '9.0'\n\npackages:\n\n other@1.0.0:\n resolution: {integrity: sha512-x}\n", + ); + let err = edit_packages(&mut lines, &ctx, &mut wiring) + .expect_err("a missing target entry must fail loudly"); + assert!(err.contains("vanished mid-rewrite"), "{err}"); + assert!(wiring.is_empty(), "{wiring:?}"); + } + + /// An importer dep entry that lost its `specifier:`/`version:` field + /// lines is "no longer exists" for the revert — warned, left alone. + #[test] + fn importer_dep_revert_with_fieldless_entry_warns_nothing_to_restore() { + let mut lines = split_lines("importers:\n\n .:\n dependencies:\n left-pad:\n"); + let rec = WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_IMPORTER_DEP.to_string(), + action: WiringAction::Rewritten, + key: Some(".|left-pad".to_string()), + original: Some(serde_json::json!({"specifier": "1.3.0", "version": "1.3.0"})), + new: Some(serde_json::json!({"specifier": "file:x", "version": "file:x"})), + }; + let mut dirty = false; + let mut warnings = Vec::new(); + revert_importer_dep(&mut lines, &rec, ".|left-pad", UUID, &mut dirty, &mut warnings); + assert!(!dirty); + assert_warning(&warnings, "vendor_lock_entry_drifted", "no longer exists"); + } + + /// A record whose `original` lost its `specifier`/`version` fields + /// (tampered or truncated state.json) is fail-closed: warned as + /// malformed, the live (still-ours) entry left alone. + #[test] + fn importer_dep_revert_with_malformed_original_warns_left_alone() { + let spec = format!("file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + let text = format!( + "importers:\n\n .:\n dependencies:\n left-pad:\n specifier: {spec}\n version: {spec}\n" + ); + let mut lines = split_lines(&text); + let before = lines.clone(); + let rec = WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_IMPORTER_DEP.to_string(), + action: WiringAction::Rewritten, + key: Some(".|left-pad".to_string()), + original: Some(serde_json::json!({"bogus": true})), + new: Some(serde_json::json!({"specifier": spec, "version": spec})), + }; + let mut dirty = false; + let mut warnings = Vec::new(); + revert_importer_dep(&mut lines, &rec, ".|left-pad", UUID, &mut dirty, &mut warnings); + assert!(!dirty); + assert_eq!(lines, before, "left alone"); + assert_warning(&warnings, "vendor_lock_entry_drifted", "original is malformed"); + } + + /// A rekeyed block that vanished, where the recorded original ALSO + /// matches no live block, is drift ("no longer exists") — the converged + /// silent return applies only when the original block is live verbatim. + #[test] + fn packages_block_revert_with_no_live_or_original_match_warns_vanished() { + let mut lines = split_lines( + "packages:\n\n other@1.0.0:\n resolution: {integrity: sha512-o}\n", + ); + let rec = WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_PACKAGE.to_string(), + action: WiringAction::Rewritten, + key: Some("left-pad@1.3.0".to_string()), + original: Some(lines_value(&[ + " left-pad@1.3.0:".to_string(), + " resolution: {integrity: sha512-old}".to_string(), + ])), + new: Some(lines_value(&[ + format!(" left-pad@file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz:"), + " resolution: {integrity: sha512-new}".to_string(), + ])), + }; + let mut dirty = false; + let mut warnings = Vec::new(); + revert_block( + &mut lines, + &rec, + "left-pad@1.3.0", + "packages", + UUID, + &mut dirty, + &mut warnings, + ); + assert!(!dirty); + assert_warning(&warnings, "vendor_lock_entry_drifted", "no longer exists"); + + // Same vanish with NO recorded original (a stripped/reconstructed + // record): the converged probe is skipped — still the same drift + // warning, never a silent pass. + let rec = WiringRecord { + original: None, + ..rec + }; + let mut dirty = false; + let mut warnings = Vec::new(); + revert_block( + &mut lines, + &rec, + "left-pad@1.3.0", + "packages", + UUID, + &mut dirty, + &mut warnings, + ); + assert!(!dirty); + assert_warning(&warnings, "vendor_lock_entry_drifted", "no longer exists"); + } + + /// The unwind helper restores exactly what was written: with no + /// package.json write to unwind, only the edited workspace file is + /// byte-restored — and without recorded original bytes it deliberately + /// leaves the file alone rather than delete or guess. + #[tokio::test] + async fn unwind_restores_edited_ws_and_skips_surfaces_it_never_wrote() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write(root.join(PACKAGE_JSON), b"pkg untouched") + .await + .unwrap(); + tokio::fs::write(root.join(PNPM_WORKSPACE), b"clobbered") + .await + .unwrap(); + + unwind_override_surfaces(root, None, b"never written", true, Some(b"original ws\n"), false) + .await; + assert_eq!( + tokio::fs::read_to_string(root.join(PACKAGE_JSON)) + .await + .unwrap(), + "pkg untouched", + "no pkg write happened, so none is unwound" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(PNPM_WORKSPACE)) + .await + .unwrap(), + "original ws\n", + "edited ws byte-restored" + ); + + // Edited-but-no-original (defensive combination): left alone. + tokio::fs::write(root.join(PNPM_WORKSPACE), b"clobbered again") + .await + .unwrap(); + unwind_override_surfaces(root, None, b"never written", true, None, false).await; + assert_eq!( + tokio::fs::read_to_string(root.join(PNPM_WORKSPACE)) + .await + .unwrap(), + "clobbered again", + "no original bytes recorded: the unwind must not delete or guess" + ); + } } diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index 17735433..d1d30f0b 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -4396,4 +4396,463 @@ packages: "the artifact dir is left behind for a re-run to clean up" ); } + + // ── coverage mop-up 2026-09: grammar naming, coordinate guard routing, + // marker degradation, malformed/hand-edited lock tolerance, and the + // remaining revert left-alone shapes ───────────────────────────────── + + /// The grammar names quoted in user-facing refusals/warnings each carry + /// their lockfileVersion (the V9 spelling is only reachable through + /// diagnostics, never the legacy surgery — pinned here directly). + #[test] + fn grammar_describe_names_each_lockfile_version() { + assert_eq!( + PnpmLockGrammar::V9.describe(), + "pnpm >= 9 (lockfileVersion 9.0)" + ); + assert_eq!( + PnpmLockGrammar::V54.describe(), + "pnpm 7 (lockfileVersion 5.4)" + ); + assert_eq!( + PnpmLockGrammar::V60.describe(), + "pnpm 8 (lockfileVersion 6.0)" + ); + } + + /// SECURITY: a tampered manifest uuid (the artifact-path escape class) + /// refuses through the shared coordinate guard BEFORE the pair is even + /// read — nothing on disk changes. + #[tokio::test] + async fn unsafe_coordinates_refuse_before_any_write() { + let fx = fixture_with(T_BEFORE_PKG, T7_BEFORE_LOCK).await; + let blobs = fx.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + let mut record = fx.record.clone(); + record.uuid = "../escape".to_string(); + let outcome = vendor_pnpm_legacy( + "pkg:npm/left-pad@1.3.0", + &fx.installed(), + fx.root(), + &record, + &sources, + "2026-08-18T00:00:00Z", + false, + false, + None, + ) + .await; + let detail = expect_refused(outcome, "unsafe_coordinates"); + assert!(detail.contains("non-canonical patch uuid"), "{detail}"); + assert_eq!(fx.read(PACKAGE_JSON).await, T_BEFORE_PKG); + assert_eq!(fx.read(PNPM_LOCK).await, T7_BEFORE_LOCK); + assert!(!fx.root().join(".socket/vendor").exists()); + } + + /// The marker is informational only: a marker that cannot be written + /// (its path squatted by a non-empty directory) degrades to the + /// `vendor_marker_write_failed` warning AFTER the pair committed — the + /// vendor still succeeds and records its ledger entry. + #[tokio::test] + async fn marker_write_failure_degrades_to_a_warning_after_commit() { + let fx = fixture_with(T_BEFORE_PKG, T7_BEFORE_LOCK).await; + let marker_path = fx.root().join(format!( + ".socket/vendor/npm/{UUID}/{}", + crate::vendor::state::VENDOR_MARKER_FILE + )); + tokio::fs::create_dir_all(marker_path.join("squatter")) + .await + .unwrap(); + + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_some(), "the wiring itself succeeded"); + assert!( + warnings.iter().any(|w| w.code == "vendor_marker_write_failed"), + "{warnings:?}" + ); + assert_eq!(fx.read(PACKAGE_JSON).await, T_AFTER_PKG); + assert_eq!( + fx.read(PNPM_LOCK).await, + fx.expected_lock(T7_AFTER_LOCK).await + ); + } + + /// A v6.0 root dep entry for the TARGET that lost its `version:` field + /// (a truncated / hand-edited lock) is tolerated, not corrupted: the + /// refs guard skips it, the root-dep surgery leaves it byte-untouched + /// (unknown fields like `extra:` included), and with no root-dep hit + /// neither the absolute specifier nor its portability warning appears — + /// while the packages rekey and the consumer ref still wire, and revert + /// restores the original bytes. + #[tokio::test] + async fn v60_root_dep_entry_missing_its_version_field_is_left_alone() { + const MALFORMED: &str = "lockfileVersion: '6.0' + +dependencies: + consumer: + specifier: file:./consumer + version: file:consumer + left-pad: + specifier: 1.3.0 + extra: x + +packages: + + /left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + dependencies: + left-pad: 1.3.0 + dev: false +"; + let fx = fixture_with(T_BEFORE_PKG, MALFORMED).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("the lock still wires through packages + refs"); + assert!( + !warnings + .iter() + .any(|w| w.code == "vendor_pnpm_legacy_absolute_specifier"), + "no root-dep hit ⇒ no absolute specifier caveat: {warnings:?}" + ); + let kinds: Vec<&str> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); + assert_eq!( + kinds, + vec![ + "pnpm_pkg_override", + KIND_LOCK_OVERRIDES, + KIND_LOCK_PACKAGE, + KIND_LOCK_PKG_DEP_REF, + ], + "{:?}", + entry.wiring + ); + + let rel = fx.rel_tgz(); + let expected = MALFORMED + .replace( + "lockfileVersion: '6.0'\n\ndependencies:", + &format!( + "lockfileVersion: '6.0'\n\noverrides:\n left-pad@1.3.0: file:{rel}\n\ndependencies:" + ), + ) + .replace( + " /left-pad@1.3.0:\n resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==}\n dev: false\n", + &format!( + " file:{rel}:\n resolution: {{integrity: {}, tarball: file:{rel}}}\n name: left-pad\n version: 1.3.0\n dev: false\n", + fx.actual_integrity().await + ), + ) + .replace( + "\n left-pad: 1.3.0\n", + &format!("\n left-pad: file:{rel}\n"), + ); + assert_eq!(fx.read(PNPM_LOCK).await, expected); + + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(fx.read(PNPM_LOCK).await, MALFORMED, "lock byte-restored"); + assert_eq!(fx.read(PACKAGE_JSON).await, T_BEFORE_PKG); + } + + /// The v5.4 specifier splice degrades quietly on hand-degraded locks: + /// with NO `specifiers:` section, and with a section that carries a + /// non-entry line plus no entry for the target, the root-dep wiring + /// (and its absolute-path caveat) still lands with no specifier record. + #[tokio::test] + async fn v54_specifier_surgery_degrades_when_the_section_or_entry_is_missing() { + // (a) no specifiers section at all. + const NO_SECTION: &str = "lockfileVersion: 5.4 + +dependencies: + left-pad: 1.3.0 + +packages: + + /left-pad/1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + dev: false +"; + let fx = fixture_with(T_BEFORE_PKG, NO_SECTION).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + let kinds: Vec<&str> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); + assert_eq!( + kinds, + vec![ + "pnpm_pkg_override", + KIND_LOCK_OVERRIDES, + KIND_LOCK_ROOT_DEP, + KIND_LOCK_PACKAGE, + ], + "{:?}", + entry.wiring + ); + assert!( + warnings + .iter() + .any(|w| w.code == "vendor_pnpm_legacy_absolute_specifier"), + "the root dep DID move: {warnings:?}" + ); + let lock = fx.read(PNPM_LOCK).await; + assert!( + lock.contains(&format!("\n left-pad: file:{}\n", fx.rel_tgz())), + "{lock}" + ); + assert!(!lock.contains("specifiers:"), "{lock}"); + + // (b) a specifiers section with a stray non-entry line and NO entry + // for the target — both skipped, the section byte-untouched. + const NO_ENTRY: &str = "lockfileVersion: 5.4 + +specifiers: + # pruned by hand + consumer: file:./consumer + +dependencies: + consumer: file:consumer + left-pad: 1.3.0 + +packages: + + /left-pad/1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + dev: false +"; + let fx = fixture_with(T_BEFORE_PKG, NO_ENTRY).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert!( + !entry.wiring.iter().any(|r| r.kind == KIND_LOCK_SPECIFIER), + "no specifier entry ⇒ no specifier record: {:?}", + entry.wiring + ); + let lock = fx.read(PNPM_LOCK).await; + assert!( + lock.contains("specifiers:\n # pruned by hand\n consumer: file:./consumer\n\n"), + "the degraded section is byte-untouched:\n{lock}" + ); + } + + /// Direct editor contracts when the pre-flighted target is missing: + /// `edit_packages` fails CLOSED (a silent success would wire a tarball + /// nothing resolves through), while `edit_pkg_dep_refs` is a benign + /// no-op (transitive refs are optional). + #[test] + fn packages_editors_fail_closed_or_noop_without_their_target() { + let rel_tgz = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + let spec = format!("file:{rel_tgz}"); + let abs_spec = format!("file:/checkout/{rel_tgz}"); + let ctx = Ctx { + grammar: PnpmLockGrammar::V54, + name: "left-pad", + version: "1.3.0", + rel_tgz: &rel_tgz, + spec: &spec, + abs_spec: &abs_spec, + integrity: SPIKE_INTEGRITY, + override_key: "left-pad@1.3.0", + }; + let mut wiring = Vec::new(); + + let mut lines = split_lines( + "lockfileVersion: 5.4\n\npackages:\n\n /other/1.0.0:\n resolution: {integrity: sha512-AAA}\n dev: false\n", + ); + let err = edit_packages(&mut lines, &ctx, &mut wiring).unwrap_err(); + assert!(err.contains("vanished mid-rewrite"), "{err}"); + assert!(wiring.is_empty(), "a failed edit records no wiring"); + + let mut lines = + split_lines("lockfileVersion: 5.4\n\ndependencies:\n left-pad: 1.3.0\n"); + assert_eq!(edit_pkg_dep_refs(&mut lines, &ctx, &mut wiring), Ok(false)); + assert!(wiring.is_empty()); + } + + /// Everything the surgery must NOT rewrite rides vendor + revert + /// byte-untouched: a `peerDependencies:` ref to the exact target (peer + /// values are ranges, never resolutions), a stray non-entry line inside + /// a dep map, and a foreign dep entry — while the real resolution ref + /// on the same map still wires and reverts. Full byte oracles both ways. + #[tokio::test] + async fn peer_maps_comments_and_foreign_deps_ride_vendor_and_revert_untouched() { + const TOLERANT: &str = "lockfileVersion: 5.4 + +specifiers: + consumer: file:./consumer + left-pad: ^1.3.0 + +dependencies: + consumer: file:consumer + left-pad: 1.3.0 + +packages: + + /left-pad/1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + version: 1.0.0 + peerDependencies: + left-pad: '>=1' + dependencies: + # hand-pinned + another: 1.0.0 + left-pad: 1.3.0 + dev: false +"; + let fx = fixture_with(T_BEFORE_PKG, TOLERANT).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + + let rel = fx.rel_tgz(); + let here = fx.canon_root_str(); + let expected = TOLERANT + .replace( + "lockfileVersion: 5.4\n\nspecifiers:", + &format!( + "lockfileVersion: 5.4\n\noverrides:\n left-pad@1.3.0: file:{rel}\n\nspecifiers:" + ), + ) + .replace( + "\n left-pad: ^1.3.0\n", + &format!("\n left-pad: file:{here}/{rel}\n"), + ) + .replace( + "\n left-pad: 1.3.0\n", + &format!("\n left-pad: file:{rel}\n"), + ) + .replace( + " /left-pad/1.3.0:\n resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==}\n deprecated: use String.prototype.padStart()\n dev: false\n", + &format!( + " file:{rel}:\n resolution: {{integrity: {}, tarball: file:{rel}}}\n name: left-pad\n version: 1.3.0\n dev: false\n", + fx.actual_integrity().await + ), + ) + .replace( + "\n left-pad: 1.3.0\n", + &format!("\n left-pad: file:{rel}\n"), + ); + // The peer ref, the stray line, and the foreign dep survive verbatim + // by construction of `expected` (only the four wired lines moved). + assert_eq!(fx.read(PNPM_LOCK).await, expected); + assert!( + expected.contains(" peerDependencies:\n left-pad: '>=1'\n"), + "oracle sanity: the peer range is present and untouched" + ); + + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(fx.read(PNPM_LOCK).await, TOLERANT, "lock byte-restored"); + assert_eq!(fx.read(PACKAGE_JSON).await, T_BEFORE_PKG); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "a clean revert removes the artifact dir" + ); + } + + /// An entry with NO pnpm meta (an older/foreign ledger shape) reverts + /// the wiring but NEVER deletes the override tables — without creation + /// provenance they might be the user's, so the emptied tables stay. + #[tokio::test] + async fn revert_without_pnpm_meta_keeps_the_emptied_tables() { + let (fx, mut entry) = vendored(T7_BEFORE_LOCK).await; + entry.pnpm = None; + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(fx.read(PNPM_LOCK).await, T7_BEFORE_LOCK, "lock restored"); + let pkg = fx.read(PACKAGE_JSON).await; + assert!( + pkg.contains("\"pnpm\": {"), + "the pnpm table stays without creation provenance: {pkg}" + ); + assert!( + pkg.contains("\"overrides\": {}"), + "the overrides table stays, emptied: {pkg}" + ); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the artifact dir is still removed" + ); + } + + /// A packages record whose `new` fragment names a block we cannot prove + /// ours (no vendor-path key) and whose live bytes differ is left alone + /// with a drift warning — the fail-closed ownership check for a + /// tampered/corrupted state.json record. + #[tokio::test] + async fn package_record_pointing_at_an_unowned_block_is_left_alone() { + let (fx, mut entry) = vendored(T7_BEFORE_LOCK).await; + let rec = entry + .wiring + .iter_mut() + .find(|r| r.kind == KIND_LOCK_PACKAGE) + .unwrap(); + rec.new = Some(lines_value(&[ + " file:consumer:".to_string(), + " resolution: {directory: consumer, type: directory}".to_string(), + ])); + assert_drift_keep( + &fx, + &entry, + "packages entry `file:consumer` was changed since vendoring", + 1, + ) + .await; + assert!( + fx.read(PNPM_LOCK).await.contains( + "\n file:consumer:\n resolution: {directory: consumer, type: directory}\n name: consumer\n" + ), + "the unowned block survives verbatim" + ); + } + + /// Our rekeyed block gone (user re-locked) AND the record's original + /// stripped (corrupted ledger): with neither the live block nor a + /// converged original to anchor on, the record warns `no longer exists` + /// and the artifact is kept. + #[tokio::test] + async fn missing_block_with_no_recorded_original_warns_no_longer_exists() { + let (fx, mut entry) = vendored(T7_BEFORE_LOCK).await; + let rel = fx.rel_tgz(); + let wired = fx.read(PNPM_LOCK).await; + let i = wired.find(&format!("\n\n file:{rel}:")).unwrap(); + let j = wired.find("\n\n file:consumer:").unwrap(); + assert!(i < j); + let tampered_lock = format!("{}{}", &wired[..i], &wired[j..]); + tokio::fs::write(fx.root().join(PNPM_LOCK), &tampered_lock) + .await + .unwrap(); + let rec = entry + .wiring + .iter_mut() + .find(|r| r.kind == KIND_LOCK_PACKAGE) + .unwrap(); + rec.original = None; + assert_drift_keep( + &fx, + &entry, + &format!("packages entry `file:{rel}` no longer exists"), + 1, + ) + .await; + } } diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 07a7cff9..e660c647 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -4226,4 +4226,361 @@ wheels = [ (true, "a-b".to_string()) ); } + + // ───────── splice-flavor orchestrator arms + wired-pin edge shapes ───────── + + /// The pipenv wired-pin reader skips (rather than trips over) malformed + /// lock shapes: a non-object category section, a file ref outside OUR + /// uuid dir, a hashes array with no `sha256:` entry, and a truncated + /// sha256 all yield no pin — the rebuild guard then stays off rather + /// than guessing — and none of them may mask a valid pin elsewhere. + #[test] + fn pipenv_wired_pin_skips_malformed_sections_and_entries() { + let dir_rel = format!(".socket/vendor/pypi/{UUID}"); + let rel_wheel = format!("{dir_rel}/six-1.16.0-py2.py3-none-any.whl"); + let all_bad = serde_json::json!({ + "_meta": {"hash": {"sha256": "x"}}, + // A top-level value that is not an object is skipped whole. + "pipfile-spec": 6, + "default": { + // A wheel ref pointing outside our uuid dir pins nothing. + "other": { + "file": "./vendor/elsewhere/other-1.0-py3-none-any.whl", + "hashes": [format!("sha256:{}", "c".repeat(64))] + }, + // Our wheel, but no sha256 entry among the hashes. + "nosha": { + "file": format!("./{rel_wheel}"), + "hashes": ["md5:0123456789abcdef0123456789abcdef"] + }, + // Our wheel, but a truncated sha256 cannot be a pin. + "shortsha": { + "file": format!("./{rel_wheel}"), + "hashes": [format!("sha256:{}", "a".repeat(10))] + } + } + }); + assert_eq!(pipenv_wired_pin(&all_bad, &dir_rel), None); + + // The same malformed neighbors must not mask a valid pin elsewhere. + let sha = "b".repeat(64); + let mut with_good = all_bad.clone(); + with_good["develop"] = serde_json::json!({ + "six": { + "file": format!("./{rel_wheel}"), + "hashes": [format!("sha256:{sha}")] + } + }); + assert_eq!( + pipenv_wired_pin(&with_good, &dir_rel), + Some((rel_wheel, sha)) + ); + } + + /// Splice-flavor lock load failures surface through the orchestrator as + /// refusals (the poetry/pdm/pipenv load-Err plan arms), leaving the tree + /// byte-untouched — the uv mirror is + /// `uv_lock_parse_failure_refuses_through_orchestrator`. + #[tokio::test] + async fn splice_flavor_lock_parse_failure_refuses_through_orchestrator() { + let cases = [ + ( + "poetry.lock", + "version = [broken\n", + "pypi_poetry_lock_parse_failed", + ), + ("pdm.lock", "version = [broken\n", "pypi_pdm_lock_parse_failed"), + ("Pipfile.lock", "{ not json", "pypi_pipenv_lock_parse_failed"), + ]; + for (lock_file, broken, expected_code) in cases { + let fx = e2e_fixture().await; + swap_to_lock_flavor(&fx, &[(lock_file, broken)]).await; + let sources = PatchSources::blobs_only(&fx.blobs); + let outcome = vendor_six(&fx, &sources, None).await; + let VendorOutcome::Refused { code, .. } = outcome else { + panic!("{lock_file}: expected Refused, got {outcome:?}"); + }; + assert_eq!(code, expected_code, "{lock_file}"); + assert!( + !fx.root.join(".socket").exists(), + "{lock_file}: a load refusal must leave the tree byte-untouched" + ); + } + } + + /// The splice-flavor mirror of + /// `uv_stale_uuid_vendor_refuses_through_orchestrator`: a lock already + /// wired to an EARLIER patch uuid refuses through the orchestrator (the + /// poetry/pdm/pipenv guard-Err plan arms), before any new uuid dir is + /// created, naming the stale uuid and the revert remediation. + #[tokio::test] + async fn splice_flavor_stale_uuid_vendor_refuses_through_orchestrator() { + const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + let cases = [ + ( + "poetry.lock", + POETRY_LOCK_REGISTRY, + "pypi_poetry_source_already_exists", + ), + ("pdm.lock", PDM_LOCK_REGISTRY, "pypi_pdm_source_already_exists"), + ( + "Pipfile.lock", + PIPENV_LOCK_REGISTRY, + "pypi_pipenv_source_already_exists", + ), + ]; + for (lock_file, lock_text, expected_code) in cases { + let fx = e2e_fixture().await; + swap_to_lock_flavor(&fx, &[(lock_file, lock_text)]).await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { result, .. } = vendor_six(&fx, &sources, None).await + else { + panic!("{lock_file}: first vendor must be Done"); + }; + assert!(result.success, "{lock_file}: {:?}", result.error); + let wired = tokio::fs::read(fx.root.join(lock_file)).await.unwrap(); + + // Same package, new patch generation (different uuid). + let mut record2 = fx.record.clone(); + record2.uuid = UUID2.to_string(); + let outcome = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &record2, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let VendorOutcome::Refused { code, detail } = outcome else { + panic!("{lock_file}: expected Refused, got {outcome:?}"); + }; + assert_eq!(code, expected_code, "{lock_file}"); + assert!(detail.contains(UUID), "{lock_file}: {detail}"); + assert!(detail.contains("vendor --revert"), "{lock_file}: {detail}"); + // Pre-flight refusal: the wired lock untouched, no second uuid dir. + assert_eq!( + tokio::fs::read(fx.root.join(lock_file)).await.unwrap(), + wired, + "{lock_file}: a pre-flight refusal must leave the wired lock untouched" + ); + assert!( + !fx.root + .join(format!(".socket/vendor/pypi/{UUID2}")) + .exists(), + "{lock_file}: no second uuid dir may appear" + ); + } + } + + /// The splice-flavor mirror of `requirements_revendor_is_in_sync_skip` + /// (the poetry/pdm/pipenv InSync plan arms): re-running vendor on a + /// wired lock is the in-sync skip (nothing recorded, lock + /// byte-identical), and a deleted uuid dir takes the artifact-only + /// rebuild guarded by the pin the WIRED LOCK still carries — no ledger + /// is ever persisted here, so the guard runs off the lock's own pin, + /// which the deterministic local build reproduces byte-for-byte. + #[tokio::test] + async fn splice_flavor_revendor_in_sync_skip_and_ledgerless_rebuild() { + let cases = [ + ("poetry.lock", POETRY_LOCK_REGISTRY), + ("pdm.lock", PDM_LOCK_REGISTRY), + ("Pipfile.lock", PIPENV_LOCK_REGISTRY), + ]; + for (lock_file, lock_text) in cases { + let fx = e2e_fixture().await; + swap_to_lock_flavor(&fx, &[(lock_file, lock_text)]).await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { result, entry, .. } = + vendor_six(&fx, &sources, None).await + else { + panic!("{lock_file}: first vendor must be Done"); + }; + assert!(result.success, "{lock_file}: {:?}", result.error); + let entry = entry.expect("entry on success"); + let wired = tokio::fs::read(fx.root.join(lock_file)).await.unwrap(); + + // Intact wheel: in-sync skip — nothing recorded, lock untouched. + let VendorOutcome::Done { + result: r2, + entry: e2, + warnings: w2, + } = vendor_six(&fx, &sources, None).await + else { + panic!("{lock_file}: re-run must be Done"); + }; + assert!(r2.success, "{lock_file}: {:?}", r2.error); + assert!(e2.is_none(), "{lock_file}: in-sync re-run records nothing"); + assert!( + !w2.iter().any(|w| w.code == "vendor_artifact_rebuilt"), + "{lock_file}: intact wheel must not claim a rebuild: {w2:?}" + ); + assert_eq!( + tokio::fs::read(fx.root.join(lock_file)).await.unwrap(), + wired, + "{lock_file}: the in-sync skip must not touch the lock" + ); + + // Deleted uuid dir: artifact-only rebuild, pin-checked against + // the wired lock itself (no state.json exists in this fixture). + tokio::fs::remove_dir_all(uuid_dir_of(&fx)).await.unwrap(); + let VendorOutcome::Done { + result: r3, + entry: e3, + warnings: w3, + } = vendor_six(&fx, &sources, None).await + else { + panic!("{lock_file}: rebuild run must be Done"); + }; + assert!(r3.success, "{lock_file}: {:?}", r3.error); + assert!( + e3.is_none(), + "{lock_file}: artifact-only rebuild records no entry" + ); + assert!( + w3.iter().any(|w| w.code == "vendor_artifact_rebuilt"), + "{lock_file}: {w3:?}" + ); + let rebuilt = tokio::fs::read(fx.root.join(&entry.artifact.path)) + .await + .unwrap_or_else(|e| panic!("{lock_file}: rebuilt wheel must exist: {e}")); + assert_eq!( + hex::encode(sha2::Sha256::digest(&rebuilt)), + entry.artifact.sha256, + "{lock_file}: the rebuild must reproduce the sha256 the lock still pins" + ); + assert_eq!( + tokio::fs::read(fx.root.join(lock_file)).await.unwrap(), + wired, + "{lock_file}: rebuild must not touch the lock" + ); + } + } + + /// A CORRUPT state.json (vs the MISSING one of the ledgerless tests) on + /// an in-sync rebuild must not silently drop the pin guard: `load_state` + /// fails, the guard falls back to the pin the wired requirements line + /// still carries, and a mismatched service wheel is rejected under + /// `auto` in favor of the deterministic local build that reproduces it. + #[tokio::test] + async fn in_sync_rebuild_with_corrupt_ledger_falls_back_to_wired_pin() { + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { result, entry, .. } = vendor_six(&fx, &sources, None).await + else { + panic!("first vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("entry on success"); + // The committed ledger got clobbered into garbage. + tokio::fs::write( + fx.root.join(crate::vendor::state::VENDOR_STATE_REL), + b"{ not json", + ) + .await + .unwrap(); + tokio::fs::remove_dir_all(uuid_dir_of(&fx)).await.unwrap(); + + // The service offers a wheel whose bytes do NOT match the wired pin. + let bytes = b"service-built wheel bytes that differ from the local build"; + let sri = sri_sha512(bytes); + let server = wiremock::MockServer::start().await; + mount_pypi_granted(&server, WHEEL_NAME, &sri, bytes).await; + + let outcome = vendor_six( + &fx, + &sources, + Some(&pypi_service_cfg(&server.uri(), VendorSource::Auto, false)), + ) + .await; + let VendorOutcome::Done { + result, + entry: e2, + warnings, + } = outcome + else { + panic!("rebuild run must be Done, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + assert!(e2.is_none(), "artifact-only rebuild records no entry"); + assert!( + warnings + .iter() + .any(|w| w.code == "vendor_prebuilt_pin_mismatch"), + "the pin must survive a corrupt ledger via the wired line: {warnings:?}" + ); + let on_disk = tokio::fs::read(fx.root.join(&entry.artifact.path)) + .await + .expect("the pinned wheel path must exist again"); + assert_eq!( + hex::encode(sha2::Sha256::digest(&on_disk)), + entry.artifact.sha256, + "the rebuilt wheel must reproduce the sha256 the wired line still pins" + ); + } + + /// The guard's last resort: an in-sync poetry rebuild with NO ledger AND + /// a wired lock whose one-line files hash element was hand-stripped has + /// no pin to check against — the deterministic local rebuild proceeds + /// unguarded (the documented "only when the wired file yields no pin + /// either" case) instead of refusing an unrecoverable state, and the + /// hand-edited lock stays untouched. + #[tokio::test] + async fn in_sync_rebuild_with_no_ledger_and_no_wired_pin_rebuilds_unguarded() { + let fx = e2e_fixture().await; + swap_to_lock_flavor(&fx, &[("poetry.lock", POETRY_LOCK_REGISTRY)]).await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { result, entry, .. } = vendor_six(&fx, &sources, None).await + else { + panic!("first vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("entry on success"); + + // Hand-strip the files hash line(s) vendor wrote; the + // [package.source] url still routes six through the uuid dir, so + // the project stays in-sync — but the lock now yields no pin. + let wired = tokio::fs::read_to_string(fx.root.join("poetry.lock")) + .await + .unwrap(); + let stripped: String = wired + .lines() + .filter(|l| !l.contains("hash = \"sha256:")) + .map(|l| format!("{l}\n")) + .collect(); + assert_ne!(stripped, wired, "the tamper must remove a hash line"); + tokio::fs::write(fx.root.join("poetry.lock"), &stripped) + .await + .unwrap(); + tokio::fs::remove_dir_all(uuid_dir_of(&fx)).await.unwrap(); + + let VendorOutcome::Done { + result: r2, + entry: e2, + warnings, + } = vendor_six(&fx, &sources, None).await + else { + panic!("rebuild run must be Done"); + }; + assert!(r2.success, "{:?}", r2.error); + assert!(e2.is_none(), "artifact-only rebuild records no entry"); + assert!( + warnings.iter().any(|w| w.code == "vendor_artifact_rebuilt"), + "{warnings:?}" + ); + assert!( + fx.root.join(&entry.artifact.path).is_file(), + "the wheel is rebuilt at the recorded path" + ); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("poetry.lock")) + .await + .unwrap(), + stripped, + "the hand-stripped lock is left alone" + ); + } } diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index a74422c2..32bd589d 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -4043,4 +4043,587 @@ wheels = [ Some("[package.metadata]\nrequires-dist = []\nprovides-extras = [\"fast\"]") ); } + + // ── coverage mop-up 2026-09: guard/error/skip arms ──────────────────── + + /// In-memory pair (no tempdir) for the pure-read helpers. + fn project_from(pyproject: &str, lock: &str) -> UvProject { + UvProject { + pyproject_text: pyproject.to_string(), + lock_text: lock.to_string(), + pyproject: pyproject.parse().unwrap(), + lock: lock.parse().unwrap(), + lock_revision: None, + warnings: Vec::new(), + } + } + + /// A `[dependency-groups]` key whose value is NOT an array (legal TOML, + /// outside the PEP 735 shape) is skipped, never a panic — and it must not + /// swallow the declarations in the well-formed sibling groups. + #[test] + fn classify_skips_a_non_array_dependency_group_value() { + let pyproject = format!( + "{DIRECT_REGISTRY_PYPROJECT}\n[dependency-groups]\nbroken = \"not-an-array\"\ndev = [\"attrs==23.1.0\"]\n" + ); + let p = project_from(&pyproject, DIRECT_REGISTRY_LOCK); + assert_eq!( + classify_dependency(&p, "attrs"), + UvDepClass::Direct, + "the array group after the malformed one still registers" + ); + assert_eq!(classify_dependency(&p, "absent"), UvDepClass::Transitive); + } + + /// A wired-shaped lock whose wheels hash is not a plausible sha256 (too + /// short, or non-hex) pins nothing: the in-sync rebuild guard must stay + /// off rather than trust a corrupted pin. + #[test] + fn wired_pin_rejects_a_malformed_wheel_hash() { + let short = DIRECT_PATH_LOCK.replace(WHEEL_SHA, "deadbeef"); + let p = project_from(DIRECT_PATH_PYPROJECT, &short); + assert_eq!(wired_pin(&p, "six", UUID), None, "a short hash pins nothing"); + + let non_hex = DIRECT_PATH_LOCK.replace(WHEEL_SHA, &"z".repeat(64)); + let p = project_from(DIRECT_PATH_PYPROJECT, &non_hex); + assert_eq!( + wired_pin(&p, "six", UUID), + None, + "a non-hex 64-char hash pins nothing" + ); + } + + /// A version string that breaks the override TOML value (an embedded + /// quote) is reported as a parse failure BEFORE any write. + #[tokio::test] + async fn wire_reports_an_unbuildable_override_value() { + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, TRANSITIVE_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0\"", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_parse_failed"); + assert!(err.1.contains("cannot build override value"), "{}", err.1); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, TRANSITIVE_REGISTRY_PYPROJECT, "refusal leaves the tree untouched"); + assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK); + } + + /// A user-authored `[tool.uv.override-dependencies]` TABLE (not the array + /// uv expects) refuses cleanly instead of panicking or clobbering it. + #[tokio::test] + async fn wire_refuses_a_table_shaped_override_dependencies() { + let pyproject = + format!("{TRANSITIVE_REGISTRY_PYPROJECT}\n[tool.uv.override-dependencies]\n"); + let tmp = write_pair(&pyproject, TRANSITIVE_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_parse_failed"); + assert!(err.1.contains("is not a value"), "{}", err.1); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, pyproject, "refusal leaves the tree untouched"); + assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK); + } + + /// A user-authored scalar `override-dependencies = "…"` (a value, but not + /// an array) refuses cleanly too — the append has nowhere to go. + #[tokio::test] + async fn wire_refuses_a_non_array_override_dependencies_value() { + let pyproject = format!( + "{TRANSITIVE_REGISTRY_PYPROJECT}\n[tool.uv]\noverride-dependencies = \"attrs==23.1.0\"\n" + ); + let tmp = write_pair(&pyproject, TRANSITIVE_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_parse_failed"); + assert!(err.1.contains("is not an array"), "{}", err.1); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, pyproject, "refusal leaves the tree untouched"); + assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK); + } + + /// A wheel path that breaks the sources TOML value (an embedded quote) is + /// reported as a parse failure BEFORE any write. + #[tokio::test] + async fn wire_reports_an_unbuildable_sources_value() { + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + ".socket/vendor/pypi/x\"y/six.whl", + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_parse_failed"); + assert!(err.1.contains("cannot build sources value"), "{}", err.1); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, DIRECT_REGISTRY_PYPROJECT, "refusal leaves the tree untouched"); + assert_eq!(lock, DIRECT_REGISTRY_LOCK); + } + + /// The lock parses (so the TOML-level guard sees the package) but the + /// [[package]] unit is spelled `name="six"` — outside the text-surgery + /// shape uv emits. The rewrite's own missing-unit error must propagate + /// out of wire_uv before any write, not panic or half-wire. + #[tokio::test] + async fn wire_propagates_a_textually_missing_package_unit() { + let lock = DIRECT_REGISTRY_LOCK.replacen( + "[[package]]\nname = \"six\"", + "[[package]]\nname=\"six\"", + 1, + ); + assert_ne!(lock, DIRECT_REGISTRY_LOCK); + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, &lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_package_missing"); + assert!(err.1.contains("six"), "{}", err.1); + let (py, on_disk) = read_pair(tmp.path()).await; + assert_eq!(py, DIRECT_REGISTRY_PYPROJECT, "refusal leaves the tree untouched"); + assert_eq!(on_disk, lock); + } + + /// A ledger record with no `new` fragment at all (malformed/truncated + /// state.json). Every Added revert arm needs the fragment it wrote to + /// remove it — each must warn and skip, never panic or guess. + fn record_without_new(kind: &str, action: WiringAction) -> WiringRecord { + WiringRecord { + file: "x".into(), + kind: kind.into(), + action, + key: Some("six".into()), + original: None, + new: None, + } + } + + #[tokio::test] + async fn revert_warns_on_records_missing_the_new_fragment() { + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, TRANSITIVE_REGISTRY_LOCK).await; + let entry = entry_for( + vec![ + record_without_new("uv_override", WiringAction::Added), + record_without_new("uv_sources_entry", WiringAction::Added), + record_without_new("uv_lock_manifest_overrides", WiringAction::Added), + ], + UvMeta { + dep_class: "override".into(), + original_specifier: None, + created_sources_table: false, + lock_revision: None, + }, + ); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(outcome.warnings.len(), 3, "{:?}", outcome.warnings); + for w in &outcome.warnings { + assert_eq!(w.code, "vendor_lock_entry_drifted"); + } + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, TRANSITIVE_REGISTRY_PYPROJECT); + assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK); + } + + /// The `[manifest]` section vendor CREATED was reshaped by hand but still + /// routes through the vendored wheel: that is drift (fail-closed), not + /// convergence — revert warns and leaves the section, while the untouched + /// [[package]] fragment and the pyproject still revert. + #[tokio::test] + async fn revert_warns_when_created_manifest_section_still_routes_after_reshape() { + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, TRANSITIVE_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let pkg_rec = wiring + .iter() + .find(|w| w.kind == "uv_lock_package") + .unwrap(); + let pkg_new = pkg_rec + .new + .as_ref() + .and_then(serde_json::Value::as_str) + .unwrap() + .to_string(); + let pkg_orig = pkg_rec + .original + .as_ref() + .and_then(serde_json::Value::as_str) + .unwrap() + .to_string(); + + let (_, wired_lock) = read_pair(tmp.path()).await; + let tampered = wired_lock.replacen( + &format!("path = \"{REL_WHEEL}\" }}]"), + &format!("path = \"{REL_WHEEL}\" }}, {{ name = \"extra\", path = \"e.whl\" }}]"), + 1, + ); + assert_ne!(tampered, wired_lock, "the tamper must hit the overrides line"); + tokio::fs::write(tmp.path().join("uv.lock"), &tampered) + .await + .unwrap(); + + let outcome = revert_uv(&entry_for(wiring, meta), tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(outcome.warnings.len(), 1, "{:?}", outcome.warnings); + assert_eq!(outcome.warnings[0].code, "vendor_lock_entry_drifted"); + assert!( + outcome.warnings[0].detail.contains("uv.lock"), + "{}", + outcome.warnings[0].detail + ); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, TRANSITIVE_REGISTRY_PYPROJECT); + let expected = tampered.replacen(&pkg_new, &pkg_orig, 1); + assert_eq!( + lock, expected, + "the [[package]] fragment reverts; the reshaped [manifest] stays" + ); + } + + /// Hand-restoring a pair whose lock had a PRE-EXISTING overrides array + /// (a Rewritten manifest record) is convergence: the recorded original + /// is already live, so revert is silent — no drift warnings. + #[tokio::test] + async fn revert_hand_restored_preexisting_overrides_lock_is_silent_convergence() { + let one_el = "overrides = [{ name = \"other\", path = \"o.whl\" }]"; + let input_lock = TRANSITIVE_REGISTRY_LOCK.replace( + "requires-python = \">=3.10\"\n", + &format!("requires-python = \">=3.10\"\n\n[manifest]\n{one_el}\n"), + ); + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, &input_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + // Hand-restore BOTH files to their pre-vendor bytes. + tokio::fs::write(tmp.path().join("pyproject.toml"), TRANSITIVE_REGISTRY_PYPROJECT) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("uv.lock"), &input_lock) + .await + .unwrap(); + + let outcome = revert_uv(&entry_for(wiring, meta), tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, TRANSITIVE_REGISTRY_PYPROJECT); + assert_eq!(lock, input_lock); + } + + /// Hand-restoring ONLY the pyproject after wiring extended a user's + /// existing `override-dependencies` array: the Rewritten uv_override arm + /// finds the recorded original live — convergence, silent — while the + /// lock side still reverts fully. + #[tokio::test] + async fn revert_hand_restored_rewritten_override_array_is_silent_convergence() { + let pyproject = format!( + "{TRANSITIVE_REGISTRY_PYPROJECT}\n[tool.uv]\noverride-dependencies = [\"attrs==23.1.0\"]\n" + ); + let tmp = write_pair(&pyproject, TRANSITIVE_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + // Hand-restore only the pyproject; leave the wired lock. + tokio::fs::write(tmp.path().join("pyproject.toml"), &pyproject) + .await + .unwrap(); + + let outcome = revert_uv(&entry_for(wiring, meta), tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, pyproject, "the hand-restored pyproject is untouched"); + assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK, "the lock still reverts"); + } + + /// The extended `override-dependencies` array was EDITED by hand into a + /// shape that is neither what vendor wrote nor the recorded original: + /// drift — warn and leave it, while everything else reverts. + #[tokio::test] + async fn revert_warns_when_rewritten_override_array_was_edited() { + let pyproject = format!( + "{TRANSITIVE_REGISTRY_PYPROJECT}\n[tool.uv]\noverride-dependencies = [\"attrs==23.1.0\"]\n" + ); + let tmp = write_pair(&pyproject, TRANSITIVE_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (wired_py, _) = read_pair(tmp.path()).await; + let tampered = wired_py.replacen("attrs==23.1.0", "attrs==23.9.9", 1); + assert_ne!(tampered, wired_py, "the tamper must hit the override array"); + tokio::fs::write(tmp.path().join("pyproject.toml"), &tampered) + .await + .unwrap(); + + let outcome = revert_uv(&entry_for(wiring, meta), tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(outcome.warnings.len(), 1, "{:?}", outcome.warnings); + assert_eq!(outcome.warnings[0].code, "vendor_lock_entry_drifted"); + assert!( + outcome.warnings[0].detail.contains("pyproject.toml"), + "{}", + outcome.warnings[0].detail + ); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK, "the lock still reverts"); + assert!( + py.contains("override-dependencies = [\"attrs==23.9.9\", \"six==1.16.0\"]"), + "the drifted array is left as the user edited it: {py}" + ); + assert!( + !py.contains("[tool.uv.sources]"), + "the undrifted sources entry still reverts: {py}" + ); + } + + /// The lock write succeeds but the pyproject write fails (an immutable + /// pyproject.toml): revert reports failure with the pyproject error, not + /// a silent partial revert. UF_IMMUTABLE blocks rename-over regardless + /// of euid, so no root-skip probe is needed. + #[cfg(target_os = "macos")] + #[tokio::test] + async fn revert_pyproject_write_failure_after_the_lock_write_reports_failure() { + fn set_flags(path: &Path, flags: libc::c_uint) { + use std::os::unix::ffi::OsStrExt; + let c_path = + std::ffi::CString::new(path.as_os_str().as_bytes()).expect("path has no NUL"); + let rc = unsafe { libc::chflags(c_path.as_ptr(), flags) }; + assert_eq!(rc, 0, "chflags failed: {}", std::io::Error::last_os_error()); + } + + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + let entry = entry_for( + Vec::new(), + UvMeta { + dep_class: "direct".into(), + original_specifier: None, + created_sources_table: false, + lock_revision: None, + }, + ); + let pyproject_path = tmp.path().join("pyproject.toml"); + set_flags(&pyproject_path, libc::UF_IMMUTABLE); + let outcome = revert_uv(&entry, tmp.path(), false).await; + // Clear the flag before asserting so the TempDir cleans up even on + // failure. + set_flags(&pyproject_path, 0); + + assert!(!outcome.success, "the failed write must fail the revert"); + assert!(!outcome.kept_artifact); + assert!( + outcome + .error + .as_deref() + .unwrap_or("") + .contains("cannot write pyproject.toml"), + "{:?}", + outcome.error + ); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, DIRECT_REGISTRY_PYPROJECT); + assert_eq!(lock, DIRECT_REGISTRY_LOCK, "the preceding lock write succeeded"); + } + + /// A SINGLE-LINE `wheels = […]` array (uv emits one for one-wheel + /// packages) is replaced in place — not duplicated by the sdist-only + /// append path. + #[test] + fn single_line_wheels_array_is_rewritten_in_place() { + let lock = "version = 1\n\n[[package]]\nname = \"six\"\nversion = \"1.15.0\"\nsource = { registry = \"https://pypi.org/simple\" }\nsdist = { url = \"https://example.invalid/six.tar.gz\", hash = \"sha256:aa\", size = 1 }\nwheels = [{ url = \"https://example.invalid/six.whl\", hash = \"sha256:bb\" }]\n"; + let (old_unit, new_unit) = rewrite_target_package_unit( + lock, + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + None, + ) + .unwrap(); + assert!(old_unit.contains("wheels = [{ url"), "{old_unit}"); + let expected_wheels = format!( + "wheels = [\n {{ filename = \"{WHEEL_NAME}\", hash = \"sha256:{WHEEL_SHA}\" }},\n]" + ); + assert!(new_unit.contains(&expected_wheels), "{new_unit}"); + assert_eq!( + new_unit.matches("wheels = [").count(), + 1, + "replaced in place, never appended a second array: {new_unit}" + ); + assert!(!new_unit.contains("url ="), "{new_unit}"); + assert!(!new_unit.contains("sdist"), "{new_unit}"); + assert!( + new_unit.contains(&format!("source = {{ path = \"{REL_WHEEL}\" }}")), + "{new_unit}" + ); + assert!(new_unit.contains("version = \"1.16.0\""), "{new_unit}"); + } + + /// Root-unit header shared by the unbalanced-array fixtures below. + const ROOT_UNIT_HDR: &str = "version = 1\n\n[[package]]\nname = \"proj\"\nversion = \"0.1.0\"\nsource = { virtual = \".\" }\n\n[package.metadata]\n"; + + /// A truncated (unbalanced) root `requires-dist` array refuses with a + /// parse error instead of slicing out of bounds or mis-splicing. + #[test] + fn unbalanced_requires_dist_array_is_a_parse_error() { + let lock = format!( + "{ROOT_UNIT_HDR}requires-dist = [{{ name = \"six\", specifier = \"==1.16.0\" }}\n" + ); + let Err(err) = rewrite_root_metadata_entries(&lock, "six", REL_WHEEL) else { + panic!("an unbalanced requires-dist array must refuse"); + }; + assert_eq!(err.0, "pypi_uv_lock_parse_failed"); + assert!(err.1.contains("requires-dist array is unbalanced"), "{}", err.1); + } + + /// Same for a truncated `[package.metadata.requires-dev]` group array. + #[test] + fn unbalanced_requires_dev_group_array_is_a_parse_error() { + let lock = format!( + "{ROOT_UNIT_HDR}requires-dist = [{{ name = \"six\", specifier = \"==1.16.0\" }}]\n\n[package.metadata.requires-dev]\ndev = [\n {{ name = \"six\", specifier = \"==1.16.0\" }},\n" + ); + let Err(err) = rewrite_root_metadata_entries(&lock, "six", REL_WHEEL) else { + panic!("an unbalanced requires-dev group array must refuse"); + }; + assert_eq!(err.0, "pypi_uv_lock_parse_failed"); + assert!( + err.1.contains("requires-dev] array is unbalanced"), + "{}", + err.1 + ); + } + + /// A requires-dev group whose entries name OTHER packages contributes no + /// edit — the scan skips it and the requires-dist edit alone survives. + #[test] + fn requires_dev_scan_skips_groups_without_the_target() { + let lock = format!( + "{ROOT_UNIT_HDR}requires-dist = [{{ name = \"six\", specifier = \"==1.16.0\" }}]\n\n[package.metadata.requires-dev]\nlint = [\n {{ name = \"black\", specifier = \"==24.4.2\" }},\n]\n" + ); + let edits = rewrite_root_metadata_entries(&lock, "six", REL_WHEEL).unwrap(); + assert_eq!(edits.len(), 1, "the black-only dev group contributes no edit"); + assert_eq!(edits[0].kind, "uv_lock_requires_dist"); + assert!(edits[0].old_entry.contains("name = \"six\"")); + } + + /// A hand-edited entry with a trailing comma splits into a final empty + /// piece — skipped, so the rebuilt entry carries no stray separator. + #[test] + fn path_source_entry_tolerates_a_trailing_comma() { + let (new_entry, specifier) = + path_source_entry("{ name = \"six\", specifier = \"==1.16.0\", }", REL_WHEEL); + assert_eq!(specifier.as_deref(), Some("==1.16.0")); + assert_eq!( + new_entry, + format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}") + ); + } + + /// A lock with neither `[manifest]` nor any `[[package]]` unit gives the + /// created section no insertion anchor: a parse-failure refusal. + #[test] + fn manifest_override_requires_a_package_entry() { + let err = add_manifest_override("version = 1\nrevision = 3\n", "six", REL_WHEEL) + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_parse_failed"); + assert!(err.1.contains("no [[package]] entries"), "{}", err.1); + } + + /// A truncated (unbalanced) existing `[manifest] overrides` array refuses + /// with a parse error instead of splicing garbage. + #[test] + fn manifest_override_reports_an_unbalanced_overrides_array() { + let lock = "version = 1\n\n[manifest]\noverrides = [{ name = \"other\", path = \"o.whl\" }\n\n[[package]]\nname = \"proj\"\nversion = \"0.1.0\"\nsource = { virtual = \".\" }\n"; + let err = add_manifest_override(lock, "six", REL_WHEEL).unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_parse_failed"); + assert!(err.1.contains("overrides array is unbalanced"), "{}", err.1); + } } diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 00521718..64ba8d90 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -2989,4 +2989,40 @@ __metadata: vec![" orphan-submap-line".to_string()] ); } + + /// An EMPTY workspace ident (`"@workspace:."` — a root package.json with + /// no `name`) must never satisfy the root-workspace probe: the extracted + /// name is embedded verbatim in the vendored `file:` locator, and an + /// empty ident there would emit a lock key/resolution yarn cannot parse. + /// The probe skips it — still finding a later named root — and with no + /// named root at all the vendor path refuses fail-closed before any + /// write. + #[tokio::test] + async fn empty_workspace_ident_is_skipped_and_vendor_refuses() { + // Unit: the empty ident is skipped, not returned as "". + let empty_only = B3_BEFORE_LOCK.replace("vendor-spike@workspace:.", "@workspace:."); + assert_eq!(root_workspace_name(&scan_blocks(&empty_only)), None); + + // Skipping means the scan CONTINUES: a later named root still wins. + let empty_then_named = format!( + "{empty_only}\n\"vendor-spike@workspace:.\":\n version: 0.0.0-use.local\n \ + resolution: \"vendor-spike@workspace:.\"\n languageName: unknown\n \ + linkType: soft\n" + ); + assert_eq!( + root_workspace_name(&scan_blocks(&empty_then_named)).as_deref(), + Some("vendor-spike") + ); + + // E2E: with only the empty-ident root, vendoring refuses fail-closed + // (same gate as a lock with no workspace entry at all) and the + // project stays byte-untouched. + let fx = fixture_with(B3_BEFORE_PKG, &empty_only).await; + let detail = expect_refused( + fx.vendor(false).await, + "vendor_lockfile_version_unsupported", + ); + assert!(detail.contains("@workspace:."), "{detail}"); + fx.assert_untouched().await; + } } diff --git a/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs b/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs new file mode 100644 index 00000000..5f712da1 --- /dev/null +++ b/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs @@ -0,0 +1,109 @@ +//! Coverage mop-up for `vendor::nuget_feed` arms that need process-level +//! isolation. The local-rebuild stage `tempfile::tempdir()` failure is forced +//! by pointing `TMPDIR` at a nonexistent directory — safe only in a test +//! binary that owns its whole process (the lib test binary runs hundreds of +//! concurrent `tempdir()` users that a clobbered `TMPDIR` would flake). +//! Keep this file to env-mutating tests; anything else belongs in the file's +//! inline `#[cfg(test)]` module. + +#![cfg(unix)] + +use std::collections::HashMap; + +use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord}; +use socket_patch_core::patch::apply::PatchSources; +use socket_patch_core::vendor::nuget_feed::vendor_nuget; +use socket_patch_core::vendor::VendorOutcome; + +/// Restores the pre-test `TMPDIR` on drop (panic-safe). +struct TmpdirGuard(Option); + +impl Drop for TmpdirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("TMPDIR", v), + None => std::env::remove_var("TMPDIR"), + } + } +} + +/// An unusable temp dir fails the local rebuild's private stage creation: +/// the vendor reports a per-package failure ("cannot create stage dir") +/// instead of panicking, and touches no project file. `TMPDIR` only steers +/// `std::env::temp_dir()` on unix. +#[cfg(unix)] +#[tokio::test] +async fn stage_tempdir_creation_failure_is_reported_not_fatal() { + // Build the whole fixture while TMPDIR is still valid. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let installed = root.join("packages/newtonsoft.json/13.0.3"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + // Any readable regular file works: the cached-nupkg read succeeds and + // `tempfile::tempdir()` fails BEFORE extraction ever parses the bytes. + tokio::fs::write(installed.join("newtonsoft.json.13.0.3.nupkg"), b"not-a-zip") + .await + .unwrap(); + let blobs = root.join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "LICENSE.md".to_string(), + PatchFileInfo { + before_hash: "0".repeat(64), + after_hash: "1".repeat(64), + }, + ); + let record = PatchRecord { + uuid: "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f".to_string(), + exported_at: "2026-06-09T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }; + let sources = PatchSources { + blobs_path: &blobs, + packages_path: None, + diffs_path: None, + mem_blobs: None, + }; + + let guard = TmpdirGuard(std::env::var_os("TMPDIR")); + std::env::set_var("TMPDIR", root.join("no-such-tmpdir")); + let outcome = vendor_nuget( + "pkg:nuget/Newtonsoft.Json@13.0.3", + &installed, + root, + &record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + drop(guard); + + match outcome { + VendorOutcome::Done { + result, entry, .. + } => { + assert!(!result.success, "the stage failure must fail the vendor"); + assert!(entry.is_none(), "no ledger entry for a failed vendor"); + let err = result.error.as_deref().unwrap_or(""); + assert!(err.contains("cannot create stage dir"), "{err}"); + } + VendorOutcome::Refused { code, detail } => panic!("refused: {code}: {detail}"), + } + assert!( + !root.join(".socket").exists(), + "no partial artifact dir after the stage failure" + ); + assert!( + !root.join("nuget.config").exists(), + "no wiring after the stage failure" + ); +}