From 8003fbaef0fb4da1981a6d8456f0b5feafafb430 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:39:59 -0400 Subject: [PATCH 1/9] Add Discovery::vendor_entry_in_use, the one vendored in-use verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prune GC and scan's ledger supplement asked a per-backend probe (npm, cargo, pypi-requirements only) whether a vendored entry is still consumed, while vex and vendor --check judge liveness by discovery. Give discovery the evidence for a tri-state in-use answer for every ecosystem: - `Discovery::read` logs every guarded read, tagged with the ecosystem whose extractor read it, so "nothing wires it" can mean "unused" only once a lockfile of that ecosystem was actually read and parsed. - `Discovery::withheld` records wiring an extractor withholds as a ref although a file still routes through the patch: npm's and bun's in-lock contests and cargo copies whose lock lags (another tag, or an untagged override). Not attested, but still wiring the GC must keep. - `vendor_entry_in_use` = live, contested or withheld -> Some(true); a read lockfile of the ecosystem and nothing wiring it -> Some(false); no lockfile, or one that cannot be read or parsed -> None. JVM entries answer from their own layout (`entry_references`). Audit B19 (§3.B "is this vendored entry live"). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-core/src/vex/discover/bun.rs | 12 ++ .../src/vex/discover/cargo.rs | 49 ++++-- .../socket-patch-core/src/vex/discover/mod.rs | 163 +++++++++++++++++- .../socket-patch-core/src/vex/discover/npm.rs | 14 ++ .../src/vex/discover/testing/golden.rs | 2 + 5 files changed, 221 insertions(+), 19 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index b219ebaae..795c09814 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -227,6 +227,12 @@ impl Bundled { out.refs.push(r); continue; }; + // Still wiring, just not attested (`Discovery::withheld`). + out.withheld.push(super::Recognized { + uuid: r.uuid.clone(), + mode: r.mode, + file: r.source_file.clone(), + }); out.diag( DIAG_REF_UNATTRIBUTABLE, file, @@ -453,6 +459,12 @@ impl Unwired { out.refs.push(r); continue; }; + // Still wiring, just not attested (`Discovery::withheld`). + out.withheld.push(super::Recognized { + uuid: r.uuid.clone(), + mode: r.mode, + file: r.source_file.clone(), + }); out.diag( DIAG_REF_UNATTRIBUTABLE, file, diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 86aab22de..e3adaca74 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 @@ -738,24 +739,36 @@ 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) { - CopyClaim::Consumed => None, - CopyClaim::OtherTag(other) => Some(format!( - "{CARGO_LOCK} builds the copy tagged for patch {other} ({name} {})", - cargo_tag::tag_version(version, other) - )), - CopyClaim::UntaggedOverride => Some(format!( - "{CARGO_LOCK} builds an untagged {name} {version}, not the copy (which \ + 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} {})", + cargo_tag::tag_version(version, other) + )), + CopyClaim::UntaggedOverride => Some(format!( + "{CARGO_LOCK} builds an untagged {name} {version}, not the copy (which \ cargo would lock as {}): another [patch] or path dependency overrides it", - cargo_tag::tag_version(version, &vref.uuid) - )), - CopyClaim::NotConsumed => Some(format!( - "{CARGO_LOCK} does not build {name}@{version} from it (an unused patch, \ + cargo_tag::tag_version(version, &vref.uuid) + )), + CopyClaim::NotConsumed => Some(format!( + "{CARGO_LOCK} does not build {name}@{version} from it (an unused patch, \ or the lock resolves it from a registry)" - )), - }; + )), + }; 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 ba855e20b..59fc9c099 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -481,6 +481,35 @@ pub struct Discovery { pub unattested: 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, + /// Wiring a file still routes through a Socket patch that an extractor + /// WITHHOLDS as a ref because the build may not consume it: a ref another + /// entry or npm lock contests (npm's in-pair contest, a bundled copy), + /// or cargo vendored wiring whose lock builds another generation's copy + /// (a `[patch]` at this copy, the lock tagged for another uuid or + /// untagged — the next relock consumes it). Not attested, but still + /// wiring: the prune GC must keep such an entry + /// ([`Discovery::vendor_entry_in_use`]). 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 { @@ -852,6 +881,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(); } } @@ -892,26 +925,38 @@ 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.recognized.extend(ctx.take_recognized()); + out.read.extend(ctx.take_read()); out.finalize(); out } @@ -934,6 +979,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> { @@ -943,9 +993,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) { @@ -1031,11 +1104,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, @@ -1067,11 +1142,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, @@ -1718,6 +1795,61 @@ 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`]). 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) + { + 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) + } + /// 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 @@ -1814,6 +1946,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); diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index c130efa95..fe260e98d 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -99,6 +99,16 @@ impl NpmLockRefs { } } +/// Record a contested npm ref as withheld wiring ([`Discovery::withheld`]): +/// the lock still resolves through the patch, it is just not attested. +fn withhold(r: &PatchedRef, file: &str, out: &mut Discovery) { + out.withheld.push(super::Recognized { + uuid: r.uuid.clone(), + mode: r.mode, + file: std::path::PathBuf::from(file), + }); +} + /// Push every ref no OTHER npm lock contests. npm <= 11 installs from /// npm-shrinkwrap.json when both exist; npm 12 auto-creates a /// package-lock.json beside it and installs from THAT (verified against real @@ -151,6 +161,7 @@ fn push_uncontested(locks: Vec, out: &mut Discovery) { lock.file, r.purl, r.uuid, ), ); + withhold(r, lock.file, out); continue; } if let Some(location) = lock.unwired.get(&r.purl) { @@ -166,6 +177,7 @@ fn push_uncontested(locks: Vec, out: &mut Discovery) { lock.file, r.purl, r.uuid, ), ); + withhold(r, lock.file, out); continue; } let contested_by = locks.iter().enumerate().find(|(j, other)| { @@ -184,6 +196,7 @@ fn push_uncontested(locks: Vec, out: &mut Discovery) { lock.file, r.purl, r.uuid, other.file, NPM_LOCKS[0], NPM_LOCKS[1], ), ); + withhold(r, lock.file, out); } else if let Some(other) = locks .iter() .enumerate() @@ -203,6 +216,7 @@ fn push_uncontested(locks: Vec, out: &mut Discovery) { lock.file, r.purl, r.uuid, other.file, NPM_LOCKS[0], NPM_LOCKS[1], ), ); + withhold(r, lock.file, out); } else { out.push(r.clone()); } 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 36b15632e..640aab0cc 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: _, } = out; let refs: Vec = refs .iter() From 76562ad575547c2f305ad24a7a8724ebc29f7147 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:40:06 -0400 Subject: [PATCH 2/9] Decide legacy Maven and NuGet "already wired" through the comment-aware readers The vendored Maven backend treated any occurrence of its repository id in pom.xml as wiring, and the NuGet backend any occurrence of its source key in nuget.config, so a commented-out (or profile-scoped) repository or source took the in-sync hot path and the build resolved the unpatched package. Use the same masking the forward writers already use: `find_wireable_anchor` (comments and masked) for the pom, and `parse_config_source_keys(blank_comments(..))` for the nuget.config . Audit B61. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vendor/maven_repo.rs | 47 +++++++++++++++++- .../src/vendor/nuget_feed.rs | 48 ++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index 2abc5f9d9..49ecf1db7 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(), @@ -2619,6 +2622,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/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 1e8f51d79..e806eea53 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; From e9dc50246f624fcc878c291800d284db1f58356d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:40:12 -0400 Subject: [PATCH 3/9] Report orphaned JVM trees whenever the ledger has no JVM entry `vendor --check` reported committed JVM trees (maven2, gradle, coursier, their indexes, the generated sbt file) as `vendor_ledger_missing` only when the whole ledger was empty, so a project that also vendors another ecosystem never noticed a lost JVM half. Fire whenever no ledger entry is a JVM entry: an empty ledger still refuses as before, otherwise the orphan is recorded as a failed artifact event beside the other checks. The path list moves to one `jvm::apply::LEDGER_OWNED_PATHS` (replacing the CLI's inline list and `coursier_tree::ORPHAN_PATHS`), and the JVM path tables spell the tree roots through their named constants. Audit B62. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/src/commands/vendor.rs | 38 +++++++++++------ .../socket-patch-cli/tests/vendor_jvm_cli.rs | 41 +++++++++++++++++++ .../socket-patch-core/src/vendor/jvm/apply.rs | 21 ++++++++-- .../src/vendor/jvm/coursier_tree.rs | 4 -- 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 2590c2673..97982edbf 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1140,18 +1140,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 { @@ -1233,6 +1237,16 @@ 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 diff --git a/crates/socket-patch-cli/tests/vendor_jvm_cli.rs b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs index 5a26b241d..903c814e4 100644 --- a/crates/socket-patch-cli/tests/vendor_jvm_cli.rs +++ b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs @@ -657,6 +657,47 @@ 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"); + assert!( + events + .iter() + .any(|e| e.to_string().contains("vendor_ledger_missing") + && e["details"]["ecosystem"] == "maven"), + "{env}" + ); + 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/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs index 421249265..1e039b8da 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, ]; 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!( From d872a10d46085b59a285c787deb4efe04b5d8ef7 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:40:21 -0400 Subject: [PATCH 4/9] Route the prune GC, scan's ledger supplement and vendor --check through one in-use verdict `dispatch_in_use_one` answered "is this vendored entry still consumed" for npm, cargo and the pypi requirements flavor only and kept every other entry (gem, golang, composer, nuget, maven/jvm, uv, poetry, pdm, pipenv, hatch, pylock) forever: `scan --prune` could never reclaim them and scan's ledger supplement resurrected them as discovered packages, never reporting `vendor_ledger_entry_unwired`, while `vendor --check` and vex already called them dead. Every caller now asks `Discovery::vendor_entry_in_use`: - run_vendor_gc (pass b) takes all verdicts from one discovery of the project before it reverts anything; - vendored_ledger_supplement reads the run's ProjectContext discovery (scan and get pass their context); - vendor --check's "dependency removed" remedy. Deleted: `dispatch_in_use_one` (CLI) and the per-backend dispatchers `vendor::npm_flavor::vendored_entry_in_use`, `vendor::cargo::vendored_entry_in_use`, `vendor::pypi::vendored_entry_in_use` and `pypi_requirements::requirements_entry_in_use` (4 in-use oracles -> 1; the pnpm/vlt structural probes stay for their revert guards). Their tests now assert the discovery verdict, with lock fixtures carrying the version an install records. Audit B19. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 2 +- .../src/commands/scan/discovery.rs | 119 ++++++++++-- .../socket-patch-cli/src/commands/scan/mod.rs | 3 +- .../socket-patch-cli/src/commands/vendor.rs | 178 +++++++++++++----- .../tests/covgap_commands_scan_mod.rs | 2 +- .../tests/e2e_vex_lockfile/cargo.rs | 2 +- crates/socket-patch-core/src/vendor/cargo.rs | 82 +++----- .../src/vendor/npm_flavor.rs | 136 +++++-------- .../socket-patch-core/src/vendor/npm_lock.rs | 6 +- crates/socket-patch-core/src/vendor/pypi.rs | 16 -- .../src/vendor/pypi_requirements.rs | 76 +++----- 11 files changed, 348 insertions(+), 274 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 0cbd9ce4b..f2eb84158 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1505,7 +1505,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 f8e6b467c..b3823f1bd 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; @@ -202,7 +206,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; @@ -1045,7 +1054,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 @@ -1071,16 +1086,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"] @@ -1150,7 +1175,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()]); @@ -1158,11 +1188,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, @@ -1181,7 +1269,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().map(|p| p.purl.as_str()).collect::>(), vec!["pkg:npm/left-pad@1.3.0"], diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 66df3be83..07099f6ac 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1823,8 +1823,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 97982edbf..46304cf75 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,26 +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 { @@ -4175,10 +4155,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 @@ -4249,8 +4230,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 @@ -4260,11 +4244,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; @@ -5227,7 +5219,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 @@ -5249,6 +5241,7 @@ 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 @@ -5490,9 +5483,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() { @@ -6257,22 +6341,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-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 86d4311e6..2d1929b1d 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/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 21fb4aa46..0660d2f40 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -23,7 +23,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::source::PackageSource; use super::state::VendorEntry; @@ -574,58 +574,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 @@ -1679,6 +1627,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(), @@ -1715,22 +1672,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). @@ -1738,11 +1695,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")); @@ -1750,23 +1707,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( @@ -1777,7 +1736,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", @@ -1786,9 +1745,9 @@ 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); } #[tokio::test] @@ -1802,14 +1761,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 @@ -1831,7 +1792,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" ); @@ -1845,9 +1806,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" ); @@ -1858,7 +1824,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" ); @@ -1876,7 +1842,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. @@ -1891,7 +1857,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. @@ -1903,12 +1869,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)] @@ -1962,10 +1928,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/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 67cdc5b6d..bbfa6da8a 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -1399,22 +1399,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 7733f6154..94465064d 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -894,37 +894,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 { @@ -2673,6 +2642,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"), @@ -2711,7 +2702,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:?}" ); @@ -2733,11 +2724,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. @@ -2748,10 +2735,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 @@ -2761,19 +2745,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); } } From 84b2d9028783e20184eae6c1f125c407bf400d3e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:05:46 -0400 Subject: [PATCH 5/9] Keep vendored entries whose wiring attestation only dropped as unattributable The prune GC's in-use verdict read every vendored ref that attestation dropped as unused unless an extractor had recorded it as withheld, and only npm's in-pair contest, bun and cargo did. Every other unattributable drop (an unpatched copy in the same lock, npm's legacy `dependencies` mirror, a non-registry nested copy, vlt's other instances and bundled copies, a yarn git block, a version-less Go replace) made `scan --prune` unwire a vendored patch the package manager still installs. `vendor_entry_in_use` now keeps an entry whenever a file that mentions its uuid also carries a DIAG_REF_UNATTRIBUTABLE diagnostic. That one rule covers every extractor, so the npm and bun `withheld` pushes are gone; cargo's relock-pending record stays because cargo reports that case as DIAG_REF_INVALID. Mentions rejected as invalid (stale bun.lockb pool strings, vlt edge specs, cargo's leftover [patch] after a hosted takeover) still read as unused. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/src/commands/vendor.rs | 50 ++++++++++- .../src/vendor/npm_flavor.rs | 84 +++++++++++++++++++ .../socket-patch-core/src/vex/discover/bun.rs | 12 --- .../socket-patch-core/src/vex/discover/mod.rs | 74 ++++++++++++++-- .../socket-patch-core/src/vex/discover/npm.rs | 14 ---- 5 files changed, 196 insertions(+), 38 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 46304cf75..6a373511d 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1231,8 +1231,7 @@ async fn run_check(args: &VendorArgs) -> i32 { // 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 @@ -5241,7 +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!( + out.failed.is_empty(), + "an in-use entry is never reverted: {out:?}" + ); assert_eq!(out.orphan_dirs, 0); assert!(load_state(tmp.path()) .await @@ -5250,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 diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 0660d2f40..83d7acd60 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -1750,6 +1750,90 @@ mod tests { 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] async fn binary_bun_in_use_ignores_old_pool_strings_and_obeys_text_precedence() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index 795c09814..b219ebaae 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -227,12 +227,6 @@ impl Bundled { out.refs.push(r); continue; }; - // Still wiring, just not attested (`Discovery::withheld`). - out.withheld.push(super::Recognized { - uuid: r.uuid.clone(), - mode: r.mode, - file: r.source_file.clone(), - }); out.diag( DIAG_REF_UNATTRIBUTABLE, file, @@ -459,12 +453,6 @@ impl Unwired { out.refs.push(r); continue; }; - // Still wiring, just not attested (`Discovery::withheld`). - out.withheld.push(super::Recognized { - uuid: r.uuid.clone(), - mode: r.mode, - file: r.source_file.clone(), - }); out.diag( DIAG_REF_UNATTRIBUTABLE, file, diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 59fc9c099..5355e48f8 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -487,14 +487,13 @@ pub struct Discovery { /// this vendored entry" means "unused" only once discovery has read /// that ecosystem's files ([`Discovery::vendor_entry_in_use`]). pub read: Vec, - /// Wiring a file still routes through a Socket patch that an extractor - /// WITHHOLDS as a ref because the build may not consume it: a ref another - /// entry or npm lock contests (npm's in-pair contest, a bundled copy), - /// or cargo vendored wiring whose lock builds another generation's copy - /// (a `[patch]` at this copy, the lock tagged for another uuid or - /// untagged — the next relock consumes it). Not attested, but still - /// wiring: the prune GC must keep such an entry - /// ([`Discovery::vendor_entry_in_use`]). Sorted, deduped. + /// 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, } @@ -1804,7 +1803,8 @@ impl Discovery { /// 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`]). A JVM entry: its tree is still + /// ([`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 @@ -1830,6 +1830,7 @@ impl Discovery { .withheld .iter() .any(|r| r.uuid == entry.uuid && r.mode == WiringMode::Vendored) + || self.unattributable_mention(&entry.uuid) { return Some(true); } @@ -1850,6 +1851,30 @@ impl Discovery { 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 @@ -2587,6 +2612,37 @@ 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); + } + #[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/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index fe260e98d..c130efa95 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -99,16 +99,6 @@ impl NpmLockRefs { } } -/// Record a contested npm ref as withheld wiring ([`Discovery::withheld`]): -/// the lock still resolves through the patch, it is just not attested. -fn withhold(r: &PatchedRef, file: &str, out: &mut Discovery) { - out.withheld.push(super::Recognized { - uuid: r.uuid.clone(), - mode: r.mode, - file: std::path::PathBuf::from(file), - }); -} - /// Push every ref no OTHER npm lock contests. npm <= 11 installs from /// npm-shrinkwrap.json when both exist; npm 12 auto-creates a /// package-lock.json beside it and installs from THAT (verified against real @@ -161,7 +151,6 @@ fn push_uncontested(locks: Vec, out: &mut Discovery) { lock.file, r.purl, r.uuid, ), ); - withhold(r, lock.file, out); continue; } if let Some(location) = lock.unwired.get(&r.purl) { @@ -177,7 +166,6 @@ fn push_uncontested(locks: Vec, out: &mut Discovery) { lock.file, r.purl, r.uuid, ), ); - withhold(r, lock.file, out); continue; } let contested_by = locks.iter().enumerate().find(|(j, other)| { @@ -196,7 +184,6 @@ fn push_uncontested(locks: Vec, out: &mut Discovery) { lock.file, r.purl, r.uuid, other.file, NPM_LOCKS[0], NPM_LOCKS[1], ), ); - withhold(r, lock.file, out); } else if let Some(other) = locks .iter() .enumerate() @@ -216,7 +203,6 @@ fn push_uncontested(locks: Vec, out: &mut Discovery) { lock.file, r.purl, r.uuid, other.file, NPM_LOCKS[0], NPM_LOCKS[1], ), ); - withhold(r, lock.file, out); } else { out.push(r.clone()); } From b3020d249a7ad2f80917e1133db8a4a4268edd88 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:05:46 -0400 Subject: [PATCH 6/9] Assert every backend test's fresh vendor reads as in use for the prune GC Each successful, non-dry-run vendor through the test_support wrappers now asks discovery's in-use verdict about the entry it just wrote and fails on Some(false). Every backend suite (npm flavors, pnpm, yarn, bun, vlt, cargo, composer, gem, golang, maven, nuget and every pypi flavor) thus pins the keep side of the GC against its real output. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vendor/test_support.rs | 123 +++++++++++++----- 1 file changed, 93 insertions(+), 30 deletions(-) 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 { From 9ff1237182bc682d994b8042f4066bfcc7f3dba0 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:05:46 -0400 Subject: [PATCH 7/9] Pin the path the JVM orphan event names The B62 test now checks that the vendor_ledger_missing event names a LEDGER_OWNED_PATHS member that exists, not just any maven-tagged event. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/tests/vendor_jvm_cli.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/tests/vendor_jvm_cli.rs b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs index 903c814e4..241498dc5 100644 --- a/crates/socket-patch-cli/tests/vendor_jvm_cli.rs +++ b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs @@ -688,13 +688,20 @@ fn check_reports_jvm_trees_without_a_jvm_entry_beside_other_entries() { 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!( - events - .iter() - .any(|e| e.to_string().contains("vendor_ledger_missing") - && e["details"]["ecosystem"] == "maven"), - "{env}" + 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); } From e9bd80572b5805aa639cc82e19e47447e9dd4d32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:40:49 +0000 Subject: [PATCH 8/9] Keep JVM trees whose Gradle wiring cannot be read `scan --prune` reverts a JVM ledger entry when `entry_references` answers false, but the Gradle arm returned `gradle::references` as is: a malformed gradle-index.tsv, a non-UTF-8 settings file, or a file the reader could not read (permission error, link out of the checkout) all read as "not referenced", so the GC could delete a tree that is still wired. The maven/sbt/scala-cli arms already map undecidable to true. Add `gradle::references_checked`, which answers None when a file it decides by exists but cannot be parsed, and have `entry_references` treat that, and any read error the ProjectReader recorded, as still in use. `gradle::references` keeps its old answers for revert and attestation. Also pin with a test that a hand-stripped, empty-wiring `jvm` entry stays undecidable (None) for the prune GC. Co-Authored-By: Claude --- .../socket-patch-core/src/vendor/jvm/apply.rs | 71 ++++++++++++++++--- .../src/vendor/jvm/gradle.rs | 64 +++++++++++------ .../socket-patch-core/src/vex/discover/mod.rs | 36 ++++++++++ 3 files changed, 141 insertions(+), 30 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs index 1e039b8da..beab455b8 100644 --- a/crates/socket-patch-core/src/vendor/jvm/apply.rs +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -807,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; @@ -819,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 @@ -1144,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/gradle.rs b/crates/socket-patch-core/src/vendor/jvm/gradle.rs index 31a678cb5..9331cb9d5 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/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 5355e48f8..1f91f99b7 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -2643,6 +2643,42 @@ mod tests { 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; From 22a1b60dc444782d049284440934b2629a49c07b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 8 Oct 2026 03:33:30 -0400 Subject: [PATCH 9/9] Merge origin/main into arch-fix/vendored-liveness Brings in #1044 (governing lock table), #1035, #1038, #1083, #724. Drop the BUN_LOCK/BUN_LOCKB/NPM_LOCKS imports #1044 added to npm_flavor.rs: their only user, vendored_entry_in_use, is removed by this PR (liveness now comes from Discovery::vendor_entry_in_use). This unused import failed clippy in the merge group. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/vendor/npm_flavor.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 60bc435bb..62c183c41 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -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, };