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 90bdfd0b..2c1aa004 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); @@ -423,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; @@ -453,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() @@ -785,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 7fcad9f1..12c5885a 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:"), @@ -2329,6 +2336,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 { @@ -4110,4 +4127,952 @@ 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}" + ); + } + + // --- coverage mop-up (2026-09 final wave) ------------------------------- + + /// `merge_metadata` is a best-effort splice: a non-object record (or a + /// non-object metadata value) must be left untouched, never panic — + /// callers hand it freshly-built json! values, but the contract is + /// defensive on both sides. + #[test] + fn merge_metadata_leaves_non_object_inputs_untouched() { + // Non-object record: nothing to insert into. + let mut record = serde_json::Value::Null; + merge_metadata(&mut record, serde_json::json!({"severity": "high"})); + assert!(record.is_null(), "a non-object record must stay untouched"); + + // Non-object metadata: nothing to splice from. + let mut record = serde_json::json!({"purl": "pkg:npm/x@1.0.0"}); + merge_metadata(&mut record, serde_json::Value::String("nope".into())); + assert_eq!(record, serde_json::json!({"purl": "pkg:npm/x@1.0.0"})); + } + + /// The `IdentifierType` Display labels are user-facing vocabulary (the + /// "No patches found for {type}: {id}" terminal) — pin all five. + #[test] + fn identifier_type_display_labels_are_stable() { + assert_eq!(IdentifierType::Uuid.to_string(), "UUID"); + assert_eq!(IdentifierType::Cve.to_string(), "CVE"); + assert_eq!(IdentifierType::Ghsa.to_string(), "GHSA"); + assert_eq!(IdentifierType::Purl.to_string(), "PURL"); + assert_eq!(IdentifierType::Package.to_string(), "package name"); + } + + /// JSON mode with multiple free patches for one purl: the + /// `selection_required` options must carry each patch's vulnerability + /// details (id/cves/severity/summary) so a bot can choose without a + /// second query. The existing json-mode test used vuln-less patches, so + /// the serialization closure never ran. + #[test] + fn select_json_mode_multi_free_options_serialize_vulnerabilities() { + let mut a = mk_patch("a", "pkg:npm/foo@1.0", "free", "2024-01-01"); + a.vulnerabilities.insert( + "GHSA-aaaa-bbbb-cccc".to_string(), + VulnerabilityResponse { + cves: vec!["CVE-2024-1111".to_string()], + summary: "summary-a".to_string(), + severity: "high".to_string(), + description: "desc-a".to_string(), + }, + ); + let mut b = mk_patch("b", "pkg:npm/foo@1.0", "free", "2024-02-01"); + b.vulnerabilities.insert( + "GHSA-dddd-eeee-ffff".to_string(), + VulnerabilityResponse { + cves: vec![], + summary: "summary-b".to_string(), + severity: "low".to_string(), + description: "desc-b".to_string(), + }, + ); + let result = select_patches(&[a, b], false, true); + assert_eq!( + result.err(), + Some(1), + "json mode with multiple free candidates must error with exit 1" + ); + } + + /// `fold_narrowing_into_result` on a non-object envelope (the error + /// shapes are the callers' concern) must be a calm no-op. + #[test] + fn fold_narrowing_ignores_non_object_result() { + let mut result = serde_json::json!(["not", "an", "object"]); + fold_narrowing_into_result( + &mut result, + &[serde_json::json!({"purl": "p", "action": "skipped"})], + &[("code".to_string(), "detail".to_string())], + ); + assert_eq!(result, serde_json::json!(["not", "an", "object"])); + } + + /// A corrupt vendor ledger must degrade the coarse narrowing to "no + /// ledger extension" (the download path's fail-closed read still guards + /// writes): a purl claimed by nothing else is skipped as not installed, + /// never kept on the strength of an unreadable state file. + #[tokio::test] + async fn filter_to_installed_purls_corrupt_vendor_state_degrades_to_no_extension() { + let tmp = tempfile::tempdir().unwrap(); + let vendor = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write(vendor.join("state.json"), b"{ not json").unwrap(); + + let common = crate::args::GlobalArgs { + cwd: tmp.path().to_path_buf(), + ..Default::default() + }; + let accessible = vec![mk_patch( + "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + "pkg:npm/covgap-ledger-only@1.0.0", + "free", + "2024-01-01", + )]; + let out = filter_to_installed_purls( + &accessible, + &common, + crate::commands::scan::ScanMode::Hosted, + ) + .await; + assert!(out.kept.is_empty(), "nothing may be kept via a corrupt ledger"); + assert_eq!(out.skip_records.len(), 1); + assert_eq!(out.skip_records[0]["errorCode"], "package_not_installed"); + } + + /// The lockfile/vendor-ledger supplements are gated OFF for + /// machine-tree-scoped runs (`--global` / `--global-prefix`): a version + /// resolved only by the PROJECT lockfile must not count as present + /// there — those runs target the machine tree, not this project. + #[tokio::test] + async fn filter_to_installed_purls_prefix_scoped_run_skips_lock_supplement() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = tempfile::tempdir().unwrap(); + // The project lockfile resolves the exact version under test. + std::fs::write( + tmp.path().join("package-lock.json"), + serde_json::json!({ + "name": "consumer", "version": "0.0.0", "lockfileVersion": 3, + "packages": { + "": { "name": "consumer", "version": "0.0.0" }, + "node_modules/covgap-lock-only": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/covgap-lock-only/-/covgap-lock-only-1.0.0.tgz", + "integrity": "sha512-AAAA==" + } + } + }) + .to_string(), + ) + .unwrap(); + + let common = crate::args::GlobalArgs { + cwd: tmp.path().to_path_buf(), + global_prefix: Some(prefix.path().to_path_buf()), + ..Default::default() + }; + let accessible = vec![mk_patch( + "ffffffff-ffff-4fff-8fff-ffffffffffff", + "pkg:npm/covgap-lock-only@1.0.0", + "free", + "2024-01-01", + )]; + let out = filter_to_installed_purls( + &accessible, + &common, + crate::commands::scan::ScanMode::Hosted, + ) + .await; + assert!( + out.kept.is_empty(), + "a prefix-scoped run must not treat lockfile resolution as presence" + ); + assert_eq!(out.skip_records.len(), 1); + assert_eq!(out.skip_records[0]["errorCode"], "package_not_installed"); + } + + /// pnpm-PnP + hosted: a purl the lock probe CANNOT judge (no `@version` + /// coordinate to look for) must keep the layout-refusal code — the same + /// no-judgment fallback as an unreadable lock — never a false + /// "not installed" verdict; a judgeable-but-absent version is a genuine + /// miss and carries `package_not_installed`. + #[tokio::test] + async fn filter_to_installed_purls_pnpm_pnp_hosted_unjudgeable_purl_keeps_layout_code() { + let tmp = tempfile::tempdir().unwrap(); + // pnpm's own node-linker=pnp layout: PnP loader + pnpm-lock.yaml + + // installed pnpm store marker, no yarn.lock. + std::fs::write(tmp.path().join(".pnp.cjs"), b"// pnp loader\n").unwrap(); + std::fs::write( + tmp.path().join("pnpm-lock.yaml"), + b"lockfileVersion: '9.0'\n\nsnapshots:\n\n some-other-pkg@2.0.0:\n", + ) + .unwrap(); + std::fs::create_dir_all(tmp.path().join("node_modules")).unwrap(); + std::fs::write(tmp.path().join("node_modules/.modules.yaml"), b"").unwrap(); + + let common = crate::args::GlobalArgs { + cwd: tmp.path().to_path_buf(), + ..Default::default() + }; + let accessible = vec![ + // Versionless: the probe has no version to anchor on. + mk_patch( + "99999999-9999-4999-8999-999999999999", + "pkg:npm/covgap-noversion", + "free", + "2024-01-01", + ), + // Versioned but absent from the lock: a judged miss. + mk_patch( + "88888888-8888-4888-8888-888888888888", + "pkg:npm/covgap-judged@1.0.0", + "free", + "2024-01-01", + ), + ]; + let out = filter_to_installed_purls( + &accessible, + &common, + crate::commands::scan::ScanMode::Hosted, + ) + .await; + assert!(out.kept.is_empty(), "neither purl may be kept"); + assert!( + out.warnings.iter().any(|(code, _)| code.contains("pnp")), + "the layout refusal must surface as a run-level warning; got {:?}", + out.warnings + ); + let code_for = |purl: &str| { + out.skip_records + .iter() + .find(|r| r["purl"] == purl) + .unwrap_or_else(|| panic!("missing skip record for {purl}"))["errorCode"] + .clone() + }; + assert_eq!( + code_for("pkg:npm/covgap-noversion"), + "pnpm_pnp_unsupported", + "an unjudgeable purl must keep the layout code" + ); + assert_eq!( + code_for("pkg:npm/covgap-judged@1.0.0"), + "package_not_installed", + "a judged miss is a genuine not-installed verdict" + ); + } + + /// `download_patch_records` with `persist_blobs`: an uncreatable blobs + /// dir (`.socket` squatted by a regular file) is a hard `error` envelope + /// BEFORE any fetch, with no records handed to the caller. + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_blobs_dir_create_failure_errors_before_any_fetch() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(".socket"), b"not a dir").unwrap(); + + let selected = vec![mk_patch( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "pkg:npm/covgap-blobfail@1.0.0", + "free", + "2024-01-01", + )]; + let mut params = detached_params(tmp.path(), server.uri()); + params.persist_blobs = true; + let (code, json, records) = download_patch_records(&selected, ¶ms).await; + + assert_eq!(code, 1, "json={json}"); + assert_eq!(json["status"], "error", "json={json}"); + assert!( + json["error"] + .as_str() + .unwrap_or_default() + .contains("blobs directory"), + "the error must name the blobs dir; json={json}" + ); + assert!(records.is_empty()); + assert!( + server.received_requests().await.unwrap_or_default().is_empty(), + "the failure must precede any fetch" + ); + assert_eq!( + std::fs::read(tmp.path().join(".socket")).unwrap(), + b"not a dir", + "the squatting file must be left untouched" + ); + } + + /// `download_patch_records` with `persist_blobs`: undecodable blob + /// content is a per-patch failure — `Blob decode or write failed`, no + /// record returned, nothing written into `.socket/blobs`. + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_persist_blobs_bad_base64_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-badblob@1.0.0"; + 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": "0".repeat(64), + "afterHash": "1".repeat(64), + "blobContent": "%%%not-base64%%%", + } + }, + "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 mut params = detached_params(tmp.path(), server.uri()); + params.persist_blobs = true; + let (code, json, records) = download_patch_records(&selected, ¶ms).await; + + assert_eq!(code, 1, "json={json}"); + assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!( + json["patches"][0]["error"], "Blob decode or write failed", + "json={json}" + ); + assert!(records.is_empty(), "a blob failure must not hand back a record"); + let blobs = tmp.path().join(".socket/blobs"); + assert!(blobs.is_dir(), "the blobs dir itself was created"); + assert_eq!( + std::fs::read_dir(&blobs).unwrap().count(), + 0, + "no blob may materialize from undecodable content" + ); + } + + /// Human-mode `download_patch_records` (json=false, silent=false): the + /// `[fetch]`, no-applicable-files `[fail]`, and fetch-miss `[fail]` + /// print paths all execute, and the envelope keeps exact per-action + /// counts alongside them. + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_human_mode_mixed_outcomes() { + 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 good_uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let good_purl = "pkg:npm/covgap-good@1.0.0"; + let nofiles_uuid = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + let nofiles_purl = "pkg:npm/covgap-nofiles@1.0.0"; + let missing_uuid = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + let missing_purl = "pkg:npm/covgap-missing@1.0.0"; + + Mock::given(method("GET")) + .and(wm_path(format!("/v0/orgs/test-org/patches/view/{good_uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": good_uuid, "purl": good_purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": "0".repeat(64), + "afterHash": "1".repeat(64), + "blobContent": "cGF0Y2hlZAo=", + } + }, + "vulnerabilities": {}, "description": "d", "license": "MIT", "tier": "free", + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(wm_path(format!( + "/v0/orgs/test-org/patches/view/{nofiles_uuid}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": nofiles_uuid, "purl": nofiles_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; + // missing_uuid's view stays unmounted -> 404 -> fetch miss. + + let tmp = tempfile::tempdir().unwrap(); + let selected = vec![ + mk_patch(good_uuid, good_purl, "free", "2024-01-01"), + mk_patch(nofiles_uuid, nofiles_purl, "free", "2024-01-01"), + mk_patch(missing_uuid, missing_purl, "free", "2024-01-01"), + ]; + let mut params = detached_params(tmp.path(), server.uri()); + params.json = false; + params.silent = false; + let (code, json, records) = download_patch_records(&selected, ¶ms).await; + + assert_eq!(code, 1, "json={json}"); + assert_eq!(json["downloaded"], 1, "json={json}"); + assert_eq!(json["failed"], 2, "json={json}"); + assert_eq!(records.len(), 1, "only the good patch yields a record"); + assert!(records.contains_key(good_purl), "json={json}"); + let errors: Vec<&str> = json["patches"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p["error"].as_str()) + .collect(); + assert!(errors.contains(&"patch has no applicable files"), "json={json}"); + assert!(errors.contains(&"could not fetch details"), "json={json}"); + } + + /// A purl already vendored DETACHED at the selected uuid is served from + /// the ledger's embedded record with ZERO network traffic — the + /// idempotent re-run contract (human mode, so the `[skip]` print runs). + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_already_vendored_detached_skips_offline() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; // trap: no mounts + let tmp = tempfile::tempdir().unwrap(); + let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let purl = "pkg:npm/covgap-vendored@1.0.0"; + + let vendor = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { purl: { + "ecosystem": "npm", + "basePurl": purl, + "uuid": uuid, + "artifact": { + "path": format!(".socket/vendor/npm/{uuid}/covgap-vendored-1.0.0.tgz"), + }, + "wiring": [], + "detached": true, + "record": { + "uuid": uuid, + "exportedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": "0".repeat(64), + "afterHash": "1".repeat(64), + } + }, + "vulnerabilities": {}, + "description": "embedded", + "license": "MIT", + "tier": "free", + } + }} + })) + .unwrap(), + ) + .unwrap(); + + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + let mut params = detached_params(tmp.path(), server.uri()); + params.json = false; + params.silent = false; + let (code, json, records) = download_patch_records(&selected, ¶ms).await; + + assert_eq!(code, 0, "json={json}"); + assert_eq!(json["skipped"], 1, "json={json}"); + assert_eq!(json["patches"][0]["action"], "skipped", "json={json}"); + assert_eq!( + records.get(purl).map(|r| r.uuid.as_str()), + Some(uuid), + "the ledger's embedded record must be reused" + ); + assert!( + server.received_requests().await.unwrap_or_default().is_empty(), + "an already-vendored entry must never touch the network" + ); + } + + /// An unreadable vendor ledger silences the drift warning (the main + /// vendor path reports unreadable state itself) instead of panicking or + /// fabricating a warning. + #[tokio::test] + async fn warn_on_vendored_uuid_drift_unreadable_state_warns_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let vendor = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write(vendor.join("state.json"), b"{ not json").unwrap(); + + let mut warnings = Vec::new(); + warn_on_vendored_uuid_drift( + tmp.path(), + true, + &[serde_json::json!({ + "purl": "pkg:npm/x@1.0.0", + "uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "action": "added", + })], + &mut warnings, + ) + .await; + assert!(warnings.is_empty(), "unreadable state must warn nothing"); + } + + /// Malformed per-patch records (missing purl/uuid) are skipped without + /// panicking, while a well-formed drifting record still warns. + #[tokio::test] + async fn warn_on_vendored_uuid_drift_skips_malformed_records_and_flags_drift() { + let tmp = tempfile::tempdir().unwrap(); + let purl = "pkg:npm/covgap-drift@1.0.0"; + let vendored_uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let new_uuid = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + let vendor = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { purl: { + "ecosystem": "npm", + "basePurl": purl, + "uuid": vendored_uuid, + "artifact": { + "path": format!(".socket/vendor/npm/{vendored_uuid}/covgap-drift-1.0.0.tgz"), + }, + "wiring": [] + }} + })) + .unwrap(), + ) + .unwrap(); + + let mut warnings = Vec::new(); + warn_on_vendored_uuid_drift( + tmp.path(), + true, + &[ + // Malformed: no purl/uuid — must be skipped, not panic. + serde_json::json!({"action": "added"}), + // Genuine drift: manifest moved to a different uuid. + serde_json::json!({"purl": purl, "uuid": new_uuid, "action": "added"}), + ], + &mut warnings, + ) + .await; + assert_eq!(warnings.len(), 1, "warnings={warnings:?}"); + assert!( + warnings[0].contains(purl) && warnings[0].contains("is vendored at patch"), + "warnings={warnings:?}" + ); + } + + /// The env guard must RESTORE a variable that was set before the scrub — + /// the suite depends on it not leaking scrubbed state across tests. + #[test] + #[serial_test::serial] + fn env_var_guard_restores_previously_set_values() { + std::env::set_var("COVGAP_GET_GUARD_PROBE", "original"); + { + let _guard = EnvVarGuard::scrub(&["COVGAP_GET_GUARD_PROBE"]); + assert!( + std::env::var("COVGAP_GET_GUARD_PROBE").is_err(), + "scrub must remove the var" + ); + } + assert_eq!( + std::env::var("COVGAP_GET_GUARD_PROBE").as_deref(), + Ok("original"), + "drop must restore the pre-scrub value" + ); + std::env::remove_var("COVGAP_GET_GUARD_PROBE"); + } } diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index cf104f52..2301c85f 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" { @@ -1394,6 +1446,87 @@ 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" + ); + } + + /// [`remove_vendor_dir`] is a best-effort guard that must never GUESS a + /// path: an eco/uuid pair that cannot map to a canonical vendor dir + /// (unknown ecosystem dir, non-canonical uuid) removes NOTHING, while + /// the mappable pair removes exactly its uuid dir. + #[tokio::test] + async fn remove_vendor_dir_refuses_unmappable_eco_or_uuid() { + let tmp = tempfile::tempdir().unwrap(); + let uuid = "11111111-1111-4111-8111-111111111111"; + let dir = tmp.path().join(format!(".socket/vendor/npm/{uuid}")); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("x.tgz"), b"bytes").await.unwrap(); + + remove_vendor_dir(tmp.path(), "jsr", uuid).await; + assert!(dir.is_dir(), "an unmappable ecosystem must remove nothing"); + remove_vendor_dir(tmp.path(), "npm", "not-a-uuid").await; + assert!(dir.is_dir(), "a non-canonical uuid must remove nothing"); + remove_vendor_dir(tmp.path(), "npm", uuid).await; + assert!(!dir.exists(), "the canonical pair removes its uuid dir"); + } + + /// 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 c9f211d8..a58cbe79 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, @@ -3541,4 +3544,567 @@ 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" + ); + } + + /// A purl absent from the source manifest contributes nothing to the + /// GC reference: the pin loop skips it (the lookup-miss `continue`) + /// rather than inserting an empty synthetic keep record, and present + /// purls around it still pin normally. + #[test] + fn pin_before_hash_blobs_skips_purls_absent_from_source() { + let mut present = make_record("uuid-present"); + present.files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: "beefbeef".to_string(), + after_hash: "cafecafe".to_string(), + }, + ); + let mut source = PatchManifest { + patches: HashMap::new(), + setup: None, + }; + source + .patches + .insert("pkg:npm/present@1.0.0".to_string(), present); + + let mut reference = PatchManifest { + patches: HashMap::new(), + setup: None, + }; + let purls = [ + "pkg:npm/ghost@9.9.9".to_string(), + "pkg:npm/present@1.0.0".to_string(), + ]; + pin_before_hash_blobs(&mut reference, &source, purls.iter()); + + assert!( + !reference.patches.contains_key("pkg:npm/ghost@9.9.9"), + "a purl the source manifest does not hold must not grow a \ + synthetic record, got {:?}", + reference.patches.keys().collect::>() + ); + let pinned = reference + .patches + .get("pkg:npm/present@1.0.0") + .expect("the present purl must still pin"); + assert_eq!(pinned.files.len(), 1, "got {:?}", pinned.files); + assert_eq!( + pinned + .files + .get("package/index.js#beforeHash-pin") + .expect("synthetic pin key") + .after_hash, + "beefbeef", + "the beforeHash must be pinned in an afterHash slot" + ); + } + + /// The vendored leg tolerates a key with no ledger entry: the scope + /// resolver guarantees keys exist, but a divergent ledger must skip + /// the key silently (the lookup-miss `continue`) rather than panic or + /// fail the leg — every outcome array stays empty. + #[tokio::test] + async fn run_vendored_leg_skips_keys_missing_from_ledger() { + let common = crate::args::GlobalArgs::default(); + let mut state = socket_patch_core::vendor::VendorState::new(); + let out = run_vendored_leg( + &common, + &["pkg:npm/ghost@1.0.0".to_string()], + &mut state, + false, + ) + .await; + assert!( + out.reverted.is_empty() + && out.preserved.is_empty() + && out.kept.is_empty() + && out.failed.is_empty() + && out.warnings.is_empty(), + "an unknown ledger key must be a silent no-op: reverted={:?} \ + preserved={:?} kept={:?} failed={:?} warnings={:?}", + out.reverted, + out.preserved, + out.kept, + out.failed, + out.warnings + ); + assert!( + state.entries.is_empty(), + "the ledger must be untouched, got {:?}", + state.entries.keys().collect::>() + ); + } } diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 60e46fb5..dd084a6f 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,177 @@ 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()); + // 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()); + } + + #[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 @@ -882,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 7ac265a4..55bd8950 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` @@ -2964,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 977284f4..a16603de 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 @@ -1605,6 +1611,15 @@ 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 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 /// unused). A missing/unreadable manifest skips (a) only (a prune must @@ -1660,14 +1675,20 @@ 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 reported as kept, never as reverted. + out.kept.push(purl); + } else { state.entries.remove(&purl); out.dropped_reverted.push(purl); - } else { - out.failed.push(purl); } } } @@ -1693,13 +1714,19 @@ 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). + out.kept.push(purl); + continue; + } state.entries.remove(&purl); if let Some(m) = manifest.as_mut() { let base = strip_purl_qualifiers(&entry.base_purl).to_string(); @@ -2019,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)] @@ -2180,6 +2272,57 @@ mod gc_tests { ); } + /// A MISSING manifest skips pass (a) entirely — a prune must not + /// mass-revert every ledger entry as "dropped" just because the + /// manifest file is gone (that is `vendor --revert`'s explicit + /// contract) — while pass (b) still runs: a lockfile-unused entry is + /// reclaimed, its manifest half is skipped (nothing to edit), and no + /// manifest file is invented; a still-wired entry is kept untouched. + #[tokio::test] + async fn vendor_gc_missing_manifest_skips_pass_a_but_b_still_runs() { + // Still wired: with no manifest, NOTHING may be reclaimed — a + // regression that treats a missing manifest as an empty one would + // land the entry in dropped_reverted. + let (tmp, common, manifest_path) = gc_fixture(false).await; + tokio::fs::remove_file(&manifest_path).await.unwrap(); + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert!( + out.dropped_reverted.is_empty(), + "no manifest must not read as every-patch-dropped: {out:?}" + ); + assert!(out.unused_reverted.is_empty(), "{out:?}"); + assert!(out.failed.is_empty(), "{out:?}"); + assert!(load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL)); + + // Dependency gone from the lock graph: (b) reclaims the entry even + // with no manifest, and invents no manifest file for its manifest + // half. + let (tmp, common, manifest_path) = gc_fixture(false).await; + tokio::fs::remove_file(&manifest_path).await.unwrap(); + tokio::fs::write(tmp.path().join("package-lock.json"), "{\"packages\":{}}") + .await + .unwrap(); + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert!(out.dropped_reverted.is_empty(), "{out:?}"); + 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(), + "the unused entry's artifacts are reclaimed" + ); + assert!( + !manifest_path.exists(), + "the GC must not invent a manifest file" + ); + } + /// Dry run lists without mutating anything. #[tokio::test] async fn vendor_gc_dry_run_is_read_only() { @@ -2379,6 +2522,215 @@ 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_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 + .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_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 + .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" + ); + } + + /// 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 + /// 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!( + 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() + .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] @@ -2404,4 +2756,534 @@ 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" + ); + } + + /// The stale-uuid sweep's dry-run guard: a dry-run caller must NEVER + /// delete the replaced uuid's dir, while the `Removed` event still + /// records (as the preview of what a wet run would reclaim). Today's + /// backends return no entry on dry runs, so this pins the helper's own + /// contract against a future caller that does. + #[tokio::test] + async fn stale_uuid_sweep_dry_run_keeps_the_dir() { + 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, + dry_run: 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)); + + 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 dry run must not delete the replaced uuid's dir" + ); + assert!( + env.events + .iter() + .any(|e| e.error_code.as_deref() == Some("vendor_stale_artifact_removed")), + "the would-be removal is still previewed as an event: {:?}", + env.events + ); + } +} + +#[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