diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 2bf5edfeb..00f659595 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1500,7 +1500,7 @@ async fn filter_to_installed_purls( if mode != super::scan::ScanMode::Agent { present.extend(supplement.entries.iter().map(|e| canon(&e.purl))); let vendored = - super::scan::project_vendored_supplement(common, &[], &ctx.loaded().await.vendor) + super::scan::project_vendored_supplement(&ctx, &[], &ctx.loaded().await.vendor) .await; present.extend(vendored.packages.iter().map(|p| canon(&p.purl))); } diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 79ca66737..f959f1f59 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -152,16 +152,20 @@ pub(crate) struct LedgerSupplement { /// the ledger `run` already loaded (`vendor::load_state`). /// /// That holds only while the lock still wires the artifact. An entry the -/// lockfile in-use probe (the one the prune GC reverts by) answers -/// `Some(false)` for is the dependency having left the lock — bumped or +/// project no longer consumes ([`Discovery::vendor_entry_in_use`] is +/// `Some(false)` — the verdict the prune GC reverts by, read from `ctx`'s +/// discovery) is the dependency having left the lock — bumped or /// uninstalled — and is reported in [`LedgerSupplement::unwired`] instead: /// re-vendoring it would fail against a lock that no longer has it. `None` -/// (no probe for the ecosystem, or no readable lock) keeps the entry. +/// (no readable lock for the ecosystem) keeps the entry. +/// +/// [`Discovery::vendor_entry_in_use`]: socket_patch_core::vex::discover::Discovery::vendor_entry_in_use pub(crate) async fn vendored_ledger_supplement( - common: &GlobalArgs, + ctx: &crate::commands::context::ProjectContext<'_>, crawled: &[socket_patch_core::crawlers::types::CrawledPackage], state: &std::io::Result, ) -> LedgerSupplement { + let common = ctx.common; let mut out = LedgerSupplement::default(); if common.is_global() { return out; @@ -205,7 +209,12 @@ pub(crate) async fn vendored_ledger_supplement( continue; } if let Some(entry) = entry { - if crate::commands::vendor::dispatch_in_use_one(entry, &common.cwd).await == Some(false) + if ctx + .discovery() + .await + .vendor_entry_in_use(&common.cwd, entry) + .await + == Some(false) { out.unwired.push(ledger_key.clone()); continue; @@ -1048,9 +1057,13 @@ mod tests { ..GlobalArgs::default() }; let state = socket_patch_core::vendor::load_state(root).await; - vendored_ledger_supplement(&args, crawled, &state) - .await - .packages + vendored_ledger_supplement( + &crate::commands::context::ProjectContext::new(&args), + crawled, + &state, + ) + .await + .packages } /// A ledger entry vendored as `@3.0.2.0` is the crawled composer @@ -1076,18 +1089,26 @@ mod tests { cwd: tmp.path().to_path_buf(), ..GlobalArgs::default() }; - let out = vendored_ledger_supplement(&args, &[crawled], &Ok(state.clone())) - .await - .packages; + let out = vendored_ledger_supplement( + &crate::commands::context::ProjectContext::new(&args), + &[crawled], + &Ok(state.clone()), + ) + .await + .packages; assert!( out.is_empty(), "{:?}", out.iter().map(|p| &p.purl).collect::>() ); - let out = vendored_ledger_supplement(&args, &[], &Ok(state)) - .await - .packages; + let out = vendored_ledger_supplement( + &crate::commands::context::ProjectContext::new(&args), + &[], + &Ok(state), + ) + .await + .packages; assert_eq!( out.iter().map(|p| p.purl.as_str()).collect::>(), vec!["pkg:composer/psr/log@3.0.2.0"] @@ -1157,7 +1178,12 @@ mod tests { }) .to_string(); let state = npm_ledger_with_lock(tmp.path(), Some(&bumped)).await; - let out = vendored_ledger_supplement(&args(tmp.path()), &[], &state).await; + let out = vendored_ledger_supplement( + &crate::commands::context::ProjectContext::new(&args(tmp.path())), + &[], + &state, + ) + .await; assert!(out.packages.is_empty(), "{:?}", out.packages); assert_eq!(out.unwired, vec!["pkg:npm/left-pad@1.3.0".to_string()]); @@ -1165,11 +1191,69 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let removed = r#"{"name":"app","lockfileVersion":3,"packages":{"":{"name":"app"}}}"#; let state = npm_ledger_with_lock(tmp.path(), Some(removed)).await; - let out = vendored_ledger_supplement(&args(tmp.path()), &[], &state).await; + let out = vendored_ledger_supplement( + &crate::commands::context::ProjectContext::new(&args(tmp.path())), + &[], + &state, + ) + .await; assert!(out.packages.is_empty(), "{:?}", out.packages); assert_eq!(out.unwired, vec!["pkg:npm/left-pad@1.3.0".to_string()]); } + /// B19: the supplement and the prune GC share one in-use verdict for + /// every ecosystem, not only npm/cargo/pypi-requirements. A COMPOSER + /// entry whose dependency composer.lock bumped to a registry release is + /// unwired — before, it was resurrected as a discovered package forever. + #[tokio::test] + async fn ledger_supplement_reports_a_bumped_composer_entry_unwired() { + const COMPOSER_PURL: &str = "pkg:composer/monolog/monolog@3.0.0"; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let leaf = format!(".socket/vendor/composer/{VENDORED_UUID}/monolog/monolog@3.0.0"); + let entry: socket_patch_core::vendor::VendorEntry = + serde_json::from_value(serde_json::json!({ + "ecosystem": "composer", + "basePurl": COMPOSER_PURL, + "uuid": VENDORED_UUID, + "artifact": {"path": leaf, "sha256": ""}, + "wiring": [], + "detached": true, + })) + .unwrap(); + let mut state = VendorState::default(); + state.entries.insert(COMPOSER_PURL.to_string(), entry); + std::fs::write( + root.join("composer.lock"), + serde_json::json!({ + "packages": [{ + "name": "monolog/monolog", + "version": "3.1.0", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc", + "reference": "abc", + }, + }], + "packages-dev": [], + }) + .to_string(), + ) + .unwrap(); + let args = GlobalArgs { + cwd: root.to_path_buf(), + ..GlobalArgs::default() + }; + let out = vendored_ledger_supplement( + &crate::commands::context::ProjectContext::new(&args), + &[], + &Ok(state), + ) + .await; + assert!(out.packages.is_empty(), "{:?}", out.packages); + assert_eq!(out.unwired, vec![COMPOSER_PURL.to_string()]); + } + /// The fresh-clone case the supplement exists for: the lock still /// resolves through the committed artifact, so the entry stays /// discoverable. With no lock at all, nothing proves the entry unused, @@ -1188,7 +1272,12 @@ mod tests { ..GlobalArgs::default() }; let state = npm_ledger_with_lock(tmp.path(), lock.as_deref()).await; - let out = vendored_ledger_supplement(&args, &[], &state).await; + let out = vendored_ledger_supplement( + &crate::commands::context::ProjectContext::new(&args), + &[], + &state, + ) + .await; assert_eq!( out.packages .iter() diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 25740fd36..29ae09b38 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1824,8 +1824,7 @@ async fn run_scan( // supplement falls back to the committed artifacts (fail-closed for the // prune), the key set degrades to empty (fail-open). let vendor_state = &ctx.loaded().await.vendor; - let ledger_supplement = - vendored_ledger_supplement(&args.common, &all_crawled, vendor_state).await; + let ledger_supplement = vendored_ledger_supplement(&ctx, &all_crawled, vendor_state).await; for pkg in &ledger_supplement.packages { if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { *eco_counts.entry(eco).or_insert(0) += 1; diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 5413d6327..6a373511d 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -395,9 +395,9 @@ pub(crate) async fn dispatch_revert_one_opts( /// same version from the registry beside a wired `yarn.lock`): name both /// locks; re-vendoring changes nothing; /// * the dependency left the lock (upgraded or uninstalled): the in-use -/// probe the prune GC reverts by says so and no lock resolves the -/// package any more, so `scan --prune` is the fix, as `scan`'s own -/// `vendor_ledger_entry_unwired` hint says; +/// verdict the prune GC reverts by ([`Discovery::vendor_entry_in_use`]) +/// says so and no lock resolves the package any more, so `scan --prune` +/// is the fix, as `scan`'s own `vendor_ledger_entry_unwired` hint says; /// * otherwise a relock dropped the reference while the package stayed. async fn unwired_check_failure( discovery: &socket_patch_core::vex::discover::Discovery, @@ -422,7 +422,7 @@ async fn unwired_check_failure( // prove the dependency is gone rather than unreadable. if matches!(entry.ecosystem.as_str(), "npm" | "pypi") && !discovery.resolves_package(&entry.base_purl) - && dispatch_in_use_one(entry, root).await == Some(false) + && discovery.vendor_entry_in_use(root, entry).await == Some(false) { return format!( "dependency removed: no lockfile resolves {} any more (it was upgraded or \ @@ -437,23 +437,6 @@ async fn unwired_check_failure( ) } -/// Is this vendored entry still consumed by its project's lockfile -/// dependency graph? `None` = cannot determine — callers must keep the -/// entry (fail-safe): ecosystems other than npm, cargo and pypi (whose -/// probe covers the requirements flavor only) have no in-use probe yet, -/// and a missing/unreadable lockfile proves nothing. -pub(crate) async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option { - match entry.ecosystem.as_str() { - "npm" => vendor::npm_flavor::vendored_entry_in_use(entry, project_root).await, - // Cargo probes the lock entry's shape: detached + `[patch]` pointing - // at this entry's copy = in use; a registry source (crates.io - // re-resolve or a hosted takeover) or a missing entry = reclaimable. - "cargo" => vendor::cargo::vendored_entry_in_use(entry, project_root).await, - "pypi" => vendor::pypi::vendored_entry_in_use(entry, project_root).await, - _ => None, - } -} - /// What the orphan sweep did with the uuid dirs no ledger entry owns. #[derive(Default)] struct OrphanSweep { @@ -1137,18 +1120,22 @@ async fn run_check(args: &VendorArgs) -> i32 { return emit_eject_refusal(&args.common, "vendor_state_unreadable", &e.to_string()) } }; - if state.entries.is_empty() - && [ - ".socket/vendor/maven2", - ".socket/vendor/gradle", - ".socket/vendor/gradle-index.tsv", - socket_patch_core::vendor::jvm::sbt::BUILD_FILE, - ] - .iter() - .chain(socket_patch_core::vendor::jvm::coursier_tree::ORPHAN_PATHS) - .any(|rel| root.join(rel).exists()) - { - return emit_eject_refusal(&args.common, "vendor_ledger_missing", "JVM artifacts exist without a vendor ledger; restore .socket/vendor/state.json from version control"); + // JVM trees are not `.socket/vendor//` dirs the reference + // scan below can name, so their layout is checked against the ledger's + // JVM entries directly: present with none of them is an orphan, whatever + // other ecosystems the ledger records. + let jvm_orphan = (!state.entries.values().any(vendor::jvm::apply::is_jvm_entry)) + .then(|| { + vendor::jvm::apply::LEDGER_OWNED_PATHS + .iter() + .copied() + .find(|rel| root.join(rel).exists()) + }) + .flatten(); + const JVM_ORPHAN_DETAIL: &str = "JVM artifacts exist without a vendor ledger entry; restore \ + .socket/vendor/state.json from version control"; + if state.entries.is_empty() && jvm_orphan.is_some() { + return emit_eject_refusal(&args.common, "vendor_ledger_missing", JVM_ORPHAN_DETAIL); } let manifest_path = args.common.resolved_manifest_path(); let manifest = match read_manifest(&manifest_path).await { @@ -1230,12 +1217,21 @@ async fn run_check(args: &VendorArgs) -> i32 { "patch has no vendored ledger entry", )); } + if let Some(rel) = jvm_orphan { + if !args.common.json { + eprintln!("vendor_ledger_missing: {JVM_ORPHAN_DETAIL}"); + } + env.record( + PatchEvent::artifact(PatchAction::Failed) + .with_error("vendor_ledger_missing", JVM_ORPHAN_DETAIL) + .with_details(serde_json::json!({ "ecosystem": "maven", "path": rel })), + ); + } // A project file still wired to a vendored artifact the ledger does not // know (the ledger was ignored or dropped from the commit along with the // manifest) leaves every fresh install failing; the manifest keys above // cannot see it, so the references are read from the wiring itself. - let references = - crate::commands::vendored_backend::repair::scan_vendor_references(root).await; + let references = crate::commands::vendored_backend::repair::scan_vendor_references(root).await; for (eco, uuid, rel) in references { let ledgered = state .entries @@ -4158,10 +4154,11 @@ pub(crate) struct VendorGcSummary { /// /// (a) revert entries whose patch was dropped from the manifest (same /// stale test as [`reconcile_dropped`], shared with the vendor flows); -/// (b) revert entries whose dependency is no longer in the lockfile graph -/// ([`dispatch_in_use_one`] == `Some(false)`; `None` keeps, fail-safe) -/// and drop their manifest entries so the caller's manifest prune + -/// blob sweep reclaims the rest in the same pass; +/// (b) revert entries the project no longer consumes +/// ([`Discovery::vendor_entry_in_use`] == `Some(false)`, the liveness +/// discovery `vendor --check` and `vex` judge by; `None` keeps, +/// fail-safe) and drop their manifest entries so the caller's manifest +/// prune + blob sweep reclaims the rest in the same pass; /// (c) sweep orphan uuid dirs. /// /// A drift-skipped revert ([`RevertOutcome::kept_artifact`]) keeps the @@ -4232,8 +4229,11 @@ pub(crate) async fn run_vendor_gc( } } - // (b) lockfile-unused entries — detached ones included: the probe asks - // the live lockfile wiring, which a detached entry has like any other. + // (b) lockfile-unused entries — detached ones included: the verdict + // reads the live lockfile wiring, which a detached entry has like any + // other. Every verdict is taken from ONE discovery of the project as + // (a) left it, before (b) reverts anything: a revert rewrites locks, + // and the entries still to judge must not see a half-pruned state. let mut manifest_dirty = false; let candidates: Vec = state .entries @@ -4243,11 +4243,19 @@ pub(crate) async fn run_vendor_gc( }) .map(|(purl, _)| purl.clone()) .collect(); - for purl in candidates { - let entry = state.entries.get(&purl).cloned().expect("listed above"); - if dispatch_in_use_one(&entry, &common.cwd).await != Some(false) { - continue; // in use, or cannot determine — keep + let mut unused: Vec = Vec::new(); + if !candidates.is_empty() { + let discovery = crate::commands::discover_wiring(common, &common.cwd).await; + for purl in candidates { + let entry = state.entries.get(&purl).expect("listed above"); + // In use, or cannot determine — keep. + if discovery.vendor_entry_in_use(&common.cwd, entry).await == Some(false) { + unused.push(purl); + } } + } + for purl in unused { + let entry = state.entries.get(&purl).cloned().expect("listed above"); if dry_run { out.unused_reverted.push(purl); continue; @@ -5210,7 +5218,7 @@ mod gc_tests { tokio::fs::write( root.join("package-lock.json"), format!( - "{{\"packages\":{{\"node_modules/left-pad\":{{\"resolved\":\"file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz\"}}}}}}" + "{{\"packages\":{{\"node_modules/left-pad\":{{\"version\":\"1.3.0\",\"resolved\":\"file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz\"}}}}}}" ), ) .await @@ -5232,6 +5240,10 @@ mod gc_tests { let out = run_vendor_gc(&common, &manifest_path, false).await; assert!(out.dropped_reverted.is_empty(), "{out:?}"); assert!(out.unused_reverted.is_empty(), "{out:?}"); + assert!( + out.failed.is_empty(), + "an in-use entry is never reverted: {out:?}" + ); assert_eq!(out.orphan_dirs, 0); assert!(load_state(tmp.path()) .await @@ -5240,6 +5252,48 @@ mod gc_tests { .contains_key(PURL)); } + /// An attestation drop is not a liveness verdict: a lockfileVersion 2 + /// lock still installs `node_modules/left-pad` from the vendored tarball + /// (npm 7+) while its legacy `dependencies` mirror (npm <= 6) resolves + /// the registry. Discovery refuses to attest that wiring, but the GC + /// must not unwire a patch npm 7+ still installs. + #[tokio::test] + async fn vendor_gc_keeps_an_entry_whose_wiring_is_only_unattributable() { + let (tmp, common, manifest_path) = gc_fixture(false).await; + tokio::fs::write( + tmp.path().join("package-lock.json"), + serde_json::json!({ + "lockfileVersion": 2, + "packages": { + "": {"dependencies": {"left-pad": "1.3.0"}}, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": format!("file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"), + }, + }, + "dependencies": { + "left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + }, + }, + }) + .to_string(), + ) + .await + .unwrap(); + for dry_run in [true, false] { + let out = run_vendor_gc(&common, &manifest_path, dry_run).await; + assert!(out.unused_reverted.is_empty(), "dry_run={dry_run}: {out:?}"); + assert!(out.failed.is_empty(), "dry_run={dry_run}: {out:?}"); + } + assert!(load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL)); + } + /// (a) the patch is gone from the manifest: revert + drop the entry. /// /// The fixture entry carries EMPTY wiring (a synthetic ledger, not a @@ -5473,9 +5527,100 @@ mod gc_tests { assert!(wet.unused_reverted.is_empty(), "{wet:?}"); } + /// B19: a vendored COMPOSER entry whose dependency was bumped to a + /// registry release in composer.lock is reclaimed by the GC — composer + /// (like gem, golang, nuget, maven and most pypi flavors) used to have + /// no in-use probe, so the GC kept it forever — while the same entry is + /// kept as long as the lock installs from its vendored path dist. + #[tokio::test] + async fn vendor_gc_reclaims_unused_composer_entry_and_keeps_a_wired_one() { + const COMPOSER_PURL: &str = "pkg:composer/monolog/monolog@3.0.0"; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let leaf = format!(".socket/vendor/composer/{UUID}/monolog/monolog@3.0.0"); + tokio::fs::create_dir_all(root.join(&leaf)).await.unwrap(); + tokio::fs::write(root.join(&leaf).join("composer.json"), b"{}") + .await + .unwrap(); + let mut state = VendorState::default(); + let mut entry = entry(true); + entry.ecosystem = "composer".into(); + entry.base_purl = COMPOSER_PURL.into(); + entry.flavor = None; + entry.artifact.path = leaf.clone(); + state.entries.insert(COMPOSER_PURL.to_string(), entry); + save_state(root, &state).await.unwrap(); + let common = GlobalArgs { + cwd: root.to_path_buf(), + json: true, + silent: true, + ..GlobalArgs::default() + }; + let manifest_path = root.join(".socket/manifest.json"); + + // The lock installs from the vendored path dist: in use, kept. + tokio::fs::write( + root.join("composer.lock"), + serde_json::json!({ + "packages": [{ + "name": "monolog/monolog", + "version": "3.0.0", + "dist": {"type": "path", "url": leaf, "reference": UUID}, + "transport-options": {"symlink": false}, + }], + "packages-dev": [], + }) + .to_string(), + ) + .await + .unwrap(); + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert!(out.unused_reverted.is_empty(), "{out:?}"); + assert!(load_state(root) + .await + .unwrap() + .entries + .contains_key(COMPOSER_PURL)); + + // Bumped to a registry release: nothing installs the vendored copy. + tokio::fs::write( + root.join("composer.lock"), + serde_json::json!({ + "packages": [{ + "name": "monolog/monolog", + "version": "3.1.0", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc", + "reference": "abc", + }, + }], + "packages-dev": [], + }) + .to_string(), + ) + .await + .unwrap(); + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert_eq!( + out.unused_reverted, + vec![COMPOSER_PURL.to_string()], + "{out:?}" + ); + assert!(out.failed.is_empty(), "{out:?}"); + assert!(load_state(root).await.unwrap().entries.is_empty()); + assert!( + !root + .join(format!(".socket/vendor/composer/{UUID}")) + .exists(), + "the reclaimed entry's artifact is removed" + ); + } + /// A vendored CARGO entry displaced by a hosted takeover (its lock entry /// re-sourced to a socket-patch sparse index) is reclaimable by the GC - /// through `dispatch_in_use_one`'s cargo probe, which drops the + /// through the discovery in-use verdict (the cargo extractor's lock + /// shape rule), which drops the /// build-breaking `[patch.crates-io]` entry. #[tokio::test] async fn vendor_gc_reclaims_cargo_entry_displaced_by_hosted_takeover() { @@ -6240,22 +6385,30 @@ mod revert_dispatch_tests { ); } - /// [`dispatch_in_use_one`]'s fail-safe arm: every ecosystem without an - /// in-use probe (everything but npm/cargo), and a pypi entry of a flavor - /// without one (here the pre-flavor `None`), reports `None` — "cannot - /// determine" — which all callers must treat as KEEP. + /// [`Discovery::vendor_entry_in_use`]'s fail-safe arm: with no file of + /// the entry's ecosystem to read (no lock), every ecosystem — and an + /// unknown one — reports `None`, "cannot determine", which all callers + /// must treat as KEEP. #[tokio::test] - async fn in_use_probe_is_none_for_unprobed_ecosystems() { + async fn in_use_is_none_without_a_lock() { let tmp = tempfile::tempdir().unwrap(); + let discovery = socket_patch_core::vex::discover_patched_refs(tmp.path()).await; for (eco, purl) in [ + ("npm", "pkg:npm/left-pad@1.3.0"), + ("cargo", "pkg:cargo/cfg-if@1.0.4"), ("gem", "pkg:gem/rails@6.0.3"), ("pypi", "pkg:pypi/foo@1.0.0"), + ("composer", "pkg:composer/monolog/monolog@3.0.0"), + ("golang", "pkg:golang/github.com/pkg/errors@v0.9.1"), + ("nuget", "pkg:nuget/Newtonsoft.Json@13.0.1"), ("frobnicate", "pkg:frobnicate/x@1.0.0"), ] { assert_eq!( - dispatch_in_use_one(&entry_for(eco, purl), tmp.path()).await, + discovery + .vendor_entry_in_use(tmp.path(), &entry_for(eco, purl)) + .await, None, - "`{eco}` has no in-use probe — must report undeterminable (keep)" + "`{eco}` with no lock must report undeterminable (keep)" ); } } diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index 71767eca0..a974a3d46 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -2582,7 +2582,7 @@ async fn scan_human_table_shows_full_purl_with_version() { // --------------------------------------------------------------------------- /// `scan --mode vendored --prune`: the lock-driven GC keeps the vlt entry -/// `vlt-lock.json` still resolves to its dir (`vendored_entry_in_use` is +/// `vlt-lock.json` still resolves to its dir (the in-use verdict is /// structural: a `file` node under the uuid) and reclaims one it does not. #[tokio::test] async fn scan_prune_keeps_a_wired_vlt_uuid_and_sweeps_an_unwired_one() { diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs index 67368f84a..d577832d4 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs @@ -1291,7 +1291,7 @@ fn cargo_vendored_f_lock_tag_for_another_uuid_never_attests() { } /// a (first build pending): no Cargo.lock yet — the `[patch]` wiring is -/// what cargo will build, so it attests (the `vendored_entry_in_use` rule). +/// what cargo will build, so it attests (the prune GC keeps it in use). #[test] fn cargo_vendored_a_attests_before_the_first_lock() { let fx = Fx::new(); diff --git a/crates/socket-patch-cli/tests/vendor_jvm_cli.rs b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs index 5a26b241d..241498dc5 100644 --- a/crates/socket-patch-cli/tests/vendor_jvm_cli.rs +++ b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs @@ -657,6 +657,54 @@ fn check_refuses_a_missing_ledger_and_honors_manifest_path() { assert_eq!(snapshot(root), before); } +/// B62: committed JVM trees with no JVM ledger entry are orphans even when +/// the ledger records OTHER ecosystems' entries — the guard used to fire +/// only on an entirely empty ledger. +#[test] +fn check_reports_jvm_trees_without_a_jvm_entry_beside_other_entries() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + ok(root, &["vendor"]); + std::fs::remove_file(root.join("proj/.socket/manifest.json")).unwrap(); + let state_path = root.join("proj/.socket/vendor/state.json"); + let mut state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + let other = "1a2b3c4d-5e6f-4a1b-8c2d-9e0f1a2b3c4d"; + state["entries"] = serde_json::json!({ + "pkg:npm/left-pad@1.3.0": { + "ecosystem": "npm", + "basePurl": "pkg:npm/left-pad@1.3.0", + "uuid": other, + "artifact": { + "path": format!(".socket/vendor/npm/{other}/left-pad-1.3.0.tgz"), + "sha256": "", + }, + "wiring": [], + } + }); + std::fs::write(&state_path, state.to_string()).unwrap(); + let before = snapshot(root); + let (code, env) = socket(root, &["vendor", "--check"]); + assert_eq!(code, Some(1), "{env}"); + let events = env["events"].as_array().expect("events"); + let orphan = events + .iter() + .find(|e| { + e.to_string().contains("vendor_ledger_missing") && e["details"]["ecosystem"] == "maven" + }) + .unwrap_or_else(|| panic!("no JVM orphan event: {env}")); + // The event names the orphaned JVM layout itself, not the npm entry + // (which fails `--check` on its own for its missing artifact). + let path = orphan["details"]["path"].as_str().expect("orphan path"); + assert!( + socket_patch_core::vendor::jvm::apply::LEDGER_OWNED_PATHS.contains(&path), + "{path}: {env}" + ); + assert!(root.join("proj").join(path).exists(), "{path}"); + assert_eq!(snapshot(root), before); +} + #[test] fn vex_reports_an_unreadable_jvm_layout_instead_of_an_unwired_patch() { for shape in [Shape::Reactor, Shape::Gradle] { diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 4a933b2c0..be6150df5 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -190,50 +190,6 @@ async fn is_vendored(project_root: &Path, name: &str, version: &str) -> bool { false } -/// Is this vendored cargo entry still consumed by the project's `Cargo.lock` -/// dependency graph? The lock is the truth source: -/// -/// * entry absent from the lock → `Some(false)` (the dependency left the -/// graph; the `[patch]` would be unused); -/// * entry carries a registry `source` (crates.io re-resolve or a hosted -/// socket-patch takeover) → `Some(false)` — the committed copy is NOT what -/// the lock consumes, so GC may reclaim the entry (its revert restores / -/// keeps the registry resolution and drops the dead `[patch]` wiring); -/// * entry detached (tagged for any uuid, or untagged — vendored before -/// tagged versions) AND a Socket-owned `[patch.crates-io]` entry (root -/// manifest, or a legacy project-config one) points at THIS entry's -/// committed copy → `Some(true)` (the wired vendored shape). A lock tag -/// for ANOTHER uuid is then only stale (a checkout/merge of the lock from -/// another patch generation): cargo re-locks any unlocked build to this -/// copy, and the vendor hot path retags it — reclaiming the entry would -/// leave the stale tag with no provider and silently build pristine -/// crates.io bytes; -/// * detached but the `[patch]` points elsewhere / is gone → `Some(false)` -/// (nothing consumes the copy — a lock tagged for another uuid builds -/// that generation's copy; the revert re-attaches the recorded registry -/// originals, repairing the half-wired lock); -/// * no readable lock → `None` (cannot determine — callers keep, fail-safe). -pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { - let (name, version) = parse_cargo_purl(&entry.base_purl)?; - let (name, version) = (name.as_ref(), version.as_ref()); - match cargo_lock::probe_lock_entry_for(project_root, name, version, Some(&entry.uuid)).await { - cargo_lock::LockEntryProbe::NoLockfile | cargo_lock::LockEntryProbe::Unreadable => None, - cargo_lock::LockEntryProbe::EntryMissing => Some(false), - cargo_lock::LockEntryProbe::Source(_) => Some(false), - cargo_lock::LockEntryProbe::Detached(_) => { - let marker = vendor_uuid_dir_rel("cargo", &entry.uuid)?; - let wired = socket_patch_paths(project_root, name) - .await - .iter() - .any(|p| { - cargo_manifest::normalize_socket_path(p) - .is_some_and(|n| n.starts_with(&format!("{marker}/"))) - }); - Some(wired) - } - } -} - /// The run's parse of the workspace-root `Cargo.toml` for the per-crate /// pre-flight, which only reads it: a cargo vendor run asks it about every /// patched crate, and on an in-sync re-run the manifest never changes. See @@ -3999,6 +3955,15 @@ mod tests { // ── cross-mode takeover: in-use probe + fail-closed hosted guard ───── + /// The prune GC's in-use verdict for `entry` + /// ([`crate::vex::discover::Discovery::vendor_entry_in_use`]). + async fn in_use(entry: &VendorEntry, root: &Path) -> Option { + crate::vex::discover::discover_patched_refs(root) + .await + .vendor_entry_in_use(root, entry) + .await + } + fn ledger_entry_for(uuid: &str) -> VendorEntry { VendorEntry { ecosystem: "cargo".into(), @@ -4040,20 +4005,20 @@ mod tests { tokio::fs::remove_file(root.join("Cargo.lock")) .await .unwrap(); - assert_eq!(vendored_entry_in_use(&entry_probe, root).await, None); + assert_eq!(in_use(&entry_probe, root).await, None); tokio::fs::write(root.join("Cargo.lock"), lock_body()) .await .unwrap(); // Registry-sourced (pre-vendor / re-resolved): not consumed. - assert_eq!(vendored_entry_in_use(&entry_probe, root).await, Some(false)); + assert_eq!(in_use(&entry_probe, root).await, Some(false)); // Fully vendored: detached lock + our [patch] entry ⇒ in use. let (result, entry, _w) = expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); assert!(result.success, "{:?}", result.error); let entry = entry.unwrap(); - assert_eq!(vendored_entry_in_use(&entry, root).await, Some(true)); + assert_eq!(in_use(&entry, root).await, Some(true)); // Hosted takeover shape: the lock re-sourced to a socket-patch sparse // index (the [patch] entry survives, but nothing consumes the copy). @@ -4066,7 +4031,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(vendored_entry_in_use(&entry, root).await, Some(false)); + assert_eq!(in_use(&entry, root).await, Some(false)); // Dependency left the lock graph entirely: reclaimable. tokio::fs::write( @@ -4075,7 +4040,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(vendored_entry_in_use(&entry, root).await, Some(false)); + assert_eq!(in_use(&entry, root).await, Some(false)); // Detached lock but the [patch] points at ANOTHER uuid's copy: this // entry's artifact is not what the lock consumes. @@ -4085,10 +4050,7 @@ mod tests { ) .await .unwrap(); - assert_eq!( - vendored_entry_in_use(&ledger_entry_for(UUID2), root).await, - Some(false) - ); + assert_eq!(in_use(&ledger_entry_for(UUID2), root).await, Some(false)); } /// FAIL CLOSED: vendoring over a LIVE hosted redirect the upstream @@ -5075,8 +5037,8 @@ mod tests { let (_, e, w) = expect_done(run_vendor(purl, root, &blobs, src, rec, false).await); assert!(e.is_none() && w.is_empty(), "{purl}: {w:?}"); } - assert_eq!(vendored_entry_in_use(&e1, root).await, Some(true)); - assert_eq!(vendored_entry_in_use(&e2, root).await, Some(true)); + assert_eq!(in_use(&e1, root).await, Some(true)); + assert_eq!(in_use(&e2, root).await, Some(true)); // Reverting one version leaves the other wired. assert!(revert_cargo_vendor(&e2, root, false).await.success); assert_eq!(manifest_path(root).await, Some(copy_rel())); @@ -6240,14 +6202,14 @@ mod tests { let (_, entry, _) = expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); let entry = entry.unwrap(); - assert_eq!(vendored_entry_in_use(&entry, root).await, Some(true)); + assert_eq!(in_use(&entry, root).await, Some(true)); let stale = lock_text(root).await.replace(UUID, UUID2); tokio::fs::write(root.join("Cargo.lock"), &stale) .await .unwrap(); - assert_eq!(vendored_entry_in_use(&entry, root).await, Some(true)); + assert_eq!(in_use(&entry, root).await, Some(true)); assert_eq!( - vendored_entry_in_use(&ledger_entry_for(UUID2), root).await, + in_use(&ledger_entry_for(UUID2), root).await, Some(false), "the lock's uuid is wired nowhere" ); @@ -6257,7 +6219,7 @@ mod tests { assert!(result.success, "{:?}", result.error); assert!(warnings.iter().any(|w| w.code == VERSION_TAGGED)); assert!(lock_text(root).await.contains(&tagged(UUID))); - assert_eq!(vendored_entry_in_use(&entry, root).await, Some(true)); + assert_eq!(in_use(&entry, root).await, Some(true)); } /// A patch that edits the crate's own `Cargo.toml`: the tag is written @@ -6461,7 +6423,7 @@ mod tests { tokio::fs::write(root.join("Cargo.lock"), &with_fork) .await .unwrap(); - assert_eq!(vendored_entry_in_use(&entry, root).await, Some(true)); + assert_eq!(in_use(&entry, root).await, Some(true)); let (result, again, warnings) = expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); diff --git a/crates/socket-patch-core/src/vendor/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs index 421249265..beab455b8 100644 --- a/crates/socket-patch-core/src/vendor/jvm/apply.rs +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -697,15 +697,28 @@ pub async fn revert(root: &Path, entry: &VendorEntry, opts: RevertOpts) -> Rever /// The vendored repository trees JVM entries write under `.socket/vendor` /// (sbt's Coursier tree included). pub(crate) const VENDOR_TREES: &[&str] = &[ - ".socket/vendor/maven2", - ".socket/vendor/gradle", + maven_reactor::TREE_ROOT, + gradle::TREE_ROOT, coursier_tree::TREE_ROOT, ]; +/// The committed JVM layout only a JVM ledger entry ([`is_jvm_entry`]) can +/// own — the repository trees plus the indexes and the generated sbt build +/// file beside them. Any of them present with no JVM entry in the ledger is +/// an orphan: `vendor --check` reports `vendor_ledger_missing` for it. +pub const LEDGER_OWNED_PATHS: &[&str] = &[ + maven_reactor::TREE_ROOT, + gradle::TREE_ROOT, + gradle::INDEX_REL, + coursier_tree::TREE_ROOT, + coursier_tree::INDEX_REL, + sbt::BUILD_FILE, +]; + /// Owned directories pruned once empty, up to and including themselves. const OWNED_DIRS: &[&str] = &[ - ".socket/vendor/maven2", - ".socket/vendor/gradle", + maven_reactor::TREE_ROOT, + gradle::TREE_ROOT, ".socket/gradle", coursier_tree::TREE_ROOT, ]; @@ -794,6 +807,9 @@ pub fn entry_wired(root: &Path, entry: &VendorEntry) -> bool { /// Whether the project still references the JVM `entry`'s tree (its /// suffixed version in a reactor pom; its index rows plus the root apply /// line for Gradle): what a revert must not pull from under a peer. +/// Fails closed: a file that exists but cannot be read or parsed (or +/// resolves outside the checkout) proves nothing absent, so the answer is +/// `true` — the `scan --prune` GC reverts on `false`. pub fn entry_references(root: &Path, entry: &VendorEntry) -> bool { let Ok((g, a, v)) = entry_gav(entry) else { return true; @@ -806,15 +822,18 @@ pub fn entry_references(root: &Path, entry: &VendorEntry) -> bool { }; let reader = ProjectReader::new(root); let read = |rel: &str| reader.read(rel); - if sbt::owns(&entry.wiring) { - return sbt::wired_checked(&read, &c).unwrap_or(true); - } - if scala_cli::owns(&entry.wiring) { - return scala_cli::wired_checked(&read, &c).unwrap_or(true); - } - let (maven, gradle) = sides(&entry.wiring); - (maven && maven_reactor::wired_checked(&read, &c).unwrap_or(true)) - || (gradle && gradle::references(&read, &c)) + let referenced = if sbt::owns(&entry.wiring) { + sbt::wired_checked(&read, &c).unwrap_or(true) + } else if scala_cli::owns(&entry.wiring) { + scala_cli::wired_checked(&read, &c).unwrap_or(true) + } else { + let (maven, gradle) = sides(&entry.wiring); + (maven && maven_reactor::wired_checked(&read, &c).unwrap_or(true)) + || (gradle && gradle::references_checked(&read, &c).unwrap_or(true)) + }; + // `read` answers `None` for an unreadable file as for a missing one; + // the error it recorded is what tells the two apart. + referenced || reader.read_error.borrow().is_some() } /// The liveness proof `vex` needs: every half of the entry is wired, and @@ -1131,6 +1150,53 @@ mod tests { } } + /// The `scan --prune` GC reverts when [`entry_references`] says + /// `false`, so a Gradle file that exists but cannot be read or parsed + /// must answer `true` (keep), as the maven/sbt/scala-cli arms do. + #[test] + fn gradle_references_fail_closed_on_unreadable_files() { + let gradle_entry = || { + entry(vec![ + record(SETTINGS_FRAGMENT_KIND, "settings.gradle"), + record(TREE_KIND, ".socket/vendor/gradle/g/a/1/a-1.jar"), + ]) + }; + assert!(is_jvm_entry(&gradle_entry())); + // The root settings file still applies the script. + let settings = |dir: &Path| { + std::fs::write( + dir.join("settings.gradle"), + "apply from: '.socket/gradle/socket-patch.settings.gradle' // socket-patch\n", + ) + .unwrap() + }; + // Decidable: applied, but no index lists the entry — unreferenced. + let dir = tempfile::tempdir().unwrap(); + settings(dir.path()); + assert!(!entry_references(dir.path(), &gradle_entry())); + // A malformed index proves nothing absent. + let dir = tempfile::tempdir().unwrap(); + settings(dir.path()); + std::fs::create_dir_all(dir.path().join(".socket/vendor")).unwrap(); + std::fs::write(dir.path().join(gradle::INDEX_REL), "garbage\n").unwrap(); + assert!(entry_references(dir.path(), &gradle_entry())); + // An index the reader cannot read (here: a link out of the checkout). + #[cfg(unix)] + { + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + settings(dir.path()); + std::fs::create_dir_all(dir.path().join(".socket/vendor")).unwrap(); + std::fs::write(outside.path().join("index.tsv"), "x").unwrap(); + std::os::unix::fs::symlink( + outside.path().join("index.tsv"), + dir.path().join(gradle::INDEX_REL), + ) + .unwrap(); + assert!(entry_references(dir.path(), &gradle_entry())); + } + } + #[test] fn recorded_paths_are_whitelisted_per_kind() { let c = coords(); diff --git a/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs b/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs index 9b22ebf7d..5ba27e4af 100644 --- a/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs +++ b/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs @@ -46,10 +46,6 @@ pub const CAPTURED_FILES: &[&str] = &[ super::sbt::TREE_GITIGNORE_REL, ]; -/// Paths whose presence without a vendor ledger means JVM artifacts were -/// orphaned (`vendor --check`'s `vendor_ledger_missing`). -pub const ORPHAN_PATHS: &[&str] = &[TREE_ROOT, INDEX_REL]; - /// The patch's tree directory (same GAV). pub fn tree_dir(c: &Coords<'_>) -> String { format!( diff --git a/crates/socket-patch-core/src/vendor/jvm/gradle.rs b/crates/socket-patch-core/src/vendor/jvm/gradle.rs index ea3df8415..8922ef071 100644 --- a/crates/socket-patch-core/src/vendor/jvm/gradle.rs +++ b/crates/socket-patch-core/src/vendor/jvm/gradle.rs @@ -1171,30 +1171,52 @@ fn undo_replace_eol(text: &str, from: &str, to: &str) -> Option { } /// Whether the root settings file applies the script and the index lists -/// `c`'s rows (the wiring revert and peers rely on). +/// `c`'s rows (the wiring revert and peers rely on). A malformed index or +/// non-UTF-8 settings file reads as unreferenced here; see +/// [`references_checked`] for the undecidable verdict. pub fn references(read: ReadFn<'_>, c: &Coords<'_>) -> bool { + references_checked(read, c).unwrap_or(false) +} + +/// [`references`], `None` when a file it decides by exists but cannot be +/// parsed (a malformed index, a non-UTF-8 settings file): that proves +/// nothing absent, so a GC must keep the tree. +pub fn references_checked(read: ReadFn<'_>, c: &Coords<'_>) -> Option { let gav = format!("{}:{}:{}", c.group_id, c.artifact_id, c.version); - let indexed = read(INDEX_REL) - .and_then(|b| String::from_utf8(b).ok()) - .and_then(|index| index_rows(&index)) - .is_some_and(|rows| { - rows.iter().any(|r| { - let cols: Vec<&str> = r.split('\t').collect(); - cols.first() == Some(&gav.as_str()) && cols.get(3) == Some(&c.uuid) - }) - }); - let wiring = WiringTarget::vendored(); - let applied = ["settings.gradle", "settings.gradle.kts"] - .iter() - .any(|rel| { - read(rel) - .and_then(|b| String::from_utf8(b).ok()) - .is_some_and(|text| { - let dsl = dsl::dsl_of(rel).unwrap_or(Dsl::Groovy); - has_apply_line(&text, dsl, &wiring, "") + let indexed = match read(INDEX_REL) { + None => Some(false), + Some(bytes) => String::from_utf8(bytes) + .ok() + .and_then(|index| index_rows(&index)) + .map(|rows| { + rows.iter().any(|r| { + let cols: Vec<&str> = r.split('\t').collect(); + cols.first() == Some(&gav.as_str()) && cols.get(3) == Some(&c.uuid) }) - }); - indexed && applied + }), + }; + let wiring = WiringTarget::vendored(); + let mut applied = Some(false); + for rel in ["settings.gradle", "settings.gradle.kts"] { + let Some(bytes) = read(rel) else { + continue; + }; + match String::from_utf8(bytes) { + Ok(text) => { + let dsl = dsl::dsl_of(rel).unwrap_or(Dsl::Groovy); + if has_apply_line(&text, dsl, &wiring, "") { + applied = Some(true); + break; + } + } + Err(_) => applied = None, + } + } + match (indexed, applied) { + (Some(false), _) | (_, Some(false)) => Some(false), + (Some(true), Some(true)) => Some(true), + _ => None, + } } /// The liveness proof `vex` needs for this layout: `c` is diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index 66a162c17..a2eedbe97 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -210,7 +210,10 @@ async fn maven_prelude( )); } - let wired = pom_xml_text.contains(&repo_id); + // Wired = our `` id where Maven reads it: a commented-out + // or profile-scoped block serves nothing (the same masking the + // forward writer's anchors use), so it must not take the hot path. + let wired = find_wireable_anchor(&pom_xml_text, &repo_id).is_some(); let in_sync = wired && artifact_in_sync(&leaf_dir, &jar_leaf, &pom_leaf, &record.files).await; Ok(MavenPrelude { group_id: group_id.to_string(), @@ -2649,6 +2652,48 @@ mod tests { ); } + /// B61: a commented-out vendored `` is not wiring Maven + /// reads, so a re-run must rewire instead of taking the in-sync hot path + /// on a raw substring match of the repository id. + #[tokio::test] + #[serial_test::serial] + async fn commented_out_repository_is_not_wired() { + let (dir, blobs, installed, record) = fixture(Some(project_pom()), true, true).await; + let root = dir.path(); + let (r1, e1, _) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r1.success && e1.is_some()); + + let block = repository_block( + &format!("{VENDOR_REPO_ID_PREFIX}{UUID}"), + &format!(".socket/vendor/maven/{UUID}"), + ); + let pom = tokio::fs::read_to_string(root.join(PROJECT_POM)) + .await + .unwrap(); + assert!(pom.contains(&block), "first run wires our block: {pom}"); + tokio::fs::write( + root.join(PROJECT_POM), + pom.replacen(&block, &format!("\n"), 1), + ) + .await + .unwrap(); + + let (r2, e2, _) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r2.success, "{:?}", r2.error); + assert!( + e2.is_some(), + "a commented-out repository must not take the in-sync hot path" + ); + let rewired = tokio::fs::read_to_string(root.join(PROJECT_POM)) + .await + .unwrap(); + assert!( + strip_xml_comments(&rewired) + .contains(&format!("{VENDOR_REPO_ID_PREFIX}{UUID}")), + "the re-run wires a live repository: {rewired}" + ); + } + #[tokio::test] #[serial_test::serial] async fn wired_missing_artifact_rebuilds_only() { diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index ce4fc0078..62c183c41 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -25,7 +25,7 @@ use std::path::Path; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; -use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; +use crate::utils::fs::read_regular_to_string; use super::lock_inventory::ProjectView; use super::source::PackageSource; @@ -34,9 +34,7 @@ use super::{ bun_lock, npm_lock, pnpm_lock, pnpm_lock_legacy, vlt_lock, yarn_berry_lock, yarn_classic_lock, RevertOpts, RevertOutcome, VendorOutcome, VendorWarning, }; -use crate::constants::npm_family::{ - BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, PNP_MARKERS, VLT_LOCK, -}; +use crate::constants::npm_family::{PNPM_LOCK, PNP_MARKERS, VLT_LOCK}; use crate::formats::governing_locks::{ npm_governing_family, npm_lock_files, npm_locks_outside, NpmLockFamily, }; @@ -639,58 +637,6 @@ pub async fn lock_text_refusals( refusals } -/// Is this npm-vendored entry still consumed by its lockfile's dependency -/// graph? -/// -/// `Some(true)`: the lockfile still resolves something to the entry's -/// artifact. `Some(false)`: the lockfile is present and parses but no -/// resolution references `.socket/vendor/npm//` — the dependency -/// was removed and re-locked, so the vendoring is unused (an override/ -/// resolutions DECLARATION alone does not count: pnpm's mirrored -/// `overrides:` section is excluded by the flavor probe, and the other -/// flavors carry no declaration inside the lock at all). `None`: cannot -/// determine (missing lock, unknown flavor) — callers keep the entry, -/// fail-safe. Detached entries are wired into the lock exactly like -/// manifest-tracked ones, so the probe applies to every entry. -pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { - match NpmLockFlavor::from_recorded(entry.flavor.as_deref())? { - NpmLockFlavor::Pnpm => pnpm_lock::pnpm_entry_in_use(entry, project_root).await, - NpmLockFlavor::PnpmLegacy => { - pnpm_lock_legacy::pnpm_legacy_entry_in_use(entry, project_root).await - } - // The remaining flavors wire resolutions into the lock itself - // (resolved URLs / file: ranges / package tuples), so a textual - // probe for the uuid dir is exact: the path appears iff some - // resolution still points at the artifact. Both npm locks are - // probed: npm <= 11 installs from the shrinkwrap, npm 12 from the - // package-lock beside it. - NpmLockFlavor::PackageLock => { - lock_text_mentions_uuid(project_root, &NPM_LOCKS, &entry.uuid).await - } - NpmLockFlavor::YarnClassic | NpmLockFlavor::YarnBerry => { - lock_text_mentions_uuid(project_root, &["yarn.lock"], &entry.uuid).await - } - NpmLockFlavor::Bun => { - if super::lock_inventory::bun::bun_text_lock_present(project_root).await { - return lock_text_mentions_uuid(project_root, &[BUN_LOCK], &entry.uuid).await; - } - let bytes = read_regular_to_bytes(&project_root.join(BUN_LOCKB)) - .await - .ok()?; - let needle = format!(".socket/vendor/npm/{}/", entry.uuid); - // The string pool can retain superseded paths. Only active - // package resolutions count, so stale bytes do not prevent GC. - Some( - super::bun_lockb::BunLockb::parse_packages(&bytes) - .ok()? - .iter() - .any(|package| package.resolution.contains(&needle)), - ) - } - NpmLockFlavor::Vlt => vlt_lock::vlt_entry_in_use(entry, project_root).await, - } -} - /// Every readable lockfile from `names`, probed for the uuid artifact dir: /// `Some(true)` when ANY of them mentions it, `Some(false)` when at least one /// was readable and none does, `None` when none was readable. Shared with @@ -1841,6 +1787,15 @@ mod tests { } /// One minimal npm vendor entry stamped with the given flavor. + /// The prune GC's in-use verdict for `entry` + /// ([`crate::vex::discover::Discovery::vendor_entry_in_use`]). + async fn in_use(entry: &VendorEntry, root: &Path) -> Option { + crate::vex::discover::discover_patched_refs(root) + .await + .vendor_entry_in_use(root, entry) + .await + } + fn probe_entry(flavor: Option<&str>) -> VendorEntry { VendorEntry { ecosystem: "npm".into(), @@ -1877,22 +1832,22 @@ mod tests { // Missing lock: undeterminable. let tmp = tempfile::tempdir().unwrap(); - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, None); + assert_eq!(in_use(&entry, tmp.path()).await, None); // Lock resolves to our artifact: in use. touch( tmp.path(), "package-lock.json", &format!( - "{{\"packages\":{{\"node_modules/left-pad\":{{\"resolved\":\"file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz\"}}}}}}" + "{{\"packages\":{{\"node_modules/left-pad\":{{\"version\":\"1.3.0\",\"resolved\":\"file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz\"}}}}}}" ), ) .await; - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(true)); + assert_eq!(in_use(&entry, tmp.path()).await, Some(true)); // Dep removed + re-locked (no reference left): unused. touch(tmp.path(), "package-lock.json", "{\"packages\":{}}").await; - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(false)); + assert_eq!(in_use(&entry, tmp.path()).await, Some(false)); // A mention in either npm lock counts (npm <= 11 installs from the // shrinkwrap, npm 12 from package-lock.json). @@ -1900,11 +1855,11 @@ mod tests { tmp.path(), "npm-shrinkwrap.json", &format!( - "{{\"packages\":{{\"node_modules/left-pad\":{{\"resolved\":\"file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz\"}}}}}}" + "{{\"packages\":{{\"node_modules/left-pad\":{{\"version\":\"1.3.0\",\"resolved\":\"file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz\"}}}}}}" ), ) .await; - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(true)); + assert_eq!(in_use(&entry, tmp.path()).await, Some(true)); // yarn flavors probe yarn.lock. let entry = probe_entry(Some("yarn-classic")); @@ -1912,23 +1867,25 @@ mod tests { touch( tmp.path(), "yarn.lock", - &format!("left-pad@1.3.0:\n resolved \"file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz#abc\"\n"), + &format!("left-pad@1.3.0:\n version \"1.3.0\"\n resolved \"file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz#abc\"\n"), ) .await; - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(true)); + assert_eq!(in_use(&entry, tmp.path()).await, Some(true)); touch(tmp.path(), "yarn.lock", "# yarn lockfile v1\n").await; - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(false)); + assert_eq!(in_use(&entry, tmp.path()).await, Some(false)); - // Unknown flavor: undeterminable, fail-safe keep. + // The verdict reads every lock discovery reads, whatever flavor the + // entry recorded: a lock that wires nothing proves it unused. let entry = probe_entry(Some("future-pm")); - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, None); + assert_eq!(in_use(&entry, tmp.path()).await, Some(false)); // vlt is structural: only a `file` node under the uuid dir counts, // never a mention in an edge spec or another node's slot. - let entry = probe_entry(Some("vlt")); + let mut entry = probe_entry(Some("vlt")); let tmp = tempfile::tempdir().unwrap(); - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, None); + assert_eq!(in_use(&entry, tmp.path()).await, None); let rel = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0/node_modules/left-pad"); + entry.artifact.path = rel.clone(); let file_id = format!("file~.socket+vendor+npm+{UUID}+left-pad-1.3.0+node__modules+left-pad"); touch( @@ -1939,7 +1896,7 @@ mod tests { ), ) .await; - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(true)); + assert_eq!(in_use(&entry, tmp.path()).await, Some(true)); touch( tmp.path(), "vlt-lock.json", @@ -1948,9 +1905,93 @@ mod tests { ), ) .await; - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(false)); + assert_eq!(in_use(&entry, tmp.path()).await, Some(false)); touch(tmp.path(), "vlt-lock.json", "\u{feff}{}").await; - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, None); + assert_eq!(in_use(&entry, tmp.path()).await, None); + } + + /// Attestation drops a vendored ref it cannot attribute to the one copy + /// the install uses (`DIAG_REF_UNATTRIBUTABLE`), but the package manager + /// may still install the artifact: the GC verdict must keep the entry. + /// Each lock here still routes `node_modules/left-pad` through the + /// vendored tarball while another entry of the SAME lock installs an + /// unpatched copy. + #[tokio::test] + async fn unattributable_wiring_stays_in_use() { + let vendored = format!("file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + let registry = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; + let cases = [ + ( + "lockfileVersion 2 whose legacy `dependencies` mirror resolves to the registry", + serde_json::json!({ + "lockfileVersion": 2, + "packages": { + "": {"dependencies": {"left-pad": "1.3.0"}}, + "node_modules/left-pad": {"version": "1.3.0", "resolved": vendored}, + }, + "dependencies": { + "left-pad": {"version": "1.3.0", "resolved": registry}, + }, + }), + ), + ( + "lockfileVersion 3 with a nested git copy of the same version", + serde_json::json!({ + "lockfileVersion": 3, + "packages": { + "": {"dependencies": {"left-pad": "1.3.0", "foo": "1.0.0"}}, + "node_modules/left-pad": {"version": "1.3.0", "resolved": vendored}, + "node_modules/foo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/foo/-/foo-1.0.0.tgz", + "dependencies": {"left-pad": "github:x/left-pad#abc"}, + }, + "node_modules/foo/node_modules/left-pad": { + "version": "1.3.0", + "resolved": "git+ssh://git@github.com/x/left-pad.git#abc", + }, + }, + }), + ), + ]; + let entry = probe_entry(Some("package-lock")); + for (label, lock) in cases { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "package-lock.json", &lock.to_string()).await; + let discovery = crate::vex::discover::discover_patched_refs(tmp.path()).await; + assert!( + discovery.refs.is_empty() + && discovery + .diagnostics + .iter() + .any(|d| d.code == crate::vex::discover::DIAG_REF_UNATTRIBUTABLE), + "{label}: the wiring must be dropped as unattributable: {:?}", + discovery.diagnostics + ); + assert_eq!(in_use(&entry, tmp.path()).await, Some(true), "{label}"); + } + + // vlt: the vendored node beside another registry instance of the + // same version. + let mut entry = probe_entry(Some("vlt")); + let rel = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0/node_modules/left-pad"); + entry.artifact.path = rel.clone(); + let file_id = + format!("file~.socket+vendor+npm+{UUID}+left-pad-1.3.0+node__modules+left-pad"); + let tmp = tempfile::tempdir().unwrap(); + touch( + tmp.path(), + "vlt-lock.json", + &format!( + "{{\n \"lockfileVersion\": 1,\n \"nodes\": {{\n \"{file_id}\": [0,\"left-pad\",null,\"{rel}\"],\n \"~npm~left-pad@1.3.0\": [0,\"left-pad\",\"sha512-UPSTREAM==\",null]\n }},\n \"edges\": {{}}\n}}\n" + ), + ) + .await; + assert_eq!( + in_use(&entry, tmp.path()).await, + Some(true), + "vlt other instance" + ); } #[tokio::test] @@ -1964,14 +2005,16 @@ mod tests { .into_iter() .find(|package| package.name == "minimist") .unwrap(); - let entry = probe_entry(Some("bun")); + let mut entry = probe_entry(Some("bun")); let target = format!(".socket/vendor/npm/{UUID}/minimist-1.2.2.tgz"); + entry.base_purl = "pkg:npm/minimist@1.2.2".into(); + entry.artifact.path = target.clone(); let sri = format!("sha512-{}", "A".repeat(86) + "=="); lock.set_package(package.id, &target, &sri).unwrap(); tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) .await .unwrap(); - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(true)); + assert_eq!(in_use(&entry, tmp.path()).await, Some(true)); assert!( bun_lock::wired_instances_all_ours(tmp.path(), "pkg:npm/minimist@1.2.2") .await @@ -1993,7 +2036,7 @@ mod tests { .await .unwrap(); assert_eq!( - vendored_entry_in_use(&entry, tmp.path()).await, + in_use(&entry, tmp.path()).await, Some(false), "old string-pool references do not prevent garbage collection" ); @@ -2007,9 +2050,14 @@ mod tests { tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) .await .unwrap(); - touch(tmp.path(), "bun.lock", "{\n \"packages\": {}\n}\n").await; + touch( + tmp.path(), + "bun.lock", + "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"app\",\n },\n },\n \"packages\": {\n \"minimist\": [\"minimist@1.2.8\", \"\", {}, \"sha512-AAAA==\"],\n }\n}\n", + ) + .await; assert_eq!( - vendored_entry_in_use(&entry, tmp.path()).await, + in_use(&entry, tmp.path()).await, Some(false), "text wins even when binary still references the artifact" ); @@ -2020,7 +2068,7 @@ mod tests { .await .unwrap(); assert_eq!( - vendored_entry_in_use(&entry, tmp.path()).await, + in_use(&entry, tmp.path()).await, None, "malformed means unknown, never garbage collect" ); @@ -2038,7 +2086,7 @@ mod tests { // Missing lock: undeterminable. let tmp = tempfile::tempdir().unwrap(); - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, None); + assert_eq!(in_use(&entry, tmp.path()).await, None); // A legacy-grammar lock whose packages section keys our artifact: // in use — only the legacy backend's structural probe says so. @@ -2053,7 +2101,7 @@ mod tests { ), ) .await; - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(true)); + assert_eq!(in_use(&entry, tmp.path()).await, Some(true)); // Dep removed + re-locked (no packages key references the artifact): // provably unused. @@ -2065,12 +2113,12 @@ mod tests { name: consumer\n version: 1.0.0\n", ) .await; - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(false)); + assert_eq!(in_use(&entry, tmp.path()).await, Some(false)); - // A v9 grammar is not the legacy backend's to judge: undeterminable, - // fail-safe keep. + // A v9 lock that references nothing: unused, whichever grammar the + // entry was vendored under. touch(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '9.0'\n").await; - assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, None); + assert_eq!(in_use(&entry, tmp.path()).await, Some(false)); } #[cfg(unix)] @@ -2124,10 +2172,10 @@ mod tests { detect_npm_lock_flavor(pnpm_dir.path()).await, detect_npm_lock_flavor(yarn_dir.path()).await, detect_npm_lock_flavor(vlt_dir.path()).await, - vendored_entry_in_use(&probe_entry(Some("package-lock")), in_use_dir.path()).await, - vendored_entry_in_use(&probe_entry(Some("yarn-classic")), in_use_dir.path()).await, - vendored_entry_in_use(&probe_entry(Some("bun")), in_use_dir.path()).await, - vendored_entry_in_use(&probe_entry(Some("vlt")), in_use_dir.path()).await, + in_use(&probe_entry(Some("package-lock")), in_use_dir.path()).await, + in_use(&probe_entry(Some("yarn-classic")), in_use_dir.path()).await, + in_use(&probe_entry(Some("bun")), in_use_dir.path()).await, + in_use(&probe_entry(Some("vlt")), in_use_dir.path()).await, ) }; let Ok((pnpm, yarn, vlt, npm_use, yarn_use, bun_use, vlt_use)) = diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 7f14ddf8b..af372e62a 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -612,9 +612,9 @@ pub(crate) async fn preflight_packages( /// them — it cannot un-wire the lock — so removing the artifact while the /// lockfile still resolves through it bricks every subsequent install /// (ENOENT on the missing `file:` tarball). -/// The in-use probe is textual and EXACT for these flavors (the uuid dir -/// path appears iff some resolution still points at the artifact — see -/// [`super::npm_flavor::vendored_entry_in_use`]), over every lock in +/// The probe is textual and EXACT for these flavors (the uuid dir path +/// appears iff some resolution still points at the artifact), over every +/// lock in /// `lock_names` (a mention in any of them counts — npm 12 installs from the /// package-lock.json beside a shrinkwrap). Mentioned ⇒ /// refuse; readable and provably absent ⇒ `None`, the caller's removal diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 37ec656d1..b2910148e 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -252,10 +252,12 @@ async fn nuget_prelude( } }; - // The idempotent hot path's test (see `vendor_nuget`). + // The idempotent hot path's test (see `vendor_nuget`): a live + // `` source under our key. A commented-out one — or the + // key merely mentioned elsewhere — is not wiring NuGet reads. let config_wired = config_text .as_deref() - .is_some_and(|t| t.contains(&source_key)); + .is_some_and(|t| parse_config_source_keys(&blank_comments(t)).contains(&source_key)); let in_sync = config_wired && { // One guarded read of the committed nupkg serves both the member-hash // check and the lock's content-hash pin. @@ -2095,6 +2097,48 @@ mod tests { ); } + /// B61: a commented-out Socket source is not wiring NuGet reads, so a + /// re-run must rewire instead of taking the in-sync hot path on a raw + /// substring match of the source key. + #[tokio::test] + async fn commented_out_source_is_not_wired() { + let (dir, blobs, installed, record) = fixture(true, None).await; + let root = dir.path(); + let (r1, e1, _) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r1.success && e1.is_some()); + + let cfg = tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(); + let add_at = cfg + .find(&format!("{}", + &cfg[..add_at], + &cfg[add_at..line_end], + &cfg[line_end..] + ); + tokio::fs::write(root.join("nuget.config"), &commented) + .await + .unwrap(); + + let (r2, e2, _) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r2.success, "{:?}", r2.error); + assert!( + e2.is_some(), + "a commented-out source must not take the in-sync hot path" + ); + let rewired = tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(); + assert!( + parse_config_source_keys(&blank_comments(&rewired)).contains(&source_key()), + "the re-run wires a live source: {rewired}" + ); + } + #[tokio::test] async fn revert_created_config_deletes_it_and_restores_lock() { let (dir, blobs, installed, record) = fixture(true, None).await; diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index fbd3ac0c0..1e79a8dbb 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -1887,22 +1887,6 @@ pub async fn revert_pypi(entry: &VendorEntry, project_root: &Path, dry_run: bool revert_pypi_opts(entry, project_root, RevertOpts::new(dry_run)).await } -/// Is this pypi-vendored entry still consumed by its project? The prune GC -/// and the vendored discovery supplement ask this; `None` keeps the entry. -/// -/// Only the `requirements` flavor has a probe: its requirements tree is -/// the lock pip installs from, so a pin the user removed or bumped there -/// proves the entry unused. The other flavors report `None` (cannot -/// determine), as before. -pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { - match entry.flavor.as_deref() { - Some("requirements") => { - super::pypi_requirements::requirements_entry_in_use(project_root, &entry.uuid).await - } - _ => None, - } -} - /// Fail-closed twin of [`super::npm_lock::guard_unwired_textual_revert`] /// for the Python backends. A ledger entry with NO wiring records cannot /// restore any project file — that is the shape `socket-patch repair` diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index 06a556c92..f97158b21 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -913,37 +913,6 @@ pub async fn requirements_include_names(root: &Path) -> std::io::Result/`? -/// The requirements tree IS this flavor's lock, so the answer is whether -/// any requirement line (its code, not its comment) reached from the root -/// `requirements.txt` through in-root `-r` includes still names the uuid -/// dir. `Some(false)` when the tree was read and none does — the user -/// removed the pin, or bumped it to another release; `None` when no file -/// of the tree could be read, or a reached include exists but cannot be -/// read (cannot prove the absence of a reference: callers keep the entry). -pub(super) async fn requirements_entry_in_use(root: &Path, uuid: &str) -> Option { - let needle = format!(".socket/vendor/pypi/{uuid}/"); - let names = requirements_include_names(root).await.ok()?; - let mut any_readable = false; - for name in &names { - match read_regular_to_string(&root.join(name)).await { - Ok(content) => { - any_readable = true; - if logical_lines(&content) - .iter() - .any(|ll| split_comment(&ll.text).0.contains(&needle)) - { - return Some(true); - } - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return None, - } - } - any_readable.then_some(false) -} - /// A root-relative requirements path that stays inside the project root /// (not `../…`, not absolute) — the only files the planner may edit. pub(crate) fn is_in_root_rel(rel: &str) -> bool { @@ -2839,6 +2808,28 @@ mod tests { const PROBE_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + /// The prune GC's in-use verdict for the requirements-flavored entry + /// of [`probe_vendor_line`]'s wheel + /// ([`crate::vex::discover::Discovery::vendor_entry_in_use`]). + async fn in_use(root: &Path) -> Option { + let entry: crate::vendor::state::VendorEntry = serde_json::from_value(serde_json::json!({ + "ecosystem": "pypi", + "basePurl": "pkg:pypi/six@1.16.0", + "uuid": PROBE_UUID, + "artifact": { + "path": format!(".socket/vendor/pypi/{PROBE_UUID}/six-1.16.0-py2.py3-none-any.whl"), + "sha256": "", + }, + "wiring": [], + "flavor": "requirements", + })) + .expect("a minimal vendor entry"); + crate::vex::discover::discover_patched_refs(root) + .await + .vendor_entry_in_use(root, &entry) + .await + } + fn probe_vendor_line(transitive: bool) -> String { vendor_line( &format!(".socket/vendor/pypi/{PROBE_UUID}/six-1.16.0-py2.py3-none-any.whl"), @@ -2877,7 +2868,7 @@ mod tests { .unwrap(); } assert_eq!( - requirements_entry_in_use(root, PROBE_UUID).await, + in_use(root).await, Some(true), "root={root_txt:?} include={include:?}" ); @@ -2899,11 +2890,7 @@ mod tests { tokio::fs::write(tmp.path().join("requirements.txt"), &root_txt) .await .unwrap(); - assert_eq!( - requirements_entry_in_use(tmp.path(), PROBE_UUID).await, - Some(false), - "{root_txt:?}" - ); + assert_eq!(in_use(tmp.path()).await, Some(false), "{root_txt:?}"); } // A line for ANOTHER uuid (a superseding patch) does not keep this // one in use either. @@ -2914,10 +2901,7 @@ mod tests { ) .await .unwrap(); - assert_eq!( - requirements_entry_in_use(tmp.path(), PROBE_UUID).await, - Some(false) - ); + assert_eq!(in_use(tmp.path()).await, Some(false)); } /// Nothing proves the entry unused when the tree cannot be read: no @@ -2927,19 +2911,13 @@ mod tests { #[tokio::test] async fn in_use_probe_is_undeterminable_without_a_readable_tree() { let tmp = tempfile::tempdir().unwrap(); - assert_eq!( - requirements_entry_in_use(tmp.path(), PROBE_UUID).await, - None - ); + assert_eq!(in_use(tmp.path()).await, None); let tmp = tempfile::tempdir().unwrap(); tokio::fs::write(tmp.path().join("requirements.txt"), "-r base.txt\n") .await .unwrap(); mkfifo(&tmp.path().join("base.txt")); - assert_eq!( - requirements_entry_in_use(tmp.path(), PROBE_UUID).await, - None - ); + assert_eq!(in_use(tmp.path()).await, None); } } diff --git a/crates/socket-patch-core/src/vendor/test_support.rs b/crates/socket-patch-core/src/vendor/test_support.rs index 423a841a8..66a17e0b6 100644 --- a/crates/socket-patch-core/src/vendor/test_support.rs +++ b/crates/socket-patch-core/src/vendor/test_support.rs @@ -472,6 +472,39 @@ pub(crate) fn tree_snapshot(root: &Path) -> std::collections::BTreeMap( purl: &str, @@ -490,7 +523,7 @@ pub(crate) async fn vendor_pnpm<'a>( } else { None }; - super::pnpm_lock::vendor_pnpm( + let outcome = super::pnpm_lock::vendor_pnpm( purl, source, project_root, @@ -501,7 +534,9 @@ pub(crate) async fn vendor_pnpm<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -522,7 +557,7 @@ pub(crate) async fn vendor_yarn_classic<'a>( } else { None }; - super::yarn_classic_lock::vendor_yarn_classic( + let outcome = super::yarn_classic_lock::vendor_yarn_classic( purl, source, project_root, @@ -533,7 +568,9 @@ pub(crate) async fn vendor_yarn_classic<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -554,7 +591,7 @@ pub(crate) async fn vendor_npm<'a>( } else { None }; - super::npm_lock::vendor_npm( + let outcome = super::npm_lock::vendor_npm( purl, source, project_root, @@ -565,7 +602,9 @@ pub(crate) async fn vendor_npm<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -586,7 +625,7 @@ pub(crate) async fn vendor_composer<'a>( } else { None }; - super::composer_lock::vendor_composer( + let outcome = super::composer_lock::vendor_composer( purl, source, project_root, @@ -597,7 +636,9 @@ pub(crate) async fn vendor_composer<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -618,7 +659,7 @@ pub(crate) async fn vendor_cargo_crate<'a>( } else { None }; - super::cargo::vendor_cargo_crate( + let outcome = super::cargo::vendor_cargo_crate( purl, source, project_root, @@ -629,7 +670,9 @@ pub(crate) async fn vendor_cargo_crate<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -650,7 +693,7 @@ pub(crate) async fn vendor_vlt<'a>( } else { None }; - super::vlt_lock::vendor_vlt( + let outcome = super::vlt_lock::vendor_vlt( purl, source, project_root, @@ -661,7 +704,9 @@ pub(crate) async fn vendor_vlt<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -682,7 +727,7 @@ pub(crate) async fn vendor_maven<'a>( } else { None }; - super::maven_repo::vendor_maven( + let outcome = super::maven_repo::vendor_maven( purl, source.path(), project_root, @@ -693,7 +738,9 @@ pub(crate) async fn vendor_maven<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -714,7 +761,7 @@ pub(crate) async fn vendor_pnpm_legacy<'a>( } else { None }; - super::pnpm_lock_legacy::vendor_pnpm_legacy( + let outcome = super::pnpm_lock_legacy::vendor_pnpm_legacy( purl, source, project_root, @@ -725,7 +772,9 @@ pub(crate) async fn vendor_pnpm_legacy<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -746,7 +795,7 @@ pub(crate) async fn vendor_nuget<'a>( } else { None }; - super::nuget_feed::vendor_nuget( + let outcome = super::nuget_feed::vendor_nuget( purl, source.path(), project_root, @@ -757,7 +806,9 @@ pub(crate) async fn vendor_nuget<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -778,7 +829,7 @@ pub(crate) async fn vendor_yarn_berry<'a>( } else { None }; - super::yarn_berry_lock::vendor_yarn_berry( + let outcome = super::yarn_berry_lock::vendor_yarn_berry( purl, source, project_root, @@ -789,7 +840,9 @@ pub(crate) async fn vendor_yarn_berry<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -810,7 +863,7 @@ pub(crate) async fn vendor_pypi<'a>( } else { None }; - super::pypi::vendor_pypi( + let outcome = super::pypi::vendor_pypi( purl, source, project_root, @@ -821,7 +874,9 @@ pub(crate) async fn vendor_pypi<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -842,7 +897,7 @@ pub(crate) async fn vendor_gem<'a>( } else { None }; - super::gem::vendor_gem( + let outcome = super::gem::vendor_gem( purl, source, project_root, @@ -853,7 +908,9 @@ pub(crate) async fn vendor_gem<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -874,7 +931,7 @@ pub(crate) async fn vendor_go_module<'a>( } else { None }; - super::golang::vendor_go_module( + let outcome = super::golang::vendor_go_module( purl, source, project_root, @@ -885,7 +942,9 @@ pub(crate) async fn vendor_go_module<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -906,7 +965,7 @@ pub(crate) async fn vendor_npm_any<'a>( } else { None }; - super::npm_flavor::vendor_npm_any( + let outcome = super::npm_flavor::vendor_npm_any( purl, source, project_root, @@ -917,7 +976,9 @@ pub(crate) async fn vendor_npm_any<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } #[allow(clippy::too_many_arguments)] @@ -938,7 +999,7 @@ pub(crate) async fn vendor_bun<'a>( } else { None }; - super::bun_lock::vendor_bun( + let outcome = super::bun_lock::vendor_bun( purl, source, project_root, @@ -949,7 +1010,9 @@ pub(crate) async fn vendor_bun<'a>( force, service.or_else(|| fixture.as_ref().map(|f| &f.cfg)), ) - .await + .await; + assert_fresh_vendor_in_use(project_root, &outcome, dry_run).await; + outcome } pub(crate) fn expect_failure(outcome: VendorOutcome) -> String { diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 9f4d9d387..9c1116072 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -88,8 +88,9 @@ //! `[patch] crates-io = { … }` forms). The path must be root-anchored //! ([`vendor_ref`]: a `../` or absolute spelling consumes some OTHER //! checkout's copy), the leaf a single `-` directory for the -//! entry's crate. Same liveness truth source as -//! `vendor::cargo::vendored_entry_in_use`: when a `Cargo.lock` parses, it +//! entry's crate. The lock is the liveness truth source (the prune GC's +//! in-use verdict, `Discovery::vendor_entry_in_use`, reads it from these +//! refs): when a `Cargo.lock` parses, it //! must hold a SOURCELESS `[[package]]` for that name + version — an entry //! with a registry source (re-resolved, or a hosted takeover) or none at //! all means the copy is not what builds, and so does a @@ -97,7 +98,7 @@ //! a `[patch]` left out of the graph — e.g. by the user's path dependency, //! whose lock entry is sourceless too), so no ref ([`DIAG_REF_INVALID`]). //! No lock (first build pending) or an unparseable one (cargo refuses to -//! build) keeps the ref, like `vendored_entry_in_use`. A manifest entry +//! build) keeps the ref. A manifest entry //! cargo ignores is no ref ([`DIAG_REF_INVALID`]) either: cargo lets a //! project-config `[patch]` item with the same key replace it (unless that //! item wires the same path — the half-migrated shape, attested through the @@ -739,7 +740,10 @@ async fn vendored_from_patches( } let copy_tagged = matches!(tag, CopyTag::Tagged(_) | CopyTag::Unreadable); if let Lock::Parsed(lock) = lock { - let why = match lock.vendored_in_use(name, version, &vref.uuid, copy_tagged) { + let claim = lock.vendored_in_use(name, version, &vref.uuid, copy_tagged); + let relock_pending = + matches!(claim, CopyClaim::OtherTag(_) | CopyClaim::UntaggedOverride); + let why = match claim { CopyClaim::Consumed => None, CopyClaim::OtherTag(other) => Some(format!( "{CARGO_LOCK} builds the copy tagged for patch {other} ({name} {})", @@ -756,6 +760,16 @@ async fn vendored_from_patches( )), }; if let Some(why) = why { + // The manifest still routes the crate to this copy and the + // lock entry is detached: only the lock's generation lags, + // and the next unlocked build consumes the copy. + if relock_pending { + out.withheld.push(super::Recognized { + uuid: vref.uuid.clone(), + mode: super::WiringMode::Vendored, + file: std::path::PathBuf::from(file), + }); + } out.diag( DIAG_REF_INVALID, file, diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 471e8344a..ce29f411b 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -546,6 +546,34 @@ pub struct Discovery { pub unwired_copies: Vec, /// Refs dropped because another lock contests them ([`ContestedRef`]). pub contested: Vec, + /// Every file an extractor read through the guarded reads + /// ([`DiscoverCtx::read_text`] / [`DiscoverCtx::read_bytes`]), with the + /// ecosystem whose extractor read it — sorted, deduped. "No ref wires + /// this vendored entry" means "unused" only once discovery has read + /// that ecosystem's files ([`Discovery::vendor_entry_in_use`]). + pub read: Vec, + /// Vendored wiring an extractor rejects as [`DIAG_REF_INVALID`] although + /// the next relock consumes it: cargo's `[patch]` at this copy while + /// `Cargo.lock` builds another generation's copy (tagged for another + /// uuid, or untagged). Not attested, but still wiring: the prune GC must + /// keep such an entry ([`Discovery::vendor_entry_in_use`]). Wiring + /// dropped as [`DIAG_REF_UNATTRIBUTABLE`] needs no record here: the GC + /// keeps it through the diagnostic itself. Sorted, deduped. + pub withheld: Vec, +} + +/// One file discovery's guarded reads touched ([`Discovery::read`]). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ReadFile { + /// The vendor ecosystem dir name (`npm`, `pypi`, `cargo`, `golang`, + /// `gem`, `composer`, `maven`, `nuget`) of the extractor that read it + /// ([`VendorEntry::ecosystem`]'s spelling). + pub ecosystem: &'static str, + /// Root-relative path. + pub file: PathBuf, + /// `false`: the file exists but could not be read + /// ([`DIAG_LOCKFILE_UNREADABLE`]). + pub readable: bool, } impl Discovery { @@ -957,6 +985,10 @@ impl Discovery { .sort_by(|a, b| (&a.file, a.code, &a.detail).cmp(&(&b.file, b.code, &b.detail))); self.recognized.sort(); self.recognized.dedup(); + self.read.sort(); + self.read.dedup(); + self.withheld.sort(); + self.withheld.dedup(); } } @@ -997,27 +1029,39 @@ pub async fn discover_patched_refs_in( discover_with_ctx(ctx).await } -async fn discover_with_ctx(ctx: DiscoverCtx<'_>) -> Discovery { +async fn discover_with_ctx(mut ctx: DiscoverCtx<'_>) -> Discovery { let mut out = Discovery::default(); + // Each extractor's reads are tagged with the vendor ecosystem it reads + // for ([`Discovery::read`]). + ctx.ecosystem = "npm"; npm::extract(&ctx, &mut out).await; yarn::extract(&ctx, &mut out).await; bun::extract(&ctx, &mut out).await; vlt::extract(&ctx, &mut out).await; + ctx.ecosystem = "cargo"; cargo::extract(&ctx, &mut out).await; + ctx.ecosystem = "golang"; golang::extract(&ctx, &mut out).await; + ctx.ecosystem = "pypi"; pypi_locks::extract(&ctx, &mut out).await; pypi_other::extract(&ctx, &mut out).await; + ctx.ecosystem = "gem"; gem::extract(&ctx, &mut out).await; + ctx.ecosystem = "composer"; composer::extract(&ctx, &mut out).await; + ctx.ecosystem = "maven"; maven::extract(&ctx, &mut out).await; gradle::extract(&ctx, &mut out).await; sbt::extract(&ctx, &mut out).await; + ctx.ecosystem = "nuget"; nuget::extract(&ctx, &mut out).await; + ctx.ecosystem = "deno"; deno::extract(&ctx, &mut out).await; out.contest_within_locks(); out.contest_across_locks(); out.unattest_unwired_copies(); out.recognized.extend(ctx.take_recognized()); + out.read.extend(ctx.take_read()); out.finalize(); out } @@ -1040,6 +1084,11 @@ pub(crate) struct DiscoverCtx<'a> { /// scratch `Discovery` (a file parsed only to explain it) still counts. /// A `Mutex` keeps the ctx `Sync` across the extractors' `.await`s. recognized: Mutex>, + /// The ecosystem the running extractor reads for (set by + /// [`discover_with_ctx`] between extractors), tagging [`Self::read`]. + ecosystem: &'static str, + /// Every guarded read so far ([`Discovery::read`]). + read: Mutex>, } impl<'a> DiscoverCtx<'a> { @@ -1049,9 +1098,32 @@ impl<'a> DiscoverCtx<'a> { view: crate::vendor::lock_inventory::ProjectView::Disk(root), patch_server_origins, recognized: Mutex::new(BTreeSet::new()), + ecosystem: "", + read: Mutex::new(BTreeSet::new()), } } + /// Log a guarded read of `rel` ([`Discovery::read`]). + fn log_read(&self, rel: &str, readable: bool) { + self.read + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(ReadFile { + ecosystem: self.ecosystem, + file: PathBuf::from(rel), + readable, + }); + } + + /// Every guarded read so far, draining the log. + pub(crate) fn take_read(&self) -> Vec { + let mut read = self + .read + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + std::mem::take(&mut *read).into_iter().collect() + } + /// Record every Socket identity `text` (the content of root-relative /// `rel`) mentions — see [`socket_identities`]. fn recognize_text(&self, rel: &str, text: &str) { @@ -1137,11 +1209,13 @@ impl<'a> DiscoverCtx<'a> { pub(crate) async fn read_text(&self, rel: &str, out: &mut Discovery) -> Option { match self.view.read_text(rel).await { Ok(text) => { + self.log_read(rel, true); self.recognize_text(rel, &text); Some(text) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => { + self.log_read(rel, false); out.diag( DIAG_LOCKFILE_UNREADABLE, rel, @@ -1173,11 +1247,13 @@ impl<'a> DiscoverCtx<'a> { pub(crate) async fn read_bytes(&self, rel: &str, out: &mut Discovery) -> Option> { match self.view.read_bytes(rel).await { Ok(bytes) => { + self.log_read(rel, true); self.recognize_text(rel, &String::from_utf8_lossy(&bytes)); Some(bytes) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => { + self.log_read(rel, false); out.diag( DIAG_LOCKFILE_UNREADABLE, rel, @@ -1825,6 +1901,87 @@ impl Discovery { vendored_wiring_live(root, &files, &entry.ecosystem, &entry.uuid).await } + /// Whether the project still CONSUMES a vendor-ledger entry's artifact — + /// the question the `scan --prune` GC reverts by and `scan`'s vendored + /// ledger supplement re-discovers by, answered from the same discovery + /// as [`Discovery::vendor_entry_live`] for every ecosystem: + /// + /// * `Some(true)` — the entry is live, or a lock wires it while another + /// lock contests that wiring ([`Discovery::vendored_contest`]: still + /// wired; `vendor --check` names both locks, reverting would not + /// settle which one installs), or an extractor withheld its wiring + /// ([`Discovery::withheld`]), or a file mentioning it had wiring + /// dropped as unattributable (`unattributable_mention`). A JVM entry: its tree is still + /// referenced ([`crate::vendor::jvm::apply::entry_references`], which + /// also answers `true` when a file cannot be read); + /// * `Some(false)` — discovery read a lockfile of this ecosystem + /// ([`decides_install`]) and nothing wires the entry: the dependency + /// left the lock, was re-resolved elsewhere, or the wiring survives + /// only in a shape the package manager does not install from (rule + /// 11); + /// * `None` — cannot determine, so callers keep the entry: discovery + /// read no lockfile of this ecosystem, or one of its files could not + /// be read or parsed. + pub async fn vendor_entry_in_use(&self, root: &Path, entry: &VendorEntry) -> Option { + if crate::vendor::jvm::apply::is_jvm_entry(entry) { + return Some(crate::vendor::jvm::apply::entry_references(root, entry)); + } + if self.vendor_entry_live(root, entry).await + || self + .vendored_contest(&entry.base_purl, &entry.uuid) + .is_some() + { + return Some(true); + } + if self + .withheld + .iter() + .any(|r| r.uuid == entry.uuid && r.mode == WiringMode::Vendored) + || self.unattributable_mention(&entry.uuid) + { + return Some(true); + } + let mut read_lock = false; + for read in self.read.iter().filter(|r| r.ecosystem == entry.ecosystem) { + // A file that could not be read or parsed — or whose tree could + // not be finished (an unreadable requirements include) — proves + // nothing absent. + let undecided = self.diagnostics.iter().any(|d| { + matches!(d.code, DIAG_LOCKFILE_UNREADABLE | DIAG_LOCKFILE_UNPARSEABLE) + && d.file == read.file + }); + if !read.readable || undecided { + return None; + } + read_lock |= decides_install(read.ecosystem, &read.file); + } + read_lock.then_some(false) + } + + /// Whether a file that mentions vendored patch `uuid` also carries a + /// [`DIAG_REF_UNATTRIBUTABLE`] diagnostic: an extractor (or the + /// orchestrator's contests) dropped wiring there because it cannot tell + /// which copy installs — an unpatched copy in the same lock, npm's + /// shrinkwrap/package-lock pair or legacy `dependencies` mirror, a + /// non-registry nested copy, vlt's other instances, a bundled copy, a + /// yarn git block, a version-less Go replace, another lock. Dropping + /// fails attestation closed, but it is no proof the install stopped + /// using the artifact, so the prune GC must keep the entry + /// ([`Discovery::vendor_entry_in_use`]). A mention rejected as + /// [`DIAG_REF_INVALID`] (a shape the package manager never installs + /// from) is not covered: that one is dead. File-grained on purpose: an + /// unattributable drop of another package in the same file errs toward + /// keeping. + fn unattributable_mention(&self, uuid: &str) -> bool { + self.recognized_files(uuid, WiringMode::Vendored) + .into_iter() + .any(|file| { + self.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE && d.file == file) + }) + } + /// Liveness of a REDIRECT-ledger record (`purl` resolves from patch /// `uuid`) — the ONE rule every reader of the redirect ledger applies /// (`vex`'s liveness gate, `scan`'s takeover classifier and its @@ -1921,6 +2078,35 @@ impl Discovery { } } +/// Whether root-relative `file` is one of `ecosystem`'s lockfiles — what an +/// install of that ecosystem resolves from: a [`ROOT`]-role row of the +/// format registry by basename (`Cargo.lock`, `composer.lock`, `go.mod`, +/// `requirements.txt`, …) or a Python lock (`uv.lock`, `pylock*.toml`, +/// `*.py.lock`). NuGet and Maven have no lock row (the wiring config is +/// what restores), so their [`PROBE`] rows count. A manifest alone +/// (`Cargo.toml`, `pyproject.toml`) does not: with no lock, the next +/// relock may still route through it, so it proves no entry unused +/// ([`Discovery::vendor_entry_in_use`]). +/// +/// [`ROOT`]: crate::formats::registry::ROOT +/// [`PROBE`]: crate::formats::registry::PROBE +fn decides_install(ecosystem: &str, file: &Path) -> bool { + use crate::formats::registry::{registry, PROBE, ROOT}; + let Some(base) = file.file_name().and_then(|b| b.to_str()) else { + return false; + }; + if ecosystem == "pypi" && crate::utils::python_lock::is_python_lock_name(base) { + return true; + } + let rows = || registry().iter().filter(|f| f.ecosystem == ecosystem); + let role = if rows().any(|f| f.has(ROOT)) { + ROOT + } else { + PROBE + }; + rows().any(|f| f.has(role) && f.basename() == base) +} + /// The gem name of a `pkg:gem/@` purl (any qualifiers). fn gem_purl_name(purl: &str) -> Option { let base = canonical_base_purl(purl); @@ -2535,6 +2721,73 @@ mod tests { use super::testing::*; use super::*; + /// A pyproject-only pypi project (hatch, or any flavor before its first + /// lock) has no install-deciding file: `pyproject.toml` / `hatch.toml` + /// are manifests, so even a pyproject that no longer names the vendored + /// wheel cannot prove the entry unused, and the prune GC keeps it. + #[tokio::test] + async fn lockless_pyproject_entries_stay_undecidable() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let entry: VendorEntry = serde_json::from_value(serde_json::json!({ + "ecosystem": "pypi", + "basePurl": "pkg:pypi/six@1.16.0", + "uuid": UUID_A, + "artifact": { + "path": format!(".socket/vendor/pypi/{UUID_A}/six-1.16.0-py2.py3-none-any.whl"), + "sha256": "", + }, + "wiring": [], + "flavor": "hatch", + })) + .unwrap(); + for file in ["pyproject.toml", "hatch.toml"] { + std::fs::write( + root.join(file), + "[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = []\n", + ) + .unwrap(); + } + let discovery = discover_patched_refs(root).await; + assert_eq!(discovery.vendor_entry_in_use(root, &entry).await, None); + } + + /// A `jvm` ledger row with no wiring records (stripped by hand) is not + /// [`crate::vendor::jvm::apply::is_jvm_entry`], so no tree layout is + /// checked; its trees are not `.socket/vendor//` dirs a lock + /// could reference, and readable Maven/Gradle files must not decide it + /// unused: discovery reads no `jvm`-ecosystem file, so the prune GC + /// keeps it. + #[tokio::test] + async fn empty_wiring_jvm_entries_stay_undecidable() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("pom.xml"), + "4.0.0g\ + app1\n", + ) + .unwrap(); + std::fs::write(root.join("settings.gradle"), "").unwrap(); + std::fs::write(root.join("gradle.lockfile"), "# Gradle lockfile\nempty=\n").unwrap(); + let discovery = discover_patched_refs(root).await; + let entry: VendorEntry = serde_json::from_value(serde_json::json!({ + "ecosystem": "jvm", + "basePurl": "pkg:maven/com.google.code.gson/gson@2.10.1", + "uuid": UUID_A, + "artifact": { + "path": format!( + ".socket/vendor/maven2/com/google/code/gson/gson/2.10.1-socket.{}/gson-2.10.1-socket.{}.jar", + &UUID_A[..8], &UUID_A[..8] + ), + "sha256": "", + }, + "wiring": [], + })) + .unwrap(); + assert_eq!(discovery.vendor_entry_in_use(root, &entry).await, None); + } + #[test] fn vendor_ref_strips_lock_suffixes_and_requires_a_root_anchor() { let a = UUID_A; diff --git a/crates/socket-patch-core/src/vex/discover/testing/golden.rs b/crates/socket-patch-core/src/vex/discover/testing/golden.rs index 823f8e4a0..c83586680 100644 --- a/crates/socket-patch-core/src/vex/discover/testing/golden.rs +++ b/crates/socket-patch-core/src/vex/discover/testing/golden.rs @@ -123,6 +123,8 @@ fn render(out: &Discovery, root: &Path) -> Value { unpatched_copies, unattested, contested, + read: _, + withheld: _, // Already folded into `unattested` by the time a run returns. unwired_copies: _, } = out;