From 19b321d523ab798bb04fe1e69ab319ebf40f6de6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 11:43:33 -0600 Subject: [PATCH 01/10] Claim only this project's vendor paths when rewriting wiring `parse_vendor_path` found the `.socket/vendor/` anchor anywhere in a string, so a lock entry naming a sibling checkout's store (`file:../.socket/vendor/npm//x.tgz`) read as ours to every vendored writer, reverter and GC, while discovery's `vendor_ref` already required a root-anchored path. With the same patch vendored in both checkouts, `vendor --revert` restored our recorded original over the other project's wiring. The ownership parse is now root-anchored and shared with discovery (`vendor_rest_from`): only `.`/`..` may precede the anchor and they must land exactly on the project root (`parse_vendor_path_from` for a file below the root). Yarn berry locators cut their `name@` first (`parse_vendor_locator`); pnpm <= 8 keeps its machine-absolute specifier (`parse_vendor_path_or_machine_absolute`), which pnpm itself writes. Readers that only ask "is this a vendored spec" (the lock inventory, composer/pnpm registry listings, the hosted-wiring probe, the reference scan behind repair and the orphan sweep) use `recognize_vendor_path_anywhere`, which nothing rewrites by. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/vendored_backend/repair.rs | 16 +- .../src/formats/composer/mod.rs | 13 +- .../socket-patch-core/src/formats/pnpm/mod.rs | 4 +- .../src/vendor/lock_inventory/npm.rs | 2 +- .../socket-patch-core/src/vendor/npm_lock.rs | 41 ++++ crates/socket-patch-core/src/vendor/path.rs | 226 ++++++++++++++++-- .../socket-patch-core/src/vendor/pnpm_lock.rs | 6 +- .../src/vendor/pnpm_lock_legacy.rs | 25 +- .../src/vendor/yarn_classic_lock.rs | 2 +- .../socket-patch-core/src/vex/discover/mod.rs | 20 +- 10 files changed, 299 insertions(+), 56 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 31075b1fa..01cc126b5 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -12,8 +12,9 @@ use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::utils::fs::read_regular_to_string; use socket_patch_core::utils::purl::normalize_purl; use socket_patch_core::vendor::{ - self, artifact_is_file_shaped, check_vendored_artifact, parse_vendor_path, - path::parse_vendor_reference, ArtifactHealth, VendorEntry, VendorState, VendorWarning, + self, artifact_is_file_shaped, check_vendored_artifact, + path::{parse_vendor_reference, recognize_vendor_path_anywhere}, + ArtifactHealth, VendorEntry, VendorState, VendorWarning, }; use super::VendoredBackend; @@ -37,6 +38,13 @@ struct Candidate { /// path parser. Used by repair (references the ledger does not cover), by /// the orphan sweeps (`vendor --revert`, `scan --prune`: a dir a lockfile /// still points at is never deleted) and by rollback. +/// +/// RECOGNITION only: a reference counts wherever its `.socket/vendor/` +/// anchor sits (a sibling's `../.socket/vendor/…` too), which only ever +/// keeps one of THIS project's dirs from being swept or flags it as +/// wired. Nothing here rewrites the referencing file; the writers and +/// reverters claim wiring through the root-anchored +/// [`socket_patch_core::vendor::parse_vendor_path`]. pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, String, String)> { let mut seen: HashSet<(String, String)> = HashSet::new(); let mut out = Vec::new(); @@ -54,7 +62,9 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, socket_patch_core::vendor::bun_lock::binary_vendor_paths(project_root).await { for path in paths { - if let Some(parts) = parse_vendor_path(&path) { + if let Some(parts) = + recognize_vendor_path_anywhere(&path).filter(|p| !p.leaf.is_empty()) + { if seen.insert((parts.eco.to_string(), parts.uuid.clone())) { let rel = format!(".socket/vendor/{}/{}/{}", parts.eco, parts.uuid, parts.leaf); diff --git a/crates/socket-patch-core/src/formats/composer/mod.rs b/crates/socket-patch-core/src/formats/composer/mod.rs index 6e3f8f3e9..8aa1a6aad 100644 --- a/crates/socket-patch-core/src/formats/composer/mod.rs +++ b/crates/socket-patch-core/src/formats/composer/mod.rs @@ -88,9 +88,9 @@ impl<'a> ComposerLockPackage<'a> { .map(LockIntegrity::Sha1Hex) } - /// The Socket-vendored path `dist.url` names, anchored anywhere - /// ([`parse_vendor_path`]: the writers' ownership rule, not discovery's - /// root-anchored attestation grammar). + /// The path into THIS project's `.socket/vendor/` that `dist.url` + /// names ([`parse_vendor_path`]: the writers' root-anchored ownership + /// rule; a sibling's `../.socket/vendor/…` is not ours). pub(crate) fn dist_vendor_path(&self) -> Option { self.dist_str("url").and_then(parse_vendor_path) } @@ -170,7 +170,12 @@ impl<'a> ComposerLock<'a> { continue; } // Our own vendored entries use a path dist — skip. - if pkg.dist_str("type") == Some("path") || pkg.dist_vendor_path().is_some() { + if pkg.dist_str("type") == Some("path") + || pkg + .dist_str("url") + .and_then(crate::vendor::path::recognize_vendor_path_anywhere) + .is_some() + { continue; } let dist_url = pkg.dist_str("url").unwrap_or(""); diff --git a/crates/socket-patch-core/src/formats/pnpm/mod.rs b/crates/socket-patch-core/src/formats/pnpm/mod.rs index 1b06aba9e..4abd400ba 100644 --- a/crates/socket-patch-core/src/formats/pnpm/mod.rs +++ b/crates/socket-patch-core/src/formats/pnpm/mod.rs @@ -450,7 +450,9 @@ impl<'t> PnpmLock<'t> { .map(|i| LockIntegrity::Sri(i.to_string())) .unwrap_or(LockIntegrity::None); let tarball = resolution.and_then(|r| r.tarball()); - if tarball.is_some_and(|t| parse_vendor_path(t).is_some()) { + if tarball + .is_some_and(|t| crate::vendor::path::recognize_vendor_path_anywhere(t).is_some()) + { continue; } out.push(LockfileEntry::npm( diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs index 259332343..0e41cfd7f 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs @@ -440,7 +440,7 @@ pub(super) async fn inventory_package_lock_in( // Our own vendored spec: not a registry dependency. if node .resolved - .is_some_and(|r| parse_vendor_path(r).is_some()) + .is_some_and(|r| crate::vendor::path::recognize_vendor_path_anywhere(r).is_some()) { continue; } diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index c20288900..cb0846761 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -4493,6 +4493,47 @@ mod tests { ); } + /// N5: an entry repointed at a SIBLING checkout's vendored copy of the + /// same patch (`file:../.socket/vendor/npm//…`) is that + /// project's wiring, not ours: the revert leaves it byte-for-byte and + /// keeps our artifact, instead of claiming it by its uuid and restoring + /// our recorded original over it. + #[tokio::test] + async fn revert_never_claims_a_sibling_checkouts_vendor_path() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + let sibling = format!("file:../.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + let mut live = fx.read_lock().await; + live["packages"]["node_modules/left-pad"]["resolved"] = json!(sibling); + tokio::fs::write(fx.lock_path(), serialize_json(&live, " ").unwrap()) + .await + .unwrap(); + + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), + "{:?}", + outcome.warnings + ); + let after = fx.read_lock().await; + assert_eq!( + after["packages"]["node_modules/left-pad"], live["packages"]["node_modules/left-pad"], + "the sibling's wiring is never rewritten" + ); + assert!( + fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the artifact is kept while an entry is left alone" + ); + } + /// #665: the user dropped the patched dependency (`npm uninstall /// left-pad`), so npm deleted every lock entry the wiring recorded and /// nothing in the lock resolves through the artifact any more. That is diff --git a/crates/socket-patch-core/src/vendor/path.rs b/crates/socket-patch-core/src/vendor/path.rs index 993d8b5d3..96163fbcf 100644 --- a/crates/socket-patch-core/src/vendor/path.rs +++ b/crates/socket-patch-core/src/vendor/path.rs @@ -134,34 +134,159 @@ pub struct VendorPathParts { pub leaf: String, } -/// Recover `(eco, uuid, leaf)` from any lockfile-recorded vendored path -/// string — `file:` npm specs, `./`-prefixed go.mod replace targets, -/// composer dist urls, requirement lines, backslashed Windows spellings. -/// This is the documented external-tool recovery rule; `None` means the -/// string is not a Socket-vendored path. +/// Recover `(eco, uuid, leaf)` from a vendored path recorded in a file at +/// the project root — `file:` npm specs, `./`-prefixed go.mod replace +/// targets, composer dist urls, requirement lines, backslashed Windows +/// spellings. `None` means the string is not a path into THIS project's +/// `.socket/vendor/`. +/// +/// This is the writers' ownership rule: every vendored writer, reverter +/// and GC claims a lock entry or wiring line only through it (or +/// [`parse_vendor_path_from`] for a file below the root). The anchor must +/// START the path, after `file:` and `./`: a `../.socket/vendor/…` or +/// `/abs/.socket/vendor/…` spelling names ANOTHER project's vendor store +/// (a sibling checkout's), and claiming it would let `vendor --revert` or +/// GC rewrite or delete wiring that project owns. To merely RECOGNIZE a +/// vendored path wherever it points (to report it, or to keep a directory +/// a lockfile may still name), use [`recognize_vendor_path_anywhere`]. pub fn parse_vendor_path(s: &str) -> Option { - parse_vendor_reference(s).filter(|parts| !parts.leaf.is_empty()) + parse_vendor_path_from("", s) +} + +/// [`parse_vendor_path`] for a string recorded in a file whose directory +/// is `base` (project-root-relative, `/`-separated, `""` for the root): a +/// pnpm importer's `specifier: file:../../.socket/vendor/…`, a +/// requirements file in `deploy/` naming `../.socket/vendor/…`. Only `.` +/// and `..` segments may precede the anchor, and they must resolve from +/// `base` to exactly the project root — never above it, never into a +/// sub-project's own `.socket/vendor/`. +pub fn parse_vendor_path_from(base: &str, s: &str) -> Option { + parse_vendor_reference_from(base, s).filter(|parts| !parts.leaf.is_empty()) +} + +/// [`parse_vendor_path`], also accepting a machine-absolute spelling +/// (`/home/ci/app/.socket/vendor/npm//…`), for pnpm <= 8 locks ONLY: +/// pnpm itself records a `file:` override specifier as an absolute path, +/// so a committed lock carries the vendoring checkout's absolute root, +/// which is this project at another path. The writer still binds the +/// result to its own entry's uuid and leaf. A relative escape +/// (`../.socket/vendor/…`) is still never ours, nor is an absolute path +/// with `.`/`..` segments. +pub fn parse_vendor_path_or_machine_absolute(s: &str) -> Option { + parse_vendor_path(s).or_else(|| { + let norm = normalize_vendor_spelling(s); + let idx = vendor_anchor_index(&norm)?; + let prefix = &norm[..idx]; + let absolute = crate::utils::relpath::is_anchored(prefix) + && !prefix.split('/').any(|seg| matches!(seg, "." | "..")); + if !absolute { + return None; + } + split_vendor_rest(&norm[idx + VENDOR_DIR.len() + 1..]).filter(|p| !p.leaf.is_empty()) + }) +} + +/// [`parse_vendor_path`] for a package locator or descriptor that prefixes +/// the path with its package name: yarn berry's +/// `resolution: "left-pad@file:./.socket/vendor/…"`, a pnpm v9 +/// `left-pad@file:.socket/vendor/…` key. The `[@scope/]name@` is cut, and +/// the rest must be root-anchored as usual; a string without that prefix +/// is parsed as it stands. +pub fn parse_vendor_locator(s: &str) -> Option { + let s = s.trim(); + let name_end = if let Some(scoped) = s.strip_prefix('@') { + scoped + .find('/') + .map(|slash| slash + 1) + .and_then(|slash| s[slash + 1..].find(['@', '/']).map(|at| slash + 1 + at)) + } else { + s.find(['@', '/']) + }; + match name_end { + Some(at) if at > 0 && s.as_bytes()[at] == b'@' => parse_vendor_path(&s[at + 1..]), + _ => parse_vendor_path(s), + } } /// [`parse_vendor_path`]'s grammar, also accepting a reference to the uuid /// directory itself (`leaf` empty): NuGet's vendored feed /// (`value=".socket/vendor/nuget/"`) and Maven's vendored repository -/// (`file://${project.basedir}/.socket/vendor/maven/`) -/// name the unit, not a file inside it. The vendored-reference scan reads -/// wiring through this, so a directory-wired unit counts as still wired. +/// (`file://${project.basedir}/.socket/vendor/maven/`, +/// whose caller strips the basedir prefix) name the unit, not a file +/// inside it. pub fn parse_vendor_reference(s: &str) -> Option { - let norm = s.replace('\\', "/"); - let norm = norm.strip_prefix("file:").unwrap_or(&norm); - let norm = norm.strip_prefix("./").unwrap_or(norm); - // Find the `.socket/vendor/` anchor anywhere in the string (a workspace - // sub-project may record `../.socket/vendor/...`). + parse_vendor_reference_from("", s) +} + +/// [`parse_vendor_reference`] for a file whose directory is `base` (see +/// [`parse_vendor_path_from`]). +pub fn parse_vendor_reference_from(base: &str, s: &str) -> Option { + split_vendor_rest(&vendor_rest_from(base, s)?) +} + +/// Recover `(eco, uuid, leaf)` from a vendored path wherever its +/// `.socket/vendor/` anchor sits — `../.socket/vendor/…`, an absolute +/// path, another project's store. For RECOGNITION only (reporting a +/// reference, keeping a directory a lockfile may still name from being +/// swept): never the basis for rewriting, reverting or deleting wiring, +/// which [`parse_vendor_path`] gates. `leaf` may be empty (a uuid-dir +/// reference). +pub fn recognize_vendor_path_anywhere(s: &str) -> Option { + let norm = normalize_vendor_spelling(s); + let idx = vendor_anchor_index(&norm)?; + split_vendor_rest(&norm[idx + VENDOR_DIR.len() + 1..]) +} + +/// `s` with surrounding whitespace, `\/` JSON escapes, backslashes and a +/// leading `file:` normalized away. +fn normalize_vendor_spelling(s: &str) -> String { + let norm = s.trim().replace("\\/", "/").replace('\\', "/"); + match norm.strip_prefix("file:") { + Some(rest) => rest.to_string(), + None => norm, + } +} + +/// The byte index of the first `.socket/vendor/` anchor in `norm` at a +/// path-component boundary. +fn vendor_anchor_index(norm: &str) -> Option { let anchor = format!("{VENDOR_DIR}/"); - let idx = norm.find(&anchor)?; - // Anchor must sit at a path-component boundary. - if idx > 0 && norm.as_bytes()[idx - 1] != b'/' { + let mut from = 0; + while let Some(found) = norm[from..].find(&anchor) { + let idx = from + found; + if idx == 0 || norm.as_bytes()[idx - 1] == b'/' { + return Some(idx); + } + from = idx + 1; + } + None +} + +/// Everything after the `.socket/vendor/` anchor of a path recorded in a +/// file at `base` (see [`parse_vendor_path_from`]) when that anchor is THIS +/// project's vendor directory, else `None`. The one root-anchoring rule the +/// writers ([`parse_vendor_path`]) and VEX discovery +/// ([`crate::vex::discover::vendor_ref`]) share. +pub(crate) fn vendor_rest_from(base: &str, s: &str) -> Option { + let norm = normalize_vendor_spelling(s); + let idx = vendor_anchor_index(&norm)?; + let prefix = &norm[..idx]; + // Only `.`/`..` may lead to the anchor: a named segment can be a link + // whose `..` the OS resolves elsewhere, and an anchored (absolute) + // spelling names whatever store it names. + if crate::utils::relpath::is_anchored(prefix) + || prefix.split('/').any(|seg| !matches!(seg, "" | "." | "..")) + { return None; } - let rest = &norm[idx + anchor.len()..]; + let landed = crate::utils::relpath::resolve_rel(base, prefix, 0)?; + landed + .is_empty() + .then(|| norm[idx + VENDOR_DIR.len() + 1..].to_string()) +} + +/// `/[/]`, split and validated. +fn split_vendor_rest(rest: &str) -> Option { let mut it = rest.splitn(3, '/'); let eco = it.next()?; let uuid = it.next()?; @@ -637,18 +762,70 @@ mod tests { ("cargo", "serde-1.0.190") ); - // anchored mid-string (workspace-relative) - assert!(parse_vendor_path(&format!( - "../.socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl" - )) - .is_some()); - // Rejections: bad uuid, unknown eco, non-boundary anchor. assert!(parse_vendor_path(".socket/vendor/npm/not-a-uuid/x.tgz").is_none()); assert!(parse_vendor_path(&format!(".socket/vendor/jsr/{UUID}/x")).is_none()); assert!(parse_vendor_path(&format!("x.socket/vendor/npm/{UUID}/y.tgz")).is_none()); } + /// N5: the writers' ownership parse claims only THIS project's + /// `.socket/vendor/`. A sibling checkout's store (`../.socket/vendor/…`), + /// an absolute path, or a sub-project's own store is recognized only + /// by [`recognize_vendor_path_anywhere`], which nothing rewrites by. + #[test] + fn ownership_parse_is_root_anchored() { + let leaf = "six-1.16.0-py2.py3-none-any.whl"; + for foreign in [ + format!("../.socket/vendor/pypi/{UUID}/{leaf}"), + format!("file:../.socket/vendor/pypi/{UUID}/{leaf}"), + format!("./../.socket/vendor/pypi/{UUID}/{leaf}"), + format!("..\\.socket\\vendor\\pypi\\{UUID}\\{leaf}"), + format!("../sibling/.socket/vendor/pypi/{UUID}/{leaf}"), + format!("/home/ci/other/.socket/vendor/pypi/{UUID}/{leaf}"), + format!("sub/.socket/vendor/pypi/{UUID}/{leaf}"), + format!("sub/../.socket/vendor/pypi/{UUID}/{leaf}"), + ] { + assert!(parse_vendor_path(&foreign).is_none(), "{foreign}"); + assert!(parse_vendor_reference(&foreign).is_none(), "{foreign}"); + assert!( + parse_vendor_locator(&format!("six@{foreign}")).is_none(), + "{foreign}" + ); + assert!( + recognize_vendor_path_anywhere(&foreign).is_some_and(|p| p.uuid == UUID), + "{foreign}" + ); + } + // From a file below the root, `..` back to exactly the root is ours; + // past the root, or short of it, is not. + let up_two = format!("file:../../.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + assert!(parse_vendor_path_from("packages/a", &up_two).is_some()); + assert!(parse_vendor_path_from("packages", &up_two).is_none()); + assert!(parse_vendor_path_from("a/b/c", &up_two).is_none()); + assert!(parse_vendor_path(&up_two).is_none()); + // Locators cut the package name, scoped or not. + for locator in [ + format!("left-pad@file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz::locator=a%40workspace%3A."), + format!("@s/left-pad@file:.socket/vendor/npm/{UUID}/@s/left-pad-1.3.0.tgz"), + format!("./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"), + ] { + assert!(parse_vendor_locator(&locator).is_some(), "{locator}"); + } + // pnpm <= 8's machine-absolute specifier is the one absolute + // spelling a writer may claim; never with `..` in it. + let abs = format!("/home/ci/app/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + assert!(parse_vendor_path_or_machine_absolute(&abs).is_some()); + assert!(parse_vendor_path_or_machine_absolute(&format!("file:{abs}")).is_some()); + assert!(parse_vendor_path_or_machine_absolute(&format!( + "/home/ci/app/../other/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz" + )) + .is_none()); + assert!(parse_vendor_path_or_machine_absolute(&format!( + "../.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz" + )) + .is_none()); + } + /// `parse_vendor_reference` is `parse_vendor_path`'s grammar plus the /// bare uuid dir (NuGet's feed, Maven's repository); `parse_vendor_path` /// itself still needs a leaf. @@ -658,7 +835,6 @@ mod tests { format!(".socket/vendor/nuget/{UUID}"), format!(".socket/vendor/nuget/{UUID}/"), format!(".socket\\vendor\\nuget\\{UUID}"), - format!("file://${{project.basedir}}/.socket/vendor/nuget/{UUID}"), ] { let p = parse_vendor_reference(&s).unwrap_or_else(|| panic!("{s}")); assert_eq!( diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 7547e780d..a02a9bbca 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -3600,6 +3600,7 @@ fn revert_snapshot_ref( entry_uuid, label: "snapshot ref", dep_maps_only: false, + parse: parse_vendor_path, }; for (header, block_end) in ref_record_blocks(lines, start, end, snapshot_key) { if target.restore_in(lines, header, block_end, dirty, warnings) { @@ -3677,6 +3678,9 @@ pub(super) struct DepRef<'a> { /// Only look inside `dependencies:` / `optionalDependencies:` maps (the /// legacy edit's scope; the v9 edit rewrites any 6-space key). pub(super) dep_maps_only: bool, + /// The ownership parse: [`parse_vendor_path`], or for pnpm <= 8 locks + /// [`super::path::parse_vendor_path_or_machine_absolute`]. + pub(super) parse: fn(&str) -> Option, } impl DepRef<'_> { @@ -3713,7 +3717,7 @@ impl DepRef<'_> { return true; } let ours = Some(rest) == rec.new.as_ref().and_then(Value::as_str) - || parse_vendor_path(rest).is_some_and(|p| p.eco == "npm" && p.uuid == self.entry_uuid); + || (self.parse)(rest).is_some_and(|p| p.eco == "npm" && p.uuid == self.entry_uuid); if !ours { warnings.push(drifted(format!( "{} `{}` was re-resolved since vendoring ({rest}); left alone", diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index 549524546..eac0fb8c4 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -62,11 +62,11 @@ use crate::patch::apply::PatchSources; use crate::utils::fs::read_regular_to_string; use super::common::refused; -use super::path::parse_vendor_path; +use super::path::parse_vendor_path_or_machine_absolute; use super::pnpm_lock::{ dep_field_lines, drifted, lines_value, merge_live_dep_refs, ref_record_blocks, removed, - revert_overrides_line, same_modulo_dep_refs, value_lines, vendor_value_is_for, DepRef, EditCtx, - PnpmDialect, KIND_LOCK_OVERRIDES, KIND_LOCK_PACKAGE, + revert_overrides_line, same_modulo_dep_refs, value_lines, DepRef, EditCtx, PnpmDialect, + KIND_LOCK_OVERRIDES, KIND_LOCK_PACKAGE, }; use super::source::PackageSource; use super::state::{VendorEntry, WiringAction, WiringRecord}; @@ -175,11 +175,12 @@ impl Ctx<'_> { } /// Does `value` point at OUR vendored tarball for THIS name@version - /// (any uuid, relative or the machine-absolute specifier spelling — - /// `parse_vendor_path` anchors on `.socket/vendor/` anywhere in the - /// string)? + /// (any uuid; root-relative, or the machine-absolute specifier spelling + /// pnpm <= 8 records — never a relative `../` escape)? fn is_ours(&self, value: &str) -> bool { - vendor_value_is_for(value, self.name, self.version) + parse_vendor_path_or_machine_absolute(value).is_some_and(|p| { + p.eco == "npm" && p.leaf == super::npm_common::tgz_rel_leaf(self.name, self.version) + }) } /// The peer-suffix marker this grammar appends to a version/key @@ -985,7 +986,7 @@ fn revert_value_line( .as_ref() .and_then(Value::as_str) .is_some_and(|new| rest == new || rest == yaml_value(new)) - || parse_vendor_path(unquote_value(rest)) + || parse_vendor_path_or_machine_absolute(unquote_value(rest)) .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { warnings.push(drifted(format!( @@ -1061,7 +1062,8 @@ fn revert_root_dep_pair( .and_then(|n| n.get("version")) .and_then(Value::as_str); let ours = Some(live_ver.as_str()) == new_ver - || parse_vendor_path(&live_ver).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + || parse_vendor_path_or_machine_absolute(&live_ver) + .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { warnings.push(drifted(format!( "root dep `{key}` was re-resolved since vendoring ({live_ver}); left alone" @@ -1131,8 +1133,8 @@ fn revert_package_block( continue; } let live: Vec = lines[block.header..block.end].to_vec(); - let key_is_ours = - parse_vendor_path(new_key).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + let key_is_ours = parse_vendor_path_or_machine_absolute(new_key) + .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if live != new_lines && !key_is_ours { warnings.push(drifted(format!( "packages entry `{new_key}` was changed since vendoring; left alone" @@ -1214,6 +1216,7 @@ fn revert_pkg_dep_ref( entry_uuid, label: "dep ref", dep_maps_only: true, + parse: parse_vendor_path_or_machine_absolute, }; for (header, block_end) in ref_record_blocks(lines, start, end, pkg_key) { if target.restore_in(lines, header, block_end, dirty, warnings) { diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 2bfbe4d12..4a68c89fb 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -850,7 +850,7 @@ pub(super) fn revert_recorded_block( let orig_lines = rec.original.as_ref().and_then(json_to_lines); let ours = |lines: &[String]| { vendor_field(lines) - .and_then(parse_vendor_path) + .and_then(super::path::parse_vendor_locator) .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid) }; let edit = { diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 815d4644d..fe04afa88 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -2351,14 +2351,12 @@ pub(crate) fn is_decorated_vendor_path(s: &str) -> bool { vendor_ref(s).is_none() && vendor_ref_decorated(s).is_some() } -/// Everything after a LEADING `.socket/vendor/` anchor (after `file:`, one -/// `./`, backslash and `\/` normalization), or `None`. +/// Everything after a LEADING `.socket/vendor/` anchor (after `file:`, +/// `./`, backslash and `\/` normalization), or `None`: the writers' own +/// root-anchoring rule ([`crate::vendor::path::vendor_rest_from`]), so +/// discovery attests exactly the references the writers claim. fn root_anchored_vendor_rest(s: &str) -> Option { - let norm = s.trim().replace("\\/", "/").replace('\\', "/"); - let norm = norm.strip_prefix("file:").unwrap_or(&norm); - let norm = norm.strip_prefix("./").unwrap_or(norm); - norm.strip_prefix(&format!("{VENDOR_DIR}/")) - .map(str::to_string) + crate::vendor::path::vendor_rest_from("", s) } /// Cut the lock-format decorations off a vendored leaf: everything from the @@ -2966,7 +2964,9 @@ pub async fn vendored_wiring_in_files(root: &Path, files: &[&str], eco: &str, uu .await .iter() .any(|resolution| { - crate::vendor::parse_vendor_path(resolution) + // A nested `sub/bun.lockb` resolves from `sub/`. + let base = file.rsplit_once('/').map_or("", |(dir, _)| dir); + crate::vendor::path::parse_vendor_path_from(base, resolution) .is_some_and(|p| p.eco == eco && p.uuid == uuid) }) { @@ -3069,8 +3069,10 @@ pub async fn hosted_wiring_in_files(root: &Path, files: &[&str], uuid: &str) -> .await .iter() .any(|resolution| { + // Any vendored spelling, ours or a sibling's, is not + // hosted wiring. resolution.contains(uuid) - && crate::vendor::parse_vendor_path(resolution).is_none() + && crate::vendor::path::recognize_vendor_path_anywhere(resolution).is_none() }) { return true; From 33c8edfd3b631027098e3cb77cfd05b286ec3773 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 12:17:15 -0600 Subject: [PATCH 02/10] Never read project files outside the repository root One shared bound (`utils::repo_root::ReadBoundary`): the git checkout enclosing the project, else the project itself; `..` and symlinked levels are resolved before the check. `bounded_project_path` refuses a climbing or absolute project-relative path as `NotFound` (a reader sees a missing file), and `is_refused_read` lets a writer refuse instead. - go.work: an ancestor's or an absolute `GOWORK` outside the root is only stat'ed; the workspace is Invalid, so writers refuse and discovery withholds. `use` members and `replace` directories outside the root read as missing (the selection stays Unknown). - ProjectView reads (discovery, lock inventory, requirements walks, yarn file:/portal:/link: copies) and the hosted restore's staged reads are bounded; the requirements planner and the lock flavors' out-of-root pin check refuse `pypi_requirements_outside_root`, the restore refuses with the reason. - NuGet parent configs above the root are not read; vendoring refuses and hosted mode skips the dep with `redirect_nuget_inherited_config_outside_root` (CLI_CONTRACT.md). Fixtures that model files beside the project inside a checkout now create the `.git` they assumed. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 14 +- crates/socket-patch-core/src/hosted/engine.rs | 8 + .../src/patch/redirect/mod.rs | 40 +++++ .../src/patch/redirect/staged.rs | 34 +++- .../src/patch/redirect/upstream/npm.rs | 2 + .../socket-patch-core/src/utils/repo_root.rs | 161 ++++++++++++++++++ .../src/vendor/go_consumer_sync.rs | 13 ++ .../src/vendor/go_mod_edit.rs | 4 + .../src/vendor/go_native_vendor.rs | 6 +- .../src/vendor/go_workspace.rs | 142 +++++++++++++-- .../src/vendor/lock_inventory/npm.rs | 1 - .../src/vendor/lock_inventory/view.rs | 50 ++++-- .../src/vendor/nuget_config.rs | 58 ++++++- .../src/vendor/nuget_feed.rs | 55 +++++- crates/socket-patch-core/src/vendor/pypi.rs | 6 + .../src/vendor/pypi_requirements.rs | 67 +++++++- .../src/vex/discover/golang.rs | 6 +- .../socket-patch-core/src/vex/discover/mod.rs | 7 +- 18 files changed, 627 insertions(+), 47 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..1327dac1b 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -661,7 +661,9 @@ are patched by `socket-patch apply` like every other ecosystem: --check` is a read-only audit of the committed redirect. The `go.work` in effect is resolved as go resolves it: `GOWORK` naming an absolute file, none under `GOWORK=off`, else (unset, empty or `auto`) the nearest `go.work` at or above the project; a relative - `GOWORK` is invalid to go, so nothing is written or attested. A `-modfile` in `GOFLAGS` (#1799) + `GOWORK` is invalid to go, so nothing is written or attested. A `go.work` outside the repository + root is never read and refuses the same way (see "Reads bounded to the repository root"). + A `-modfile` in `GOFLAGS` (#1799) that names any file other than the project's `go.mod` (a relative one is taken from the project directory) makes go build from that file and ignore `go.mod`'s replaces, and in workspace mode go refuses `-modfile` outright: `apply`, `vendor` and `scan --mode hosted` write nothing @@ -902,6 +904,16 @@ socket-patch creates `.socket/` and everything under it itself and never writes Not covered: the agent-mode blob cache and manifest are checked from `.socket` down, so a `.socket` that is itself a link still redirects those writes (deciding that at lock time is deferred). +### Reads bounded to the repository root + +VEX discovery, the lock inventory, hosted mode and vendored mode never read a PROJECT file outside the repository root: the git checkout enclosing the project (the nearest `.git`, by the same rules as the repository lookup), or the project directory itself when there is none. One check (core `utils::repo_root::ReadBoundary`) decides it; a level that resolves outside through `..` or a symlink counts as outside. A refused read behaves like a missing file to a reader; a writer refuses with the reason (`… is outside the repository root …; socket-patch does not read project files outside it`). Machine-level tool configuration (the user `~/.npmrc` / `.yarnrc.yml` / `NuGet.Config`, the `GOENV` file, `$CARGO_HOME`, module and package caches) is not a project file and is read as before. + +- **Go**: a `go.work` outside the root (an ancestor's, or one an absolute `GOWORK` names) is only noticed, never read: the workspace is invalid, so vendored and hosted writes refuse with the Go workspace refusal and discovery withholds every Go ref. Inside the root, a `use` member or a `replace` directory outside it reads as missing, so the selection it would decide is unknown. +- **Requirements includes**: a `-r` include outside the root is never read. Discovery and the lock inventory see it as missing; vendored mode refuses `pypi_requirements_outside_root`, since the pins pip installs from it are unknowable. +- **NuGet parent configs**: a `nuget.config` in a directory above the root is never read. Vendoring refuses (`… also merges , which is outside the repository root …`) and hosted mode skips each NuGet dep with `redirect_nuget_inherited_config_outside_root`, writing nothing for it. +- **Relative and absolute references read through the project view** (yarn `file:` / `portal:` / `link:` copies, include chains, any `../` path a lockfile names) and the hosted upstream restore's staged reads: outside the root they read as missing, and the restore refuses. +- **Vendored ownership**: writers, reverters and the GC claim a lockfile reference only when it is root-anchored into THIS project's `.socket/vendor/` (`./.socket/vendor/…`, or `../` from a file below the root back to exactly the root). A sibling checkout's `../.socket/vendor///…`, even for the same patch uuid, is never rewritten, reverted or deleted; the revert leaves it with `vendor_lock_entry_drifted` and keeps the artifact. The one exception is pnpm <= 8, which records override specifiers as machine-absolute paths itself: an absolute `…/.socket/vendor/npm//` naming the entry's own uuid and leaf is still the entry's. + ## Vendor command contract `vendor` converts the hosted patches pinned in the project's lockfiles into committable diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 09901663b..cb01e8259 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -807,6 +807,12 @@ pub async fn read_candidate_files( String::new(), ); } + if let Some(why) = inherited.outside_refusal() { + out.files.insert( + crate::patch::redirect::NUGET_INHERITED_OUTSIDE_KEY.to_string(), + why, + ); + } } } @@ -4403,6 +4409,8 @@ mod tests { #[tokio::test] async fn issue_1951_yarn_classic_copy_manifests_read_as_vendored_reads_them() { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = tmp.path().join("app"); let manifest = r#"{"name":"left-pad","version":"1.3.0"}"#; for dir in ["app/lpdir", "ext/lpdir", "abs/lpdir"] { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0c778ac74..66033cead 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -7069,6 +7069,11 @@ pub const NUGET_INHERITED_SOURCES_KEY: &str = "\n\n \n \n\n"; @@ -7296,6 +7301,16 @@ fn rewrite_nuget( }); continue; } + // A parent config above the repository root is merged by NuGet but + // never read here, so the sources and mappings this edit must + // account for are unknown: skip rather than guess. + if let Some(why) = files.get(NUGET_INHERITED_OUTSIDE_KEY) { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_inherited_config_outside_root".into(), + detail: format!("{why}; {} not redirected", dep.name), + }); + continue; + } let unwritable = || RewriteWarning { code: "redirect_nuget_config_unwritable".into(), @@ -27581,6 +27596,31 @@ packages: assert_eq!(nuget_catch_all(&r.files["nuget.config"]), ["nuget.org"]); } + /// A parent config above the repository root, which the engine never + /// reads, skips the dep with a warning and writes nothing. + #[test] + fn nuget_inherited_config_outside_the_repository_skips_the_dep() { + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert( + NUGET_INHERITED_SOURCES_KEY.to_string(), + "nuget.org".to_string(), + ); + files.insert( + NUGET_INHERITED_OUTSIDE_KEY.to_string(), + "NuGet also merges /x/nuget.config, which is outside the repository root".to_string(), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.files.is_empty(), "{:?}", r.files.keys()); + assert_eq!( + r.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), + ["redirect_nuget_inherited_config_outside_root"] + ); + } + /// A PRESENT but unparseable packages.lock.json refuses the whole nuget /// redirect up front: landing the source + mapping while the lock kept /// the upstream contentHash would NU1403 every restore, with the ledger diff --git a/crates/socket-patch-core/src/patch/redirect/staged.rs b/crates/socket-patch-core/src/patch/redirect/staged.rs index c632f9391..8ffc33e10 100644 --- a/crates/socket-patch-core/src/patch/redirect/staged.rs +++ b/crates/socket-patch-core/src/patch/redirect/staged.rs @@ -25,7 +25,11 @@ pub(super) type StagedBytes = BTreeMap>; /// blocking open — the same posture as every other raw read in the patch /// engine. Errors read `read : `. pub(super) async fn read_rel(project_root: &Path, rel: &str) -> Result, String> { - match crate::utils::fs::read_regular_to_string(&project_root.join(rel)).await { + // A writer's read outside the repository root refuses rather than + // reading as missing: what it would write next depends on it. + let path = crate::utils::repo_root::bounded_project_path(project_root, rel) + .map_err(|refused| format!("read {rel}: {refused}"))?; + match crate::utils::fs::read_regular_to_string(&path).await { Ok(content) => Ok(Some(content)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(format!("read {rel}: {e}")), @@ -103,3 +107,31 @@ async fn refuse_non_regular(path: &Path, rel: &str) -> Result<(), String> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::read_rel; + + /// The restore's reads stay inside the repository root: a file outside + /// it refuses (the restore stops) instead of reading as missing. + #[tokio::test] + async fn reads_outside_the_repository_refuse() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + let root = repo.join("app"); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(repo.join(".npmrc"), "x=1\n").unwrap(); + std::fs::write(tmp.path().join(".npmrc"), "x=2\n").unwrap(); + assert_eq!( + read_rel(&root, "../.npmrc").await, + Ok(Some("x=1\n".to_string())) + ); + assert_eq!(read_rel(&root, "../missing").await, Ok(None)); + let above = tmp.path().join(".npmrc"); + for rel in ["../../.npmrc".to_string(), above.display().to_string()] { + let err = read_rel(&root, &rel).await.unwrap_err(); + assert!(err.contains("outside the repository root"), "{rel}: {err}"); + } + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 1bb703280..eff9b2884 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -4410,6 +4410,8 @@ mod tests { use super::{pnpm_settings_prefix, read_sibling}; let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = std::fs::canonicalize(dir.path()).unwrap(); write( &ws, diff --git a/crates/socket-patch-core/src/utils/repo_root.rs b/crates/socket-patch-core/src/utils/repo_root.rs index 504268698..7c76598f0 100644 --- a/crates/socket-patch-core/src/utils/repo_root.rs +++ b/crates/socket-patch-core/src/utils/repo_root.rs @@ -124,6 +124,119 @@ pub fn find_git_repo(start: &Path) -> Option { repo_at_end_of(search_dirs(start)) } +/// The directory no read of a PROJECT file may leave: the git checkout +/// enclosing `project_root` ([`find_git_repo`]), else `project_root` +/// itself. Canonical when `project_root` exists. +/// +/// Every reader and writer that follows a project file's reference +/// (an ancestor `go.work`, a `-r ../x.txt` include, a parent +/// `nuget.config`, a `file:../x` dependency) asks [`ReadBoundary::contains`] +/// before opening it: a refused read behaves like a missing file for a +/// reader, and a writer refuses with a diagnostic instead of guessing. +/// Machine-level tool configuration (`~/.npmrc`, the user NuGet.Config, +/// the `GOENV` file, `$CARGO_HOME`) is not a project file and is not +/// bounded by this. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadBoundary { + root: PathBuf, +} + +impl ReadBoundary { + /// The boundary for a project at `project_root`. + pub fn of(project_root: &Path) -> Self { + let project = canonical_or_lexical(project_root); + let root = find_git_repo(&project).map_or(project, |repo| repo.root); + ReadBoundary { root } + } + + /// The boundary directory itself. + pub fn root(&self) -> &Path { + &self.root + } + + /// Whether `path` lies at or below the boundary. `path` is made + /// absolute against the working directory, `..` folded, and its + /// deepest existing level canonicalized, so neither a `..` nor a + /// symlinked level inside the tree reaches outside it. + pub fn contains(&self, path: &Path) -> bool { + canonical_or_lexical(path).starts_with(&self.root) + } + + /// The user-facing reason a read of `path` was refused. + pub fn refusal(&self, path: &Path) -> String { + format!( + "{} is outside the repository root {}; socket-patch does not read project \ + files outside it", + path.display(), + self.root.display() + ) + } +} + +/// `project_root.join(rel)` for a project file named by a root-relative +/// `rel` that may climb (`../shared/requirements.txt`) or be absolute; +/// `Err` of kind `NotFound` (a refused read reads as a missing file) +/// carrying [`ReadBoundary::refusal`] when it lies outside the repository +/// root. A plain in-tree `rel` (no `..`, not absolute) is never refused, +/// and costs no filesystem lookups. +pub fn bounded_project_path(project_root: &Path, rel: &str) -> std::io::Result { + let path = project_root.join(rel); + let climbs = + crate::utils::relpath::is_anchored(rel) || rel.split(['/', '\\']).any(|seg| seg == ".."); + if climbs { + let bound = ReadBoundary::of(project_root); + if !bound.contains(&path) { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + OutsideRepoRoot(bound.refusal(&path)), + )); + } + } + Ok(path) +} + +/// The payload of a read [`bounded_project_path`] refused. +#[derive(Debug)] +struct OutsideRepoRoot(String); + +impl std::fmt::Display for OutsideRepoRoot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for OutsideRepoRoot {} + +/// Whether `e` is a read [`bounded_project_path`] refused. It reads as +/// `NotFound` (missing) to a reader; a writer, which must not proceed as +/// if the file were absent, refuses on it instead. +pub fn is_refused_read(e: &std::io::Error) -> bool { + e.get_ref() + .is_some_and(|inner| inner.is::()) +} + +/// `path` absolute, `..` folded lexically, with its deepest existing level +/// canonicalized (the rest re-appended as spelled). +fn canonical_or_lexical(path: &Path) -> PathBuf { + let abs = crate::utils::relpath::absolute_lexically(path); + let mut tail = Vec::new(); + let mut probe: &Path = &abs; + loop { + if let Ok(canon) = std::fs::canonicalize(probe) { + let mut out = canon; + out.extend(tail.iter().rev()); + return out; + } + match (probe.parent(), probe.file_name()) { + (Some(parent), Some(name)) => { + tail.push(name.to_os_string()); + probe = parent; + } + _ => return abs, + } + } +} + /// The checkout AT the home directory that a lookup from `start` stopped /// short of (the home rule), if there is one: for callers that should say /// why a repository the user may expect was not used. @@ -270,6 +383,54 @@ mod tests { assert!(dirs.is_empty(), "{dirs:?}"); } + #[test] + fn read_boundary_is_the_checkout_else_the_project() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let repo = base.join("repo"); + let project = repo.join("packages/app"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::create_dir_all(&project).unwrap(); + let bound = ReadBoundary::of(&project); + assert_eq!(bound.root(), repo.as_path()); + assert!(bound.contains(&project.join("../../go.work"))); + assert!(bound.contains(&repo.join("not/yet/created.txt"))); + assert!(!bound.contains(&project.join("../../../go.work"))); + assert!(!bound.contains(&base.join("sibling/.socket/vendor"))); + // No checkout: the project itself is the boundary. + let plain = base.join("plain/app"); + fs::create_dir_all(&plain).unwrap(); + let bound = ReadBoundary::of(&plain); + assert_eq!(bound.root(), plain.as_path()); + assert!(!bound.contains(&plain.join("../go.work"))); + // A project file named by a climbing path: refused as missing. + let err = bounded_project_path(&plain, "../go.work").unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + assert!(is_refused_read(&err)); + assert_eq!( + bounded_project_path(&plain, "sub/go.work").unwrap(), + plain.join("sub/go.work") + ); + let missing = std::io::Error::from(std::io::ErrorKind::NotFound); + assert!(!is_refused_read(&missing)); + } + + /// A symlink inside the tree that points out of it does not carry a + /// read across the boundary. + #[cfg(unix)] + #[test] + fn read_boundary_sees_through_links() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let repo = base.join("repo"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::create_dir_all(base.join("outside")).unwrap(); + std::os::unix::fs::symlink(base.join("outside"), repo.join("link")).unwrap(); + let bound = ReadBoundary::of(&repo); + assert!(!bound.contains(&repo.join("link/requirements.txt"))); + assert!(bound.contains(&repo.join("requirements.txt"))); + } + #[test] fn walk_stops_at_ceiling_dirs() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs index e4db0b3e8..3d5f6eb78 100644 --- a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs +++ b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs @@ -975,6 +975,8 @@ mod tests { #[tokio::test] async fn workspace_modules_txt_is_checked_against_every_member() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let root = dir.path(); consumer(root, REPLACE); write( @@ -1040,6 +1042,8 @@ mod tests { ("../a", COPY), ] { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path().join("ws"); let member = ws.join(use_dir); // How go spells a path relative to the go.work directory. @@ -1084,6 +1088,7 @@ mod tests { // has the same spelling as `a`'s recorded one but another target, // so the line is stale and `b`'s is not recorded. let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path().join("ws"); consumer(&ws.join("a"), ""); consumer(&ws.join("b"), REPLACE); @@ -1186,6 +1191,8 @@ mod tests { #[tokio::test] async fn go_work_replace_is_judged_by_where_it_points() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let (app, b) = (ws.join("app"), ws.join("b")); consumer(&app, REPLACE); @@ -1215,6 +1222,8 @@ mod tests { #[tokio::test] async fn go_work_that_does_not_use_the_project_makes_the_replace_inert() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let root = ws.join("app"); consumer(&root, REPLACE); @@ -1245,6 +1254,8 @@ mod tests { #[tokio::test] async fn go_work_member_matches_through_a_symlinked_workspace() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let real = dir.path().join("real"); std::fs::create_dir(&real).unwrap(); let ws = dir.path().join("link"); @@ -1265,6 +1276,8 @@ mod tests { #[tokio::test] async fn member_replaces_of_one_module_version_must_agree() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let (a, b) = (ws.join("a"), ws.join("b")); consumer(&a, REPLACE); diff --git a/crates/socket-patch-core/src/vendor/go_mod_edit.rs b/crates/socket-patch-core/src/vendor/go_mod_edit.rs index 6bffc966f..54cb9029c 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -1920,6 +1920,8 @@ replace ( #[tokio::test] async fn test_ensure_refuses_a_replace_go_work_defeats() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let root = ws.join("app"); let go_mod = "module example.com/app\n\ngo 1.21\n\nrequire example.com/m v1.0.0\n"; @@ -1972,6 +1974,8 @@ replace ( #[tokio::test] async fn test_symlinked_replace_host_is_refused() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let root = &dir.path().join("ws"); for m in ["a", "b"] { std::fs::create_dir_all(root.join(m)).unwrap(); diff --git a/crates/socket-patch-core/src/vendor/go_native_vendor.rs b/crates/socket-patch-core/src/vendor/go_native_vendor.rs index ef11df038..5791c611c 100644 --- a/crates/socket-patch-core/src/vendor/go_native_vendor.rs +++ b/crates/socket-patch-core/src/vendor/go_native_vendor.rs @@ -50,7 +50,11 @@ async fn expected_text(t: &Target<'_>, name: &str, hash: &str) -> Option e.module == t.module && (e.version.is_none() || e.version.as_deref() == Some(t.version)) }) { if let Some(path) = &entry.path { - paths.push(t.root.join(path).join(name)); + // A replacement directory outside the repository is never read. + let file = t.root.join(path).join(name); + if crate::utils::repo_root::ReadBoundary::of(t.root).contains(&file) { + paths.push(file); + } } else if let (Some(cache), Some(target), Some(ver)) = ( GoCrawler::get_gomodcache(), &entry.rhs_module, diff --git a/crates/socket-patch-core/src/vendor/go_workspace.rs b/crates/socket-patch-core/src/vendor/go_workspace.rs index 2c2218035..c477751b1 100644 --- a/crates/socket-patch-core/src/vendor/go_workspace.rs +++ b/crates/socket-patch-core/src/vendor/go_workspace.rs @@ -5,6 +5,12 @@ //! unset, empty or `auto` searches the project directory and its //! ancestors; any other value must be an absolute path to the file, and a //! relative one makes go fail (`invalid GOWORK: not an absolute path`). +//! A `go.work` outside the repository root +//! ([`crate::utils::repo_root::ReadBoundary`]) is never read: go would +//! use it, so the workspace is [`GoWork::Invalid`] (writers refuse, +//! discovery withholds) rather than read or silently ignored. Members, +//! `go.work.sum` and replacement directories outside the root read as +//! missing. //! * **What the build selects** ([`GoWorkspace::selection`]): MVS over the //! module graph go loads ([`GoWorkspace::read_graph`]): every `use` //! member's `require`s, the `require`s in each of those modules' go.mod, @@ -67,18 +73,32 @@ pub fn resolve(project_root: &Path) -> GoWork { /// [`resolve`] for an explicit `GOWORK` value. pub fn resolve_with(project_root: &Path, gowork: Option<&str>) -> GoWork { + let bounded = |file: PathBuf| { + let bound = crate::utils::repo_root::ReadBoundary::of(project_root); + if bound.contains(&file) { + GoWork::File(file) + } else { + GoWork::Invalid(format!( + "go builds in workspace mode from {}, which socket-patch cannot model: {} \ + (set GOWORK=off, or keep the go.work inside the repository)", + file.display(), + bound.refusal(&file) + )) + } + }; match gowork { Some("off") => GoWork::None, None | Some("") | Some("auto") => { let base = std::env::current_dir() .unwrap_or_default() .join(project_root); + // Only a stat above the root: the file is never opened there. base.ancestors() .map(|dir| dir.join("go.work")) .find(|file| file.is_file()) - .map_or(GoWork::None, GoWork::File) + .map_or(GoWork::None, bounded) } - Some(path) if Path::new(path).is_absolute() => GoWork::File(PathBuf::from(path)), + Some(path) if Path::new(path).is_absolute() => bounded(PathBuf::from(path)), Some(path) => GoWork::Invalid(format!( "GOWORK={path} is not an absolute path, so go refuses to build (\"invalid \ GOWORK: not an absolute path\")" @@ -228,11 +248,19 @@ pub async fn load_with(project_root: &Path, gowork: Option<&str>) -> Loaded { match resolve_with(project_root, gowork) { GoWork::None => Loaded::None, GoWork::Invalid(why) => Loaded::Invalid(why), - GoWork::File(work_file) => load_file(work_file).await, + GoWork::File(work_file) => { + load_file( + work_file, + &crate::utils::repo_root::ReadBoundary::of(project_root), + ) + .await + } } } -async fn load_file(work_file: PathBuf) -> Loaded { +/// Read `work_file` and what it names. A `use` member outside `bound` +/// reads as one without a go.mod or go.sum (missing). +async fn load_file(work_file: PathBuf, bound: &crate::utils::repo_root::ReadBoundary) -> Loaded { let text = match read_regular_to_string(&work_file).await { Ok(text) => go_mod_edit::normalize_for_read(&text), Err(e) => { @@ -252,11 +280,17 @@ async fn load_file(work_file: PathBuf) -> Loaded { let mut members = Vec::new(); for dir in go_mod_edit::parse_use_dirs(&text) { let dir = work_dir.join(dir); - let go_mod = read_regular_to_string(&dir.join("go.mod")) - .await - .ok() - .map(|t| go_mod_edit::normalize_for_read(&t)); - let go_sum = read_regular_to_string(&dir.join("go.sum")).await.ok(); + let (go_mod, go_sum) = if bound.contains(&dir) { + ( + read_regular_to_string(&dir.join("go.mod")) + .await + .ok() + .map(|t| go_mod_edit::normalize_for_read(&t)), + read_regular_to_string(&dir.join("go.sum")).await.ok(), + ) + } else { + (None, None) + }; members.push(Member { dir, go_mod, @@ -639,6 +673,7 @@ impl GoWorkspace { /// [`GoWorkspace::read_graph`] against the module cache `cache`. pub async fn read_graph_from(&mut self, cache: Option<&Path>) { let replaces = self.replace_sources(); + let bound = crate::utils::repo_root::ReadBoundary::of(self.work_dir()); let mut graph: BTreeMap> = BTreeMap::new(); // go loads each member at its `workspace` level, which queues the // member's requirements at the member's own pruning. @@ -666,7 +701,7 @@ impl GoWorkspace { continue; } if !graph.contains_key(&key) { - let dep = read_dep_go_mod(&replaces, cache, &path, &version).await; + let dep = read_dep_go_mod(&replaces, cache, &bound, &path, &version).await; graph.insert(key.clone(), dep); } let Some(Some(dep)) = graph.get(&key) else { @@ -1025,10 +1060,13 @@ fn replacement_of<'a>( } /// The go.mod go reads for the dependency `path@version` (see -/// [`GoWorkspace::read_graph`]). +/// [`GoWorkspace::read_graph`]). A replacement directory outside `bound` +/// is never read: its go.mod is missing, so the selection stays +/// [`Selection::Unknown`] unless a higher version already shows. async fn read_dep_go_mod( sources: &[Vec<(ReplaceEntry, PathBuf)>; 2], cache: Option<&Path>, + bound: &crate::utils::repo_root::ReadBoundary, path: &str, version: &str, ) -> Option { @@ -1049,7 +1087,10 @@ async fn read_dep_go_mod( Some((entry, dir)) => { match (&entry.path, &entry.rhs_module, &entry.rhs_version) { (Some(rel), _, _) => { - candidates.push(dir.join(rel.replace('\\', "/")).join("go.mod")) + let go_mod = dir.join(rel.replace('\\', "/")).join("go.mod"); + if bound.contains(&go_mod) { + candidates.push(go_mod); + } } (None, Some(m), Some(v)) => candidates.extend(cached(m, v)), _ => {} @@ -1116,7 +1157,12 @@ pub async fn module_roots(root: &Path) -> Vec { if resolve(root) == GoWork::None { return Vec::new(); } - match load_file(root.join("go.work")).await { + match load_file( + root.join("go.work"), + &crate::utils::repo_root::ReadBoundary::of(root), + ) + .await + { Loaded::Workspace(ws) => ws .members_within(root) .into_iter() @@ -1237,6 +1283,8 @@ mod tests { #[test] fn gowork_resolves_as_go_does() { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let project = tmp.path().join("ws").join("app"); std::fs::create_dir_all(&project).unwrap(); for unset in [None, Some(""), Some("auto")] { @@ -1271,11 +1319,77 @@ mod tests { assert_eq!(resolve_with(&project, None), GoWork::None); } + /// A go.work outside the repository root (an ancestor's, or one an + /// absolute GOWORK names) is never read: go would build from it, so + /// the workspace is Invalid and every writer refuses. Inside the root, + /// a `use` member or replacement directory outside it reads as missing. + #[tokio::test] + async fn go_work_outside_the_repository_is_never_read() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + let project = repo.join("app"); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + member(&repo, "app", "1.21", &require("v1.0.0")); + write(tmp.path(), "go.work", "go 1.21\n\nuse ./repo/app\n"); + for gowork in [None, Some("auto")] { + assert!( + matches!(resolve_with(&project, gowork), + GoWork::Invalid(why) if why.contains("outside the repository root")), + "{gowork:?}" + ); + assert!(matches!( + load_with(&project, gowork).await, + Loaded::Invalid(_) + )); + } + let elsewhere = tmp.path().join("go.work").to_string_lossy().into_owned(); + assert!(matches!( + resolve_with(&project, Some(&elsewhere)), + GoWork::Invalid(why) if why.contains("outside the repository root") + )); + // Without a repository the project itself is the root. + std::fs::remove_dir(repo.join(".git")).unwrap(); + assert!(matches!(resolve_with(&project, None), GoWork::Invalid(_))); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + + // The repository's own go.work: a member outside it, and a replace + // pointing outside it, are missing rather than read. + member(tmp.path(), "outside", "1.21", &require("v1.9.0")); + write( + &repo, + "go.work", + &format!( + "go 1.21\n\nuse (\n\t./app\n\t../outside\n)\n\nreplace {DEP} v1.0.0 => ../fork\n" + ), + ); + write( + tmp.path(), + "fork/go.mod", + &format!("module {DEP}\n\ngo 1.21\n"), + ); + let Loaded::Workspace(mut ws) = load_with(&project, None).await else { + panic!("the repository's go.work loads"); + }; + let outside = ws + .members + .iter() + .find(|m| m.dir.ends_with("outside")) + .unwrap(); + assert_eq!((&outside.go_mod, &outside.go_sum), (&None, &None)); + let cache = tmp.path().join("cache"); + ws.read_graph_from(Some(&cache)).await; + let graph = ws.graph.as_ref().unwrap(); + assert_eq!(graph.get(&format!("{DEP}@v1.0.0")), Some(&None)); + assert_eq!(ws.selection(DEP, "v1.0.0"), Selection::Unknown); + } + /// #1799: `-modfile` keeps a go.mod replace from the build unless it /// names the project's own go.mod; in workspace mode go refuses it. #[test] fn goflags_modfile_conflicts_unless_it_names_the_own_go_mod() { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let project = tmp.path().join("app"); write(tmp.path(), "app/go.mod", "module example.com/app\n"); let conflict = |goflags: &str, gowork: Option<&str>| { @@ -1364,6 +1478,8 @@ mod tests { #[tokio::test] async fn selection_spans_every_member() { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = &tmp.path().join("ws"); let cache = &tmp.path().join("cache"); for v in ["v1.0.0", "v1.1.0"] { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs index 0e41cfd7f..a3260abfe 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs @@ -13,7 +13,6 @@ use crate::constants::npm_family::NPM_LOCKS; use crate::utils::digest::is_sri_pin; use crate::vendor::npm_common::declares_bundled_deps; use crate::vendor::npm_origin::legacy_packages_key; -use crate::vendor::path::parse_vendor_path; use super::view::ProjectView; use super::{http_url, LockIntegrity, LockfileEntry}; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index c55ec4549..fcfbd9676 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -13,6 +13,7 @@ use std::path::Path; use std::sync::Arc; use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; +use crate::utils::repo_root::bounded_project_path; /// One in-memory file. #[derive(Debug, Clone, PartialEq, Eq)] @@ -615,10 +616,12 @@ impl<'a> DiskSnapshot<'a> { async fn read_bytes(&self, rel: &str) -> io::Result> { self.touch(rel); + // Before the cache, which would not keep the refusal's payload. + let path = bounded_project_path(self.root, rel)?; if let Some(hit) = self.cached(rel) { return hit.map(|b| b.to_vec()); } - let read = read_regular_to_bytes(&self.root.join(rel)).await; + let read = read_regular_to_bytes(&path).await; self.remember(rel, &read); read } @@ -736,35 +739,46 @@ impl<'a> ProjectView<'a> { } } - /// FIFO-safe regular-file text read. + /// FIFO-safe regular-file text read. A `rel` that climbs out of the + /// repository root (`../…`, absolute) is refused as missing + /// ([`bounded_project_path`]). pub async fn read_text(&self, rel: &str) -> io::Result { if let ProjectView::Snapshot(snap) = self { snap.touch(rel); } match self { - ProjectView::Disk(root) => read_regular_to_string(&root.join(rel)).await, + ProjectView::Disk(root) => { + read_regular_to_string(&bounded_project_path(root, rel)?).await + } ProjectView::Memory(project) => project.read_text(rel), - ProjectView::Snapshot(snap) => match snap.cached(rel) { - Some(hit) => hit.and_then(|b| utf8(b.to_vec())), - None => { - let read = read_regular_to_string(&snap.root.join(rel)).await; - snap.remember( - rel, - &read - .as_ref() - .map(|t| t.as_bytes().to_vec()) - .map_err(|e| io::Error::new(e.kind(), e.to_string())), - ); - read + ProjectView::Snapshot(snap) => { + // Before the cache, which would not keep the refusal's + // payload ([`crate::utils::repo_root::is_refused_read`]). + let path = bounded_project_path(snap.root, rel)?; + match snap.cached(rel) { + Some(hit) => hit.and_then(|b| utf8(b.to_vec())), + None => { + let read = read_regular_to_string(&path).await; + snap.remember( + rel, + &read + .as_ref() + .map(|t| t.as_bytes().to_vec()) + .map_err(|e| io::Error::new(e.kind(), e.to_string())), + ); + read + } } - }, + } } } - /// FIFO-safe regular-file byte read. + /// FIFO-safe regular-file byte read, bounded as [`Self::read_text`]. pub async fn read_bytes(&self, rel: &str) -> io::Result> { match self { - ProjectView::Disk(root) => read_regular_to_bytes(&root.join(rel)).await, + ProjectView::Disk(root) => { + read_regular_to_bytes(&bounded_project_path(root, rel)?).await + } ProjectView::Memory(project) => project.read_bytes(rel), ProjectView::Snapshot(snap) => snap.read_bytes(rel).await, } diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 0aa773fd2..073670db1 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -90,7 +90,8 @@ pub(crate) mod tests_support { /// default — the `nuget.org` source it writes on first run), then each /// parent directory's config from the filesystem root down, each `` /// dropping the farther ones. A file that cannot be read or parsed is -/// skipped: it contributes nothing NuGet could use either. +/// skipped: it contributes nothing NuGet could use either. A parent config +/// above the repository root is not read ([`Inherited::outside`]). #[cfg(test)] pub(crate) async fn inherited_source_keys(project_root: &std::path::Path) -> Vec { inherited_source_keys_traced(project_root).await.0.keys @@ -106,6 +107,26 @@ pub(crate) struct Inherited { /// already route every other package, and a `*` catch-all written here /// would WIDEN a source they restrict. pub(crate) mapped: bool, + /// The nearest parent config above the repository root + /// ([`crate::utils::repo_root::ReadBoundary`]): NuGet merges it, but it + /// is never read, so `keys` and `mapped` cannot account for it and a + /// writer refuses ([`Inherited::outside_refusal`]). + pub(crate) outside: Option, +} + +impl Inherited { + /// Why a writer cannot wire `nuget.config` under a parent config it may + /// not read, if there is one. + pub(crate) fn outside_refusal(&self) -> Option { + self.outside.as_ref().map(|path| { + format!( + "NuGet also merges {}, which is outside the repository root, so socket-patch \ + does not read it and cannot tell which sources and mappings it adds; move \ + the settings into the repository's nuget.config", + path.display() + ) + }) + } } /// [`Inherited`] for `project_root`. @@ -137,6 +158,8 @@ pub(crate) async fn inherited_source_keys_traced( )], ..NugetConfig::default() })); + let bound = crate::utils::repo_root::ReadBoundary::of(project_root); + let mut outside = None; let mut ancestors: Vec<&std::path::Path> = project_root.ancestors().skip(1).collect(); ancestors.reverse(); for dir in ancestors { @@ -147,6 +170,12 @@ pub(crate) async fn inherited_source_keys_traced( if !exists { continue; } + // Above the repository root: never read, only noted (nearest + // wins), so a writer can refuse instead of guessing. + if !bound.contains(&path) { + outside = Some(path); + break; + } if let Some(cfg) = crate::utils::fs::read_regular_to_string(&path) .await .ok() @@ -162,6 +191,7 @@ pub(crate) async fn inherited_source_keys_traced( Inherited { keys: effective_source_keys(&chain), mapped, + outside, }, touched, ) @@ -534,6 +564,7 @@ mod tests { .unwrap(); let project = tmp.path().join("repo/app"); std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap(); USER_CONFIG.with(|c| *c.borrow_mut() = Some(user.clone())); assert_eq!(inherited_source_keys(&project).await, ["nuget.org", "corp"]); // A repo-root config adds its own feed. @@ -565,6 +596,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path().join("repo/app"); std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap(); assert!(!super::inherited_sources(&project).await.mapped); std::fs::write( tmp.path().join("repo/nuget.config"), @@ -574,6 +606,30 @@ mod tests { let got = super::inherited_sources(&project).await; assert!(got.mapped); assert_eq!(got.keys, ["nuget.org", "corp"]); + assert_eq!(got.outside, None); + } + + /// A parent config above the repository root is never read: its + /// sources and mappings do not show, and it is named so writers refuse. + #[tokio::test] + async fn a_parent_config_above_the_repository_is_never_read() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("repo/app"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap(); + let above = tmp.path().join("nuget.config"); + std::fs::write( + &above, + "", + ) + .unwrap(); + let got = super::inherited_sources(&project).await; + assert_eq!(got.keys, ["nuget.org"], "never read"); + assert!(!got.mapped); + assert_eq!(got.outside.as_deref(), Some(above.as_path())); + assert!(got + .outside_refusal() + .is_some_and(|why| why.contains("outside the repository root"))); } /// The walk reads a symlinked project file, never enters build output diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index b6252a5d1..98ef840c1 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -566,14 +566,18 @@ pub async fn vendor_nuget( // ── nuget.config wiring (runs after the artifact) ───────────────────── let inherited = super::nuget_config::inherited_sources(project_root).await; - let config_edit = match build_config_edit_with( - config_text.as_deref(), - &inherited.keys, - inherited.mapped, - &source_key, - &uuid_dir_rel, - name, - ) { + let built = match inherited.outside_refusal() { + Some(refusal) => Err(refusal), + None => build_config_edit_with( + config_text.as_deref(), + &inherited.keys, + inherited.mapped, + &source_key, + &uuid_dir_rel, + name, + ), + }; + let config_edit = match built { Ok(edit) => edit, Err(detail) => { let _ = remove_tree(&uuid_dir).await; @@ -5246,6 +5250,8 @@ mod tests { async fn vendor_keeps_a_parent_directorys_feed_routable() { let (dir, blobs, installed, record) = fixture(true, None).await; let outer = dir.path(); + // The parent is inside the repository. + tokio::fs::create_dir_all(outer.join(".git")).await.unwrap(); tokio::fs::write( outer.join("nuget.config"), "\n \n \n \n\n", @@ -5267,6 +5273,39 @@ mod tests { assert_eq!(catch_all_of(&t), ["nuget.org", "corp"], "{t}"); } + /// The same parent config, but above the repository root: it is never + /// read, so vendoring cannot tell which sources the catch-all must keep + /// and refuses, writing no config and leaving no artifact. + #[tokio::test] + async fn vendor_refuses_under_a_parent_config_above_the_repository() { + let (dir, blobs, installed, record) = fixture(true, None).await; + let outer = dir.path(); + tokio::fs::write( + outer.join("nuget.config"), + "\n \n \n \n\n", + ) + .await + .unwrap(); + let root = outer.join("app"); + tokio::fs::create_dir_all(root.join(".git")).await.unwrap(); + tokio::fs::rename(outer.join(PACKAGES_LOCK), root.join(PACKAGES_LOCK)) + .await + .unwrap(); + let (result, _entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(!result.success); + assert!( + result + .error + .as_deref() + .is_some_and(|e| e.contains("outside the repository root")), + "{:?}", + result.error + ); + assert!(!root.join("nuget.config").exists()); + assert!(!root.join(".socket/vendor/nuget").exists()); + } + /// A key listed twice gets one catch-all, and an `` without a key /// gets none. #[test] diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 597720df8..761f07b88 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -4156,6 +4156,8 @@ mod tests { ("poetry.lock", ""), ] { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = tmp.path().join("app"); tokio::fs::create_dir_all(&root).await.unwrap(); touch(tmp.path(), "base.txt", "six==1.16.0\n").await; @@ -4213,6 +4215,8 @@ mod tests { shapes.push(("utf-16 six==1.16.0", utf16)); for (shape, bytes) in shapes { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = tmp.path().join("app"); tokio::fs::create_dir_all(&root).await.unwrap(); tokio::fs::write(tmp.path().join("base.txt"), &bytes) @@ -4232,6 +4236,7 @@ mod tests { // unknowable, so it refuses too. { let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = tmp.path().join("app"); tokio::fs::create_dir_all(&root).await.unwrap(); tokio::fs::write(tmp.path().join("base.txt"), b"\x80\x81six==1.16.0\n") @@ -4253,6 +4258,7 @@ mod tests { "../wheels/idna-3.7-py3-none-any.whl\n", ] { let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = tmp.path().join("app"); tokio::fs::create_dir_all(&root).await.unwrap(); touch(tmp.path(), "base.txt", other).await; diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index 490ef46ec..a27b57d6a 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -537,7 +537,13 @@ pub(super) async fn pin_outside_root( // pip reads an include this walk can't (or fails the install): // its pins are unknowable, so fail closed rather than wire the // lock beside it. Discovery diagnoses the same file. - Err(e) if outside && e.kind() != std::io::ErrorKind::NotFound => { + // One outside the repository root is never read at all: its + // pins are just as unknowable. + Err(e) + if outside + && (e.kind() != std::io::ErrorKind::NotFound + || crate::utils::repo_root::is_refused_read(&e)) => + { found = Some(( "pypi_requirements_outside_root", format!( @@ -1389,6 +1395,16 @@ async fn collect_requirements_files(root: &Path) -> Result, (&'stat "pypi_no_requirements", format!("cannot read {}", root.join(rel).display()), )), + // An include outside the repository root is never read, so the + // pins pip installs from it are unknowable: refuse, as for a pin + // found in an out-of-root include. + Err(e) if crate::utils::repo_root::is_refused_read(&e) => Err(( + "pypi_requirements_outside_root", + format!( + "{rel}: {e}, so vendor cannot tell what pip installs from it; inline it or \ + use agent mode (`scan --mode agent` + `socket-patch apply`) instead" + ), + )), // A broken include is pip's error to report; vendor just can't see // inside it. Skip. Err(_) => Ok(false), @@ -3208,6 +3224,55 @@ mod tests { ); } + /// An include inside the repository but outside the project is read + /// (its pin refuses as one outside the project); one outside the + /// repository is never read, and refuses because its pins are + /// unknowable. Discovery's and the lock flavors' walks see the same. + #[tokio::test] + async fn includes_outside_the_repository_are_never_read() { + let outer = tempfile::tempdir().unwrap(); + let repo = outer.path().join("repo"); + let root = repo.join("project"); + tokio::fs::create_dir_all(repo.join(".git")).await.unwrap(); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(repo.join("shared.txt"), "six==1.16.0\n") + .await + .unwrap(); + tokio::fs::write(outer.path().join("above.txt"), "six==1.16.0\n") + .await + .unwrap(); + let view = || crate::vendor::lock_inventory::ProjectView::Disk(&root); + + tokio::fs::write(root.join("requirements.txt"), "-r ../shared.txt\n") + .await + .unwrap(); + let err = wire_requirements(&root, "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap_err(); + assert!(err.1.contains("is pinned in"), "{err:?}"); + assert_eq!( + requirements_outside_root_names_in(view()).await, + ["../shared.txt"] + ); + + tokio::fs::write(root.join("requirements.txt"), "-r ../../above.txt\n") + .await + .unwrap(); + let err = wire_requirements(&root, "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_requirements_outside_root"); + assert!(err.1.contains("outside the repository root"), "{err:?}"); + let err = pin_outside_root(&root, "six", "1.16.0").await.unwrap(); + assert_eq!(err.0, "pypi_requirements_outside_root"); + assert!(err.1.contains("outside the repository root"), "{err:?}"); + // To a reader it is missing. + let read = view().read_text("../../above.txt").await.unwrap_err(); + assert_eq!(read.kind(), std::io::ErrorKind::NotFound); + assert!(crate::utils::repo_root::is_refused_read(&read)); + assert!(requirements_outside_root_names_in(view()).await.is_empty()); + } + // ── revert edge cases ──────────────────────────────────────────────── /// SECURITY: a poisoned state.json wiring record naming a diff --git a/crates/socket-patch-core/src/vex/discover/golang.rs b/crates/socket-patch-core/src/vex/discover/golang.rs index a44a3dbc6..938088ea0 100644 --- a/crates/socket-patch-core/src/vex/discover/golang.rs +++ b/crates/socket-patch-core/src/vex/discover/golang.rs @@ -1964,7 +1964,11 @@ mod tests { let missing = outside.path().join("nonexistent").join("go.work"); for (gowork, why) in [ ("go.work".to_string(), "not an absolute path"), - (missing.to_string_lossy().into_owned(), "other than"), + // Outside the repository root: never read, never modeled. + ( + missing.to_string_lossy().into_owned(), + "outside the repository root", + ), (elsewhere.to_string_lossy().into_owned(), "other than"), ] { let out = extract(Some(gowork.clone())).await; diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index fe04afa88..81e12df36 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -3332,9 +3332,14 @@ pub(crate) mod testing { /// A project rooted at `sub/` inside the throwaway directory, so a /// fixture can write files beside it (`../ext/…`) without leaving - /// the tempdir (#1516). + /// the tempdir (#1516). The throwaway directory is the git + /// checkout, so what lies beside the project is inside the + /// repository root that bounds every read. pub(crate) fn nested(sub: &str) -> Self { let dir = tempfile::tempdir().expect("create tempdir"); + if !sub.is_empty() { + std::fs::create_dir_all(dir.path().join(".git")).expect("create .git"); + } let root = dir.path().join(sub); std::fs::create_dir_all(&root).expect("create project root"); Project { From 3295be6c468f7afa8c5b7bfdc7b01d1cb042faf3 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 13:24:03 -0600 Subject: [PATCH 03/10] Bound upward workspace and settings lookups to the repository root Inside a git checkout, the walks that search a project's ancestors for a workspace root or a settings file never read above the repository root (`repo_root::upward_bound`). When a file the walk would read sits above it (stat only, never in the home directory or above), the tool may honor it, so writers refuse with the existing workspace codes and readers withhold: npm/yarn/Bun/pnpm/vlt governing roots and stray member locks, the governing pnpm-workspace.yaml (and its gitBranchLockfile and restore settings), uv workspace roots and uv settings, the cargo workspace root, `.cargo/config` chain and `[patch]` paths, `.yarnrc.yml` linker/store/env files, Berry registry settings, yarn classic rc files and linked members, and Maven/Gradle/sbt build roots. Hosted cargo refuses with the new `redirect_cargo_config_outside_root`. Without a checkout every lookup behaves as before. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 1 + .../src/crawlers/gradle_cache.rs | 9 +- .../src/crawlers/pkg_managers.rs | 44 ++- .../src/formats/cargo/source_replacement.rs | 6 + crates/socket-patch-core/src/hosted/engine.rs | 18 +- .../src/hosted/governing_root.rs | 99 ++++++- .../src/patch/redirect/mod.rs | 16 +- .../src/patch/redirect/staged.rs | 25 +- .../src/patch/redirect/upstream/npm.rs | 42 ++- .../src/patch/redirect/yarnrc.rs | 62 +++- .../src/patch/redirect/yarnrc_tests.rs | 31 ++ .../src/utils/cargo_build_cache.rs | 25 +- .../src/utils/pnpm_workspace.rs | 35 ++- .../src/utils/python_script.rs | 26 ++ .../socket-patch-core/src/utils/repo_root.rs | 49 ++++ .../src/utils/repo_root_upward_tests.rs | 277 ++++++++++++++++++ .../src/utils/uv_workspace.rs | 59 +++- crates/socket-patch-core/src/vendor/cargo.rs | 44 +++ .../src/vendor/cargo_config.rs | 18 ++ .../src/vendor/maven_repo.rs | 36 +++ crates/socket-patch-core/src/vendor/pypi.rs | 8 +- .../socket-patch-core/src/vendor/pypi_lock.rs | 24 +- .../socket-patch-core/src/vendor/pypi_uv.rs | 13 +- crates/socket-patch-core/src/vex/verify.rs | 22 ++ 24 files changed, 939 insertions(+), 50 deletions(-) create mode 100644 crates/socket-patch-core/src/utils/repo_root_upward_tests.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 1327dac1b..af1881d05 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -912,6 +912,7 @@ VEX discovery, the lock inventory, hosted mode and vendored mode never read a PR - **Requirements includes**: a `-r` include outside the root is never read. Discovery and the lock inventory see it as missing; vendored mode refuses `pypi_requirements_outside_root`, since the pins pip installs from it are unknowable. - **NuGet parent configs**: a `nuget.config` in a directory above the root is never read. Vendoring refuses (`… also merges , which is outside the repository root …`) and hosted mode skips each NuGet dep with `redirect_nuget_inherited_config_outside_root`, writing nothing for it. - **Relative and absolute references read through the project view** (yarn `file:` / `portal:` / `link:` copies, include chains, any `../` path a lockfile names) and the hosted upstream restore's staged reads: outside the root they read as missing, and the restore refuses. +- **Upward lookups** (a workspace root or settings file the package manager searches the project's ancestors for): inside a git checkout none reads above the repository root. When one of the files the lookup would read exists above it (a stat, never a read, and never in the home directory or above), the tool may honor it, so writers refuse and readers withhold: a lockless npm-family member or a member's own npm/Bun/vlt lock under a `package.json`, `pnpm-workspace.yaml` or `vlt.json` above (`redirect_workspace_lockfile_elsewhere` in hosted mode, `vendor_lockfile_missing` in vendored mode, unattested in discovery); the nearest `pnpm-workspace.yaml` above counts as governing (`redirect_pnpm_settings_elsewhere`, `vendor_pnpm_settings_elsewhere`, and a `gitBranchLockfile` that may be on); a uv workspace `pyproject.toml` above (`redirect_workspace_lockfile_elsewhere`, `pypi_uv_workspace_unsupported`) or uv settings above (`pypi_uv_ancestor_settings`; hosted `redirect_uv_project_unsupported` / `redirect_uv_script_unsupported` / `redirect_uv_lock_unsupported`); a `Cargo.toml` above (`cargo_manifest_not_workspace_root`) or a `.cargo/config(.toml)` above (`user_authored_patch_entry` in vendored mode; hosted mode refuses the run with `redirect_cargo_config_outside_root`); a `.yarnrc.yml` above (its `nodeLinker` reads as unknown, so PnP; no `pnpmStoreFolder` store is admitted; the Berry upstream restore refuses the registry lookup); a yarn classic `.yarnrc` / `.npmrc` above, or a workspace member linked from outside the repository (`redirect_yarn_classic_offline_mirror`, naming the file or member); a `pom.xml`, Gradle settings or `build.sbt` above (`not_build_root`). Gradle settings above are not analysed by the crawler, an `injectEnvironmentFiles` dotenv file outside the repository (other than beside the home rc) injects nothing, a cargo `[patch]` path outside it reads as unknown (`user_authored_patch_entry`), and agent mode's build-cache invalidation stays inside it. Without a git checkout every upward lookup walks on as before. - **Vendored ownership**: writers, reverters and the GC claim a lockfile reference only when it is root-anchored into THIS project's `.socket/vendor/` (`./.socket/vendor/…`, or `../` from a file below the root back to exactly the root). A sibling checkout's `../.socket/vendor///…`, even for the same patch uuid, is never rewritten, reverted or deleted; the revert leaves it with `vendor_lock_entry_drifted` and keeps the artifact. The one exception is pnpm <= 8, which records override specifiers as machine-absolute paths itself: an absolute `…/.socket/vendor/npm//` naming the entry's own uuid and leaf is still the entry's. ## Vendor command contract diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index fdab8df73..ad8b009c7 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -906,7 +906,14 @@ pub fn build_roots(cwd: &Path) -> Vec { } let mut roots = vec![cwd.to_path_buf()]; if !has_settings(cwd) { - if let Some(up) = cwd.ancestors().skip(1).find(|d| has_settings(d)) { + // Settings above the repository root are never read: that build + // reads as absent (the crawler is a reader). + if let Some(up) = cwd + .ancestors() + .skip(1) + .find(|d| has_settings(d)) + .filter(|up| crate::utils::repo_root::upward_readable(cwd, up)) + { roots.push(up.to_path_buf()); } } diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index 419e9e5d3..31918c210 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -239,9 +239,26 @@ impl YarnEnv { let Some((start, env_list)) = &self.env_files.run else { return Default::default(); }; - let read = |path: &Path| crate::utils::fs::read_regular_to_string_sync(path).ok(); + // Inside a git checkout neither an rc file nor an env file + // above its root is read: what it would inject is missing. + // The home folder's rc file and the env files beside it are + // machine-level, read as before. + let bound = crate::utils::repo_root::upward_bound(start); + let within = |path: &Path| { + bound.as_ref().is_none_or(|b| b.contains(path)) + || self + .home + .as_deref() + .is_some_and(|home| path.parent() == Some(home)) + }; + let read = |path: &Path| { + within(path) + .then(|| crate::utils::fs::read_regular_to_string_sync(path).ok()) + .flatten() + }; let mut rcs: Vec<(PathBuf, String)> = start .ancestors() + .take_while(|dir| bound.as_ref().is_none_or(|b| b.contains(dir))) .filter_map(|dir| Some((dir.to_path_buf(), read(&dir.join(&self.rc_filename))?))) .collect(); if let Some(home) = &self.home { @@ -361,13 +378,23 @@ pub(crate) fn yarn_node_linker_in(project_root: &Path, env: &YarnEnv) -> Option< return Some(env.resolve_linker(linker)); } let start = std::path::absolute(project_root).unwrap_or_else(|_| project_root.to_path_buf()); + let bound = crate::utils::repo_root::upward_bound(&start); start .ancestors() + .take_while(|dir| bound.as_ref().is_none_or(|b| b.contains(dir))) .find_map(|dir| { let rc = crate::utils::fs::read_regular_to_string_sync(&dir.join(&env.rc_filename)).ok()?; rc_node_linker(&rc, env) }) + // An rc file above the repository root is never read, and may set + // the linker: fail closed with a value no linker has, which reads + // as PnP (see [`YarnEnv::resolve_linker`]) and names the file. + .or_else(|| { + let bound = bound.as_ref()?; + let marker = bound.marker_above(&[env.rc_filename.as_str()])?; + Some(format!("", bound.marker_refusal(&marker))) + }) .or_else(|| env.home_node_linker()) } @@ -396,8 +423,23 @@ pub(crate) fn yarn_pnpm_store_folder_in(project_root: &Path, env: &YarnEnv) -> O crate::formats::yarn::rc::raw_setting(&rc, "pnpmStoreFolder") .map(|raw| expand(&raw).map(|value| dir.join(value))) }; + // An rc file above the repository root is never read: the store it + // may name is unknown, so none is admitted. + let bound = crate::utils::repo_root::upward_bound(&start); + if bound + .as_ref() + .and_then(|b| b.marker_above(&[env.rc_filename.as_str()])) + .is_some() + && !start + .ancestors() + .take_while(|dir| bound.as_ref().is_none_or(|b| b.contains(dir))) + .any(|dir| from_rc(dir).is_some()) + { + return None; + } let found = start .ancestors() + .take_while(|dir| bound.as_ref().is_none_or(|b| b.contains(dir))) .find_map(from_rc) .or_else(|| env.home.as_deref().and_then(from_rc)); match found { diff --git a/crates/socket-patch-core/src/formats/cargo/source_replacement.rs b/crates/socket-patch-core/src/formats/cargo/source_replacement.rs index cc8e8eca4..8bb00cb78 100644 --- a/crates/socket-patch-core/src/formats/cargo/source_replacement.rs +++ b/crates/socket-patch-core/src/formats/cargo/source_replacement.rs @@ -124,7 +124,13 @@ pub fn config_layer_paths( }; let mut layers = Vec::new(); let mut dirs_seen: Vec = Vec::new(); + // Inside a git checkout a config above its root is never read + // (writers refuse on [`crate::vendor::cargo_config::config_above_repo`]). + let bound = crate::utils::repo_root::upward_bound(cwd); for ancestor in cwd.ancestors() { + if bound.as_ref().is_some_and(|b| !b.contains(ancestor)) { + break; + } let dot_cargo = ancestor.join(".cargo"); if let Some(file) = pick(&dot_cargo) { layers.push((file, ancestor.to_path_buf())); diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index cb01e8259..9a92740cb 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -897,6 +897,14 @@ pub async fn read_candidate_files( crate::patch::redirect::UV_ANCESTOR_CONFIG_KEY.to_string(), format!("{}\n{}", config.rel, config.text), ); + } else if let Some(why) = view + .disk_root_reading(std::iter::empty::<&std::path::Path>()) + .and_then(crate::utils::python_script::uv_config_above_repo) + { + out.files.insert( + crate::patch::redirect::UV_ANCESTOR_OUTSIDE_KEY.to_string(), + why, + ); } } @@ -2781,12 +2789,18 @@ fn pnpm_trust( // only from the workspace root's file, and a nested one would be // ignored (#880). The governing-root pre-check refused every root // file but these two, so nothing is written here. + // One above the repository root is never read (the pre-check + // refuses it; this keeps the read bounded on its own). + let readable = view + .disk_root() + .is_some_and(|root| crate::utils::repo_root::upward_readable(root, &root_file)); let root_file = root_file.display().to_string(); if let Some(found) = pnpm_trust_env_override(view, files, options.pnpm_env) { pnpm_trust_env_set_detail(&server, &found.source) } else { - match read_workspace_for_trust(std::path::Path::new(&root_file)) - .ok() + match readable + .then(|| read_workspace_for_trust(std::path::Path::new(&root_file))) + .and_then(Result::ok) .flatten() .map(|text| plan_workspace_trust(Some(&text))) { diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 205008fa7..c4bf6d38e 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -35,6 +35,7 @@ use crate::utils::fs::{ read_regular_to_string_sync, }; use crate::utils::pnpm_workspace::governing_workspace_file; +use crate::utils::repo_root::upward_bound; use crate::utils::workspace_globs::{workspaces_include_as, WorkspacesDialect}; use crate::vendor::cargo::NOT_WORKSPACE_ROOT; use crate::vendor::cargo_manifest; @@ -64,6 +65,11 @@ pub const WORKSPACE_LOCKFILE_ELSEWHERE: &str = "redirect_workspace_lockfile_else /// that does not trust the lock yet. pub const PNPM_SETTINGS_ELSEWHERE: &str = "redirect_pnpm_settings_elsewhere"; +/// Refusal code for a cargo project inside a git checkout when a +/// `.cargo/config(.toml)` exists above the repository root: cargo merges +/// it, but socket-patch never reads it. +pub const CARGO_CONFIG_OUTSIDE_ROOT: &str = "redirect_cargo_config_outside_root"; + const PNPM_LOCK: &str = "pnpm-lock.yaml"; const PNPM_WORKSPACE: &str = "pnpm-workspace.yaml"; const VLT_JSON: &str = "vlt.json"; @@ -97,12 +103,20 @@ pub async fn refusal( if let Some(refusal) = cargo_member_refusal(root).await { return Some(refusal); } + // A cargo config above the repository root is never read; its + // source replacement or `[patch]` may decide what cargo builds. + if let Some(detail) = crate::vendor::cargo_config::config_above_repo(root) { + return Some(Refusal { + code: CARGO_CONFIG_OUTSIDE_ROOT.to_string(), + message: format!("{detail}; nothing was written"), + }); + } } if candidates.iter().any(|c| c.dep.ecosystem == "pypi") { - if let Some(workspace) = crate::utils::uv_workspace::governing_uv_workspace(root).await { + if let Some(message) = crate::utils::uv_workspace::governing_uv_refusal(root).await { return Some(Refusal { code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), - message: crate::utils::uv_workspace::member_detail(root, &workspace), + message, }); } } @@ -133,7 +147,9 @@ fn pnpm_settings_elsewhere(root: &Path) -> Option { return None; } let file = governing_workspace_file(root)?; - if let Ok(Some(text)) = read_workspace_for_trust(&file) { + // One above the repository root is never read: it refuses. + let readable = crate::utils::repo_root::upward_readable(root, &file); + if let Some(Ok(Some(text))) = readable.then(|| read_workspace_for_trust(&file)) { if matches!( plan_workspace_trust(Some(&text)), TrustPlan::AlreadyTrue | TrustPlan::UserSet(_) @@ -244,7 +260,7 @@ pub(crate) async fn npm_lock_elsewhere(root: &Path) -> Option { vlt_workspace_elsewhere(root).await, ); let Some(lock) = pnpm_lock_elsewhere(root).await else { - return workspace; + return workspace.or_else(|| lock_marker_above(root)); }; let shown_dir = lock.parent().unwrap_or(&lock).to_path_buf(); // Canonical, like the other branches' roots: a relative `lockfile-dir` @@ -290,7 +306,12 @@ async fn pnpm_lock_elsewhere(root: &Path) -> Option { .unwrap_or_else(|_| root.to_path_buf()); let mut workspace = None; + let bound = upward_bound(&canonical); for ancestor in canonical.ancestors() { + if bound.as_ref().is_some_and(|b| !b.contains(ancestor)) { + // Above the repository: [`npm_lock_elsewhere`] stats it. + break; + } let path = ancestor.join(PNPM_WORKSPACE); if let Ok(yaml) = read_regular_to_string(&path).await { workspace = Some((ancestor.to_path_buf(), yaml)); @@ -502,7 +523,15 @@ pub(crate) async fn member_stray_lock(root: &Path) -> Option { .await .clone(), }; - let (ancestor, locks) = found?; + let Some((ancestor, locks)) = found else { + // Nothing inside the repository lists the member; a workspace + // file above it is never read, so it may still govern. + let markers: &[&str] = match family { + NpmLockFamily::Vlt => &[VLT_JSON, PNPM_WORKSPACE, "package.json"], + _ => &["package.json"], + }; + return stray_lock_marker_above(root, &own, markers); + }; let root_locks: Vec<&'static str> = locks .into_iter() .filter(|name| files.contains(name)) @@ -542,6 +571,58 @@ pub(crate) async fn member_stray_lock(root: &Path) -> Option { }) } +/// The workspace files an npm-family member's governing root may hold. +const WORKSPACE_MARKERS: [&str; 3] = [PNPM_WORKSPACE, VLT_JSON, "package.json"]; + +/// [`member_stray_lock`] when the only candidate root is above the +/// repository root: one of `markers` exists there (stat only), so the +/// member's own locks `own` may be ignored. Callers treat it like a +/// governing root: hosted and vendored refuse, discovery withholds. +fn stray_lock_marker_above( + root: &Path, + own: &[&'static str], + markers: &[&str], +) -> Option { + let bound = upward_bound(root)?; + let marker = bound.marker_above(markers)?; + let dir = marker.parent()?.to_path_buf(); + Some(MemberStrayLock { + detail: format!( + "{} may be a member of a workspace rooted above the repository, whose package \ + manager would ignore its own {}: {}", + root.display(), + join_paths(root, own), + bound.marker_refusal(&marker) + ), + root: dir, + root_kind: "workspace root", + ignored: own.to_vec(), + }) +} + +/// [`npm_lock_elsewhere`] when nothing inside the repository governs a +/// lockless project but a workspace file sits above the repository root +/// (stat only): pnpm, npm, yarn, Bun or vlt may install the project from +/// there, so hosted and vendored refuse rather than write a member. +fn lock_marker_above(root: &Path) -> Option { + let bound = upward_bound(root)?; + let marker = bound.marker_above(&WORKSPACE_MARKERS)?; + let dir = marker.parent()?.to_path_buf(); + Some(LockElsewhere { + code: WORKSPACE_LOCKFILE_ELSEWHERE, + detail: format!( + "{} has no lockfile of its own and may be installed from a workspace rooted \ + above the repository: {}", + root.display(), + bound.marker_refusal(&marker) + ), + shown_dir: dir.clone(), + dir, + dir_kind: "workspace root", + vendorable_dir: false, + }) +} + /// [`package_json_workspace_root`], walked at most once per check. async fn cached_package_json_root<'a>( root: &Path, @@ -771,7 +852,11 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat .await .unwrap_or_else(|_| root.to_path_buf()); let mut member: &Path = &canonical; + let bound = upward_bound(&canonical); for ancestor in canonical.ancestors().skip(1) { + if bound.as_ref().is_some_and(|b| !b.contains(ancestor)) { + break; + } let Ok(text) = read_regular_to_string(&ancestor.join("package.json")).await else { continue; }; @@ -1031,7 +1116,11 @@ async fn vlt_workspace_root(root: &Path) -> Option<(PathBuf, VltWorkspaceSource) let canonical = tokio::fs::canonicalize(root) .await .unwrap_or_else(|_| root.to_path_buf()); + let bound = upward_bound(&canonical); for ancestor in canonical.ancestors().skip(1) { + if bound.as_ref().is_some_and(|b| !b.contains(ancestor)) { + break; + } let Some((groups, source)) = vlt_workspace_groups(ancestor).await else { continue; }; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 66033cead..00906c4bc 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -6546,6 +6546,9 @@ fn plan_python_metadata_with( detail: format!("{metadata_path}: {detail}"), }; source_scope().map_err(unsupported)?; + if let Some(why) = files.get(UV_ANCESTOR_OUTSIDE_KEY) { + return Err(unsupported(why.clone())); + } let project_file = |rel: &str| files.get(rel).map(String::as_str); let ancestor = files.get(UV_ANCESTOR_CONFIG_KEY).and_then(|value| { let (rel, text) = value.split_once('\n')?; @@ -6772,9 +6775,10 @@ fn rewrite_uv_lock( // Every patched artifact is a wheel, which uv refuses to // install under no-binary (#1603). let no_binary = match (&planned, uv_pip_settings) { - (Ok(Some(_)), Some(settings)) => { - upstream::pylock_no_binary(original, &dep.name, settings) - } + (Ok(Some(_)), Some(settings)) => files + .get(UV_ANCESTOR_OUTSIDE_KEY) + .cloned() + .or_else(|| upstream::pylock_no_binary(original, &dep.name, settings)), _ => None, }; if let Some(why) = no_binary { @@ -7088,6 +7092,12 @@ const EMPTY_NUGET_CONFIG: &str = "\n>; /// engine. Errors read `read : `. pub(super) async fn read_rel(project_root: &Path, rel: &str) -> Result, String> { // A writer's read outside the repository root refuses rather than - // reading as missing: what it would write next depends on it. - let path = crate::utils::repo_root::bounded_project_path(project_root, rel) - .map_err(|refused| format!("read {rel}: {refused}"))?; + // reading as missing: what it would write next depends on it. An + // absolute `rel` is an upward lookup's (a pnpm workspace root's + // settings above the project), bounded as one: only inside a checkout. + let path = if crate::utils::relpath::is_anchored(rel) { + let path = project_root.join(rel); + match crate::utils::repo_root::upward_bound(project_root) { + Some(bound) if !bound.contains(&path) => { + return Err(format!("read {rel}: {}", bound.refusal(&path))) + } + _ => path, + } + } else { + crate::utils::repo_root::bounded_project_path(project_root, rel) + .map_err(|refused| format!("read {rel}: {refused}"))? + }; match crate::utils::fs::read_regular_to_string(&path).await { Ok(content) => Ok(Some(content)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), @@ -133,5 +145,12 @@ mod tests { let err = read_rel(&root, &rel).await.unwrap_err(); assert!(err.contains("outside the repository root"), "{rel}: {err}"); } + // Without a checkout an absolute path (an upward lookup's, the + // pnpm workspace root's settings) is read as before. + std::fs::remove_dir(repo.join(".git")).unwrap(); + assert_eq!( + read_rel(&root, &above.display().to_string()).await, + Ok(Some("x=2\n".to_string())) + ); } } diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index eff9b2884..47f141212 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -620,6 +620,9 @@ struct BerryRegistrySettings { /// The variables a value's `${NAME}` reads: this process's /// environment under the project's injected dotenv files (#1611). env: crate::crawlers::pkg_managers::YarnEnv, + /// Why the registry is unknowable: an rc file above the repository + /// root that is never read. + unread: Option, } impl BerryRegistrySettings { @@ -634,8 +637,18 @@ impl BerryRegistrySettings { .unwrap_or_else(|| ".yarnrc.yml".to_string()); let root = view.root().join(dir_prefix); let root = tokio::fs::canonicalize(&root).await.unwrap_or(root); - let mut read: Vec = - root.ancestors().map(|d| d.join(&rc_name)).collect(); + // Inside a git checkout an rc file above its root is never read; + // one there may name the registry, so every lookup refuses. + let bound = crate::utils::repo_root::upward_bound(&root); + let unread = bound.as_ref().and_then(|bound| { + let marker = bound.marker_above(&[rc_name.as_str()])?; + Some(bound.marker_refusal(&marker)) + }); + let mut read: Vec = root + .ancestors() + .take_while(|d| bound.as_ref().is_none_or(|b| b.contains(d))) + .map(|d| d.join(&rc_name)) + .collect(); let home = std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .filter(|h| !h.is_empty()) @@ -657,10 +670,14 @@ impl BerryRegistrySettings { rcs, env_registry: std::env::var("YARN_NPM_REGISTRY_SERVER").ok(), env: crate::crawlers::pkg_managers::YarnEnv::for_project(&root), + unread, } } fn registry(&self, name: &str) -> Result, String> { + if let Some(why) = &self.unread { + return Err(why.clone()); + } berry_lookup_registry( &self.rcs, self.env_registry.as_deref(), @@ -1912,6 +1929,27 @@ pub(crate) async fn restore_pnpm_locks( .iter() .map(|(_, u, n, v)| (u.clone(), n.clone(), v.clone())) .collect(); + // pnpm reads a member's settings from its workspace root; one + // above the repository root is never read, so the restore cannot + // tell how pnpm writes this lock and refuses it. + let prefix = pnpm_settings_prefix(view, rel).await; + if let Some(bound) = std::path::Path::new(&prefix) + .is_absolute() + .then(|| crate::utils::repo_root::upward_bound(view.root())) + .flatten() + .filter(|bound| !bound.contains(std::path::Path::new(&prefix))) + { + let file = std::path::Path::new(&prefix).join("pnpm-workspace.yaml"); + let why = format!( + "pnpm reads the settings for {rel} from {}: {}", + file.display(), + bound.refusal(&file) + ); + for (_, uuid, ..) in &hits { + result.refuse(uuid, why.clone()); + } + continue; + } let policy = pnpm_tarball_policy(view, rel, &text, ctx).await; let dists = fetch_dists_on(&wanted, |name| policy.registry(name), ctx, &mut result).await; let mut splices: Vec<(std::ops::Range, String)> = Vec::new(); diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs index aeda2a574..8256a68cc 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -264,12 +264,25 @@ pub fn resolve_outer_yarn_mirror( file: first_file( files("yarnrc", env_value(env, &["yarn_"], "userconfig")), yarnrc_value, - ), + ) + .or_else(|| unread_rc_above(project)), }, member: None, } } +/// Inside a git checkout, a `.yarnrc` or `.npmrc` above the repository +/// root (stat only, never read) may set `yarn-offline-mirror`: fail closed +/// as a mirror, so the hosted rewriter refuses with the file named. +fn unread_rc_above(project: &Path) -> Option { + let bound = crate::utils::repo_root::upward_bound(project)?; + let marker = bound.marker_above(&[".yarnrc", ".npmrc"])?; + Some(MirrorSetting { + value: MirrorValue::Path("".to_string()), + origin: bound.marker_refusal(&marker), + }) +} + /// Where yarn 1 looks for rc files outside the project (see the module /// docs). struct RcLayout { @@ -332,10 +345,14 @@ impl RcLayout { out.push(home.join(&local)); } } + // Inside a git checkout, never above its root (see + // [`unread_rc_above`]). + let bound = crate::utils::repo_root::upward_bound(project); out.extend( project .ancestors() .skip(1) + .take_while(|dir| bound.as_ref().is_none_or(|b| b.contains(dir))) .filter(|dir| dir.parent().is_some()) .map(|dir| dir.join(&local)), ); @@ -624,6 +641,9 @@ pub fn yarn_classic_workspace_members(root: &Path) -> Vec { .unwrap_or(0); let mut members = Vec::new(); let mut budget = MEMBER_SEARCH_DIR_BUDGET; + // Inside a git checkout a directory link out of it is never followed: + // its rc files are outside the repository. + let bound = crate::utils::repo_root::upward_bound(root); let mut stack = vec![(root.to_path_buf(), Vec::::new())]; while let Some((dir, rel)) = stack.pop() { if !rel.is_empty() @@ -648,6 +668,19 @@ pub fn yarn_classic_workspace_members(root: &Path) -> Vec { if name == "node_modules" || name == ".git" || !path.is_dir() { continue; } + if bound.as_ref().is_some_and(|b| !b.contains(&path)) { + // Not descended into: only a member AT the link counts + // (stat only), and its rc files are never read + // ([`resolve_outer_yarn_mirror_for_process`] fails closed). + let mut child = rel.clone(); + child.push(name); + if path.join("package.json").is_file() + && workspaces_include_as(&patterns, &child, WorkspacesDialect::YARN_CLASSIC) + { + members.push(path); + } + continue; + } let mut child = rel.clone(); child.push(name); stack.push((path, child)); @@ -675,15 +708,30 @@ pub fn resolve_outer_yarn_mirror_for_process(project: &Path) -> OuterYarnMirror let root_user = process_is_root_user(); let read = |path: &Path| crate::utils::fs::read_regular_to_string_sync(path).ok(); let mut outer = resolve_outer_yarn_mirror(&env, root_user, &project, read); - outer.member = resolve_member_yarn_mirror( - &env, - root_user, - &yarn_classic_workspace_members(&project), - read, - ); + let members = yarn_classic_workspace_members(&project); + outer.member = linked_member_outside(&project, &members) + .or_else(|| resolve_member_yarn_mirror(&env, root_user, &members, read)); outer } +/// Inside a git checkout, a workspace member linked from outside it +/// ([`yarn_classic_workspace_members`]): yarn run there reads its own +/// `.yarnrc` / `.npmrc`, which are never read here, so fail closed as a +/// mirror and the hosted rewriter refuses with the member named. +fn linked_member_outside(project: &Path, members: &[PathBuf]) -> Option { + let bound = crate::utils::repo_root::upward_bound(project)?; + let member = members.iter().find(|member| !bound.contains(member))?; + Some(MirrorSetting { + value: MirrorValue::Path("".to_string()), + origin: format!( + "the workspace member {}, linked from outside the repository root {}, whose \ + .yarnrc / .npmrc socket-patch does not read", + member.display(), + bound.root().display() + ), + }) +} + #[cfg(test)] #[path = "yarnrc_tests.rs"] mod tests; diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs index 2bb9e1d0f..e5d4ca18a 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs @@ -428,6 +428,37 @@ fn yarn_classic_workspace_members_follow_the_root_globs() { assert!(yarn_classic_workspace_members(root).is_empty()); } +/// Inside a git checkout a member linked from outside it is listed (stat +/// only) but never descended into, and its rc files are never read: the +/// member mirror fails closed naming it. Without a checkout the link is +/// followed as before. +#[cfg(unix)] +#[test] +fn a_member_linked_from_outside_the_repository_fails_closed() { + let tmp = tempfile::tempdir().unwrap(); + let base = std::fs::canonicalize(tmp.path()).unwrap(); + let root = base.join("repo"); + let write = |p: &Path, text: &str| { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + }; + write(&root.join("yarn.lock"), "# yarn lockfile v1\n\n"); + write( + &root.join("package.json"), + r#"{"private":true,"workspaces":["linked/*"]}"#, + ); + write(&base.join("outside/s/package.json"), "{}"); + std::fs::create_dir_all(root.join("linked")).unwrap(); + std::os::unix::fs::symlink(base.join("outside/s"), root.join("linked/s")).unwrap(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + let members = yarn_classic_workspace_members(&root); + assert_eq!(members, vec![root.join("linked/s")]); + let setting = linked_member_outside(&root, &members).unwrap(); + assert!(setting.origin.contains("linked/s"), "{setting:?}"); + std::fs::remove_dir(root.join(".git")).unwrap(); + assert_eq!(linked_member_outside(&root, &members), None); +} + impl Host { /// [`configured_yarn_global_folders`] for yarn run in `cwd`. fn global_folders(&self, cwd: &str) -> Vec { diff --git a/crates/socket-patch-core/src/utils/cargo_build_cache.rs b/crates/socket-patch-core/src/utils/cargo_build_cache.rs index e4b3668c4..1fbe3d79d 100644 --- a/crates/socket-patch-core/src/utils/cargo_build_cache.rs +++ b/crates/socket-patch-core/src/utils/cargo_build_cache.rs @@ -123,13 +123,18 @@ fn expand_build_dir(raw: &str, ws_root: &Path, cargo_home: Option<&Path>) -> Opt /// The nearest ancestor of `cwd` holding a `Cargo.lock` (cargo writes it /// beside the workspace root manifest), else the nearest one holding a /// `[workspace]` manifest, else `cwd`. +/// +/// Inside a git checkout neither walk leaves the repository root: a +/// workspace above it is never read nor its build cache touched. fn workspace_root(cwd: &Path) -> PathBuf { - for dir in cwd.ancestors() { + let bound = crate::utils::repo_root::upward_bound(cwd); + let within = |dir: &&Path| bound.as_ref().is_none_or(|b| b.contains(dir)); + for dir in cwd.ancestors().take_while(within) { if dir.join("Cargo.lock").is_file() { return dir.to_path_buf(); } } - for dir in cwd.ancestors() { + for dir in cwd.ancestors().take_while(within) { let manifest = dir.join("Cargo.toml"); if let Ok(text) = crate::utils::fs::read_regular_to_string_sync(&manifest) { if text @@ -362,6 +367,22 @@ pub fn cargo_purl_name(purl: &str) -> Option<&str> { mod tests { use super::*; + /// A workspace above the repository root is neither read nor its + /// build cache touched; without a checkout it is found as before. + #[test] + fn workspace_root_stays_in_the_repository() { + let tmp = tempfile::tempdir().unwrap(); + let outer = std::fs::canonicalize(tmp.path()).unwrap(); + let app = outer.join("repo/app"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write(outer.join("Cargo.lock"), "").unwrap(); + std::fs::write(outer.join("Cargo.toml"), "[workspace]\n").unwrap(); + std::fs::create_dir_all(outer.join("repo/.git")).unwrap(); + assert_eq!(workspace_root(&app), app); + std::fs::remove_dir(outer.join("repo/.git")).unwrap(); + assert_eq!(workspace_root(&app), outer); + } + fn mkfp(root: &Path, rel: &str) -> PathBuf { let p = root.join(rel); std::fs::create_dir_all(&p).unwrap(); diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 8671daab2..2cfde979d 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -795,17 +795,34 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option let mut setting = pnpm_setting(workspace.as_deref(), npmrc.as_deref(), KEYS.0, KEYS.1); if setting.is_none() { if let Some(file) = governing_file(view) { - let workspace = read_regular_to_string(&file).await.ok(); - let npmrc = read_regular_to_string(&file.with_file_name(".npmrc")) - .await - .ok(); - setting = + // Above the repository root nothing is read, so the setting is + // unknowable: fail closed, as if it were on, so writers refuse + // rather than pin a lock pnpm may not read. + let readable = view + .disk_root() + .is_some_and(|root| crate::utils::repo_root::upward_readable(root, &file)); + setting = if readable { + let workspace = read_regular_to_string(&file).await.ok(); + let npmrc = read_regular_to_string(&file.with_file_name(".npmrc")) + .await + .ok(); pnpm_setting(workspace.as_deref(), npmrc.as_deref(), KEYS.0, KEYS.1).map(|found| { PnpmSetting { source: format!("{} (at {})", found.source, file.display()), ..found } - }); + }) + } else { + Some(PnpmSetting { + value: "true".to_string(), + source: format!( + "{} (above the repository root, never read, so `gitBranchLockfile` \ + may be on)", + file.display() + ), + yaml: true, + }) + }; } } if setting.is_none() { @@ -985,6 +1002,12 @@ pub fn governing_workspace_file(project_root: &Path) -> Option { .skip(1) .find(|dir| dir.join(PNPM_WORKSPACE).is_file())?; let file = dir.join(PNPM_WORKSPACE); + // Above the repository root the file is never read: fail closed, as a + // member, like an unreadable one (callers do not read it either, see + // [`crate::utils::repo_root::upward_readable`]). + if !crate::utils::repo_root::upward_readable(&canonical, &file) { + return Some(without_verbatim_prefix(file)); + } let rel: Vec = canonical .strip_prefix(dir) .ok()? diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index cafa232de..1917d8ce9 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -479,8 +479,16 @@ fn pyproject_has_uv_settings(pyproject: &str) -> bool { /// a `[tool.uv]` table, with `uv.toml` first in each directory. A /// user-level `uv.toml` /// (`~/.config/uv`) is the installing machine's and is not looked up. +/// +/// Inside a git checkout the lookup never reads above its root +/// ([`crate::utils::repo_root::upward_bound`]); [`uv_config_above_repo`] +/// then says whether a settings file there may apply. pub fn find_ancestor_uv_config(root: &Path) -> Option { + let bound = crate::utils::repo_root::upward_bound(root); for (rel, path) in ancestor_uv_config_files(root) { + if bound.as_ref().is_some_and(|b| !b.contains(&path)) { + return None; + } let text = match crate::utils::fs::read_regular_to_string_sync(&path) { Ok(text) => text, Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, @@ -495,6 +503,24 @@ pub fn find_ancestor_uv_config(root: &Path) -> Option { None } +/// Why the uv settings for `root` are unknowable: inside a git checkout, +/// [`find_ancestor_uv_config`] found none up to the repository root, and +/// a `uv.toml` or `pyproject.toml` exists above it (stat only, never +/// read), which uv may apply. Writers refuse on it rather than wire what +/// those settings may reject. +pub fn uv_config_above_repo(root: &Path) -> Option { + let bound = crate::utils::repo_root::upward_bound(root)?; + if find_ancestor_uv_config(root).is_some() { + return None; + } + let marker = bound.marker_above(&[UV_TOML, "pyproject.toml"])?; + Some(format!( + "uv may apply the settings in {}: {}", + marker.display(), + bound.marker_refusal(&marker) + )) +} + /// `[tool.uv]` keys that describe the project declaring them rather than /// settings another project inherits: uv documents them as project /// metadata, read from the project's (or its workspace root's) own diff --git a/crates/socket-patch-core/src/utils/repo_root.rs b/crates/socket-patch-core/src/utils/repo_root.rs index 7c76598f0..64bc5c5d2 100644 --- a/crates/socket-patch-core/src/utils/repo_root.rs +++ b/crates/socket-patch-core/src/utils/repo_root.rs @@ -171,6 +171,51 @@ impl ReadBoundary { self.root.display() ) } + + /// The nearest file named one of `names` in a directory ABOVE the + /// boundary, nearest directory first and `names` in order. Stat only: + /// the file is never opened. Like git's own lookup it never reaches + /// the home directory or anything above it, so a stray `~/package.json` + /// governs nothing. + pub fn marker_above(&self, names: &[&str]) -> Option { + let home = home(); + self.root + .ancestors() + .skip(1) + .take_while(|dir| home.as_deref().is_none_or(|home| !home.starts_with(dir))) + .flat_map(|dir| names.iter().map(move |name| dir.join(name))) + .find(|path| std::fs::metadata(path).is_ok()) + } + + /// The reason an upward lookup stopped at the boundary although the + /// tool it models would read `marker` above it. + pub fn marker_refusal(&self, marker: &Path) -> String { + format!( + "{} exists above the repository root {}, and socket-patch does not read \ + project files outside the repository, so it cannot tell whether that file \ + governs this project", + marker.display(), + self.root.display() + ) + } +} + +/// The bound of an UPWARD lookup from `start` (a workspace root, an +/// inherited config, a settings file the package manager searches its +/// ancestors for): inside a git checkout the lookup never reads above the +/// checkout's root, and a caller asks [`ReadBoundary::marker_above`] +/// whether the tool would have found something there (a writer then +/// refuses, a reader withholds). `None` outside any checkout, where the +/// lookup walks on as it always has: stopping it at the project would +/// silently treat a workspace member as standalone. +pub fn upward_bound(start: &Path) -> Option { + find_git_repo(&canonical_or_lexical(start)).map(|repo| ReadBoundary { root: repo.root }) +} + +/// Whether an upward lookup from `start` may read `path` +/// ([`upward_bound`]): always outside a checkout, else only inside it. +pub fn upward_readable(start: &Path, path: &Path) -> bool { + upward_bound(start).is_none_or(|bound| bound.contains(path)) } /// `project_root.join(rel)` for a project file named by a root-relative @@ -310,6 +355,10 @@ fn trusted_owner(_meta: &std::fs::Metadata) -> bool { true } +#[cfg(test)] +#[path = "repo_root_upward_tests.rs"] +mod upward_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs b/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs new file mode 100644 index 000000000..8d579b377 --- /dev/null +++ b/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs @@ -0,0 +1,277 @@ +//! The upward lookups bounded by [`super::upward_bound`]: inside a git +//! checkout none reads a file above the repository root, and each one +//! that would have found something there refuses or withholds; outside +//! any checkout each walks on as before. Every test here fails with the +//! bound disabled (`upward_bound` answering `None`). + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::*; + +/// `/outer/repo` is the checkout (with `.git` unless `git` is +/// false) and `/outer/repo/app` the project; `/outer` lies +/// above the repository root. +struct Layout { + _tmp: tempfile::TempDir, + outer: PathBuf, + repo: PathBuf, + app: PathBuf, +} + +fn layout(git: bool) -> Layout { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let outer = base.join("outer"); + let repo = outer.join("repo"); + let app = repo.join("app"); + fs::create_dir_all(&app).unwrap(); + if git { + fs::create_dir_all(repo.join(".git")).unwrap(); + } + Layout { + _tmp: tmp, + outer, + repo, + app, + } +} + +fn write(path: &Path, text: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); +} + +#[test] +fn marker_above_is_stat_only_and_starts_above_the_checkout() { + let l = layout(true); + let bound = upward_bound(&l.app).unwrap(); + assert_eq!(bound.root(), l.repo.as_path()); + assert_eq!(bound.marker_above(&["package.json"]), None); + write(&l.repo.join("package.json"), "{}"); + assert_eq!(bound.marker_above(&["package.json"]), None, "inside"); + write(&l.outer.join("package.json"), "not json"); + assert_eq!( + bound.marker_above(&["package.json"]), + Some(l.outer.join("package.json")) + ); + assert!(upward_readable(&l.app, &l.repo.join("package.json"))); + assert!(!upward_readable(&l.app, &l.outer.join("package.json"))); + // No checkout: no bound, every ancestor stays readable. + let plain = layout(false); + assert_eq!(upward_bound(&plain.app), None); + assert!(upward_readable( + &plain.app, + &plain.outer.join("package.json") + )); +} + +/// npm / yarn / Bun `package.json` workspaces (and the pnpm and vlt +/// files) above the repository: a lockless member is refused naming the +/// unread file, never read as the workspace root. +#[tokio::test] +async fn npm_family_workspace_root_above_the_repository() { + for git in [true, false] { + let l = layout(git); + write(&l.app.join("package.json"), r#"{"name":"app"}"#); + write( + &l.outer.join("package.json"), + r#"{"name":"ws","workspaces":["repo/app"]}"#, + ); + write(&l.outer.join("package-lock.json"), "{}"); + let found = crate::hosted::governing_root::npm_lock_elsewhere(&l.app) + .await + .unwrap(); + assert_eq!(found.dir, l.outer, "git: {git}"); + assert_eq!( + found.detail.contains("outside the repository"), + git, + "git: {git}: {}", + found.detail + ); + } +} + +/// A member's own lock under a workspace root above the repository. +#[tokio::test] +async fn member_stray_lock_above_the_repository() { + for git in [true, false] { + let l = layout(git); + write(&l.app.join("package.json"), r#"{"name":"app"}"#); + write(&l.app.join("package-lock.json"), "{}"); + write( + &l.outer.join("package.json"), + r#"{"name":"ws","workspaces":["repo/app"]}"#, + ); + write(&l.outer.join("package-lock.json"), "{}"); + let stray = crate::hosted::governing_root::member_stray_lock(&l.app) + .await + .unwrap(); + assert_eq!(stray.root, l.outer); + assert_eq!( + stray.detail.contains("outside the repository"), + git, + "{}", + stray.detail + ); + } +} + +/// The nearest `pnpm-workspace.yaml` above the repository fails closed +/// as governing, even one that would not list the member when read. +#[test] +fn pnpm_workspace_file_above_the_repository_fails_closed() { + let l = layout(true); + let file = l.outer.join("pnpm-workspace.yaml"); + write(&file, "packages:\n - other\n"); + assert_eq!( + crate::utils::pnpm_workspace::governing_workspace_file(&l.app), + Some(file) + ); + let plain = layout(false); + write( + &plain.outer.join("pnpm-workspace.yaml"), + "packages:\n - other\n", + ); + assert_eq!( + crate::utils::pnpm_workspace::governing_workspace_file(&plain.app), + None, + "read as before" + ); +} + +#[tokio::test] +async fn uv_workspace_root_above_the_repository() { + for git in [true, false] { + let l = layout(git); + write(&l.app.join("pyproject.toml"), "[project]\nname = \"app\"\n"); + write( + &l.outer.join("pyproject.toml"), + "[tool.uv.workspace]\nmembers = [\"repo/app\"]\n", + ); + let why = crate::utils::uv_workspace::governing_uv_refusal(&l.app) + .await + .unwrap(); + assert_eq!(why.contains("outside the repository"), git, "{why}"); + assert_eq!(why.contains("is a member of the uv workspace"), !git); + } +} + +#[test] +fn uv_settings_above_the_repository() { + use crate::utils::python_script::{find_ancestor_uv_config, uv_config_above_repo}; + for git in [true, false] { + let l = layout(git); + write(&l.outer.join("uv.toml"), "[pip]\nno-binary = [\"six\"]\n"); + assert_eq!(find_ancestor_uv_config(&l.app).is_some(), !git); + assert_eq!(uv_config_above_repo(&l.app).is_some(), git); + } +} + +#[tokio::test] +async fn cargo_workspace_and_configs_above_the_repository() { + for git in [true, false] { + let l = layout(git); + write( + &l.app.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ); + write( + &l.outer.join("Cargo.toml"), + "[workspace]\nmembers = [\"repo/app\"]\n", + ); + let doc = crate::vendor::cargo_manifest::parse_manifest( + &fs::read_to_string(l.app.join("Cargo.toml")).unwrap(), + ) + .unwrap(); + let why = crate::vendor::cargo::workspace_root_refusal(&l.app, &doc, "hint") + .await + .unwrap(); + assert_eq!(why.contains("outside the repository"), git, "{why}"); + + write( + &l.outer.join(".cargo/config.toml"), + "[patch.crates-io]\nserde = { path = \"x\" }\n", + ); + let chain = crate::vendor::cargo_config::read_config_chain_with(&l.app, None).await; + assert_eq!(chain.is_empty(), git, "{chain:?}"); + assert_eq!( + crate::vendor::cargo_config::config_above_repo(&l.app).is_some(), + git + ); + let layers = + crate::formats::cargo::source_replacement::config_layer_paths(&l.app, None, |p| { + p.is_file() + }); + assert_eq!(layers.is_empty(), git, "{layers:?}"); + } +} + +#[test] +fn gradle_settings_above_the_repository() { + for git in [true, false] { + let l = layout(git); + write(&l.app.join("build.gradle"), ""); + write(&l.outer.join("settings.gradle"), "include 'repo:app'\n"); + let roots = crate::crawlers::gradle_cache::build_roots(&l.app); + assert_eq!(roots.contains(&l.outer), !git, "{roots:?}"); + } +} + +fn yarn_env() -> crate::crawlers::pkg_managers::YarnEnv { + crate::crawlers::pkg_managers::YarnEnv { + node_linker: None, + pnpm_store_folder: None, + rc_filename: ".yarnrc.yml".to_string(), + home: None, + vars: Some(Default::default()), + env_files: Default::default(), + } +} + +#[test] +fn yarnrc_yml_above_the_repository() { + use crate::crawlers::pkg_managers::{yarn_node_linker_in, yarn_pnpm_store_folder_in}; + for git in [true, false] { + let l = layout(git); + write( + &l.outer.join(".yarnrc.yml"), + "nodeLinker: node-modules\npnpmStoreFolder: store\n\ + injectEnvironmentFiles: [vars.env]\n", + ); + write(&l.outer.join("vars.env"), "INJECTED=1\n"); + let linker = yarn_node_linker_in(&l.app, &yarn_env()).unwrap(); + assert_eq!(linker == "node-modules", !git, "{linker}"); + assert_eq!( + yarn_pnpm_store_folder_in(&l.app, &yarn_env()), + (!git).then(|| l.outer.join("store")) + ); + let env = yarn_env().with_env_files(&l.app, None); + assert_eq!(env.var("INJECTED").is_some(), !git); + } +} + +/// yarn classic's `.yarnrc` / `.npmrc` above the repository: the outer +/// mirror fails closed (the hosted rewriter then refuses). +#[test] +fn yarn_classic_rc_above_the_repository() { + use crate::patch::redirect::yarnrc::{resolve_outer_yarn_mirror, MirrorValue}; + let env = crate::patch::redirect::npmrc::NpmConfigEnv { + vars: Vec::new(), + home: None, + node_exe: None, + windows: false, + }; + for git in [true, false] { + let l = layout(git); + write(&l.outer.join(".yarnrc"), "yarn-offline-mirror \"./m\"\n"); + let read = |p: &Path| fs::read_to_string(p).ok(); + let outer = resolve_outer_yarn_mirror(&env, false, &l.app, read); + let file = outer.yarn.file.unwrap(); + if git { + assert!(file.origin.contains("outside the repository"), "{file:?}"); + } else { + assert_eq!(file.value, MirrorValue::Path("./m".to_string())); + } + } +} diff --git a/crates/socket-patch-core/src/utils/uv_workspace.rs b/crates/socket-patch-core/src/utils/uv_workspace.rs index 77e56eae6..2e3a070bc 100644 --- a/crates/socket-patch-core/src/utils/uv_workspace.rs +++ b/crates/socket-patch-core/src/utils/uv_workspace.rs @@ -25,7 +25,41 @@ use crate::utils::workspace_globs::glob_matches; /// installs a listed member from the workspace root's `uv.lock` and never /// reads a `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock` or pylock /// left in the member directory. +#[cfg(test)] pub(crate) async fn governing_uv_workspace(dir: &Path) -> Option { + match walk(dir).await { + Walk::Governed(root) => Some(root), + Walk::Ungoverned | Walk::Beyond(..) => None, + } +} + +/// The refusal both modes give a run from `dir`: [`member_detail`] for a +/// governed member, or, inside a git checkout whose walk ran off the +/// repository root with a `pyproject.toml` above it (stat only, never +/// read), the reason it cannot tell whether that one is the workspace +/// root. +pub(crate) async fn governing_uv_refusal(dir: &Path) -> Option { + match walk(dir).await { + Walk::Governed(root) => Some(member_detail(dir, &root)), + Walk::Ungoverned => None, + Walk::Beyond(bound, marker) => Some(format!( + "{} may be a member of a uv workspace rooted above the repository: {}; \ + nothing was written", + dir.display(), + bound.marker_refusal(&marker) + )), + } +} + +/// How the walk up from a member ended. +enum Walk { + Governed(PathBuf), + Ungoverned, + /// It reached the repository root, and a `pyproject.toml` exists above. + Beyond(crate::utils::repo_root::ReadBoundary, PathBuf), +} + +async fn walk(dir: &Path) -> Walk { let dir = tokio::fs::canonicalize(dir) .await .unwrap_or_else(|_| dir.to_path_buf()); @@ -33,9 +67,16 @@ pub(crate) async fn governing_uv_workspace(dir: &Path) -> Option { .await .is_err() { - return None; + return Walk::Ungoverned; } + let bound = crate::utils::repo_root::upward_bound(&dir); for ancestor in dir.ancestors().skip(1) { + if let Some(bound) = bound.as_ref().filter(|b| !b.contains(ancestor)) { + return match bound.marker_above(&["pyproject.toml"]) { + Some(marker) => Walk::Beyond(bound.clone(), marker), + None => Walk::Ungoverned, + }; + } let Ok(text) = read_regular_to_string(&ancestor.join("pyproject.toml")).await else { continue; }; @@ -51,13 +92,14 @@ pub(crate) async fn governing_uv_workspace(dir: &Path) -> Option { .and_then(Item::as_table_like) else { if doc.contains_key("project") { - return None; + return Walk::Ungoverned; } continue; }; - let rel: Vec = dir - .strip_prefix(ancestor) - .ok()? + let Ok(rel) = dir.strip_prefix(ancestor) else { + return Walk::Ungoverned; + }; + let rel: Vec = rel .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect(); @@ -76,9 +118,12 @@ pub(crate) async fn governing_uv_workspace(dir: &Path) -> Option { && !globs("exclude") .iter() .any(|pattern| glob_matches(pattern, &rel)); - return member.then(|| ancestor.to_path_buf()); + return match member { + true => Walk::Governed(ancestor.to_path_buf()), + false => Walk::Ungoverned, + }; } - None + Walk::Ungoverned } /// The one-line detail both modes refuse a governed member with: names the diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 95e27e8d5..ced738c40 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -456,6 +456,11 @@ async fn cargo_prelude( // `$CARGO_HOME`) — that overrides, or may override, this crate@version // is never shadowed or overwritten. let chain = cargo_config::read_config_chain(project_root).await; + // A config above the repository root is never read, so a user patch + // there cannot be ruled out. + if let Some(detail) = cargo_config::config_above_repo(project_root) { + return Err(refused("user_authored_patch_entry", detail)); + } if let Some(detail) = user_patch_conflict(project_root, &manifest_entries, &chain, name, version).await { @@ -1414,10 +1419,18 @@ pub(crate) async fn workspace_root_refusal( let root = tokio::fs::canonicalize(project_root) .await .unwrap_or_else(|_| project_root.to_path_buf()); + let bound = crate::utils::repo_root::upward_bound(&root); for ancestor in root.ancestors().skip(1) { if ancestor.ends_with("target/package") { break; } + // Above the repository root nothing is read; a Cargo.toml there + // may be the workspace root cargo finds, so the run refuses. + if let Some(bound) = bound.as_ref().filter(|b| !b.contains(ancestor)) { + return bound + .marker_above(&[cargo_manifest::CARGO_TOML]) + .map(|marker| format!("{}; {hint}", bound.marker_refusal(&marker))); + } let manifest = ancestor.join(cargo_manifest::CARGO_TOML); let Ok(text) = read_regular_to_string(&manifest).await else { continue; @@ -1548,6 +1561,11 @@ async fn path_crate_version(base: &Path, rel: &str) -> Option { } else { base.join(rel) }; + // A path outside the repository root is never read: its version is + // unknown, so the patch may override the target and the caller refuses. + if !crate::utils::repo_root::upward_readable(base, &dir) { + return None; + } let text = read_regular_to_string(&dir.join("Cargo.toml")).await.ok()?; let doc = text.parse::().ok()?; doc.get("package")? @@ -1720,6 +1738,9 @@ async fn move_wiring_into_manifest( } let manifest_entries = cargo_manifest::crates_io_patch_entries(&doc); let chain = cargo_config::read_config_chain(project_root).await; + if let Some(detail) = cargo_config::config_above_repo(project_root) { + return Err(detail); + } if let Some(detail) = user_patch_conflict(project_root, &manifest_entries, &chain, name, version).await { @@ -1948,6 +1969,29 @@ pub async fn revert_cargo_vendor_opts( mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; + + /// A user `[patch]` path outside the repository root is never read: + /// its version is unknown, so the caller refuses. + #[tokio::test] + async fn patch_path_outside_the_repository_is_never_read() { + let tmp = tempfile::tempdir().unwrap(); + let repo = std::fs::canonicalize(tmp.path()).unwrap().join("repo"); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + let fork = tmp.path().join("fork"); + std::fs::create_dir_all(&fork).unwrap(); + std::fs::write( + fork.join("Cargo.toml"), + "[package]\nname = \"serde\"\nversion = \"9.9.9\"\n", + ) + .unwrap(); + assert_eq!(path_crate_version(&repo, "../fork").await, None); + std::fs::remove_dir(repo.join(".git")).unwrap(); + assert_eq!( + path_crate_version(&repo, "../fork").await.as_deref(), + Some("9.9.9"), + "without a checkout, read as before" + ); + } use crate::manifest::schema::{PatchFileInfo, VulnerabilityInfo}; use crate::patch::apply::ApplyResult; use crate::vendor::common::backup_dir_for; diff --git a/crates/socket-patch-core/src/vendor/cargo_config.rs b/crates/socket-patch-core/src/vendor/cargo_config.rs index 3a3966708..2378c7094 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -157,6 +157,20 @@ pub async fn read_config_chain(project_root: &Path) -> Vec { read_config_chain_with(project_root, cargo_home.as_deref()).await } +/// Inside a git checkout, why the config chain is incomplete: a +/// `.cargo/config` or `.cargo/config.toml` exists above the repository +/// root (stat only, never read), and cargo merges its `[patch]` and +/// source settings too. Writers refuse on it. +pub fn config_above_repo(project_root: &Path) -> Option { + let bound = crate::utils::repo_root::upward_bound(project_root)?; + let marker = bound.marker_above(&[".cargo/config", ".cargo/config.toml"])?; + Some(format!( + "cargo merges {} into this build: {}", + marker.display(), + bound.marker_refusal(&marker) + )) +} + /// [`read_config_chain`] with an explicit `$CARGO_HOME`. pub async fn read_config_chain_with( project_root: &Path, @@ -165,8 +179,12 @@ pub async fn read_config_chain_with( let root = fs::canonicalize(project_root) .await .unwrap_or_else(|_| project_root.to_path_buf()); + // Inside a git checkout, a config above its root is never read + // ([`config_above_repo`] names one for the writers to refuse on). + let bound = crate::utils::repo_root::upward_bound(&root); let mut dirs: Vec<(PathBuf, PathBuf, bool)> = root .ancestors() + .take_while(|dir| bound.as_ref().is_none_or(|b| b.contains(dir))) .map(|dir| (dir.join(".cargo"), dir.to_path_buf(), dir == root)) .collect(); if let Some(cargo_home) = cargo_home { diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index 0f320ad8b..c62a23539 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -335,8 +335,26 @@ pub(super) fn not_build_root(project_root: &Path) -> Option { let canonical_root = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf()); let ancestors = crate::utils::repo_root::ancestor_search_dirs(&canonical_root); + let bound = crate::utils::repo_root::upward_bound(&canonical_root); for ancestor in &ancestors { let ancestor = ancestor.as_path(); + // Above the repository root (a submodule's superproject, a + // directory around the checkout) nothing is read: a build file + // there may make this project a module, so it refuses. + if let Some(bound) = bound.as_ref().filter(|b| !b.contains(ancestor)) { + let marker = std::iter::once("pom.xml") + .chain(layout::GRADLE_SETTINGS_FILES.iter().copied()) + .chain(["build.sbt"]) + .map(|name| ancestor.join(name)) + .find(|path| std::fs::metadata(path).is_ok()); + if let Some(marker) = marker { + return Some(format!( + "reason: not_build_root: {}", + bound.marker_refusal(&marker) + )); + } + continue; + } let reader = super::jvm::apply::ProjectReader::new(ancestor); let rel = canonical_root .strip_prefix(ancestor) @@ -1690,6 +1708,24 @@ mod tests { use std::collections::HashMap; use super::*; + + /// A reactor `pom.xml` above the repository root is never read: the + /// project may be one of its modules, so vendoring refuses. + #[test] + fn a_build_above_the_repository_refuses_unread() { + let tmp = tempfile::tempdir().unwrap(); + let outer = std::fs::canonicalize(tmp.path()).unwrap(); + let app = outer.join("repo"); + std::fs::create_dir_all(app.join(".git")).unwrap(); + std::fs::write(app.join("pom.xml"), "").unwrap(); + std::fs::write( + outer.join("pom.xml"), + "other", + ) + .unwrap(); + let why = not_build_root(&app).unwrap(); + assert!(why.contains("outside the repository"), "{why}"); + } use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; use crate::patch::apply::ApplyResult; diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 761f07b88..6c069df84 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -317,12 +317,8 @@ async fn detect_pypi_flavor( // uv.lock, which this run cannot see; routing it by its own files would // rewrite it as a lockless (Hatch) project, or rewrite a lock left in // the member that uv never reads, and leave the root lock stale. - if let Some(workspace) = crate::utils::uv_workspace::governing_uv_workspace(project_root).await - { - return Err(( - "pypi_uv_workspace_unsupported", - crate::utils::uv_workspace::member_detail(project_root, &workspace), - )); + if let Some(detail) = crate::utils::uv_workspace::governing_uv_refusal(project_root).await { + return Err(("pypi_uv_workspace_unsupported", detail)); } let exists = |name: &str| { let p = project_root.join(name); diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 726314b21..ed31271a1 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -188,6 +188,18 @@ pub(super) async fn load_python_locks( load_listed_locks(root, paths, name, version, uuid, true).await } +/// Why the uv settings [`project_config_of`] stands for are unknowable: +/// the root sets none, none was found up to the repository root, and one +/// may apply from above it ([`crate::utils::python_script::uv_config_above_repo`]). +fn ancestor_settings_unknown( + root: &Path, + (pyproject, uv_toml, ancestor): &(Option, Option, Option), +) -> Option { + (ancestor.is_none() && !root_governs_script(pyproject.as_deref(), uv_toml.as_deref())) + .then(|| crate::utils::python_script::uv_config_above_repo(root)) + .flatten() +} + /// The project's pyproject.toml and uv.toml, and the uv settings file /// above the root that uv applies when the root holds none (#1501), read /// into `cache` on the first call. @@ -272,7 +284,11 @@ async fn load_listed_locks( in_sync = false; } if uv_pip && is_pep751_lock_name(&path) { - let (pyproject, uv_toml, ancestor) = project_config_of(root, &mut project_config).await; + let config = project_config_of(root, &mut project_config).await; + if let Some(why) = ancestor_settings_unknown(root, config) { + return Err(("pypi_uv_ancestor_settings", format!("{path}: {why}"))); + } + let (pyproject, uv_toml, ancestor) = config; let settings: Vec<_> = root_uv_settings(uv_toml.as_deref(), pyproject.as_deref(), ancestor.as_ref()) .into_iter() @@ -303,7 +319,11 @@ async fn load_listed_locks( return Err(("pypi_script_multiple_versions", format!("{path} resolves multiple versions of {name}; a script source override cannot select only {version}"))); } let script = read_file(&root.join(script_name)).await?; - let (pyproject, uv_toml, ancestor) = project_config_of(root, &mut project_config).await; + let config = project_config_of(root, &mut project_config).await; + if let Some(why) = ancestor_settings_unknown(root, config) { + return Err(("pypi_uv_ancestor_settings", format!("{script_name}: {why}"))); + } + let (pyproject, uv_toml, ancestor) = config; refuse_uv_script_no_sources( name, &script, diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index c00b33934..581e259e7 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -230,9 +230,16 @@ pub(super) async fn load_uv_project(root: &Path) -> Result, String)], owner: Option<&str>, name: &st /// The copy of the package `name` node resolves from the package directory /// `from`: `/node_modules/` for the nearest of `from` and its /// ancestors that is not itself a `node_modules` directory. +/// +/// Inside a git checkout a copy above its root is never read: `None`, so +/// the dependency is unresolved rather than verified from outside. fn node_resolve(from: &Path, name: &str) -> Option { from.ancestors() .filter(|dir| dir.file_name().is_none_or(|n| n != "node_modules")) .map(|dir| dir.join("node_modules").join(name)) .find(|copy| copy.exists()) + .filter(|copy| crate::utils::repo_root::upward_readable(from, copy)) } /// The registry semver range of a `dependencies` value: the value itself, @@ -764,6 +768,24 @@ mod tests { use crate::manifest::schema::{PatchFileInfo, PatchRecord}; use std::collections::HashMap; + /// Node resolution never verifies from a copy above the repository + /// root; without a checkout it resolves as before. + #[test] + fn node_resolve_stays_in_the_repository() { + let tmp = tempfile::tempdir().unwrap(); + let outer = std::fs::canonicalize(tmp.path()).unwrap(); + let from = outer.join("repo/node_modules/a"); + std::fs::create_dir_all(&from).unwrap(); + std::fs::create_dir_all(outer.join("node_modules/dep")).unwrap(); + std::fs::create_dir_all(outer.join("repo/.git")).unwrap(); + assert_eq!(node_resolve(&from, "dep"), None); + std::fs::remove_dir(outer.join("repo/.git")).unwrap(); + assert_eq!( + node_resolve(&from, "dep"), + Some(outer.join("node_modules/dep")) + ); + } + fn record_with_one_file(after_hash: &str) -> PatchRecord { let mut files = HashMap::new(); files.insert( From 07e330c69d0c9b5367f347ca2b8455e257fb5a80 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 13:53:50 -0600 Subject: [PATCH 04/10] Make the repository-root bound a workspace-only mode Owner clarification: the bound applies to workspace runs only; a global run (`--global` / `-g`, `--global-prefix`) must read anywhere on the system. `ReadScope { Workspace, Global }` is chosen once from the CLI flags (`GlobalArgs::read_scope`, `CrawlerOptions::read_scope`) and passed to every bounded read; `ReadBoundary` is now `Workspace { root } | Unbounded`, and `ReadBoundary::of`, `upward_bound`, `upward_readable` and `bounded_project_path` take the scope explicitly instead of inferring it. Global-reachable paths take the run's scope: the cargo crawler's source replacement and `config_layer_paths`, agent-mode cargo build-cache invalidation from apply/rollback, the npm crawler's relocated yarn store, apply's PnP gate, `YarnEnv`, `go_workspace::resolve_in` and Gradle `build_roots`. Hosted, vendored, VEX discovery/verify, the lock inventory and the upstream restore never run globally and pass `Workspace`. CLI_CONTRACT.md documents the distinction. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 + crates/socket-patch-cli/src/args.rs | 8 + crates/socket-patch-cli/src/commands/apply.rs | 7 +- .../socket-patch-cli/src/commands/rollback.rs | 1 + .../src/ecosystem_dispatch.rs | 12 +- .../src/crawlers/cargo_crawler.rs | 24 +- .../src/crawlers/go_crawler.rs | 4 +- .../src/crawlers/gradle_cache.rs | 10 +- .../src/crawlers/npm_crawler.rs | 37 ++- .../src/crawlers/pkg_managers.rs | 210 +++++++++++++----- .../src/crawlers/pnpm_layout.rs | 19 +- .../socket-patch-core/src/crawlers/types.rs | 10 + .../src/formats/cargo/source_replacement.rs | 5 +- crates/socket-patch-core/src/hosted/engine.rs | 10 +- .../src/hosted/governing_root.rs | 16 +- .../src/patch/redirect/staged.rs | 13 +- .../src/patch/redirect/upstream/npm.rs | 17 +- .../src/patch/redirect/yarnrc.rs | 20 +- .../src/patch/shared_store.rs | 5 +- .../src/utils/cargo_build_cache.rs | 82 +++++-- .../src/utils/pnpm_workspace.rs | 16 +- .../src/utils/python_script.rs | 6 +- .../socket-patch-core/src/utils/repo_root.rs | 171 +++++++++----- .../src/utils/repo_root_upward_tests.rs | 133 ++++++++++- .../src/utils/uv_workspace.rs | 3 +- crates/socket-patch-core/src/vendor/cargo.rs | 9 +- .../src/vendor/cargo_config.rs | 8 +- .../src/vendor/go_native_vendor.rs | 7 +- .../src/vendor/go_workspace.rs | 40 +++- .../src/vendor/lock_inventory/view.rs | 31 ++- .../src/vendor/maven_repo.rs | 5 +- .../src/vendor/npm_flavor.rs | 16 +- .../src/vendor/nuget_config.rs | 5 +- crates/socket-patch-core/src/vex/verify.rs | 8 +- 34 files changed, 755 insertions(+), 215 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index af1881d05..70a63c683 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -906,6 +906,8 @@ Not covered: the agent-mode blob cache and manifest are checked from `.socket` d ### Reads bounded to the repository root +**Workspace runs only.** The bound applies to project-scoped runs (the default `--cwd` project). A global run (`--global` / `-g`, or `--global-prefix`, on any command) reads anywhere on the system, so it can scan global `node_modules`, `site-packages`, `~/.m2`, the cargo and Go caches, and their settings wherever they live: nothing in this section applies to it. The mode is fixed once per run from those flags and passed to every bounded read (core `utils::repo_root::ReadScope`: `Workspace` or `Global`; `ReadBoundary::Workspace { root }` or `ReadBoundary::Unbounded`). It is never inferred from the filesystem. Hosted and vendored mode refuse global runs anyway, and VEX discovery and verification do not run in them. + VEX discovery, the lock inventory, hosted mode and vendored mode never read a PROJECT file outside the repository root: the git checkout enclosing the project (the nearest `.git`, by the same rules as the repository lookup), or the project directory itself when there is none. One check (core `utils::repo_root::ReadBoundary`) decides it; a level that resolves outside through `..` or a symlink counts as outside. A refused read behaves like a missing file to a reader; a writer refuses with the reason (`… is outside the repository root …; socket-patch does not read project files outside it`). Machine-level tool configuration (the user `~/.npmrc` / `.yarnrc.yml` / `NuGet.Config`, the `GOENV` file, `$CARGO_HOME`, module and package caches) is not a project file and is read as before. - **Go**: a `go.work` outside the root (an ancestor's, or one an absolute `GOWORK` names) is only noticed, never read: the workspace is invalid, so vendored and hosted writes refuse with the Go workspace refusal and discovery withholds every Go ref. Inside the root, a `use` member or a `replace` directory outside it reads as missing, so the selection it would decide is unknown. diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 128e19687..32119be10 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -447,6 +447,14 @@ impl GlobalArgs { self.global || self.global_prefix.is_some() } + /// The read scope this run's mode fixes for every bounded read: a + /// global run (`--global` / `--global-prefix`) reads anywhere on the + /// system; a project run never reads a project file outside the + /// repository root (see `socket_patch_core::utils::repo_root`). + pub(crate) fn read_scope(&self) -> socket_patch_core::utils::repo_root::ReadScope { + socket_patch_core::utils::repo_root::ReadScope::of_global(self.is_global()) + } + /// Whether `--ecosystems` selects `eco` (every ecosystem when unset or /// empty). The names are validated at parse time, so this is an exact /// match. diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 4a3732800..6022c56dc 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -471,7 +471,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { // The same PnP gate `apply` runs: a PnP tree hides every npm copy, so // without it each npm patch would read as not installed and pass calm. if matches!( - detect_npm_pkg_manager(&args.common.cwd), + detect_npm_pkg_manager(args.common.read_scope(), &args.common.cwd), NpmPkgManager::YarnBerryPnP ) && eco_in_local_scope(&args.common, Ecosystem::Npm) && manifest_targets_npm(&tree) @@ -1061,7 +1061,7 @@ pub async fn run(args: ApplyArgs) -> i32 { // filter excluding npm never touches it. if eco_in_local_scope(&args.common, Ecosystem::Npm) && matches!( - detect_npm_pkg_manager(&args.common.cwd), + detect_npm_pkg_manager(args.common.read_scope(), &args.common.cwd), NpmPkgManager::YarnBerryPnP ) { @@ -1388,7 +1388,7 @@ pub(crate) async fn run_locked( // informational note; the substantive safety is core's rename-over // write (`utils::fs::atomic_write_bytes` never touches the store's // shared inode). - match detect_npm_pkg_manager(&args.common.cwd) { + match detect_npm_pkg_manager(args.common.read_scope(), &args.common.cwd) { NpmPkgManager::YarnBerryPnP => { if eco_in_local_scope(&args.common, Ecosystem::Npm) && manifest_targets_npm(&manifest) { let code = refuse_yarn_pnp_with_skips(&args, &user_patch_skips); @@ -1512,6 +1512,7 @@ pub(crate) async fn run_locked( }); cache_warnings.extend( socket_patch_core::utils::cargo_build_cache::invalidate_project( + args.common.read_scope(), &args.common.cwd, rewritten.map(|r| r.package_key.as_str()), ) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 78ac53026..bee7450cc 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -3400,6 +3400,7 @@ pub(crate) async fn rollback_patches_inner( if !common.dry_run { warnings.extend( socket_patch_core::utils::cargo_build_cache::invalidate_project( + common.read_scope(), &common.cwd, results .iter() diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 8c0ec1b2a..795770239 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -1209,10 +1209,14 @@ impl JvmScope { let probe_env = env.clone(); let probe = move || { let gate = m2_gate(&cwd, &probe_env); - let metadata = gradle_cache::build_roots(&cwd) - .into_iter() - .map(|root| root.join("gradle").join("verification-metadata.xml")) - .find(|p| p.is_file()); + // A global run returned above: this is a project run. + let metadata = gradle_cache::build_roots( + socket_patch_core::utils::repo_root::ReadScope::Workspace, + &cwd, + ) + .into_iter() + .map(|root| root.join("gradle").join("verification-metadata.xml")) + .find(|p| p.is_file()); (gate, metadata) }; // Script reads and stats: off the async workers. diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs index b262e4a5b..916e9534f 100644 --- a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs @@ -55,7 +55,8 @@ impl CargoCrawler { /// directory, never the shared registry cache. The directory need not /// exist. pub async fn directory_source(&self, cwd: &Path) -> Option { - match crates_io_source(cwd).await { + // A local project's question (a global crawl has no project). + match crates_io_source(crate::utils::repo_root::ReadScope::Workspace, cwd).await { CratesIoSource::Directory(dir) => Some(dir), CratesIoSource::Registry | CratesIoSource::LocalRegistry(_) @@ -272,7 +273,12 @@ impl CargoCrawler { /// each parent directory's `.cargo/config*`, then `$CARGO_HOME`'s). /// Blocking file reads; the hosted scan's cargo refusal reads it too. pub fn crates_io_source_for(cwd: &Path) -> CratesIoSource { - resolve_for(cwd, Self::cargo_home().as_deref()) + // Only a project (workspace) run asks: hosted mode is never global. + resolve_for( + crate::utils::repo_root::ReadScope::Workspace, + cwd, + Self::cargo_home().as_deref(), + ) } } @@ -328,7 +334,7 @@ async fn source_roots(options: &CrawlerOptions) -> (Vec, bool) { // registry cache, even when an unwired `vendor/` sits beside the // manifest (#338). A replacement directory that is missing yields // nothing: cargo can't build from the cache either. - match crates_io_source(&options.cwd).await { + match crates_io_source(options.read_scope(), &options.cwd).await { CratesIoSource::Directory(dir) => { if is_dir(&dir).await { (vec![dir], false) @@ -354,10 +360,14 @@ async fn source_roots(options: &CrawlerOptions) -> (Vec, bool) { /// reads `a/proj`'s ancestors, not `a/b`'s; a directory under the folded /// cwd is reported back under the cwd as given, which path-scoped /// commands strip as a prefix. -fn resolve_for(cwd: &Path, cargo_home: Option<&Path>) -> CratesIoSource { +fn resolve_for( + scope: crate::utils::repo_root::ReadScope, + cwd: &Path, + cargo_home: Option<&Path>, +) -> CratesIoSource { let given = std::path::absolute(cwd).unwrap_or_else(|_| cwd.to_path_buf()); let folded = fold_parent_dirs(&given); - let files = config_layer_paths(&folded, cargo_home, |p| p.is_file()); + let files = config_layer_paths(scope, &folded, cargo_home, |p| p.is_file()); let texts: Vec<(PathBuf, String)> = files .into_iter() .filter_map(|(file, base)| { @@ -398,10 +408,10 @@ fn fold_parent_dirs(path: &Path) -> PathBuf { /// Where cargo takes crates.io packages from for a build in `cwd` /// ([`resolve_for`]), off the runtime. -async fn crates_io_source(cwd: &Path) -> CratesIoSource { +async fn crates_io_source(scope: crate::utils::repo_root::ReadScope, cwd: &Path) -> CratesIoSource { let cwd = cwd.to_path_buf(); let cargo_home = CargoCrawler::cargo_home(); - run_blocking(move || resolve_for(&cwd, cargo_home.as_deref())).await + run_blocking(move || resolve_for(scope, &cwd, cargo_home.as_deref())).await } /// The registry crates `options`' build takes from the shared registry diff --git a/crates/socket-patch-core/src/crawlers/go_crawler.rs b/crates/socket-patch-core/src/crawlers/go_crawler.rs index 24333f73b..d199f33f8 100644 --- a/crates/socket-patch-core/src/crawlers/go_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/go_crawler.rs @@ -301,9 +301,11 @@ fn go_sum_scope(cwd: &Path) -> Option> { /// Whether the go command would build `cwd` in workspace mode (or refuses /// to build it over an invalid `GOWORK`), resolved as go resolves it. +/// Only a local (workspace) crawl asks ([`GoCrawler::crawl_all`] skips the +/// `go.sum` scope under `--global` / `--global-prefix`). fn workspace_in_effect(cwd: &Path) -> bool { !matches!( - crate::vendor::go_workspace::resolve(cwd), + crate::vendor::go_workspace::resolve_in(crate::utils::repo_root::ReadScope::Workspace, cwd), crate::vendor::go_workspace::GoWork::None ) } diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ad8b009c7..40109037c 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -900,7 +900,7 @@ fn has_settings(dir: &Path) -> bool { /// the cwd itself and, when it has no settings script, the nearest ancestor /// that has one (Gradle searches upwards for the settings of a /// subproject). Empty when the cwd has no Gradle marker. -pub fn build_roots(cwd: &Path) -> Vec { +pub fn build_roots(scope: crate::utils::repo_root::ReadScope, cwd: &Path) -> Vec { if !layout::has_build(cwd, BuildTool::Gradle) { return Vec::new(); } @@ -912,7 +912,7 @@ pub fn build_roots(cwd: &Path) -> Vec { .ancestors() .skip(1) .find(|d| has_settings(d)) - .filter(|up| crate::utils::repo_root::upward_readable(cwd, up)) + .filter(|up| crate::utils::repo_root::upward_readable(scope, cwd, up)) { roots.push(up.to_path_buf()); } @@ -939,7 +939,8 @@ pub fn script_graph(root: &Path, init: &InitScripts) -> ScriptGraph { /// wins over not declared. pub fn maven_local(cwd: &Path, home: Option<&GradleHome>) -> MavenLocal { let mut verdict = MavenLocal::NotDeclared; - for root in build_roots(cwd) { + // Per-project (a global crawl never asks for a build's m2 gate). + for root in build_roots(crate::utils::repo_root::ReadScope::Workspace, cwd) { let init = match home { Some(home) => init_scripts_for_build(home, &root), None => InitScripts { @@ -966,7 +967,8 @@ pub fn maven_local(cwd: &Path, home: Option<&GradleHome>) -> MavenLocal { /// discovery. pub fn locked_gavs(cwd: &Path) -> BTreeSet { let mut out = BTreeSet::new(); - for root in build_roots(cwd) { + // Per-project (a global crawl never asks for a build's m2 gate). + for root in build_roots(crate::utils::repo_root::ReadScope::Workspace, cwd) { let graph = script_graph(&root, &InitScripts::default()); let read = fs_text_read(&root); for rel in graph.lockfile_paths(&fs_list(&root)) { diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 23d2600c7..11f5c41a3 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1594,13 +1594,16 @@ fn relocated_pnpm_virtual_store_sync(nm: &Path) -> Option { /// one elsewhere (a home rc's folder) may be shared with other projects, /// and patching it would patch them too. `None` for the default location, /// which the walks find by name. The path keeps the project's spelling. -fn relocated_yarn_pnpm_store_sync(nm: &Path) -> Option { +fn relocated_yarn_pnpm_store_sync( + scope: crate::utils::repo_root::ReadScope, + nm: &Path, +) -> Option { if nm.file_name() != Some(OsStr::new("node_modules")) || !is_dir_sync(nm) { return None; } let (abs_project, project) = crate::utils::relpath::ancestors_keeping_spelling(nm.parent()?) .find(|(abs, _)| abs.join("yarn.lock").is_file())?; - let store = super::pkg_managers::active_yarn_pnpm_store(&abs_project)?; + let store = super::pkg_managers::active_yarn_pnpm_store(scope, &abs_project)?; let (Ok(real_store), Ok(real_project)) = ( std::fs::canonicalize(store), std::fs::canonicalize(&abs_project), @@ -2757,7 +2760,7 @@ impl NpmCrawler { let roots: Vec<(&PathBuf, Option)> = nm_paths .iter() .map(|nm| { - let store = relocated_yarn_pnpm_store_sync(nm) + let store = relocated_yarn_pnpm_store_sync(options.read_scope(), nm) .filter(|store| seen_stores.insert(store.clone())); (nm, store) }) @@ -2932,7 +2935,11 @@ impl NpmCrawler { let mut visited = HashSet::new(); // The root's relocated Yarn pnpm-linker store (#859), contributed // by the root's visit like a store found there by name. - let mut yarn_store = relocated_yarn_pnpm_store_sync(node_modules_path).map(|store| { + let mut yarn_store = relocated_yarn_pnpm_store_sync( + crate::utils::repo_root::ReadScope::Workspace, + node_modules_path, + ) + .map(|store| { NestedNodeModules::StoreEntries(StoreEntry::from_dirs( Self::list_npm_store_entries_sync(&store, false), )) @@ -3458,7 +3465,9 @@ impl NpmCrawler { // `node_modules` of its entries are not importers of their own. let stores: Vec = results .iter() - .filter_map(|nm| relocated_yarn_pnpm_store_sync(nm)) + .filter_map(|nm| { + relocated_yarn_pnpm_store_sync(crate::utils::repo_root::ReadScope::Workspace, nm) + }) .filter_map(|store| std::fs::canonicalize(store).ok()) .collect(); if !stores.is_empty() { @@ -4571,7 +4580,12 @@ async fn find_store_peer_variant_copies_reusing( let yarn_stores = run_walk(move || { projects .iter() - .filter_map(|nm| relocated_yarn_pnpm_store_sync(nm)) + .filter_map(|nm| { + relocated_yarn_pnpm_store_sync( + crate::utils::repo_root::ReadScope::Workspace, + nm, + ) + }) .filter(|store| { std::fs::canonicalize(store).is_ok_and(|real| canonical.starts_with(real)) }) @@ -8126,7 +8140,10 @@ mod tests { let member_nm = root.join("packages").join("a").join("node_modules"); std::fs::create_dir_all(&member_nm).unwrap(); assert_eq!( - relocated_yarn_pnpm_store_sync(&member_nm), + relocated_yarn_pnpm_store_sync( + crate::utils::repo_root::ReadScope::Workspace, + &member_nm + ), Some(store.clone()), "{folder}: member" ); @@ -8138,7 +8155,8 @@ mod tests { .unwrap(); std::fs::create_dir_all(root.join("../shared")).unwrap(); assert!( - relocated_yarn_pnpm_store_sync(&nm).is_none(), + relocated_yarn_pnpm_store_sync(crate::utils::repo_root::ReadScope::Workspace, &nm) + .is_none(), "{folder}: outside the project" ); @@ -8149,7 +8167,8 @@ mod tests { ) .unwrap(); assert!( - relocated_yarn_pnpm_store_sync(&nm).is_none(), + relocated_yarn_pnpm_store_sync(crate::utils::repo_root::ReadScope::Workspace, &nm) + .is_none(), "{folder}: node-modules linker" ); } diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index 31918c210..f2694e9ac 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -95,7 +95,10 @@ pub enum NpmPkgManager { /// linker (v1.3.2+ default) populates `node_modules/.bun/` which /// superficially resembles pnpm's `.pnpm/` content store. The /// lockfile filename disambiguates cleanly. -pub fn detect_npm_pkg_manager(project_root: &Path) -> NpmPkgManager { +pub fn detect_npm_pkg_manager( + scope: crate::utils::repo_root::ReadScope, + project_root: &Path, +) -> NpmPkgManager { // 1. yarn-berry PnP — highest priority because it determines // whether the npm crawler can find anything at all. Yarn 3+ // emits `.pnp.cjs`; Yarn 2.x emitted `.pnp.js` (renamed to @@ -104,7 +107,7 @@ pub fn detect_npm_pkg_manager(project_root: &Path) -> NpmPkgManager { // mean "packages aren't on disk" — refuse rather than silently // fall through to Unknown (a Yarn 2 PnP tree has no // `node_modules/`, so it would otherwise escape the refusal). - if live_pnp_marker(project_root, |m| project_root.join(m).is_file()).is_some() { + if live_pnp_marker(scope, project_root, |m| project_root.join(m).is_file()).is_some() { // Carve-out: pnpm has its OWN PnP mode (`node-linker=pnp` in // `.npmrc`) which also writes a `.pnp.cjs` loader at the root // — but unlike yarn-berry the packages are real directories in @@ -183,6 +186,9 @@ pub(crate) struct YarnEnv { /// `injectEnvironmentFiles` ([`Self::with_env_files`]): they win over /// `vars`, as yarn applies them over its environment (#1611). pub env_files: EnvFiles, + /// The read scope of the run (from its CLI mode): a workspace run + /// never reads an rc or env file above the repository root. + pub scope: crate::utils::repo_root::ReadScope, } /// The dotenv files a yarn run injects, read on the first `${NAME}` @@ -196,7 +202,7 @@ pub(crate) struct EnvFiles { } impl YarnEnv { - pub(crate) fn current() -> Self { + pub(crate) fn current(scope: crate::utils::repo_root::ReadScope) -> Self { Self { // Yarn takes the last `yarn_node_linker` spelling in the // environment (each one overwrites the setting) but the first @@ -208,14 +214,18 @@ impl YarnEnv { home: crate::utils::fs::home_dir(), vars: None, env_files: Default::default(), + scope, } } /// [`Self::current`] for a yarn run in `project_root`: with the /// variables of the project's `injectEnvironmentFiles` (#1611). - pub(crate) fn for_project(project_root: &Path) -> Self { + pub(crate) fn for_project( + scope: crate::utils::repo_root::ReadScope, + project_root: &Path, + ) -> Self { let env_list = yarn_env_key(std::env::vars_os(), "YARN_INJECT_ENVIRONMENT_FILES", true); - Self::current().with_env_files(project_root, env_list.as_deref()) + Self::current(scope).with_env_files(project_root, env_list.as_deref()) } /// This environment with the variables yarn 4 injects for a run in @@ -243,7 +253,7 @@ impl YarnEnv { // above its root is read: what it would inject is missing. // The home folder's rc file and the env files beside it are // machine-level, read as before. - let bound = crate::utils::repo_root::upward_bound(start); + let bound = crate::utils::repo_root::upward_bound(self.scope, start); let within = |path: &Path| { bound.as_ref().is_none_or(|b| b.contains(path)) || self @@ -288,6 +298,7 @@ impl YarnEnv { home: None, vars: Some(Default::default()), env_files: Default::default(), + scope: crate::utils::repo_root::ReadScope::Workspace, } } @@ -369,8 +380,11 @@ pub(crate) fn rc_node_linker(rc: &str, env: &YarnEnv) -> Option { /// the closest winning), else the home folder's rc file. The rc file name /// is `YARN_RC_FILENAME` when set. `None` when nothing sets it: yarn berry /// then uses its default linker, `pnp`. -pub fn yarn_node_linker(project_root: &Path) -> Option { - yarn_node_linker_in(project_root, &YarnEnv::for_project(project_root)) +pub fn yarn_node_linker( + scope: crate::utils::repo_root::ReadScope, + project_root: &Path, +) -> Option { + yarn_node_linker_in(project_root, &YarnEnv::for_project(scope, project_root)) } pub(crate) fn yarn_node_linker_in(project_root: &Path, env: &YarnEnv) -> Option { @@ -378,7 +392,7 @@ pub(crate) fn yarn_node_linker_in(project_root: &Path, env: &YarnEnv) -> Option< return Some(env.resolve_linker(linker)); } let start = std::path::absolute(project_root).unwrap_or_else(|_| project_root.to_path_buf()); - let bound = crate::utils::repo_root::upward_bound(&start); + let bound = crate::utils::repo_root::upward_bound(env.scope, &start); start .ancestors() .take_while(|dir| bound.as_ref().is_none_or(|b| b.contains(dir))) @@ -425,7 +439,7 @@ pub(crate) fn yarn_pnpm_store_folder_in(project_root: &Path, env: &YarnEnv) -> O }; // An rc file above the repository root is never read: the store it // may name is unknown, so none is admitted. - let bound = crate::utils::repo_root::upward_bound(&start); + let bound = crate::utils::repo_root::upward_bound(env.scope, &start); if bound .as_ref() .and_then(|b| b.marker_above(&[env.rc_filename.as_str()])) @@ -451,8 +465,11 @@ pub(crate) fn yarn_pnpm_store_folder_in(project_root: &Path, env: &YarnEnv) -> O /// The store of an active yarn berry pnpm-linker install of the project at /// `project_root` ([`yarn_pnpm_store_folder_in`]), or `None` when the /// linker yarn resolves there ([`yarn_node_linker_in`]) is not `pnpm`. -pub(crate) fn active_yarn_pnpm_store(project_root: &Path) -> Option { - let env = YarnEnv::for_project(project_root); +pub(crate) fn active_yarn_pnpm_store( + scope: crate::utils::repo_root::ReadScope, + project_root: &Path, +) -> Option { + let env = YarnEnv::for_project(scope, project_root); if yarn_node_linker_in(project_root, &env).as_deref() != Some("pnpm") { return None; } @@ -491,12 +508,18 @@ pub fn yarn_linker_is_pnp(linker: Option<&str>) -> bool { /// always counts ([`effective_yarn_linker`]). /// Every `PNP_MARKERS` decision goes through here or /// [`live_pnp_marker_with`]. -pub fn live_pnp_marker(project_root: &Path, exists: impl Fn(&str) -> bool) -> Option<&'static str> { +pub fn live_pnp_marker( + scope: crate::utils::repo_root::ReadScope, + project_root: &Path, + exists: impl Fn(&str) -> bool, +) -> Option<&'static str> { live_pnp_marker_with( || { let lock = crate::utils::fs::read_regular_to_string_sync(&project_root.join("yarn.lock")); - effective_yarn_linker(lock.ok().as_deref(), || yarn_node_linker(project_root)) + effective_yarn_linker(lock.ok().as_deref(), || { + yarn_node_linker(scope, project_root) + }) }, exists, ) @@ -571,7 +594,9 @@ impl YarnPnpLoader { /// `node-linker=pnp` tree is not). A loader file that cannot be read /// contributes nothing, so it never vouches for a patch. pub fn detect(project_root: &Path) -> Option { - if detect_npm_pkg_manager(project_root) != NpmPkgManager::YarnBerryPnP { + if detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, project_root) + != NpmPkgManager::YarnBerryPnP + { return None; } let texts = crate::constants::npm_family::PNP_MARKERS @@ -917,6 +942,7 @@ mod tests { home: None, vars: Some(Default::default()), env_files: Default::default(), + scope: crate::utils::repo_root::ReadScope::Workspace, } } @@ -1087,14 +1113,20 @@ mod tests { #[test] fn unknown_for_empty_dir() { let d = tempfile::tempdir().unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Unknown + ); } #[test] fn npm_for_bare_node_modules() { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Npm); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Npm + ); } #[test] @@ -1102,14 +1134,20 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); std::fs::write(d.path().join("node_modules/.modules.yaml"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Pnpm); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Pnpm + ); } #[test] fn pnpm_via_pnpm_dir() { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Pnpm); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Pnpm + ); } #[test] @@ -1117,7 +1155,10 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); std::fs::write(d.path().join("yarn.lock"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::YarnClassic); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::YarnClassic + ); } /// yarn.lock without an installed node_modules is "fresh @@ -1126,7 +1167,10 @@ mod tests { fn yarn_classic_requires_installed_node_modules() { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("yarn.lock"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Unknown + ); } #[test] @@ -1134,7 +1178,7 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join(".pnp.cjs"), "").unwrap(); assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::YarnBerryPnP ); } @@ -1147,7 +1191,7 @@ mod tests { std::fs::write(d.path().join(".pnp.cjs"), "").unwrap(); std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap(); assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::YarnBerryPnP ); } @@ -1157,7 +1201,10 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); std::fs::write(d.path().join("bun.lock"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Bun + ); } #[test] @@ -1165,7 +1212,10 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); std::fs::write(d.path().join("bun.lockb"), b"").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Bun + ); } /// `bun.lock` without an installed `node_modules/` is a fresh @@ -1174,7 +1224,10 @@ mod tests { fn bun_requires_installed_node_modules() { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("bun.lock"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Unknown + ); } /// Bun's isolated linker (v1.3.2+ default) creates @@ -1186,7 +1239,10 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap(); std::fs::write(d.path().join("bun.lock"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Bun + ); } /// yarn-berry beats bun (PnP is a structural override of @@ -1198,7 +1254,7 @@ mod tests { std::fs::write(d.path().join("bun.lock"), "").unwrap(); std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::YarnBerryPnP ); } @@ -1213,7 +1269,7 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join(".pnp.loader.mjs"), "").unwrap(); assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::YarnBerryPnP ); } @@ -1228,7 +1284,7 @@ mod tests { std::fs::write(d.path().join(".pnp.cjs"), "").unwrap(); std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::YarnBerryPnP ); } @@ -1242,7 +1298,10 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap(); std::fs::write(d.path().join("yarn.lock"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Pnpm); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Pnpm + ); } /// bun is checked before yarn-classic too: a `bun.lock` plus a @@ -1253,7 +1312,10 @@ mod tests { std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); std::fs::write(d.path().join("bun.lock"), "").unwrap(); std::fs::write(d.path().join("yarn.lock"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Bun + ); } /// Robustness: a malformed layout where `node_modules` is a regular @@ -1266,7 +1328,10 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("node_modules"), "not a dir").unwrap(); std::fs::write(d.path().join("bun.lock"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Unknown + ); } /// The bun-before-pnpm precedence must hold for the *binary* legacy @@ -1279,7 +1344,10 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap(); std::fs::write(d.path().join("bun.lockb"), b"").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Bun + ); } /// yarn-berry PnP outranks bun via the ESM loader marker as well as @@ -1294,7 +1362,7 @@ mod tests { std::fs::write(d.path().join("bun.lock"), "").unwrap(); std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::YarnBerryPnP ); } @@ -1310,7 +1378,7 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join(".pnp.js"), "").unwrap(); assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::YarnBerryPnP ); } @@ -1326,7 +1394,7 @@ mod tests { std::fs::write(d.path().join("bun.lock"), "").unwrap(); std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::YarnBerryPnP ); } @@ -1357,7 +1425,10 @@ mod tests { ) .unwrap(); std::fs::write(d.path().join("node_modules/.modules.yaml"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Pnpm); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Pnpm + ); } /// The pnpm-PnP carve-out must stay fail-closed: a project carrying @@ -1372,7 +1443,7 @@ mod tests { std::fs::write(d.path().join("yarn.lock"), "").unwrap(); std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap(); assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::YarnBerryPnP ); } @@ -1387,7 +1458,7 @@ mod tests { std::fs::write(d.path().join(".pnp.cjs"), "").unwrap(); std::fs::write(d.path().join("pnpm-lock.yaml"), "").unwrap(); assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::YarnBerryPnP ); } @@ -1399,7 +1470,10 @@ mod tests { fn pnp_js_as_dir_does_not_trigger_pnp() { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join(".pnp.js")).unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Unknown + ); } /// Layout assumption: detection is *install*-based, not @@ -1411,7 +1485,10 @@ mod tests { fn npm_lockfile_without_node_modules_is_unknown() { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("package-lock.json"), "{}").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Unknown + ); } /// Robustness: a malformed pnpm marker where `.modules.yaml` is a @@ -1422,7 +1499,10 @@ mod tests { fn modules_yaml_as_dir_does_not_trigger_pnpm() { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules/.modules.yaml")).unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Npm); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Npm + ); } /// Layout assumption: `node_modules` reached through a symlink to a @@ -1438,14 +1518,20 @@ mod tests { std::fs::create_dir_all(&real).unwrap(); std::os::unix::fs::symlink(&real, d.path().join("node_modules")).unwrap(); std::fs::write(d.path().join("yarn.lock"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::YarnClassic); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::YarnClassic + ); } #[test] fn vlt_via_store_dir() { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules/.vlt")).unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Vlt); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Vlt + ); } #[test] @@ -1453,7 +1539,10 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); std::fs::write(d.path().join("node_modules/.vlt-lock.json"), "{}").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Vlt); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Vlt + ); } /// A committed `vlt-lock.json` is not install state: the tree beside it @@ -1462,13 +1551,22 @@ mod tests { fn vlt_lockfile_without_vlt_install_state_falls_through() { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("vlt-lock.json"), "{}").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Unknown + ); std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Npm); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Npm + ); std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Pnpm); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Pnpm + ); } #[test] @@ -1478,7 +1576,7 @@ mod tests { std::fs::create_dir_all(d.path().join("node_modules/.vlt")).unwrap(); std::fs::write(d.path().join("node_modules/.vlt-lock.json"), "{}").unwrap(); assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::YarnBerryPnP ); } @@ -1500,7 +1598,7 @@ mod tests { std::fs::create_dir_all(d.path().join(marker)).unwrap(); } assert_eq!( - detect_npm_pkg_manager(d.path()), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), NpmPkgManager::Vlt, "{marker}" ); @@ -1513,7 +1611,10 @@ mod tests { std::fs::write(d.path().join("node_modules"), "not a dir").unwrap(); std::fs::write(d.path().join("vlt-lock.json"), "{}").unwrap(); std::fs::write(d.path().join("vlt.json"), "{}").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Unknown + ); } /// Robustness: `.vlt` as a regular file, and the hidden lock as a @@ -1523,6 +1624,9 @@ mod tests { let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("node_modules/.vlt-lock.json")).unwrap(); std::fs::write(d.path().join("node_modules/.vlt"), "not a dir").unwrap(); - assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Npm); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, d.path()), + NpmPkgManager::Npm + ); } } diff --git a/crates/socket-patch-core/src/crawlers/pnpm_layout.rs b/crates/socket-patch-core/src/crawlers/pnpm_layout.rs index fc3957733..4b30b7a28 100644 --- a/crates/socket-patch-core/src/crawlers/pnpm_layout.rs +++ b/crates/socket-patch-core/src/crawlers/pnpm_layout.rs @@ -237,7 +237,7 @@ mod tests { "snapshot: {layout:?}" ); assert_eq!( - detect_npm_pkg_manager(root), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, root), NpmPkgManager::Pnpm, "{layout:?}" ); @@ -250,7 +250,7 @@ mod tests { // Without the loader it is a plain pnpm install in deps/. std::fs::remove_file(root.join(".pnp.cjs")).unwrap(); assert_eq!( - detect_npm_pkg_manager(root), + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, root), NpmPkgManager::Pnpm, "{layout:?}" ); @@ -269,12 +269,21 @@ mod tests { (".npmrc", "modules-dir=deps\n", "deps/x/package.json"), ); assert!(!installed_store(root)); - assert_eq!(detect_npm_pkg_manager(root), NpmPkgManager::YarnBerryPnP); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, root), + NpmPkgManager::YarnBerryPnP + ); std::fs::create_dir_all(root.join("deps/.pnpm")).unwrap(); - assert_eq!(detect_npm_pkg_manager(root), NpmPkgManager::Pnpm); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, root), + NpmPkgManager::Pnpm + ); std::fs::write(root.join("yarn.lock"), "").unwrap(); - assert_eq!(detect_npm_pkg_manager(root), NpmPkgManager::YarnBerryPnP); + assert_eq!( + detect_npm_pkg_manager(crate::utils::repo_root::ReadScope::Workspace, root), + NpmPkgManager::YarnBerryPnP + ); } /// A modules dir both configured and holding an install record is diff --git a/crates/socket-patch-core/src/crawlers/types.rs b/crates/socket-patch-core/src/crawlers/types.rs index 9102f7072..47af48ec3 100644 --- a/crates/socket-patch-core/src/crawlers/types.rs +++ b/crates/socket-patch-core/src/crawlers/types.rs @@ -124,6 +124,16 @@ pub struct CrawlerOptions { pub global_prefix: Option, } +impl CrawlerOptions { + /// The read scope of this crawl, from its CLI mode: a `--global` / + /// `--global-prefix` crawl reads anywhere on the system + /// ([`crate::utils::repo_root::ReadScope::Global`]), a project crawl + /// stays inside the repository root. + pub fn read_scope(&self) -> crate::utils::repo_root::ReadScope { + crate::utils::repo_root::ReadScope::of_global(self.global || self.global_prefix.is_some()) + } +} + impl Default for CrawlerOptions { fn default() -> Self { Self { diff --git a/crates/socket-patch-core/src/formats/cargo/source_replacement.rs b/crates/socket-patch-core/src/formats/cargo/source_replacement.rs index 8bb00cb78..4aeab1a93 100644 --- a/crates/socket-patch-core/src/formats/cargo/source_replacement.rs +++ b/crates/socket-patch-core/src/formats/cargo/source_replacement.rs @@ -112,6 +112,7 @@ pub fn crates_io_source(layers: &[ConfigLayer<'_>]) -> CratesIoSource { /// exist) or `.cargo/config.toml`, deepest first, then `$CARGO_HOME`'s when /// no ancestor already named it. `exists` is the caller's file probe. pub fn config_layer_paths( + scope: crate::utils::repo_root::ReadScope, cwd: &Path, cargo_home: Option<&Path>, exists: impl Fn(&Path) -> bool, @@ -126,7 +127,7 @@ pub fn config_layer_paths( let mut dirs_seen: Vec = Vec::new(); // Inside a git checkout a config above its root is never read // (writers refuse on [`crate::vendor::cargo_config::config_above_repo`]). - let bound = crate::utils::repo_root::upward_bound(cwd); + let bound = crate::utils::repo_root::upward_bound(scope, cwd); for ancestor in cwd.ancestors() { if bound.as_ref().is_some_and(|b| !b.contains(ancestor)) { break; @@ -284,6 +285,7 @@ mod tests { ]; let exists = |p: &Path| present.iter().any(|q| Path::new(q) == p); let got = config_layer_paths( + crate::utils::repo_root::ReadScope::Workspace, Path::new("/ws/m"), Some(Path::new("/home/u/.cargo")), exists, @@ -304,6 +306,7 @@ mod tests { ); // $CARGO_HOME inside the ancestor chain is read once. let got = config_layer_paths( + crate::utils::repo_root::ReadScope::Workspace, Path::new("/home/u/proj"), Some(Path::new("/home/u/.cargo")), exists, diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 9a92740cb..a346e5083 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -2791,9 +2791,13 @@ fn pnpm_trust( // file but these two, so nothing is written here. // One above the repository root is never read (the pre-check // refuses it; this keeps the read bounded on its own). - let readable = view - .disk_root() - .is_some_and(|root| crate::utils::repo_root::upward_readable(root, &root_file)); + let readable = view.disk_root().is_some_and(|root| { + crate::utils::repo_root::upward_readable( + crate::utils::repo_root::ReadScope::Workspace, + root, + &root_file, + ) + }); let root_file = root_file.display().to_string(); if let Some(found) = pnpm_trust_env_override(view, files, options.pnpm_env) { pnpm_trust_env_set_detail(&server, &found.source) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index c4bf6d38e..72f8967bd 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -148,7 +148,11 @@ fn pnpm_settings_elsewhere(root: &Path) -> Option { } let file = governing_workspace_file(root)?; // One above the repository root is never read: it refuses. - let readable = crate::utils::repo_root::upward_readable(root, &file); + let readable = crate::utils::repo_root::upward_readable( + crate::utils::repo_root::ReadScope::Workspace, + root, + &file, + ); if let Some(Ok(Some(text))) = readable.then(|| read_workspace_for_trust(&file)) { if matches!( plan_workspace_trust(Some(&text)), @@ -306,7 +310,7 @@ async fn pnpm_lock_elsewhere(root: &Path) -> Option { .unwrap_or_else(|_| root.to_path_buf()); let mut workspace = None; - let bound = upward_bound(&canonical); + let bound = upward_bound(crate::utils::repo_root::ReadScope::Workspace, &canonical); for ancestor in canonical.ancestors() { if bound.as_ref().is_some_and(|b| !b.contains(ancestor)) { // Above the repository: [`npm_lock_elsewhere`] stats it. @@ -583,7 +587,7 @@ fn stray_lock_marker_above( own: &[&'static str], markers: &[&str], ) -> Option { - let bound = upward_bound(root)?; + let bound = upward_bound(crate::utils::repo_root::ReadScope::Workspace, root)?; let marker = bound.marker_above(markers)?; let dir = marker.parent()?.to_path_buf(); Some(MemberStrayLock { @@ -605,7 +609,7 @@ fn stray_lock_marker_above( /// (stat only): pnpm, npm, yarn, Bun or vlt may install the project from /// there, so hosted and vendored refuse rather than write a member. fn lock_marker_above(root: &Path) -> Option { - let bound = upward_bound(root)?; + let bound = upward_bound(crate::utils::repo_root::ReadScope::Workspace, root)?; let marker = bound.marker_above(&WORKSPACE_MARKERS)?; let dir = marker.parent()?.to_path_buf(); Some(LockElsewhere { @@ -852,7 +856,7 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat .await .unwrap_or_else(|_| root.to_path_buf()); let mut member: &Path = &canonical; - let bound = upward_bound(&canonical); + let bound = upward_bound(crate::utils::repo_root::ReadScope::Workspace, &canonical); for ancestor in canonical.ancestors().skip(1) { if bound.as_ref().is_some_and(|b| !b.contains(ancestor)) { break; @@ -1116,7 +1120,7 @@ async fn vlt_workspace_root(root: &Path) -> Option<(PathBuf, VltWorkspaceSource) let canonical = tokio::fs::canonicalize(root) .await .unwrap_or_else(|_| root.to_path_buf()); - let bound = upward_bound(&canonical); + let bound = upward_bound(crate::utils::repo_root::ReadScope::Workspace, &canonical); for ancestor in canonical.ancestors().skip(1) { if bound.as_ref().is_some_and(|b| !b.contains(ancestor)) { break; diff --git a/crates/socket-patch-core/src/patch/redirect/staged.rs b/crates/socket-patch-core/src/patch/redirect/staged.rs index c041e7d38..67a74773e 100644 --- a/crates/socket-patch-core/src/patch/redirect/staged.rs +++ b/crates/socket-patch-core/src/patch/redirect/staged.rs @@ -31,15 +31,22 @@ pub(super) async fn read_rel(project_root: &Path, rel: &str) -> Result { return Err(format!("read {rel}: {}", bound.refusal(&path))) } _ => path, } } else { - crate::utils::repo_root::bounded_project_path(project_root, rel) - .map_err(|refused| format!("read {rel}: {refused}"))? + crate::utils::repo_root::bounded_project_path( + crate::utils::repo_root::ReadScope::Workspace, + project_root, + rel, + ) + .map_err(|refused| format!("read {rel}: {refused}"))? }; match crate::utils::fs::read_regular_to_string(&path).await { Ok(content) => Ok(Some(content)), diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 47f141212..534858115 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -639,7 +639,10 @@ impl BerryRegistrySettings { let root = tokio::fs::canonicalize(&root).await.unwrap_or(root); // Inside a git checkout an rc file above its root is never read; // one there may name the registry, so every lookup refuses. - let bound = crate::utils::repo_root::upward_bound(&root); + let bound = crate::utils::repo_root::upward_bound( + crate::utils::repo_root::ReadScope::Workspace, + &root, + ); let unread = bound.as_ref().and_then(|bound| { let marker = bound.marker_above(&[rc_name.as_str()])?; Some(bound.marker_refusal(&marker)) @@ -669,7 +672,10 @@ impl BerryRegistrySettings { BerryRegistrySettings { rcs, env_registry: std::env::var("YARN_NPM_REGISTRY_SERVER").ok(), - env: crate::crawlers::pkg_managers::YarnEnv::for_project(&root), + env: crate::crawlers::pkg_managers::YarnEnv::for_project( + crate::utils::repo_root::ReadScope::Workspace, + &root, + ), unread, } } @@ -1935,7 +1941,12 @@ pub(crate) async fn restore_pnpm_locks( let prefix = pnpm_settings_prefix(view, rel).await; if let Some(bound) = std::path::Path::new(&prefix) .is_absolute() - .then(|| crate::utils::repo_root::upward_bound(view.root())) + .then(|| { + crate::utils::repo_root::upward_bound( + crate::utils::repo_root::ReadScope::Workspace, + view.root(), + ) + }) .flatten() .filter(|bound| !bound.contains(std::path::Path::new(&prefix))) { diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs index 8256a68cc..4cb6df9e3 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -275,7 +275,10 @@ pub fn resolve_outer_yarn_mirror( /// root (stat only, never read) may set `yarn-offline-mirror`: fail closed /// as a mirror, so the hosted rewriter refuses with the file named. fn unread_rc_above(project: &Path) -> Option { - let bound = crate::utils::repo_root::upward_bound(project)?; + let bound = crate::utils::repo_root::upward_bound( + crate::utils::repo_root::ReadScope::Workspace, + project, + )?; let marker = bound.marker_above(&[".yarnrc", ".npmrc"])?; Some(MirrorSetting { value: MirrorValue::Path("".to_string()), @@ -347,7 +350,10 @@ impl RcLayout { } // Inside a git checkout, never above its root (see // [`unread_rc_above`]). - let bound = crate::utils::repo_root::upward_bound(project); + let bound = crate::utils::repo_root::upward_bound( + crate::utils::repo_root::ReadScope::Workspace, + project, + ); out.extend( project .ancestors() @@ -643,7 +649,8 @@ pub fn yarn_classic_workspace_members(root: &Path) -> Vec { let mut budget = MEMBER_SEARCH_DIR_BUDGET; // Inside a git checkout a directory link out of it is never followed: // its rc files are outside the repository. - let bound = crate::utils::repo_root::upward_bound(root); + let bound = + crate::utils::repo_root::upward_bound(crate::utils::repo_root::ReadScope::Workspace, root); let mut stack = vec![(root.to_path_buf(), Vec::::new())]; while let Some((dir, rel)) = stack.pop() { if !rel.is_empty() @@ -719,7 +726,10 @@ pub fn resolve_outer_yarn_mirror_for_process(project: &Path) -> OuterYarnMirror /// `.yarnrc` / `.npmrc`, which are never read here, so fail closed as a /// mirror and the hosted rewriter refuses with the member named. fn linked_member_outside(project: &Path, members: &[PathBuf]) -> Option { - let bound = crate::utils::repo_root::upward_bound(project)?; + let bound = crate::utils::repo_root::upward_bound( + crate::utils::repo_root::ReadScope::Workspace, + project, + )?; let member = members.iter().find(|member| !bound.contains(member))?; Some(MirrorSetting { value: MirrorValue::Path("".to_string()), @@ -727,7 +737,7 @@ fn linked_member_outside(project: &Path, members: &[PathBuf]) -> Option bool { else { return false; }; - let Some(store) = crate::crawlers::pkg_managers::active_yarn_pnpm_store(project) else { + let Some(store) = crate::crawlers::pkg_managers::active_yarn_pnpm_store( + crate::utils::repo_root::ReadScope::Workspace, + project, + ) else { return false; }; let (Ok(store), Ok(project)) = (std::fs::canonicalize(store), std::fs::canonicalize(project)) diff --git a/crates/socket-patch-core/src/utils/cargo_build_cache.rs b/crates/socket-patch-core/src/utils/cargo_build_cache.rs index 1fbe3d79d..eaa11dc6b 100644 --- a/crates/socket-patch-core/src/utils/cargo_build_cache.rs +++ b/crates/socket-patch-core/src/utils/cargo_build_cache.rs @@ -44,8 +44,9 @@ pub struct Invalidation { } /// The project's build directories, reading the process environment. -pub fn build_dirs(cwd: &Path) -> BuildDirs { +pub fn build_dirs(scope: crate::utils::repo_root::ReadScope, cwd: &Path) -> BuildDirs { build_dirs_with( + scope, cwd, &|k| std::env::var(k).ok(), super::cargo_home::cargo_home().as_deref(), @@ -54,6 +55,7 @@ pub fn build_dirs(cwd: &Path) -> BuildDirs { /// [`build_dirs`] with an injected environment and `CARGO_HOME`. pub fn build_dirs_with( + scope: crate::utils::repo_root::ReadScope, cwd: &Path, env: &dyn Fn(&str) -> Option, cargo_home: Option<&Path>, @@ -65,8 +67,8 @@ pub fn build_dirs_with( &std::path::absolute(cwd).unwrap_or_else(|_| cwd.to_path_buf()), ); let env = |k: &str| env(k).filter(|v| !v.trim().is_empty()); - let ws_root = workspace_root(cwd); - let config = ConfigDirs::read(cwd, cargo_home); + let ws_root = workspace_root(scope, cwd); + let config = ConfigDirs::read(scope, cwd, cargo_home); let mut candidates: Vec = Vec::new(); let mut unresolved = Vec::new(); @@ -126,8 +128,8 @@ fn expand_build_dir(raw: &str, ws_root: &Path, cargo_home: Option<&Path>) -> Opt /// /// Inside a git checkout neither walk leaves the repository root: a /// workspace above it is never read nor its build cache touched. -fn workspace_root(cwd: &Path) -> PathBuf { - let bound = crate::utils::repo_root::upward_bound(cwd); +fn workspace_root(scope: crate::utils::repo_root::ReadScope, cwd: &Path) -> PathBuf { + let bound = crate::utils::repo_root::upward_bound(scope, cwd); let within = |dir: &&Path| bound.as_ref().is_none_or(|b| b.contains(dir)); for dir in cwd.ancestors().take_while(within) { if dir.join("Cargo.lock").is_file() { @@ -160,9 +162,13 @@ struct ConfigDirs { } impl ConfigDirs { - fn read(cwd: &Path, cargo_home: Option<&Path>) -> Self { + fn read( + scope: crate::utils::repo_root::ReadScope, + cwd: &Path, + cargo_home: Option<&Path>, + ) -> Self { let mut out = ConfigDirs::default(); - for (file, base) in config_layer_paths(cwd, cargo_home, |p| { + for (file, base) in config_layer_paths(scope, cwd, cargo_home, |p| { crate::utils::group_commit::exists(p).unwrap_or_else(|| p.is_file()) }) { let text = crate::utils::fs::read_regular_to_string_sync(&file).ok(); @@ -314,6 +320,7 @@ pub const STALE_WARNING: &str = "cargo_build_cache_stale"; /// `(code, detail)` for what could not be invalidated, each naming the /// `cargo clean -p` remedy. pub fn invalidate_project<'a>( + scope: crate::utils::repo_root::ReadScope, cwd: &Path, purls: impl IntoIterator, ) -> Vec<(String, String)> { @@ -327,7 +334,7 @@ pub fn invalidate_project<'a>( if names.is_empty() { return Vec::new(); } - let dirs = build_dirs(cwd); + let dirs = build_dirs(scope, cwd); let inv = invalidate(&dirs.dirs, &names); let mut warnings = Vec::new(); for (path, err) in &inv.failed { @@ -378,9 +385,15 @@ mod tests { std::fs::write(outer.join("Cargo.lock"), "").unwrap(); std::fs::write(outer.join("Cargo.toml"), "[workspace]\n").unwrap(); std::fs::create_dir_all(outer.join("repo/.git")).unwrap(); - assert_eq!(workspace_root(&app), app); + assert_eq!( + workspace_root(crate::utils::repo_root::ReadScope::Workspace, &app), + app + ); std::fs::remove_dir(outer.join("repo/.git")).unwrap(); - assert_eq!(workspace_root(&app), outer); + assert_eq!( + workspace_root(crate::utils::repo_root::ReadScope::Workspace, &app), + outer + ); } fn mkfp(root: &Path, rel: &str) -> PathBuf { @@ -463,7 +476,12 @@ mod tests { "[build]\ntarget-dir = \"modern\"\n", ) .unwrap(); - let d = build_dirs_with(&root, &|_| None, None); + let d = build_dirs_with( + crate::utils::repo_root::ReadScope::Workspace, + &root, + &|_| None, + None, + ); assert_eq!( d.dirs, vec![std::fs::canonicalize(root.join("legacy")).unwrap()] @@ -498,9 +516,19 @@ mod tests { .unwrap(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&member).unwrap(); - let from_member = build_dirs_with(Path::new("."), &|_| None, None); + let from_member = build_dirs_with( + crate::utils::repo_root::ReadScope::Workspace, + Path::new("."), + &|_| None, + None, + ); std::env::set_current_dir(&left).unwrap(); - let from_sibling = build_dirs_with(Path::new("../ws/member"), &|_| None, None); + let from_sibling = build_dirs_with( + crate::utils::repo_root::ReadScope::Workspace, + Path::new("../ws/member"), + &|_| None, + None, + ); std::env::set_current_dir(prev).unwrap(); let out = vec![std::fs::canonicalize(root.join("out")).unwrap()]; assert_eq!(from_member.dirs, out); @@ -516,7 +544,12 @@ mod tests { std::fs::create_dir_all(root.join("target")).unwrap(); std::fs::write(root.join("Cargo.lock"), "").unwrap(); let none = |_: &str| None; - let d = build_dirs_with(&member, &none, None); + let d = build_dirs_with( + crate::utils::repo_root::ReadScope::Workspace, + &member, + &none, + None, + ); let canon = |p: PathBuf| std::fs::canonicalize(p).unwrap(); assert_eq!( d.dirs, @@ -532,7 +565,12 @@ mod tests { ) .unwrap(); std::fs::create_dir_all(root.join("bd")).unwrap(); - let d = build_dirs_with(&member, &none, None); + let d = build_dirs_with( + crate::utils::repo_root::ReadScope::Workspace, + &member, + &none, + None, + ); assert_eq!(d.dirs[0], canon(root.join("out"))); assert_eq!(d.dirs[1], canon(root.join("bd"))); @@ -541,7 +579,12 @@ mod tests { std::fs::create_dir_all(&ext).unwrap(); let ext_s = ext.to_string_lossy().to_string(); let env = move |k: &str| (k == "CARGO_TARGET_DIR").then(|| ext_s.clone()); - let d = build_dirs_with(&member, &env, None); + let d = build_dirs_with( + crate::utils::repo_root::ReadScope::Workspace, + &member, + &env, + None, + ); assert_eq!(d.dirs[0], canon(ext.clone())); // An unresolvable template is reported. @@ -550,7 +593,12 @@ mod tests { "[build]\nbuild-dir = \"{cargo-cache-home}/b/{workspace-path-hash}\"\n", ) .unwrap(); - let d = build_dirs_with(&member, &none, Some(t.path())); + let d = build_dirs_with( + crate::utils::repo_root::ReadScope::Workspace, + &member, + &none, + Some(t.path()), + ); assert_eq!( d.unresolved, vec!["{cargo-cache-home}/b/{workspace-path-hash}"] diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 2cfde979d..466ed550a 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -798,9 +798,13 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option // Above the repository root nothing is read, so the setting is // unknowable: fail closed, as if it were on, so writers refuse // rather than pin a lock pnpm may not read. - let readable = view - .disk_root() - .is_some_and(|root| crate::utils::repo_root::upward_readable(root, &file)); + let readable = view.disk_root().is_some_and(|root| { + crate::utils::repo_root::upward_readable( + crate::utils::repo_root::ReadScope::Workspace, + root, + &file, + ) + }); setting = if readable { let workspace = read_regular_to_string(&file).await.ok(); let npmrc = read_regular_to_string(&file.with_file_name(".npmrc")) @@ -1005,7 +1009,11 @@ pub fn governing_workspace_file(project_root: &Path) -> Option { // Above the repository root the file is never read: fail closed, as a // member, like an unreadable one (callers do not read it either, see // [`crate::utils::repo_root::upward_readable`]). - if !crate::utils::repo_root::upward_readable(&canonical, &file) { + if !crate::utils::repo_root::upward_readable( + crate::utils::repo_root::ReadScope::Workspace, + &canonical, + &file, + ) { return Some(without_verbatim_prefix(file)); } let rel: Vec = canonical diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index 1917d8ce9..a9e225394 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -484,7 +484,8 @@ fn pyproject_has_uv_settings(pyproject: &str) -> bool { /// ([`crate::utils::repo_root::upward_bound`]); [`uv_config_above_repo`] /// then says whether a settings file there may apply. pub fn find_ancestor_uv_config(root: &Path) -> Option { - let bound = crate::utils::repo_root::upward_bound(root); + let bound = + crate::utils::repo_root::upward_bound(crate::utils::repo_root::ReadScope::Workspace, root); for (rel, path) in ancestor_uv_config_files(root) { if bound.as_ref().is_some_and(|b| !b.contains(&path)) { return None; @@ -509,7 +510,8 @@ pub fn find_ancestor_uv_config(root: &Path) -> Option { /// read), which uv may apply. Writers refuse on it rather than wire what /// those settings may reject. pub fn uv_config_above_repo(root: &Path) -> Option { - let bound = crate::utils::repo_root::upward_bound(root)?; + let bound = + crate::utils::repo_root::upward_bound(crate::utils::repo_root::ReadScope::Workspace, root)?; if find_ancestor_uv_config(root).is_some() { return None; } diff --git a/crates/socket-patch-core/src/utils/repo_root.rs b/crates/socket-patch-core/src/utils/repo_root.rs index 64bc5c5d2..1df420e3a 100644 --- a/crates/socket-patch-core/src/utils/repo_root.rs +++ b/crates/socket-patch-core/src/utils/repo_root.rs @@ -124,9 +124,35 @@ pub fn find_git_repo(start: &Path) -> Option { repo_at_end_of(search_dirs(start)) } -/// The directory no read of a PROJECT file may leave: the git checkout -/// enclosing `project_root` ([`find_git_repo`]), else `project_root` -/// itself. Canonical when `project_root` exists. +/// Which project files a run may read, chosen ONCE from the CLI mode and +/// passed to every bounded read (never inferred from the filesystem). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadScope { + /// A project run (the default `--cwd` project): project files are read + /// only inside the repository root ([`ReadBoundary::Workspace`]). + Workspace, + /// A global run (`--global` / `--global-prefix`): global installs, + /// caches and their settings are read wherever they live on the system + /// ([`ReadBoundary::Unbounded`]). + Global, +} + +impl ReadScope { + /// The scope of a run whose global flag (`--global` or + /// `--global-prefix`) is `global`. + pub fn of_global(global: bool) -> Self { + if global { + ReadScope::Global + } else { + ReadScope::Workspace + } + } +} + +/// The directory no read of a PROJECT file may leave in a +/// [`ReadScope::Workspace`] run: the git checkout enclosing the project +/// ([`find_git_repo`]), else the project itself. A +/// [`ReadScope::Global`] run reads anywhere ([`ReadBoundary::Unbounded`]). /// /// Every reader and writer that follows a project file's reference /// (an ancestor `go.work`, a `-r ../x.txt` include, a parent @@ -137,29 +163,43 @@ pub fn find_git_repo(start: &Path) -> Option { /// the `GOENV` file, `$CARGO_HOME`) is not a project file and is not /// bounded by this. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReadBoundary { - root: PathBuf, +pub enum ReadBoundary { + /// Reads stay at or below `root` (canonical when it exists). + Workspace { root: PathBuf }, + /// A global run: every path is readable. + Unbounded, } impl ReadBoundary { - /// The boundary for a project at `project_root`. - pub fn of(project_root: &Path) -> Self { - let project = canonical_or_lexical(project_root); - let root = find_git_repo(&project).map_or(project, |repo| repo.root); - ReadBoundary { root } + /// The boundary of a `scope` run for the project at `project_root`. + pub fn of(scope: ReadScope, project_root: &Path) -> Self { + match scope { + ReadScope::Global => ReadBoundary::Unbounded, + ReadScope::Workspace => { + let project = canonical_or_lexical(project_root); + let root = find_git_repo(&project).map_or(project, |repo| repo.root); + ReadBoundary::Workspace { root } + } + } } - /// The boundary directory itself. - pub fn root(&self) -> &Path { - &self.root + /// The boundary directory; `None` when unbounded. + pub fn root(&self) -> Option<&Path> { + match self { + ReadBoundary::Workspace { root } => Some(root), + ReadBoundary::Unbounded => None, + } } - /// Whether `path` lies at or below the boundary. `path` is made - /// absolute against the working directory, `..` folded, and its - /// deepest existing level canonicalized, so neither a `..` nor a - /// symlinked level inside the tree reaches outside it. + /// Whether `path` lies at or below the boundary (always, unbounded). + /// `path` is made absolute against the working directory, `..` + /// folded, and its deepest existing level canonicalized, so neither a + /// `..` nor a symlinked level inside the tree reaches outside it. pub fn contains(&self, path: &Path) -> bool { - canonical_or_lexical(path).starts_with(&self.root) + match self { + ReadBoundary::Workspace { root } => canonical_or_lexical(path).starts_with(root), + ReadBoundary::Unbounded => true, + } } /// The user-facing reason a read of `path` was refused. @@ -168,19 +208,21 @@ impl ReadBoundary { "{} is outside the repository root {}; socket-patch does not read project \ files outside it", path.display(), - self.root.display() + self.shown_root() ) } /// The nearest file named one of `names` in a directory ABOVE the - /// boundary, nearest directory first and `names` in order. Stat only: - /// the file is never opened. Like git's own lookup it never reaches - /// the home directory or anything above it, so a stray `~/package.json` - /// governs nothing. + /// boundary, nearest directory first and `names` in order (`None` + /// unbounded: nothing is above). Stat only: the file is never opened. + /// Like git's own lookup it never reaches the home directory or + /// anything above it, so a stray `~/package.json` governs nothing. pub fn marker_above(&self, names: &[&str]) -> Option { + let ReadBoundary::Workspace { root } = self else { + return None; + }; let home = home(); - self.root - .ancestors() + root.ancestors() .skip(1) .take_while(|dir| home.as_deref().is_none_or(|home| !home.starts_with(dir))) .flat_map(|dir| names.iter().map(move |name| dir.join(name))) @@ -195,41 +237,55 @@ impl ReadBoundary { project files outside the repository, so it cannot tell whether that file \ governs this project", marker.display(), - self.root.display() + self.shown_root() ) } + + fn shown_root(&self) -> std::path::Display<'_> { + self.root().unwrap_or(Path::new("/")).display() + } } /// The bound of an UPWARD lookup from `start` (a workspace root, an /// inherited config, a settings file the package manager searches its -/// ancestors for): inside a git checkout the lookup never reads above the -/// checkout's root, and a caller asks [`ReadBoundary::marker_above`] -/// whether the tool would have found something there (a writer then -/// refuses, a reader withholds). `None` outside any checkout, where the -/// lookup walks on as it always has: stopping it at the project would -/// silently treat a workspace member as standalone. -pub fn upward_bound(start: &Path) -> Option { - find_git_repo(&canonical_or_lexical(start)).map(|repo| ReadBoundary { root: repo.root }) +/// ancestors for) in a `scope` run: in a [`ReadScope::Workspace`] run +/// inside a git checkout the lookup never reads above the checkout's +/// root, and a caller asks [`ReadBoundary::marker_above`] whether the tool +/// would have found something there (a writer then refuses, a reader +/// withholds). `None` in a [`ReadScope::Global`] run, and outside any +/// checkout, where the lookup walks on as it always has: stopping it at +/// the project would silently treat a workspace member as standalone. +pub fn upward_bound(scope: ReadScope, start: &Path) -> Option { + match scope { + ReadScope::Global => None, + ReadScope::Workspace => find_git_repo(&canonical_or_lexical(start)) + .map(|repo| ReadBoundary::Workspace { root: repo.root }), + } } -/// Whether an upward lookup from `start` may read `path` -/// ([`upward_bound`]): always outside a checkout, else only inside it. -pub fn upward_readable(start: &Path, path: &Path) -> bool { - upward_bound(start).is_none_or(|bound| bound.contains(path)) +/// Whether an upward lookup from `start` may read `path` in a `scope` run +/// ([`upward_bound`]): always in a global run or outside a checkout, else +/// only inside it. +pub fn upward_readable(scope: ReadScope, start: &Path, path: &Path) -> bool { + upward_bound(scope, start).is_none_or(|bound| bound.contains(path)) } /// `project_root.join(rel)` for a project file named by a root-relative /// `rel` that may climb (`../shared/requirements.txt`) or be absolute; /// `Err` of kind `NotFound` (a refused read reads as a missing file) -/// carrying [`ReadBoundary::refusal`] when it lies outside the repository -/// root. A plain in-tree `rel` (no `..`, not absolute) is never refused, -/// and costs no filesystem lookups. -pub fn bounded_project_path(project_root: &Path, rel: &str) -> std::io::Result { +/// carrying [`ReadBoundary::refusal`] when, in a `scope` run, it lies +/// outside the boundary ([`ReadBoundary::of`]). A plain in-tree `rel` (no +/// `..`, not absolute) is never refused, and costs no filesystem lookups. +pub fn bounded_project_path( + scope: ReadScope, + project_root: &Path, + rel: &str, +) -> std::io::Result { let path = project_root.join(rel); let climbs = crate::utils::relpath::is_anchored(rel) || rel.split(['/', '\\']).any(|seg| seg == ".."); if climbs { - let bound = ReadBoundary::of(project_root); + let bound = ReadBoundary::of(scope, project_root); if !bound.contains(&path) { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, @@ -440,8 +496,8 @@ mod tests { let project = repo.join("packages/app"); fs::create_dir_all(repo.join(".git")).unwrap(); fs::create_dir_all(&project).unwrap(); - let bound = ReadBoundary::of(&project); - assert_eq!(bound.root(), repo.as_path()); + let bound = ReadBoundary::of(ReadScope::Workspace, &project); + assert_eq!(bound.root(), Some(repo.as_path())); assert!(bound.contains(&project.join("../../go.work"))); assert!(bound.contains(&repo.join("not/yet/created.txt"))); assert!(!bound.contains(&project.join("../../../go.work"))); @@ -449,19 +505,34 @@ mod tests { // No checkout: the project itself is the boundary. let plain = base.join("plain/app"); fs::create_dir_all(&plain).unwrap(); - let bound = ReadBoundary::of(&plain); - assert_eq!(bound.root(), plain.as_path()); + let bound = ReadBoundary::of(ReadScope::Workspace, &plain); + assert_eq!(bound.root(), Some(plain.as_path())); assert!(!bound.contains(&plain.join("../go.work"))); // A project file named by a climbing path: refused as missing. - let err = bounded_project_path(&plain, "../go.work").unwrap_err(); + let err = bounded_project_path(ReadScope::Workspace, &plain, "../go.work").unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::NotFound); assert!(is_refused_read(&err)); assert_eq!( - bounded_project_path(&plain, "sub/go.work").unwrap(), + bounded_project_path(ReadScope::Workspace, &plain, "sub/go.work").unwrap(), plain.join("sub/go.work") ); let missing = std::io::Error::from(std::io::ErrorKind::NotFound); assert!(!is_refused_read(&missing)); + // A global run is unbounded: it reads anywhere on the system. + let global = ReadBoundary::of(ReadScope::Global, &project); + assert_eq!(global, ReadBoundary::Unbounded); + assert!(global.contains(&base.join("sibling/.socket/vendor"))); + assert_eq!(global.marker_above(&["go.work"]), None); + assert_eq!( + bounded_project_path(ReadScope::Global, &plain, "../go.work").unwrap(), + plain.join("../go.work") + ); + assert_eq!(upward_bound(ReadScope::Global, &project), None); + assert!(upward_readable( + ReadScope::Global, + &project, + &base.join("x") + )); } /// A symlink inside the tree that points out of it does not carry a @@ -475,7 +546,7 @@ mod tests { fs::create_dir_all(repo.join(".git")).unwrap(); fs::create_dir_all(base.join("outside")).unwrap(); std::os::unix::fs::symlink(base.join("outside"), repo.join("link")).unwrap(); - let bound = ReadBoundary::of(&repo); + let bound = ReadBoundary::of(ReadScope::Workspace, &repo); assert!(!bound.contains(&repo.join("link/requirements.txt"))); assert!(bound.contains(&repo.join("requirements.txt"))); } diff --git a/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs b/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs index 8d579b377..43744c509 100644 --- a/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs +++ b/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs @@ -45,8 +45,8 @@ fn write(path: &Path, text: &str) { #[test] fn marker_above_is_stat_only_and_starts_above_the_checkout() { let l = layout(true); - let bound = upward_bound(&l.app).unwrap(); - assert_eq!(bound.root(), l.repo.as_path()); + let bound = upward_bound(ReadScope::Workspace, &l.app).unwrap(); + assert_eq!(bound.root(), Some(l.repo.as_path())); assert_eq!(bound.marker_above(&["package.json"]), None); write(&l.repo.join("package.json"), "{}"); assert_eq!(bound.marker_above(&["package.json"]), None, "inside"); @@ -55,12 +55,21 @@ fn marker_above_is_stat_only_and_starts_above_the_checkout() { bound.marker_above(&["package.json"]), Some(l.outer.join("package.json")) ); - assert!(upward_readable(&l.app, &l.repo.join("package.json"))); - assert!(!upward_readable(&l.app, &l.outer.join("package.json"))); + assert!(upward_readable( + ReadScope::Workspace, + &l.app, + &l.repo.join("package.json") + )); + assert!(!upward_readable( + ReadScope::Workspace, + &l.app, + &l.outer.join("package.json") + )); // No checkout: no bound, every ancestor stays readable. let plain = layout(false); - assert_eq!(upward_bound(&plain.app), None); + assert_eq!(upward_bound(ReadScope::Workspace, &plain.app), None); assert!(upward_readable( + ReadScope::Workspace, &plain.app, &plain.outer.join("package.json") )); @@ -199,10 +208,12 @@ async fn cargo_workspace_and_configs_above_the_repository() { crate::vendor::cargo_config::config_above_repo(&l.app).is_some(), git ); - let layers = - crate::formats::cargo::source_replacement::config_layer_paths(&l.app, None, |p| { - p.is_file() - }); + let layers = crate::formats::cargo::source_replacement::config_layer_paths( + ReadScope::Workspace, + &l.app, + None, + |p| p.is_file(), + ); assert_eq!(layers.is_empty(), git, "{layers:?}"); } } @@ -213,7 +224,7 @@ fn gradle_settings_above_the_repository() { let l = layout(git); write(&l.app.join("build.gradle"), ""); write(&l.outer.join("settings.gradle"), "include 'repo:app'\n"); - let roots = crate::crawlers::gradle_cache::build_roots(&l.app); + let roots = crate::crawlers::gradle_cache::build_roots(ReadScope::Workspace, &l.app); assert_eq!(roots.contains(&l.outer), !git, "{roots:?}"); } } @@ -226,6 +237,7 @@ fn yarn_env() -> crate::crawlers::pkg_managers::YarnEnv { home: None, vars: Some(Default::default()), env_files: Default::default(), + scope: ReadScope::Workspace, } } @@ -275,3 +287,104 @@ fn yarn_classic_rc_above_the_repository() { } } } + +/// A global run (`--global` / `--global-prefix`) is unbounded: every +/// lookup the CLI threads `ReadScope::Global` into reads above the +/// repository root, exactly as before the bound, while the same layout in +/// a workspace run stays bounded (the tests above). +#[test] +fn global_scope_reads_above_the_repository() { + let l = layout(true); + write(&l.outer.join("go.work"), "go 1.22\n"); + write(&l.outer.join(".cargo/config.toml"), "[build]\njobs = 1\n"); + write(&l.app.join("build.gradle"), ""); + write(&l.outer.join("settings.gradle"), "include 'repo:app'\n"); + write( + &l.outer.join(".yarnrc.yml"), + "nodeLinker: node-modules\npnpmStoreFolder: store\n", + ); + + assert_eq!(upward_bound(ReadScope::Global, &l.app), None); + assert!(upward_readable( + ReadScope::Global, + &l.app, + &l.outer.join("go.work") + )); + assert!(ReadBoundary::of(ReadScope::Global, &l.app).contains(&l.outer)); + assert!(bounded_project_path(ReadScope::Global, &l.app, "../../go.work").is_ok()); + assert!(bounded_project_path(ReadScope::Workspace, &l.app, "../../go.work").is_err()); + + // `GOWORK` in the environment would decide instead. + if std::env::var_os("GOWORK").is_none() { + use crate::vendor::go_workspace::{resolve_in, GoWork}; + assert_eq!( + resolve_in(ReadScope::Global, &l.app), + GoWork::File(l.outer.join("go.work")) + ); + assert!(matches!( + resolve_in(ReadScope::Workspace, &l.app), + GoWork::Invalid(_) + )); + } + + let layers = crate::formats::cargo::source_replacement::config_layer_paths( + ReadScope::Global, + &l.app, + None, + |p| p.is_file(), + ); + assert!( + layers.contains(&(l.outer.join(".cargo/config.toml"), l.outer.clone())), + "{layers:?}" + ); + + let roots = crate::crawlers::gradle_cache::build_roots(ReadScope::Global, &l.app); + assert!(roots.contains(&l.outer), "{roots:?}"); + + use crate::crawlers::pkg_managers::{yarn_node_linker_in, yarn_pnpm_store_folder_in}; + let env = crate::crawlers::pkg_managers::YarnEnv { + scope: ReadScope::Global, + ..yarn_env() + }; + assert_eq!( + yarn_node_linker_in(&l.app, &env).as_deref(), + Some("node-modules") + ); + assert_eq!( + yarn_pnpm_store_folder_in(&l.app, &env), + Some(l.outer.join("store")) + ); +} + +/// The crawler's scope comes from its CLI mode, never from the +/// filesystem: a `--global-prefix` crawl outside any repository finds its +/// packages while the working directory sits in a checkout. +#[tokio::test] +async fn global_crawl_reads_a_package_outside_the_repository() { + use crate::crawlers::types::CrawlerOptions; + let l = layout(true); + let global = l.outer.join("global/node_modules"); + write( + &global.join("left-pad/package.json"), + r#"{"name":"left-pad","version":"1.3.0"}"#, + ); + let options = CrawlerOptions { + cwd: l.app.clone(), + global: true, + global_prefix: Some(global.clone()), + }; + assert_eq!(options.read_scope(), ReadScope::Global); + let local = CrawlerOptions { + cwd: l.app.clone(), + global: false, + global_prefix: None, + }; + assert_eq!(local.read_scope(), ReadScope::Workspace); + let found = crate::crawlers::npm_crawler::NpmCrawler + .crawl_all(&options) + .await; + assert!( + found.iter().any(|p| p.purl == "pkg:npm/left-pad@1.3.0"), + "{found:?}" + ); +} diff --git a/crates/socket-patch-core/src/utils/uv_workspace.rs b/crates/socket-patch-core/src/utils/uv_workspace.rs index 2e3a070bc..f1dc1f263 100644 --- a/crates/socket-patch-core/src/utils/uv_workspace.rs +++ b/crates/socket-patch-core/src/utils/uv_workspace.rs @@ -69,7 +69,8 @@ async fn walk(dir: &Path) -> Walk { { return Walk::Ungoverned; } - let bound = crate::utils::repo_root::upward_bound(&dir); + let bound = + crate::utils::repo_root::upward_bound(crate::utils::repo_root::ReadScope::Workspace, &dir); for ancestor in dir.ancestors().skip(1) { if let Some(bound) = bound.as_ref().filter(|b| !b.contains(ancestor)) { return match bound.marker_above(&["pyproject.toml"]) { diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index ced738c40..03a884b6d 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -1419,7 +1419,8 @@ pub(crate) async fn workspace_root_refusal( let root = tokio::fs::canonicalize(project_root) .await .unwrap_or_else(|_| project_root.to_path_buf()); - let bound = crate::utils::repo_root::upward_bound(&root); + let bound = + crate::utils::repo_root::upward_bound(crate::utils::repo_root::ReadScope::Workspace, &root); for ancestor in root.ancestors().skip(1) { if ancestor.ends_with("target/package") { break; @@ -1563,7 +1564,11 @@ async fn path_crate_version(base: &Path, rel: &str) -> Option { }; // A path outside the repository root is never read: its version is // unknown, so the patch may override the target and the caller refuses. - if !crate::utils::repo_root::upward_readable(base, &dir) { + if !crate::utils::repo_root::upward_readable( + crate::utils::repo_root::ReadScope::Workspace, + base, + &dir, + ) { return None; } let text = read_regular_to_string(&dir.join("Cargo.toml")).await.ok()?; diff --git a/crates/socket-patch-core/src/vendor/cargo_config.rs b/crates/socket-patch-core/src/vendor/cargo_config.rs index 2378c7094..a68e51620 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -162,7 +162,10 @@ pub async fn read_config_chain(project_root: &Path) -> Vec { /// root (stat only, never read), and cargo merges its `[patch]` and /// source settings too. Writers refuse on it. pub fn config_above_repo(project_root: &Path) -> Option { - let bound = crate::utils::repo_root::upward_bound(project_root)?; + let bound = crate::utils::repo_root::upward_bound( + crate::utils::repo_root::ReadScope::Workspace, + project_root, + )?; let marker = bound.marker_above(&[".cargo/config", ".cargo/config.toml"])?; Some(format!( "cargo merges {} into this build: {}", @@ -181,7 +184,8 @@ pub async fn read_config_chain_with( .unwrap_or_else(|_| project_root.to_path_buf()); // Inside a git checkout, a config above its root is never read // ([`config_above_repo`] names one for the writers to refuse on). - let bound = crate::utils::repo_root::upward_bound(&root); + let bound = + crate::utils::repo_root::upward_bound(crate::utils::repo_root::ReadScope::Workspace, &root); let mut dirs: Vec<(PathBuf, PathBuf, bool)> = root .ancestors() .take_while(|dir| bound.as_ref().is_none_or(|b| b.contains(dir))) diff --git a/crates/socket-patch-core/src/vendor/go_native_vendor.rs b/crates/socket-patch-core/src/vendor/go_native_vendor.rs index 5791c611c..9cbeb796d 100644 --- a/crates/socket-patch-core/src/vendor/go_native_vendor.rs +++ b/crates/socket-patch-core/src/vendor/go_native_vendor.rs @@ -52,7 +52,12 @@ async fn expected_text(t: &Target<'_>, name: &str, hash: &str) -> Option if let Some(path) = &entry.path { // A replacement directory outside the repository is never read. let file = t.root.join(path).join(name); - if crate::utils::repo_root::ReadBoundary::of(t.root).contains(&file) { + if crate::utils::repo_root::ReadBoundary::of( + crate::utils::repo_root::ReadScope::Workspace, + t.root, + ) + .contains(&file) + { paths.push(file); } } else if let (Some(cache), Some(target), Some(ver)) = ( diff --git a/crates/socket-patch-core/src/vendor/go_workspace.rs b/crates/socket-patch-core/src/vendor/go_workspace.rs index c477751b1..1a99eeba7 100644 --- a/crates/socket-patch-core/src/vendor/go_workspace.rs +++ b/crates/socket-patch-core/src/vendor/go_workspace.rs @@ -63,9 +63,16 @@ pub enum GoWork { } /// The `go.work` go would use for `project_root`, reading `GOWORK` from -/// the environment, then the `go env -w` file. +/// the environment, then the `go env -w` file, in a workspace run. pub fn resolve(project_root: &Path) -> GoWork { - resolve_with( + resolve_in(crate::utils::repo_root::ReadScope::Workspace, project_root) +} + +/// [`resolve`] in a `scope` run: a global run (the Go crawler's +/// `--global`) is not bounded by the repository root. +pub fn resolve_in(scope: crate::utils::repo_root::ReadScope, project_root: &Path) -> GoWork { + resolve_scoped( + scope, project_root, crate::utils::go_env::go_env("GOWORK").as_deref(), ) @@ -73,8 +80,20 @@ pub fn resolve(project_root: &Path) -> GoWork { /// [`resolve`] for an explicit `GOWORK` value. pub fn resolve_with(project_root: &Path, gowork: Option<&str>) -> GoWork { + resolve_scoped( + crate::utils::repo_root::ReadScope::Workspace, + project_root, + gowork, + ) +} + +fn resolve_scoped( + scope: crate::utils::repo_root::ReadScope, + project_root: &Path, + gowork: Option<&str>, +) -> GoWork { let bounded = |file: PathBuf| { - let bound = crate::utils::repo_root::ReadBoundary::of(project_root); + let bound = crate::utils::repo_root::ReadBoundary::of(scope, project_root); if bound.contains(&file) { GoWork::File(file) } else { @@ -251,7 +270,10 @@ pub async fn load_with(project_root: &Path, gowork: Option<&str>) -> Loaded { GoWork::File(work_file) => { load_file( work_file, - &crate::utils::repo_root::ReadBoundary::of(project_root), + &crate::utils::repo_root::ReadBoundary::of( + crate::utils::repo_root::ReadScope::Workspace, + project_root, + ), ) .await } @@ -673,7 +695,10 @@ impl GoWorkspace { /// [`GoWorkspace::read_graph`] against the module cache `cache`. pub async fn read_graph_from(&mut self, cache: Option<&Path>) { let replaces = self.replace_sources(); - let bound = crate::utils::repo_root::ReadBoundary::of(self.work_dir()); + let bound = crate::utils::repo_root::ReadBoundary::of( + crate::utils::repo_root::ReadScope::Workspace, + self.work_dir(), + ); let mut graph: BTreeMap> = BTreeMap::new(); // go loads each member at its `workspace` level, which queues the // member's requirements at the member's own pruning. @@ -1159,7 +1184,10 @@ pub async fn module_roots(root: &Path) -> Vec { } match load_file( root.join("go.work"), - &crate::utils::repo_root::ReadBoundary::of(root), + &crate::utils::repo_root::ReadBoundary::of( + crate::utils::repo_root::ReadScope::Workspace, + root, + ), ) .await { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index fcfbd9676..1753cd841 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -617,7 +617,11 @@ impl<'a> DiskSnapshot<'a> { async fn read_bytes(&self, rel: &str) -> io::Result> { self.touch(rel); // Before the cache, which would not keep the refusal's payload. - let path = bounded_project_path(self.root, rel)?; + let path = bounded_project_path( + crate::utils::repo_root::ReadScope::Workspace, + self.root, + rel, + )?; if let Some(hit) = self.cached(rel) { return hit.map(|b| b.to_vec()); } @@ -748,13 +752,22 @@ impl<'a> ProjectView<'a> { } match self { ProjectView::Disk(root) => { - read_regular_to_string(&bounded_project_path(root, rel)?).await + read_regular_to_string(&bounded_project_path( + crate::utils::repo_root::ReadScope::Workspace, + root, + rel, + )?) + .await } ProjectView::Memory(project) => project.read_text(rel), ProjectView::Snapshot(snap) => { // Before the cache, which would not keep the refusal's // payload ([`crate::utils::repo_root::is_refused_read`]). - let path = bounded_project_path(snap.root, rel)?; + let path = bounded_project_path( + crate::utils::repo_root::ReadScope::Workspace, + snap.root, + rel, + )?; match snap.cached(rel) { Some(hit) => hit.and_then(|b| utf8(b.to_vec())), None => { @@ -777,7 +790,12 @@ impl<'a> ProjectView<'a> { pub async fn read_bytes(&self, rel: &str) -> io::Result> { match self { ProjectView::Disk(root) => { - read_regular_to_bytes(&bounded_project_path(root, rel)?).await + read_regular_to_bytes(&bounded_project_path( + crate::utils::repo_root::ReadScope::Workspace, + root, + rel, + )?) + .await } ProjectView::Memory(project) => project.read_bytes(rel), ProjectView::Snapshot(snap) => snap.read_bytes(rel).await, @@ -873,7 +891,10 @@ impl<'a> ProjectView<'a> { let root = disk_root.expect("a disk view has a root"); let lock = crate::utils::fs::read_regular_to_string_sync(&root.join("yarn.lock")); effective_yarn_linker(lock.ok().as_deref(), || { - crate::crawlers::pkg_managers::yarn_node_linker(root) + crate::crawlers::pkg_managers::yarn_node_linker( + crate::utils::repo_root::ReadScope::Workspace, + root, + ) }) } ProjectView::Memory(project) => { diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index c62a23539..33626f829 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -335,7 +335,10 @@ pub(super) fn not_build_root(project_root: &Path) -> Option { let canonical_root = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf()); let ancestors = crate::utils::repo_root::ancestor_search_dirs(&canonical_root); - let bound = crate::utils::repo_root::upward_bound(&canonical_root); + let bound = crate::utils::repo_root::upward_bound( + crate::utils::repo_root::ReadScope::Workspace, + &canonical_root, + ); for ancestor in &ancestors { let ancestor = ancestor.as_path(); // Above the repository root (a submodule's superproject, a diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 06355d051..7a2e51c9c 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -378,7 +378,10 @@ async fn detect_vendorable_npm_flavor_code( project_root: &Path, ) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { detect_vendorable_npm_flavor_with(project_root, || { - crate::crawlers::pkg_managers::yarn_node_linker(project_root) + crate::crawlers::pkg_managers::yarn_node_linker( + crate::utils::repo_root::ReadScope::Workspace, + project_root, + ) }) .await } @@ -660,9 +663,11 @@ pub async fn vlt_routes(project_root: &Path) -> Option Some(Ok(())), Ok(_) => None, Err(refusal) => { - let pnp = crate::crawlers::pkg_managers::live_pnp_marker(project_root, |m| { - std::fs::symlink_metadata(project_root.join(m)).is_ok() - }) + let pnp = crate::crawlers::pkg_managers::live_pnp_marker( + crate::utils::repo_root::ReadScope::Workspace, + project_root, + |m| std::fs::symlink_metadata(project_root.join(m)).is_ok(), + ) .is_some(); let vlt = tokio::fs::metadata(project_root.join(VLT_LOCK)) .await @@ -1427,6 +1432,7 @@ mod tests { home: Some(home.path().to_path_buf()), vars: Some(Default::default()), env_files: Default::default(), + scope: crate::utils::repo_root::ReadScope::Workspace, }; let (flavor, _) = detect_vendorable_npm_flavor_with(tmp.path(), || yarn_node_linker_in(tmp.path(), &env)) @@ -1444,6 +1450,7 @@ mod tests { home: None, vars: Some(Default::default()), env_files: Default::default(), + scope: crate::utils::repo_root::ReadScope::Workspace, }; let (flavor, _) = detect_vendorable_npm_flavor_with(tmp.path(), || yarn_node_linker_in(tmp.path(), &env)) @@ -1477,6 +1484,7 @@ mod tests { home: None, vars: Some([("SOCKET_PATCH_T_SECRET".to_string(), "secret".to_string())].into()), env_files: Default::default(), + scope: crate::utils::repo_root::ReadScope::Workspace, }; let (code, message) = detect_vendorable_npm_flavor_with(tmp.path(), || { yarn_node_linker_in(tmp.path(), &env) diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 073670db1..921c5478b 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -158,7 +158,10 @@ pub(crate) async fn inherited_source_keys_traced( )], ..NugetConfig::default() })); - let bound = crate::utils::repo_root::ReadBoundary::of(project_root); + let bound = crate::utils::repo_root::ReadBoundary::of( + crate::utils::repo_root::ReadScope::Workspace, + project_root, + ); let mut outside = None; let mut ancestors: Vec<&std::path::Path> = project_root.ancestors().skip(1).collect(); ancestors.reverse(); diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index 425c9488c..4fe5d3026 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -694,7 +694,13 @@ fn node_resolve(from: &Path, name: &str) -> Option { .filter(|dir| dir.file_name().is_none_or(|n| n != "node_modules")) .map(|dir| dir.join("node_modules").join(name)) .find(|copy| copy.exists()) - .filter(|copy| crate::utils::repo_root::upward_readable(from, copy)) + .filter(|copy| { + crate::utils::repo_root::upward_readable( + crate::utils::repo_root::ReadScope::Workspace, + from, + copy, + ) + }) } /// The registry semver range of a `dependencies` value: the value itself, From 4292e8465b2b3b6b3fed6f2b6b86cd66385cdaaf Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 21:55:20 -0600 Subject: [PATCH 05/10] Thread the read scope into the npm install-tree lookups The last lookups without the run's mode now get it, so a global run is unbounded and a workspace run stays inside the repository root: - `NpmCrawler` stores its scope (`for_options`, `with_scope`; `new()` is a workspace crawler). `find_by_purls` and local `node_modules` discovery pass it to the relocated yarn-store probe; the CLI's npm crawls and resolvers build the crawler from their `CrawlerOptions`. - The agent-mode engines (`apply_package_patch`, `rollback_package_patch`, `rollback_store_copies_holding_patch`, `gem_ext::apply_gem_package_patch`) take the scope from the CLI and thread it into the store fan-out (`find_store_peer_variant_copies`) and the shared-store write guard (`shared_store_of*`). Test: a yarn project in a git checkout configured by a `.yarnrc.yml` above it; the global scope sees the relocated store everywhere, the workspace scope nowhere. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/src/commands/apply.rs | 5 + .../socket-patch-cli/src/commands/rollback.rs | 14 +- .../src/commands/scan/discovery.rs | 2 +- .../socket-patch-cli/src/commands/scan/mod.rs | 2 +- .../src/commands/scan/policy.rs | 1 + .../src/ecosystem_dispatch.rs | 41 ++- .../src/crawlers/npm_crawler.rs | 303 +++++++++++++++--- .../socket-patch-core/src/manifest/schema.rs | 12 +- crates/socket-patch-core/src/patch/apply.rs | 66 +++- crates/socket-patch-core/src/patch/gem_ext.rs | 26 +- .../src/patch/redirect/golang_local.rs | 24 +- .../socket-patch-core/src/patch/rollback.rs | 230 ++++++++++--- .../src/patch/shared_store.rs | 297 +++++++++++++---- .../src/patch/store_copies.rs | 37 ++- .../src/utils/repo_root_upward_tests.rs | 2 +- .../tests/crawler_npm_e2e.rs | 270 ++++++++++------ .../tests/crawlers_empty_paths_e2e.rs | 6 +- .../tests/global_probe_spawn_e2e.rs | 2 +- 18 files changed, 1051 insertions(+), 289 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 6022c56dc..881e56de1 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -2713,6 +2713,7 @@ async fn apply_patches_inner( // extension, or is refused with nothing written (#1996). let result = if Ecosystem::from_purl(purl) == Some(Ecosystem::Gem) { let applied = gem_ext::apply_gem_package_patch( + args.common.read_scope(), variant_purl, pkg_path, &patch.files, @@ -2736,6 +2737,7 @@ async fn apply_patches_inner( applied.result } else { apply_package_patch( + args.common.read_scope(), variant_purl, pkg_path, &patch.files, @@ -2893,6 +2895,7 @@ async fn apply_patches_inner( } None => { apply_package_patch( + args.common.read_scope(), purl, pkg_path, &patch.files, @@ -3424,6 +3427,7 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied { } attempted = true; let mut result = apply_package_patch( + args.common.read_scope(), variant, &dir, &files, @@ -3444,6 +3448,7 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied { if copy_attempted && !absent.is_empty() { attempted = true; let result = apply_package_patch( + args.common.read_scope(), variant, copy, &absent, diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index bee7450cc..1c38f14f1 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -3069,6 +3069,7 @@ pub(crate) async fn rollback_patches_inner( for path in &found { for copy in socket_patch_core::crawlers::npm_crawler::find_store_peer_variant_copies( + common.read_scope(), path, ) .await @@ -3277,6 +3278,7 @@ pub(crate) async fn rollback_patches_inner( Some(r) => r, None => { let mut result = rollback_package_patch( + common.read_scope(), purl, pkg_path, &patch.files, @@ -3318,6 +3320,7 @@ pub(crate) async fn rollback_patches_inner( // isolated-store entry, #1084) is restored here, or fails the // run and keeps the record, before the record is dropped. match socket_patch_core::patch::rollback::rollback_store_copies_holding_patch( + common.read_scope(), purl, pkg_path, files, @@ -3818,8 +3821,15 @@ async fn rollback_maven_target( } } } - let mut result = - rollback_package_patch(&target.purl, &target.dir, files, blobs_path, common.dry_run).await; + let mut result = rollback_package_patch( + common.read_scope(), + &target.purl, + &target.dir, + files, + blobs_path, + common.dry_run, + ) + .await; if !result.success || common.dry_run { return result; } diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 0562e26ee..e0c691cc6 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -614,7 +614,7 @@ pub(crate) async fn nested_project_skips( // keeps the package the root's, whatever copy the crawl kept. // An unreadable tree keeps every candidate the root's (the backend // then reports it), rather than dropping a package the root installs. - let crawler = socket_patch_core::crawlers::NpmCrawler::new(); + let crawler = socket_patch_core::crawlers::NpmCrawler::for_options(options); let wanted: Vec = candidates.keys().cloned().collect(); let Ok(nm_roots) = crawler.get_node_modules_paths(options).await else { return Default::default(); diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index dc2ed2974..1c790bf40 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -2228,7 +2228,7 @@ async fn run_scan( // The socket.yml root/ecosystem/package filters, after the flags // (which only narrow further) and after the prune-universe capture. if policy.judges_nested_roots() && args.common.ecosystem_selected(Ecosystem::Npm) { - let nm_roots = socket_patch_core::crawlers::NpmCrawler::new() + let nm_roots = socket_patch_core::crawlers::NpmCrawler::for_options(&crawler_options) .get_node_modules_paths(&crawler_options) .await .unwrap_or_default(); diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 417bd9e79..6113f0309 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -361,6 +361,7 @@ impl ScanPolicy { skipped_purls.push(pkg.purl.clone()); } } + // socket.yml policy judges a project's nested roots: a workspace run. let crawler = socket_patch_core::crawlers::NpmCrawler::new(); let mut more: HashMap> = HashMap::new(); for (root, ok) in roots { diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 795770239..dfb444bb8 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -445,7 +445,10 @@ async fn dispatch_find( eco = Ecosystem::Npm, options = options, silent = silent, - crawler = NpmRootsCrawler { roots: npm_roots }, + crawler = NpmRootsCrawler { + roots: npm_roots, + crawler: NpmCrawler::for_options(options), + }, get_paths = get_node_modules_paths, using_label = "global npm packages", err_label = "npm packages", @@ -760,7 +763,10 @@ async fn dispatch_find_with_npm_links( let npm_roots = match prior.and_then(|p| p.roots_for(options)) { _ if wanted.is_empty() => None, Some(roots) => Some(roots.to_vec()), - None => NpmCrawler.get_node_modules_paths(options).await.ok(), + None => NpmCrawler::for_options(options) + .get_node_modules_paths(options) + .await + .ok(), }; let mut found = dispatch_find( partitioned, @@ -988,6 +994,8 @@ fn merge_each_copy( /// an earlier crawl's roots when it has them. struct NpmRootsCrawler<'a> { roots: Option<&'a [PathBuf]>, + /// The crawler for the run's options, carrying its read scope. + crawler: NpmCrawler, } impl NpmRootsCrawler<'_> { @@ -997,7 +1005,7 @@ impl NpmRootsCrawler<'_> { ) -> Result, std::io::Error> { match self.roots { Some(roots) => Ok(roots.to_vec()), - None => NpmCrawler.get_node_modules_paths(options).await, + None => self.crawler.get_node_modules_paths(options).await, } } @@ -1006,7 +1014,7 @@ impl NpmRootsCrawler<'_> { node_modules_path: &std::path::Path, purls: &[String], ) -> Result>, std::io::Error> { - NpmCrawler.find_by_purls(node_modules_path, purls).await + self.crawler.find_by_purls(node_modules_path, purls).await } } @@ -1027,7 +1035,7 @@ pub(crate) async fn npm_paths_by_identity( if purls.is_empty() { return HashMap::new(); } - let installed = NpmCrawler::new().crawl_all(options).await; + let installed = NpmCrawler::for_options(options).crawl_all(options).await; npm_paths_by_identity_in(&installed, purls) } @@ -1388,7 +1396,11 @@ async fn crawl_every_ecosystem( let (npm, pypi, cargo, gems, golang, maven, composer, nuget, deno) = if walk_pool::fd_limit_is_tight() { ( - crawl!(Ecosystem::Npm, NpmCrawler.crawl_all_with_roots(options)).await, + crawl!( + Ecosystem::Npm, + NpmCrawler::for_options(options).crawl_all_with_roots(options) + ) + .await, crawl!(Ecosystem::Pypi, PythonCrawler::new().crawl_all(options)).await, crawl!(Ecosystem::Cargo, CargoCrawler.crawl_all(options)).await, crawl!( @@ -1404,7 +1416,10 @@ async fn crawl_every_ecosystem( ) } else { tokio::join!( - crawl!(Ecosystem::Npm, NpmCrawler.crawl_all_with_roots(options)), + crawl!( + Ecosystem::Npm, + NpmCrawler::for_options(options).crawl_all_with_roots(options) + ), crawl!(Ecosystem::Pypi, PythonCrawler::new().crawl_all(options)), crawl!(Ecosystem::Cargo, CargoCrawler.crawl_all(options)), crawl!( @@ -2558,11 +2573,14 @@ mod tests { }; assert_eq!( snapshot.roots, - NpmCrawler.get_node_modules_paths(&options).await.unwrap() + NpmCrawler::for_options(&options) + .get_node_modules_paths(&options) + .await + .unwrap() ); assert_eq!( pairs(snapshot.packages_for(&options).unwrap()), - pairs(&NpmCrawler.crawl_all(&options).await) + pairs(&NpmCrawler::for_options(&options).crawl_all(&options).await) ); assert_eq!(pairs(&snapshot.packages), pairs(&packages[..npm_count])); assert!(snapshot.roots.len() >= 2, "roots={:?}", snapshot.roots); @@ -2808,7 +2826,10 @@ mod tests { serial.extend(pkgs); }}; } - serial!(Ecosystem::Npm, NpmCrawler.crawl_all(&options).await); + serial!( + Ecosystem::Npm, + NpmCrawler::for_options(&options).crawl_all(&options).await + ); serial!( Ecosystem::Pypi, PythonCrawler::new().crawl_all(&options).await diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 11f5c41a3..c0dae8842 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -2668,7 +2668,14 @@ fn find_node_dirs_sync(base: &Path, segments: &[&str]) -> Vec { // --------------------------------------------------------------------------- /// NPM ecosystem crawler for discovering packages in `node_modules`. -pub struct NpmCrawler; +/// +/// It carries its run's read scope ([`NpmCrawler::for_options`]): a +/// global run's lookups (a relocated yarn store's settings) read +/// anywhere, a workspace run's stay inside the repository root. +#[derive(Debug, Clone, Copy)] +pub struct NpmCrawler { + scope: crate::utils::repo_root::ReadScope, +} /// One still-unresolved `find_by_purls` lookup. /// @@ -2691,9 +2698,25 @@ struct Target { } impl NpmCrawler { - /// Create a new `NpmCrawler`. + /// A crawler for a workspace (project) run. pub fn new() -> Self { - Self + Self::with_scope(crate::utils::repo_root::ReadScope::Workspace) + } + + /// A crawler for a run whose scope is `scope`. + pub fn with_scope(scope: crate::utils::repo_root::ReadScope) -> Self { + Self { scope } + } + + /// A crawler for the run `options` describes: global under + /// `--global` / `--global-prefix`, else workspace. + pub fn for_options(options: &CrawlerOptions) -> Self { + Self::with_scope(options.read_scope()) + } + + /// The run's read scope. + pub fn scope(&self) -> crate::utils::repo_root::ReadScope { + self.scope } // ------------------------------------------------------------------ @@ -2858,6 +2881,7 @@ impl NpmCrawler { // probed there and drives the descent (see // `resolve_pending_targets`). let node_modules_path = node_modules_path.to_path_buf(); + let scope = self.scope; Ok(run_walk(move || { let mut result: HashMap> = HashMap::new(); @@ -2867,8 +2891,13 @@ impl NpmCrawler { // installed here, and probing every entry of a large monorepo // store for them would add a readdir+stat storm to every // apply/rollback run). - let pending = - Self::resolve_pending_targets(&node_modules_path, pending, &mut result, true); + let pending = Self::resolve_pending_targets( + scope, + &node_modules_path, + pending, + &mut result, + true, + ); // Pass 2 — unfiltered fallback, only for targets pass 1 could not // resolve: a target can physically exist ONLY inside another @@ -2880,7 +2909,13 @@ impl NpmCrawler { // every store entry for just the leftovers; the common all- // resolved case never reaches this pass, so its perf is intact. if !pending.is_empty() { - Self::resolve_pending_targets(&node_modules_path, pending, &mut result, false); + Self::resolve_pending_targets( + scope, + &node_modules_path, + pending, + &mut result, + false, + ); } result @@ -2921,6 +2956,7 @@ impl NpmCrawler { /// — folding matches into `result` and the unmatched-name store filter /// — is then replayed sequentially in queue order. fn resolve_pending_targets( + scope: crate::utils::repo_root::ReadScope, node_modules_path: &Path, mut pending: Vec, result: &mut HashMap>, @@ -2935,15 +2971,12 @@ impl NpmCrawler { let mut visited = HashSet::new(); // The root's relocated Yarn pnpm-linker store (#859), contributed // by the root's visit like a store found there by name. - let mut yarn_store = relocated_yarn_pnpm_store_sync( - crate::utils::repo_root::ReadScope::Workspace, - node_modules_path, - ) - .map(|store| { - NestedNodeModules::StoreEntries(StoreEntry::from_dirs( - Self::list_npm_store_entries_sync(&store, false), - )) - }); + let mut yarn_store = + relocated_yarn_pnpm_store_sync(scope, node_modules_path).map(|store| { + NestedNodeModules::StoreEntries(StoreEntry::from_dirs( + Self::list_npm_store_entries_sync(&store, false), + )) + }); while !level.is_empty() { // A bundled node_modules may link back to an ancestor (or to // another already-visited tree). Keep the first/root-first @@ -3434,16 +3467,19 @@ impl NpmCrawler { } return pnpm_isolated_global_install_roots(custom); } - return NpmCrawler.get_global_node_modules_paths(); + return NpmCrawler::for_options(options).get_global_node_modules_paths(); } - Self::find_local_node_modules_dirs(&options.cwd) + Self::find_local_node_modules_dirs(options.read_scope(), &options.cwd) } /// Find `node_modules` directories within the project root. /// Recursively searches for workspace `node_modules` but stays within the /// project. - fn find_local_node_modules_dirs(start_path: &Path) -> Vec { + fn find_local_node_modules_dirs( + scope: crate::utils::repo_root::ReadScope, + start_path: &Path, + ) -> Vec { let mut results = Vec::new(); let listing = list_dir_sync(start_path); @@ -3465,9 +3501,7 @@ impl NpmCrawler { // `node_modules` of its entries are not importers of their own. let stores: Vec = results .iter() - .filter_map(|nm| { - relocated_yarn_pnpm_store_sync(crate::utils::repo_root::ReadScope::Workspace, nm) - }) + .filter_map(|nm| relocated_yarn_pnpm_store_sync(scope, nm)) .filter_map(|store| std::fs::canonicalize(store).ok()) .collect(); if !stores.is_empty() { @@ -4465,8 +4499,11 @@ impl StoreLayout { /// store entry). Callers write through the hardened per-file pipeline, /// which breaks content-store hardlinks per copy — CoW safety holds for /// every copy independently. -pub async fn find_store_peer_variant_copies(pkg_path: &Path) -> Vec { - find_store_peer_variant_copies_reusing(pkg_path, &mut HashSet::new()).await +pub async fn find_store_peer_variant_copies( + scope: crate::utils::repo_root::ReadScope, + pkg_path: &Path, +) -> Vec { + find_store_peer_variant_copies_reusing(scope, pkg_path, &mut HashSet::new()).await } /// Discover candidate stores for every input path, but enumerate a @@ -4474,6 +4511,7 @@ pub async fn find_store_peer_variant_copies(pkg_path: &Path) -> Vec { /// aggregate expansion. Alias ancestry can expose additional stores even /// when the input's canonical package was already seen. async fn find_store_peer_variant_copies_reusing( + scope: crate::utils::repo_root::ReadScope, pkg_path: &Path, scanned: &mut HashSet<(PathBuf, StoreLayout, String, String)>, ) -> Vec { @@ -4580,12 +4618,7 @@ async fn find_store_peer_variant_copies_reusing( let yarn_stores = run_walk(move || { projects .iter() - .filter_map(|nm| { - relocated_yarn_pnpm_store_sync( - crate::utils::repo_root::ReadScope::Workspace, - nm, - ) - }) + .filter_map(|nm| relocated_yarn_pnpm_store_sync(scope, nm)) .filter(|store| { std::fs::canonicalize(store).is_ok_and(|real| canonical.starts_with(real)) }) @@ -4714,7 +4747,14 @@ pub async fn with_store_peer_variant_copies(paths: Vec) -> Vec // by a store's first scan. Sharing scan state therefore loses no copy. let mut scanned = HashSet::new(); for path in &paths { - for copy in find_store_peer_variant_copies_reusing(path, &mut scanned).await { + // `vex`'s every-copy check: a workspace run. + for copy in find_store_peer_variant_copies_reusing( + crate::utils::repo_root::ReadScope::Workspace, + path, + &mut scanned, + ) + .await + { let canonical = tokio::fs::canonicalize(©).await.unwrap_or(copy.clone()); if seen.insert(canonical) { out.push(copy); @@ -5982,7 +6022,12 @@ mod tests { .stack_size(256 * 1024) .build() .unwrap(); - let found = pool.install(|| NpmCrawler::find_local_node_modules_dirs(&root)); + let found = pool.install(|| { + NpmCrawler::find_local_node_modules_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &root, + ) + }); assert_eq!(found, expected); } @@ -6301,7 +6346,11 @@ mod tests { .unwrap(); let primary = &found["pkg:npm/foo@1.0.0"][0].path; assert_eq!(primary, &nm.join("foo")); - assert_eq!(find_store_peer_variant_copies(primary).await, vec![twin]); + assert_eq!( + find_store_peer_variant_copies(crate::utils::repo_root::ReadScope::Workspace, primary) + .await, + vec![twin] + ); } /// vlt fan-out gates: every real copy whose DepID decodes to the @@ -6344,7 +6393,11 @@ mod tests { std::os::unix::fs::symlink(&primary, nm.join("foo")).unwrap(); for start in [primary.clone(), nm.join("foo")] { - let mut got = find_store_peer_variant_copies(&start).await; + let mut got = find_store_peer_variant_copies( + crate::utils::repo_root::ReadScope::Workspace, + &start, + ) + .await; got.sort(); let mut want = twins.to_vec(); want.sort(); @@ -6443,7 +6496,11 @@ mod tests { // Standalone apply/rollback discovery gets a fresh scan and still // excludes only its own primary copy. - let copies = find_store_peer_variant_copies(&inputs[0]).await; + let copies = find_store_peer_variant_copies( + crate::utils::repo_root::ReadScope::Workspace, + &inputs[0], + ) + .await; assert_eq!(copies.len(), 1); assert_ne!( copies[0].canonicalize().unwrap(), @@ -6595,7 +6652,8 @@ mod tests { assert_eq!(found["pkg:npm/@babel/code-frame@7.0.0"][0].path, frame); assert_eq!( - find_store_peer_variant_copies(&number).await, + find_store_peer_variant_copies(crate::utils::repo_root::ReadScope::Workspace, &number) + .await, vec![number_twin.clone()] ); } @@ -6638,7 +6696,11 @@ mod tests { .unwrap(); assert_eq!(copy_paths(&found, &purl), vec![nm.join("left-pad")]); - let mut variants = find_store_peer_variant_copies(&nm.join("left-pad")).await; + let mut variants = find_store_peer_variant_copies( + crate::utils::repo_root::ReadScope::Workspace, + &nm.join("left-pad"), + ) + .await; variants.sort(); let mut want = vec![alias.clone(), scoped_alias.clone()]; want.sort(); @@ -6750,9 +6812,12 @@ mod tests { "{store_name}: resolver walked a nested store: {found:?}" ); assert!( - !find_store_peer_variant_copies(&outer) - .await - .contains(&nested_twin), + !find_store_peer_variant_copies( + crate::utils::repo_root::ReadScope::Workspace, + &outer + ) + .await + .contains(&nested_twin), "{store_name}: peer fan-out walked a nested store" ); } @@ -6793,7 +6858,11 @@ mod tests { entry_nm.join(".pnpm/@s+outer@1.0.0(react@18.2.0)/node_modules/@s/outer"); write_pkg(&nested_twin, "@s/outer", "1.0.0"); - let copies = find_store_peer_variant_copies(&outer).await; + let copies = find_store_peer_variant_copies( + crate::utils::repo_root::ReadScope::Workspace, + &outer, + ) + .await; assert!( !copies.contains(&nested_twin), "{outer_entry_nm}: peer fan-out walked a nested store: {copies:?}" @@ -6821,7 +6890,11 @@ mod tests { write_pkg(&primary, "a", "1.0.0"); write_pkg(&twin, "a", "1.0.0"); - let copies = find_store_peer_variant_copies(&primary).await; + let copies = find_store_peer_variant_copies( + crate::utils::repo_root::ReadScope::Workspace, + &primary, + ) + .await; assert!( copies.contains(&twin), "{project}: peer fan-out dropped the project's store: {copies:?}" @@ -6895,13 +6968,19 @@ mod tests { "{store_rel}" ); - let mut variants = find_store_peer_variant_copies(&foo).await; + let mut variants = + find_store_peer_variant_copies(crate::utils::repo_root::ReadScope::Workspace, &foo) + .await; variants.sort(); assert_eq!(variants, vec![foo_twin.clone()], "{store_rel}"); // From a direct dep's importer link into the store too. link_dir(&foo, &nm.join("foo")); assert_eq!( - find_store_peer_variant_copies(&nm.join("foo")).await, + find_store_peer_variant_copies( + crate::utils::repo_root::ReadScope::Workspace, + &nm.join("foo") + ) + .await, vec![foo_twin.clone()], "{store_rel}" ); @@ -6934,7 +7013,11 @@ mod tests { let twin = inner_store.join("foo@1.0.0(react@16.14.0)/node_modules/foo"); write_pkg(&primary, "foo", "1.0.0"); write_pkg(&twin, "foo", "1.0.0"); - assert_eq!(find_store_peer_variant_copies(&primary).await, vec![twin]); + assert_eq!( + find_store_peer_variant_copies(crate::utils::repo_root::ReadScope::Workspace, &primary) + .await, + vec![twin] + ); } /// A relocated store counts only when it sits strictly below the @@ -7296,7 +7379,11 @@ mod tests { assert_eq!(got, want, "{purl}"); } - assert_eq!(find_store_peer_variant_copies(primary).await, twins); + assert_eq!( + find_store_peer_variant_copies(crate::utils::repo_root::ReadScope::Workspace, primary) + .await, + twins + ); } /// #366 / #405: Bun's isolated linker keeps every package in @@ -7786,7 +7873,8 @@ mod tests { ); assert_eq!(paths("pkg:npm/left-pad@1.1.0"), vec![churned.clone()]); assert_eq!( - find_store_peer_variant_copies(&number).await, + find_store_peer_variant_copies(crate::utils::repo_root::ReadScope::Workspace, &number) + .await, vec![stale_number.clone()] ); @@ -8080,11 +8168,122 @@ mod tests { .await; // The fan-out from the importer link reaches the same store. assert_eq!( - find_store_peer_variant_copies(&nm.join("is-odd")).await, + find_store_peer_variant_copies( + crate::utils::repo_root::ReadScope::Workspace, + &nm.join("is-odd") + ) + .await, Vec::::new() ); } + /// A yarn project inside a git checkout whose `nodeLinker: pnpm` and + /// `pnpmStoreFolder` come from a `.yarnrc.yml` ABOVE the checkout. A + /// global run reads that rc (it is unbounded), so its crawl, its + /// resolver, its peer fan-out and the shared-store write guard all see + /// the relocated store. A workspace run never reads it, so none of + /// them sees the store: the transitive copy is not found, and the + /// guard refuses the link into it as a source outside every + /// `node_modules` tree. + #[tokio::test] + async fn global_scope_reads_a_yarn_store_configured_above_the_repository() { + use crate::utils::repo_root::ReadScope; + let tmp = tempfile::tempdir().unwrap(); + let outer = std::fs::canonicalize(tmp.path()).unwrap(); + let repo = outer.join("repo"); + let root = repo.join("proj"); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("yarn.lock"), "").unwrap(); + std::fs::write( + outer.join(".yarnrc.yml"), + "nodeLinker: pnpm\npnpmStoreFolder: repo/proj/store\n", + ) + .unwrap(); + let store = root.join("store"); + let nm = root.join("node_modules"); + let odd_entry = store.join("is-odd-npm-3.0.1-3e1a2b4c5d"); + write_pkg(&odd_entry.join("package"), "is-odd", "3.0.1"); + link_own_package(&odd_entry, "is-odd"); + let number_entry = store.join("is-number-npm-6.0.0-9f8e7d6c5b"); + let number = number_entry.join("package"); + write_pkg(&number, "is-number", "6.0.0"); + link_own_package(&number_entry, "is-number"); + let twin_entry = store.join("is-number-npm-6.0.0-0a1b2c3d4e"); + let number_twin = twin_entry.join("package"); + write_pkg(&number_twin, "is-number", "6.0.0"); + link_own_package(&twin_entry, "is-number"); + link_dir(&number, &odd_entry.join("node_modules/is-number")); + std::fs::create_dir_all(&nm).unwrap(); + link_dir(&odd_entry.join("package"), &nm.join("is-odd")); + let purls = vec!["pkg:npm/is-number@6.0.0".to_string()]; + + // Global: unbounded. + assert_eq!( + relocated_yarn_pnpm_store_sync(ReadScope::Global, &nm), + Some(store.clone()) + ); + let found = NpmCrawler::with_scope(ReadScope::Global) + .find_by_purls(&nm, &purls) + .await + .unwrap(); + let mut got: Vec = found["pkg:npm/is-number@6.0.0"] + .iter() + .map(|p| p.path.clone()) + .collect(); + got.sort(); + let mut want = vec![number.clone(), number_twin.clone()]; + want.sort(); + assert_eq!(got, want); + assert_eq!( + find_store_peer_variant_copies(ReadScope::Global, &number).await, + vec![number_twin.clone()] + ); + assert_eq!( + crate::patch::shared_store::shared_store_of(ReadScope::Global, &nm.join("is-odd")) + .await, + None, + "a link into the yarn store is an installed copy" + ); + let global = CrawlerOptions { + cwd: root.clone(), + global: false, + global_prefix: Some(nm.clone()), + }; + let crawled = NpmCrawler::for_options(&global).crawl_all(&global).await; + // The store-only transitive copy (one per purl). + assert!( + crawled + .iter() + .any(|p| p.purl == "pkg:npm/is-number@6.0.0" && p.path.starts_with(&store)), + "{crawled:?}" + ); + + // Workspace: the rc above the checkout is never read. + assert_eq!( + relocated_yarn_pnpm_store_sync(ReadScope::Workspace, &nm), + None + ); + let found = NpmCrawler::with_scope(ReadScope::Workspace) + .find_by_purls(&nm, &purls) + .await + .unwrap(); + assert!(found + .get("pkg:npm/is-number@6.0.0") + .is_none_or(Vec::is_empty)); + assert!( + find_store_peer_variant_copies(ReadScope::Workspace, &number) + .await + .is_empty() + ); + assert!( + crate::patch::shared_store::shared_store_of(ReadScope::Workspace, &nm.join("is-odd")) + .await + .is_some(), + "unknown store: the link is refused as a source outside every node_modules tree" + ); + } + /// #859: `pnpmStoreFolder` moves Yarn 4's pnpm-linker store out of /// `node_modules`, to a hidden folder (`.cache/.store`, which the /// workspace walk skips) or a plain one (`store`, whose entries' @@ -8843,7 +9042,10 @@ mod tests { } } } - NpmCrawler::find_local_node_modules_dirs(&project) + NpmCrawler::find_local_node_modules_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &project, + ) }; let want = vec![project.join("specific")]; for (project_rc, repo_rc) in [ @@ -8912,7 +9114,10 @@ mod tests { let (tx, rx) = std::sync::mpsc::channel(); let probe = root.clone(); std::thread::spawn(move || { - let _ = tx.send(NpmCrawler::find_local_node_modules_dirs(&probe)); + let _ = tx.send(NpmCrawler::find_local_node_modules_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &probe, + )); }); let roots = rx .recv_timeout(std::time::Duration::from_secs(10)) diff --git a/crates/socket-patch-core/src/manifest/schema.rs b/crates/socket-patch-core/src/manifest/schema.rs index f3619e4c3..9ec859ed4 100644 --- a/crates/socket-patch-core/src/manifest/schema.rs +++ b/crates/socket-patch-core/src/manifest/schema.rs @@ -671,6 +671,7 @@ mod tests { "upper={upper}: {verify:?}" ); let applied = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/demo@1.0.0", &pkg, files, @@ -688,8 +689,15 @@ mod tests { VerifyRollbackStatus::Ready, "upper={upper}: {verify:?}" ); - let rolled = - rollback_package_patch("pkg:npm/demo@1.0.0", &pkg, files, &blobs, false).await; + let rolled = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/demo@1.0.0", + &pkg, + files, + &blobs, + false, + ) + .await; assert!(rolled.success, "upper={upper}: {:?}", rolled.error); assert_eq!(std::fs::read(pkg.join("index.js")).unwrap(), original); } diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index 7901f5043..c0693b16f 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -912,6 +912,7 @@ async fn chown_blocking( /// still reports the package as applied (see /// [`store_copies`](crate::patch::store_copies)). pub async fn apply_package_patch( + scope: crate::utils::repo_root::ReadScope, package_key: &str, pkg_path: &Path, files: &HashMap, @@ -919,8 +920,8 @@ pub async fn apply_package_patch( dry_run: bool, policy: MismatchPolicy, ) -> ApplyResult { - crate::patch::store_copies::fan_out(package_key, pkg_path, |path| async move { - apply_package_patch_at(package_key, &path, files, sources, dry_run, policy).await + crate::patch::store_copies::fan_out(scope, package_key, pkg_path, |path| async move { + apply_package_patch_at(scope, package_key, &path, files, sources, dry_run, policy).await }) .await } @@ -1064,6 +1065,7 @@ pub async fn needs_npm_bin_rewrite( /// The single-copy apply engine behind [`apply_package_patch`]: verifies /// and patches the package at exactly the one `pkg_path` it is given. async fn apply_package_patch_at( + scope: crate::utils::repo_root::ReadScope, package_key: &str, pkg_path: &Path, files: &HashMap, @@ -1088,6 +1090,7 @@ async fn apply_package_patch_at( // project using it. Refused in every state, dry run and already-patched // included, so this project never records the shared copy as its patch. if let Some(store) = crate::patch::shared_store::shared_store_of_patch_dirs( + scope, pkg_path, files.keys().map(String::as_str), ) @@ -2063,6 +2066,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:golang/example.com/x@1.0.0", pkg_dir.path(), &files, @@ -2114,6 +2118,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2150,6 +2155,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2191,6 +2197,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2223,6 +2230,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2269,6 +2277,7 @@ mod tests { let sources = PatchSources::blobs_only(blobs_dir.path()); let run = || { apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2326,6 +2335,7 @@ mod tests { .await .unwrap(); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2356,6 +2366,7 @@ mod tests { .await .unwrap(); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2382,6 +2393,7 @@ mod tests { .unwrap(); for policy in [MismatchPolicy::Warn, MismatchPolicy::Strict] { let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2410,6 +2422,7 @@ mod tests { // Without force: should fail (NotFound for non-new file) let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2422,6 +2435,7 @@ mod tests { // With force: should succeed by skipping the missing file let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2451,6 +2465,7 @@ mod tests { ); } let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/lodash@4.17.20", pkg_dir.path(), &files, @@ -2806,6 +2821,7 @@ mod tests { ); let blobs = tempfile::tempdir().unwrap(); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:maven/g/a@1.0", pkg, &files, @@ -2883,6 +2899,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:cargo/mycrate@1.0.0", pkg, &files, @@ -2970,6 +2987,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -3021,6 +3039,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -3143,6 +3162,7 @@ mod tests { }, ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/evil@1.0.0", &pkg, &files, @@ -3212,6 +3232,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/evil@1.0.0", &pkg, &files, @@ -3299,6 +3320,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", &pkg_dir, &files, @@ -3357,6 +3379,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", &pkg_dir, &files, @@ -3401,6 +3424,7 @@ mod tests { let sources = PatchSources::blobs_only(&blobs_dir); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/x@1.0.0", &pkg_dir, &files, @@ -3622,9 +3646,16 @@ mod tests { let (_root, [a, b], key, blobs, files, original, _patched) = shared_store_fixture(pdm); let sources = PatchSources::blobs_only(&blobs); for dry_run in [true, false] { - let result = - apply_package_patch(purl, &a, &files, &sources, dry_run, MismatchPolicy::Warn) - .await; + let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + purl, + &a, + &files, + &sources, + dry_run, + MismatchPolicy::Warn, + ) + .await; assert!(!result.success, "{purl} dry_run={dry_run}: must refuse"); let err = result.error.unwrap_or_default(); assert!( @@ -3646,6 +3677,7 @@ mod tests { let (_root, [a, _b], key, blobs, files, _original, patched) = shared_store_fixture(false); std::fs::write(a.join(key), &patched).unwrap(); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/left-pad@1.3.0", &a, &files, @@ -3711,8 +3743,16 @@ mod tests { ] { for policy in [MismatchPolicy::Warn, MismatchPolicy::Force] { for dry_run in [true, false] { - let result = - apply_package_patch(purl, &pkg, &files, &sources, dry_run, policy).await; + let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + purl, + &pkg, + &files, + &sources, + dry_run, + policy, + ) + .await; assert!(!result.success, "{}: must refuse", pkg.display()); let err = result.error.unwrap_or_default(); assert!( @@ -3752,6 +3792,7 @@ mod tests { }, ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/left-pad@1.3.0", &nm.join("left-pad"), &files, @@ -3963,6 +4004,7 @@ mod tests { ); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:pypi/pkg@1.0.0", site.path(), &files, @@ -4076,6 +4118,7 @@ mod tests { .unwrap(); let sources = PatchSources::blobs_only(blobs.path()); let res = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/crlfbin@1.0.0", pkg.path(), &files, @@ -4092,6 +4135,7 @@ mod tests { ); let again = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/crlfbin@1.0.0", pkg.path(), &files, @@ -4119,6 +4163,7 @@ mod tests { .await .unwrap(); let res = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/crlfbin@1.0.0", pkg.path(), &files, @@ -4158,6 +4203,7 @@ mod tests { .await .unwrap(); let res = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/crlfbin@1.0.0", pkg.path(), &files, @@ -4202,6 +4248,7 @@ mod tests { let sources = PatchSources::blobs_only(blobs.path()); let apply = || { apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/crlfbin@1.0.0", &pkg, &files, @@ -4269,6 +4316,7 @@ mod tests { .await ); let res = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, purl, &pkg, &files, @@ -4329,6 +4377,7 @@ mod tests { let sources = PatchSources::blobs_only(blobs.path()); let apply = || { apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/crlfbin@1.0.0", &pkg, &files, @@ -4358,6 +4407,7 @@ mod tests { ); let rolled = crate::patch::rollback::rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/crlfbin@1.0.0", &pkg, &files, @@ -4385,6 +4435,7 @@ mod tests { .await .unwrap(); let res = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:pypi/crlfbin@1.0.0", pkg.path(), &files, @@ -4411,6 +4462,7 @@ mod tests { .await .unwrap(); let res = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/crlfbin@1.0.0", pkg.path(), &files, diff --git a/crates/socket-patch-core/src/patch/gem_ext.rs b/crates/socket-patch-core/src/patch/gem_ext.rs index ffcd89126..a226eb24b 100644 --- a/crates/socket-patch-core/src/patch/gem_ext.rs +++ b/crates/socket-patch-core/src/patch/gem_ext.rs @@ -1127,7 +1127,9 @@ fn list_inputs(scope: &NativeScope) -> String { /// native extension when the record changes a build input (see the module /// docs). A record without native inputs is applied exactly as /// [`apply_package_patch`] applies it. +#[allow(clippy::too_many_arguments)] pub async fn apply_gem_package_patch( + read_scope: crate::utils::repo_root::ReadScope, package_key: &str, pkg_path: &Path, files: &HashMap, @@ -1139,8 +1141,16 @@ pub async fn apply_gem_package_patch( let (scope, copy) = scope_of(pkg_path, files).await; let plain = || async { GemApplied { - result: apply_package_patch(package_key, pkg_path, files, sources, dry_run, policy) - .await, + result: apply_package_patch( + read_scope, + package_key, + pkg_path, + files, + sources, + dry_run, + policy, + ) + .await, warning: None, } }; @@ -1238,8 +1248,16 @@ pub async fn apply_gem_package_patch( .await }; - let mut result = - apply_package_patch(package_key, pkg_path, files, sources, dry_run, policy).await; + let mut result = apply_package_patch( + read_scope, + package_key, + pkg_path, + files, + sources, + dry_run, + policy, + ) + .await; if dry_run { // The build is not one made from these sources (the marker check // above), so a real run rebuilds it. With every source already diff --git a/crates/socket-patch-core/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs index e2afd5b57..839a16f5d 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -382,8 +382,17 @@ pub async fn apply_go_redirect<'a>( // Verify (read-only) against the pristine source for an accurate // "would patch" report, without creating the copy or editing go.mod. let pristine_src = pristine_src.path(); - let mut result = - apply_package_patch(purl, pristine_src, files, sources, true, policy).await; + let mut result = apply_package_patch( + // A project's Go redirect: a workspace run. + crate::utils::repo_root::ReadScope::Workspace, + purl, + pristine_src, + files, + sources, + true, + policy, + ) + .await; result.package_path = copy_dir.display().to_string(); result.sidecar = None; // a replace copy is not the cache (no go.sum advisory) return result; @@ -425,7 +434,16 @@ pub async fn apply_go_redirect<'a>( } // Delegate to the hardened pipeline, pointed at the copy. - let mut result = apply_package_patch(purl, ©_dir, files, sources, false, policy).await; + let mut result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + purl, + ©_dir, + files, + sources, + false, + policy, + ) + .await; result.package_path = copy_dir.display().to_string(); // The golang sidecar advisory ("go mod verify will fail against go.sum") // is about in-cache patching; a `replace` copy bypasses go.sum entirely, so diff --git a/crates/socket-patch-core/src/patch/rollback.rs b/crates/socket-patch-core/src/patch/rollback.rs index f7cb2928c..e9b86a87e 100644 --- a/crates/socket-patch-core/src/patch/rollback.rs +++ b/crates/socket-patch-core/src/patch/rollback.rs @@ -380,14 +380,15 @@ pub fn cannot_rollback_error(file: &str, why: &str) -> String { /// a restore of a twin alone still counts as rolled back (see /// [`store_copies`](crate::patch::store_copies)). pub async fn rollback_package_patch( + scope: crate::utils::repo_root::ReadScope, package_key: &str, pkg_path: &Path, files: &HashMap, blobs_path: &Path, dry_run: bool, ) -> RollbackResult { - crate::patch::store_copies::fan_out(package_key, pkg_path, |path| async move { - rollback_package_patch_at(package_key, &path, files, blobs_path, dry_run).await + crate::patch::store_copies::fan_out(scope, package_key, pkg_path, |path| async move { + rollback_package_patch_at(scope, package_key, &path, files, blobs_path, dry_run).await }) .await } @@ -408,6 +409,7 @@ pub async fn rollback_package_patch( /// record) is left, as the primary is. `None` when no copy holds this /// record's patch (or `package_key` is not npm). pub async fn rollback_store_copies_holding_patch( + scope: crate::utils::repo_root::ReadScope, package_key: &str, pkg_path: &Path, files: &HashMap, @@ -418,12 +420,13 @@ pub async fn rollback_store_copies_holding_patch( return None; } let mut folded: Option = None; - for copy in crate::crawlers::npm_crawler::find_store_peer_variant_copies(pkg_path).await { + for copy in crate::crawlers::npm_crawler::find_store_peer_variant_copies(scope, pkg_path).await + { if !holds_this_patch(©, files).await { continue; } let copy_result = - rollback_package_patch_at(package_key, ©, files, blobs_path, dry_run).await; + rollback_package_patch_at(scope, package_key, ©, files, blobs_path, dry_run).await; let result = folded.get_or_insert_with(|| RollbackResult { package_key: package_key.to_string(), package_path: pkg_path.display().to_string(), @@ -606,6 +609,7 @@ async fn mark_crlf_bin_rewrite( } async fn rollback_package_patch_at( + scope: crate::utils::repo_root::ReadScope, package_key: &str, pkg_path: &Path, files: &HashMap, @@ -664,6 +668,7 @@ async fn rollback_package_patch_at( // already-original shared copy needs no write and passes. if !all_original { if let Some(store) = crate::patch::shared_store::shared_store_of_patch_dirs( + scope, pkg_path, files.keys().map(String::as_str), ) @@ -937,6 +942,7 @@ mod tests { ); } let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/lodash@4.17.20", pkg_dir.path(), &files, @@ -1263,6 +1269,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:golang/example.com/x@1.0.0", pkg_dir.path(), &files, @@ -1326,6 +1333,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -1372,6 +1380,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -1416,6 +1425,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -1448,6 +1458,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -1533,6 +1544,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -1610,8 +1622,15 @@ mod tests { }, ); - let result = - rollback_package_patch("pkg:npm/test@1.0.0", &pkg_dir, &files, &blobs_dir, false).await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/test@1.0.0", + &pkg_dir, + &files, + &blobs_dir, + false, + ) + .await; assert!(!result.success, "escaping delete must be refused"); assert!(result.files_rolled_back.is_empty()); @@ -1645,6 +1664,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -1678,6 +1698,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -1724,6 +1745,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:golang/example.com/x@1.0.0", pkg_dir.path(), &files, @@ -1784,6 +1806,7 @@ mod tests { let files = write_added_files(site.path(), &["six_safe/__init__.py"]).await; let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:pypi/six@1.16.0", site.path(), &files, @@ -1820,6 +1843,7 @@ mod tests { .unwrap(); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:pypi/six@1.16.0", site.path(), &files, @@ -1858,6 +1882,7 @@ mod tests { .unwrap(); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:pypi/pkg@1.0.0", site.path(), &files, @@ -1889,9 +1914,15 @@ mod tests { .await .unwrap(); - let result = - rollback_package_patch("pkg:npm/x@1.0.0", pkg.path(), &files, blobs.path(), false) - .await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/x@1.0.0", + pkg.path(), + &files, + blobs.path(), + false, + ) + .await; assert!(result.success, "{:?}", result.error); assert!(!pkg.path().join("lib/new").exists()); @@ -1915,9 +1946,15 @@ mod tests { std::os::unix::fs::symlink(&target, pkg.path().join("link")).unwrap(); let files = write_added_files(pkg.path(), &["link/inner/added.js"]).await; - let result = - rollback_package_patch("pkg:npm/x@1.0.0", pkg.path(), &files, blobs.path(), false) - .await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/x@1.0.0", + pkg.path(), + &files, + blobs.path(), + false, + ) + .await; assert!(!result.success); let err = result.error.as_deref().unwrap_or_default(); @@ -1950,6 +1987,7 @@ mod tests { .unwrap(); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:golang/example.com/x@1.0.0", pkg.path(), &files, @@ -2053,8 +2091,15 @@ mod tests { }, ); - let result = - rollback_package_patch("pkg:npm/test@1.0.0", &pkg_dir, &files, &blobs_dir, false).await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/test@1.0.0", + &pkg_dir, + &files, + &blobs_dir, + false, + ) + .await; assert!(!result.success, "escaping blob hash must be refused"); assert!(result.files_rolled_back.is_empty()); @@ -2100,6 +2145,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2191,9 +2237,15 @@ mod tests { }, ); - let result = - rollback_package_patch("pkg:cargo/demo@1.0.0", pkg, &files, blobs_dir.path(), false) - .await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:cargo/demo@1.0.0", + pkg, + &files, + blobs_dir.path(), + false, + ) + .await; assert!(result.success, "rollback failed: {:?}", result.error); assert_eq!(result.files_rolled_back.len(), 2); @@ -2268,9 +2320,15 @@ mod tests { }, ); - let result = - rollback_package_patch("pkg:cargo/demo@1.0.0", pkg, &files, blobs_dir.path(), false) - .await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:cargo/demo@1.0.0", + pkg, + &files, + blobs_dir.path(), + false, + ) + .await; assert!( result.success, @@ -2360,6 +2418,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:cargo/mycrate@1.0.0", pkg, &files, @@ -2477,8 +2536,15 @@ mod tests { let mut files = HashMap::new(); files.insert("index.js".to_string(), file_info); - let result = - rollback_package_patch("pkg:npm/test@1.0.0", &pkg_dir, &files, &blobs_dir, false).await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/test@1.0.0", + &pkg_dir, + &files, + &blobs_dir, + false, + ) + .await; assert!(!result.success); let err = result.error.unwrap(); assert!( @@ -2584,8 +2650,15 @@ mod tests { }, ); - let result = - rollback_package_patch("pkg:npm/test@1.0.0", &pkg_dir, &files, &blobs_dir, false).await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/test@1.0.0", + &pkg_dir, + &files, + &blobs_dir, + false, + ) + .await; assert!(!result.success, "symlinked blob entry must be refused"); assert!(result.files_rolled_back.is_empty()); @@ -2773,6 +2846,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2837,6 +2911,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2907,6 +2982,7 @@ mod tests { assert!(status.success(), "chflags uchg failed"); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/test@1.0.0", pkg_dir.path(), &files, @@ -2981,6 +3057,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/foo@1.0.0", &primary, &files, @@ -3013,6 +3090,7 @@ mod tests { .await .unwrap(); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/foo@1.0.0", &primary, &files, @@ -3079,7 +3157,15 @@ mod tests { }, ); for dry_run in [true, false] { - let result = rollback_package_patch(purl, &pkg_root, &files, &blobs, dry_run).await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + purl, + &pkg_root, + &files, + &blobs, + dry_run, + ) + .await; assert!(!result.success, "{purl} dry_run={dry_run}: must refuse"); let err = result.error.unwrap_or_default(); assert!( @@ -3091,7 +3177,15 @@ mod tests { // Already original: nothing to write, so nothing to refuse. std::fs::write(store_pkg.join("index.js"), &original).unwrap(); - let result = rollback_package_patch(purl, &pkg_root, &files, &blobs, false).await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + purl, + &pkg_root, + &files, + &blobs, + false, + ) + .await; assert!(result.success, "{purl}: {:?}", result.error); } } @@ -3126,6 +3220,7 @@ mod tests { ); for dry_run in [true, false] { let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/left-pad@1.3.0", &nm.join("left-pad"), &files, @@ -3178,6 +3273,7 @@ mod tests { ); let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:pypi/pkg@1.0.0", site.path(), &files, @@ -3221,18 +3317,30 @@ mod tests { after_hash: compute_git_sha256_from_bytes(after), }, )]); - let res = - rollback_package_patch("pkg:npm/x@1.0.0", pkg.path(), &files, blobs.path(), false) - .await; + let res = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/x@1.0.0", + pkg.path(), + &files, + blobs.path(), + false, + ) + .await; assert!(res.success, "{res:?}"); assert_eq!(res.files_rolled_back, vec!["package/cli.js".to_string()]); assert_eq!( tokio::fs::read(pkg.path().join("cli.js")).await.unwrap(), b"#!/usr/bin/env node\nconsole.log(1);\r\n" ); - let again = - rollback_package_patch("pkg:npm/x@1.0.0", pkg.path(), &files, blobs.path(), false) - .await; + let again = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/x@1.0.0", + pkg.path(), + &files, + blobs.path(), + false, + ) + .await; assert!(again.success, "{again:?}"); assert!(again.files_rolled_back.is_empty(), "{again:?}"); } @@ -3273,8 +3381,15 @@ mod tests { after_hash: compute_git_sha256_from_bytes(after), }, )]); - let res = - rollback_package_patch("pkg:npm/x@1.0.0", &pkg, &files, blobs.path(), false).await; + let res = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/x@1.0.0", + &pkg, + &files, + blobs.path(), + false, + ) + .await; assert!(res.success, "{res:?}"); assert_eq!( tokio::fs::read(pkg.join("cli.js")).await.unwrap(), @@ -3321,8 +3436,15 @@ mod tests { .await .unwrap(); tokio::fs::write(pkg.join("cli.js"), before).await.unwrap(); - let res = - rollback_package_patch("pkg:npm/x@1.0.0", &pkg, &files, blobs.path(), false).await; + let res = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/x@1.0.0", + &pkg, + &files, + blobs.path(), + false, + ) + .await; assert!(res.success, "{with_blob}: {res:?}"); let expected: &[u8] = if with_blob { b"#!/usr/bin/env node\nconsole.log(1);\r\n" @@ -3356,9 +3478,15 @@ mod tests { after_hash: compute_git_sha256_from_bytes(b"#!/usr/bin/env node\r\nnew();\r\n"), }, )]); - let res = - rollback_package_patch("pkg:npm/x@1.0.0", pkg.path(), &files, blobs.path(), false) - .await; + let res = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/x@1.0.0", + pkg.path(), + &files, + blobs.path(), + false, + ) + .await; assert!(res.success, "{res:?}"); assert!(!pkg.path().join("cli.js").exists()); } @@ -3387,9 +3515,15 @@ mod tests { after_hash: compute_git_sha256_from_bytes(after), }, )]); - let res = - rollback_package_patch("pkg:npm/x@1.0.0", pkg.path(), &files, blobs.path(), false) - .await; + let res = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/x@1.0.0", + pkg.path(), + &files, + blobs.path(), + false, + ) + .await; assert!(res.success, "{res:?}"); assert_eq!( tokio::fs::read(pkg.path().join("cli.js")).await.unwrap(), @@ -3418,9 +3552,15 @@ mod tests { after_hash: compute_git_sha256_from_bytes(after), }, )]); - let res = - rollback_package_patch("pkg:pypi/x@1.0.0", pkg.path(), &files, blobs.path(), false) - .await; + let res = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:pypi/x@1.0.0", + pkg.path(), + &files, + blobs.path(), + false, + ) + .await; assert!(res.success, "{res:?}"); assert_eq!( tokio::fs::read(pkg.path().join("cli.py")).await.unwrap(), diff --git a/crates/socket-patch-core/src/patch/shared_store.rs b/crates/socket-patch-core/src/patch/shared_store.rs index d573c5941..ce3dd01d2 100644 --- a/crates/socket-patch-core/src/patch/shared_store.rs +++ b/crates/socket-patch-core/src/patch/shared_store.rs @@ -164,9 +164,12 @@ impl SharedStore { /// Classify `pkg_path`: `Some` when its real location is inside a shared /// store (see the module docs). A path that does not exist, or cannot be /// resolved, is `None`; the normal verify step reports a missing package. -pub async fn shared_store_of(pkg_path: &Path) -> Option { +pub async fn shared_store_of( + scope: crate::utils::repo_root::ReadScope, + pkg_path: &Path, +) -> Option { let pkg_path = pkg_path.to_path_buf(); - tokio::task::spawn_blocking(move || shared_store_of_blocking(&pkg_path)) + tokio::task::spawn_blocking(move || shared_store_of_blocking(scope, &pkg_path)) .await .ok() .flatten() @@ -181,6 +184,7 @@ pub async fn shared_store_of(pkg_path: &Path) -> Option { /// its nearest existing ancestor at or below `pkg_path`. Keys that escape /// the package dir are skipped; the caller refuses them on its own. pub async fn shared_store_of_patch_dirs<'a>( + scope: crate::utils::repo_root::ReadScope, pkg_path: &Path, file_keys: impl IntoIterator, ) -> Option { @@ -201,7 +205,7 @@ pub async fn shared_store_of_patch_dirs<'a>( Some(dir) })); dirs.filter(|dir| seen.insert(dir.clone())).find_map(|dir| { - shared_store_of_blocking(&dir).or_else(|| outside_install_tree(&pkg_path, &dir)) + shared_store_of_blocking(scope, &dir).or_else(|| outside_install_tree(&pkg_path, &dir)) }) }) .await @@ -209,7 +213,10 @@ pub async fn shared_store_of_patch_dirs<'a>( .flatten() } -fn shared_store_of_blocking(pkg_path: &Path) -> Option { +fn shared_store_of_blocking( + scope: crate::utils::repo_root::ReadScope, + pkg_path: &Path, +) -> Option { let real = std::fs::canonicalize(pkg_path).ok()?; for dir in real.ancestors() { let Some(name) = dir.file_name().and_then(|n| n.to_str()) else { @@ -251,7 +258,7 @@ fn shared_store_of_blocking(pkg_path: &Path) -> Option { }); } } - linked_source_of(pkg_path, real) + linked_source_of(scope, pkg_path, real) } /// [`SharedStoreKind::OutsideInstallTree`]: `dir` (the package directory @@ -291,7 +298,11 @@ fn composer_vendor_dir(pkg_path: &Path) -> Option<&Path> { /// `node_modules/@scope/`), yet its real path `real` is neither below /// that `node_modules` (a real dir, or a link into its own store) nor below /// any other `node_modules` (a workspace member's link into the root store). -fn linked_source_of(pkg_path: &Path, real: PathBuf) -> Option { +fn linked_source_of( + scope: crate::utils::repo_root::ReadScope, + pkg_path: &Path, + real: PathBuf, +) -> Option { let parent = pkg_path.parent()?; let node_modules = if is_node_modules(parent) { parent @@ -306,7 +317,7 @@ fn linked_source_of(pkg_path: &Path, real: PathBuf) -> Option { let real_node_modules = std::fs::canonicalize(node_modules).ok()?; if real.starts_with(&real_node_modules) || real.components().any(|c| c.as_os_str() == "node_modules") - || in_yarn_pnpm_store(node_modules, &real) + || in_yarn_pnpm_store(scope, node_modules, &real) { return None; } @@ -335,7 +346,11 @@ fn linked_source_of(pkg_path: &Path, real: PathBuf) -> Option { /// * `real` is exactly `//package`, where `` is Yarn's /// slug of a registry locator ([`is_yarn_copy_slug`]): the layout /// Yarn gives a hard (installed) package, never a workspace. -fn in_yarn_pnpm_store(node_modules: &Path, real: &Path) -> bool { +fn in_yarn_pnpm_store( + scope: crate::utils::repo_root::ReadScope, + node_modules: &Path, + real: &Path, +) -> bool { let Some(project) = node_modules .ancestors() .skip(1) @@ -343,10 +358,7 @@ fn in_yarn_pnpm_store(node_modules: &Path, real: &Path) -> bool { else { return false; }; - let Some(store) = crate::crawlers::pkg_managers::active_yarn_pnpm_store( - crate::utils::repo_root::ReadScope::Workspace, - project, - ) else { + let Some(store) = crate::crawlers::pkg_managers::active_yarn_pnpm_store(scope, project) else { return false; }; let (Ok(store), Ok(project)) = (std::fs::canonicalize(store), std::fs::canonicalize(project)) @@ -529,7 +541,9 @@ mod tests { async fn pnpm_global_virtual_store_is_shared() { let dir = tempfile::tempdir().unwrap(); let pkg = make_pnpm_gvs(dir.path()); - let got = shared_store_of(&pkg).await.expect("shared"); + let got = shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &pkg) + .await + .expect("shared"); assert_eq!(got.kind, SharedStoreKind::PnpmGlobalVirtualStore); assert_eq!(got.real_path, std::fs::canonicalize(&pkg).unwrap()); } @@ -538,13 +552,20 @@ mod tests { async fn pdm_package_cache_is_shared() { let dir = tempfile::tempdir().unwrap(); let pkg = make_pdm_cache_entry(dir.path()); - let got = shared_store_of(&pkg).await.expect("shared"); + let got = shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &pkg) + .await + .expect("shared"); assert_eq!(got.kind, SharedStoreKind::PdmPackageCache); // A file-level entry beside the package (a top-level module) is // inside the shared `lib/` too. let entry_lib = pkg.parent().unwrap().to_path_buf(); std::fs::write(entry_lib.join("six.py"), "x").unwrap(); - assert!(shared_store_of(&entry_lib.join("six.py")).await.is_some()); + assert!(shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &entry_lib.join("six.py") + ) + .await + .is_some()); } /// pnpm's per-project virtual store and plain directories carry no @@ -560,17 +581,26 @@ mod tests { .join("node_modules") .join("left-pad"); std::fs::create_dir_all(&pnpm).unwrap(); - assert_eq!(shared_store_of(&pnpm).await, None); + assert_eq!( + shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &pnpm).await, + None + ); // A `links` dir without the store's `files/` sibling, or under a // non-version parent, is an ordinary directory. let links = dir.path().join("v10").join("links").join("pkg"); std::fs::create_dir_all(&links).unwrap(); - assert_eq!(shared_store_of(&links).await, None); + assert_eq!( + shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &links).await, + None + ); let other = dir.path().join("vendor").join("links").join("pkg"); std::fs::create_dir_all(&other).unwrap(); std::fs::create_dir_all(dir.path().join("vendor").join("files")).unwrap(); - assert_eq!(shared_store_of(&other).await, None); + assert_eq!( + shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &other).await, + None + ); // A site-packages `lib/` without the PDM entry markers. let venv = dir @@ -581,7 +611,10 @@ mod tests { .join("site-packages") .join("six"); std::fs::create_dir_all(&venv).unwrap(); - assert_eq!(shared_store_of(&venv).await, None); + assert_eq!( + shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &venv).await, + None + ); let no_referrers = dir .path() .join("packages") @@ -589,10 +622,20 @@ mod tests { .join("lib") .join("x"); std::fs::create_dir_all(&no_referrers).unwrap(); - assert_eq!(shared_store_of(&no_referrers).await, None); + assert_eq!( + shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &no_referrers).await, + None + ); // Missing paths are not classified. - assert_eq!(shared_store_of(&dir.path().join("missing")).await, None); + assert_eq!( + shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &dir.path().join("missing") + ) + .await, + None + ); } #[cfg(unix)] @@ -603,7 +646,12 @@ mod tests { let nm = dir.path().join("project").join("node_modules"); std::fs::create_dir_all(&nm).unwrap(); std::os::unix::fs::symlink(&pkg, nm.join("left-pad")).unwrap(); - assert!(shared_store_of(&nm.join("left-pad")).await.is_some()); + assert!(shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &nm.join("left-pad") + ) + .await + .is_some()); // A per-project `.pnpm` link is not shared. let private = nm @@ -613,7 +661,14 @@ mod tests { .join("is-odd"); std::fs::create_dir_all(&private).unwrap(); std::os::unix::fs::symlink(&private, nm.join("is-odd")).unwrap(); - assert_eq!(shared_store_of(&nm.join("is-odd")).await, None); + assert_eq!( + shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &nm.join("is-odd") + ) + .await, + None + ); } /// #635: with Bun's global store each `node_modules/.bun/` is a @@ -652,7 +707,9 @@ mod tests { via_bun.clone(), pkg.join("node_modules/bundled"), ] { - let got = shared_store_of(&path).await.expect("shared"); + let got = shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &path) + .await + .expect("shared"); assert_eq!( got.kind, SharedStoreKind::BunGlobalStore, @@ -660,7 +717,12 @@ mod tests { path.display() ); } - let got = shared_store_of_patch_dirs(&link, ["lib/new/a.js"]).await; + let got = shared_store_of_patch_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &link, + ["lib/new/a.js"], + ) + .await; assert_eq!(got.map(|s| s.kind), Some(SharedStoreKind::BunGlobalStore)); } } @@ -675,7 +737,10 @@ mod tests { .path() .join("node_modules/.bun/left-pad@1.3.0/node_modules/left-pad"); std::fs::create_dir_all(&private).unwrap(); - assert_eq!(shared_store_of(&private).await, None); + assert_eq!( + shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &private).await, + None + ); for not_entry in [ // No hex hash after the entry name. "links/left-pad@1.3.0/node_modules/left-pad", @@ -690,7 +755,11 @@ mod tests { ] { let path = dir.path().join("x").join(not_entry); std::fs::create_dir_all(&path).unwrap(); - assert_eq!(shared_store_of(&path).await, None, "{not_entry}"); + assert_eq!( + shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &path).await, + None, + "{not_entry}" + ); } } @@ -708,18 +777,42 @@ mod tests { std::fs::create_dir_all(&site).unwrap(); std::os::unix::fs::symlink(&pkg, site.join("urllib3")).unwrap(); - assert_eq!(shared_store_of(&site).await, None); + assert_eq!( + shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &site).await, + None + ); for key in ["urllib3/response.py", "urllib3/new/dir/added.py"] { - let got = shared_store_of_patch_dirs(&site, [key]).await; + let got = shared_store_of_patch_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &site, + [key], + ) + .await; assert_eq!( got.map(|s| s.kind), Some(SharedStoreKind::PdmPackageCache), "{key}" ); } - assert_eq!(shared_store_of_patch_dirs(&site, ["six.py"]).await, None); + assert_eq!( + shared_store_of_patch_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &site, + ["six.py"] + ) + .await, + None + ); // An escaping key is ignored here (apply refuses it separately). - assert_eq!(shared_store_of_patch_dirs(&site, ["../x/y.py"]).await, None); + assert_eq!( + shared_store_of_patch_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &site, + ["../x/y.py"] + ) + .await, + None + ); } /// #626: a `node_modules` entry linked to first-party source (a @@ -754,11 +847,18 @@ mod tests { (nm.join("is-odd"), &checkout), (global_nm.join("is-odd"), &checkout), ] { - let got = shared_store_of(&pkg).await.expect("refused"); + let got = shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &pkg) + .await + .expect("refused"); assert_eq!(got.kind, SharedStoreKind::LinkedSource, "{}", pkg.display()); assert_eq!(got.real_path, std::fs::canonicalize(real).unwrap()); // Classified through the patch's own file keys too. - let got = shared_store_of_patch_dirs(&pkg, ["index.js", "lib/a.js"]).await; + let got = shared_store_of_patch_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &pkg, + ["index.js", "lib/a.js"], + ) + .await; assert_eq!(got.map(|s| s.kind), Some(SharedStoreKind::LinkedSource)); } } @@ -808,11 +908,23 @@ mod tests { linked_root.join("node_modules").join("six"), linked_root.join("node_modules").join("ms"), ] { - assert_eq!(shared_store_of(&pkg).await, None, "{}", pkg.display()); + assert_eq!( + shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &pkg).await, + None, + "{}", + pkg.display() + ); } // A first-party dir that is not spelled as a node_modules entry // (a PyPI site-packages root, a cargo vendor dir) is not classified. - assert_eq!(shared_store_of(&root.join("packages")).await, None); + assert_eq!( + shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &root.join("packages") + ) + .await, + None + ); } /// Yarn's pnpm linker with a relocated `pnpmStoreFolder` links @@ -825,7 +937,10 @@ mod tests { async fn relocated_yarn_pnpm_store_is_not_refused() { use std::os::unix::fs::symlink; let is_linked_source = |pkg: PathBuf| async move { - shared_store_of(&pkg).await.map(|s| s.kind) == Some(SharedStoreKind::LinkedSource) + shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &pkg) + .await + .map(|s| s.kind) + == Some(SharedStoreKind::LinkedSource) }; let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("ws"); @@ -868,16 +983,44 @@ mod tests { // With a yarn.lock, an ancestor's settings resolve against its dir. std::fs::write(root.join("yarn.lock"), "").unwrap(); - assert_eq!(shared_store_of(&nm.join("left-pad")).await, None); - assert_eq!(shared_store_of(&nm.join("react-dom")).await, None); - assert_eq!(shared_store_of(&nm.join("@acme").join("tool")).await, None); + assert_eq!( + shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &nm.join("left-pad") + ) + .await, + None + ); + assert_eq!( + shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &nm.join("react-dom") + ) + .await, + None + ); + assert_eq!( + shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &nm.join("@acme").join("tool") + ) + .await, + None + ); // The project's own `.yarnrc.yml` wins over the ancestor's. std::fs::write( root.join(".yarnrc.yml"), "pnpmStoreFolder: .cache/.store # relocated\n", ) .unwrap(); - assert_eq!(shared_store_of(&nm.join("left-pad")).await, None); + assert_eq!( + shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &nm.join("left-pad") + ) + .await, + None + ); for pkg in &refused { assert!(is_linked_source(pkg.clone()).await, "{}", pkg.display()); } @@ -930,7 +1073,11 @@ mod tests { std::fs::create_dir_all(&odd_nm).unwrap(); symlink(&number, odd_nm.join("is-number")).unwrap(); assert_eq!( - shared_store_of(&odd_nm.join("is-number")).await, + shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &odd_nm.join("is-number") + ) + .await, None, "{folder}" ); @@ -941,9 +1088,12 @@ mod tests { ) .unwrap(); assert_eq!( - shared_store_of(&odd_nm.join("is-number")) - .await - .map(|s| s.kind), + shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &odd_nm.join("is-number") + ) + .await + .map(|s| s.kind), Some(SharedStoreKind::LinkedSource), "{folder}: PnP linker" ); @@ -978,7 +1128,9 @@ mod tests { symlink(&named, nm.join("named")).unwrap(); for pkg in [nm.join("left-pad"), nm.join("named")] { assert_eq!( - shared_store_of(&pkg).await.map(|s| s.kind), + shared_store_of(crate::utils::repo_root::ReadScope::Workspace, &pkg) + .await + .map(|s| s.kind), Some(SharedStoreKind::LinkedSource), "{}", pkg.display() @@ -991,7 +1143,12 @@ mod tests { ) .unwrap(); assert_eq!( - shared_store_of(&nm.join("left-pad")).await.map(|s| s.kind), + shared_store_of( + crate::utils::repo_root::ReadScope::Workspace, + &nm.join("left-pad") + ) + .await + .map(|s| s.kind), Some(SharedStoreKind::LinkedSource) ); } @@ -1044,9 +1201,13 @@ mod tests { std::fs::create_dir_all(source.join("src")).unwrap(); std::os::unix::fs::symlink(&source, vendor.join("acme/pkg")).unwrap(); - let got = shared_store_of_patch_dirs(&vendor.join("acme/pkg"), ["src/a.php"]) - .await - .expect("refused"); + let got = shared_store_of_patch_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &vendor.join("acme/pkg"), + ["src/a.php"], + ) + .await + .expect("refused"); assert_eq!(got.kind, SharedStoreKind::OutsideInstallTree); assert_eq!(got.real_path, std::fs::canonicalize(&source).unwrap()); assert!(got @@ -1065,14 +1226,24 @@ mod tests { let installed = vendor.join("acme/real"); std::fs::create_dir_all(installed.join("src")).unwrap(); assert_eq!( - shared_store_of_patch_dirs(&installed, ["src/a.php"]).await, + shared_store_of_patch_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &installed, + ["src/a.php"] + ) + .await, None ); // Without Composer's marker the layout is not recognized as a // vendor dir, and the package dir itself is the install tree. std::fs::remove_file(vendor.join("composer/installed.json")).unwrap(); assert_eq!( - shared_store_of_patch_dirs(&vendor.join("acme/pkg"), ["src/a.php"]).await, + shared_store_of_patch_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &vendor.join("acme/pkg"), + ["src/a.php"] + ) + .await, None ); } @@ -1089,15 +1260,24 @@ mod tests { let source = dir.path().join("src/mypkg"); std::fs::create_dir_all(&source).unwrap(); std::os::unix::fs::symlink(&source, site.join("mypkg")).unwrap(); - let got = shared_store_of_patch_dirs(&site, ["mypkg/__init__.py"]) - .await - .expect("refused"); + let got = shared_store_of_patch_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &site, + ["mypkg/__init__.py"], + ) + .await + .expect("refused"); assert_eq!(got.kind, SharedStoreKind::OutsideInstallTree); std::fs::create_dir_all(site.join("real/inner")).unwrap(); std::os::unix::fs::symlink(site.join("real/inner"), site.join("alias")).unwrap(); assert_eq!( - shared_store_of_patch_dirs(&site, ["real/x.py", "alias/y.py"]).await, + shared_store_of_patch_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &site, + ["real/x.py", "alias/y.py"] + ) + .await, None ); @@ -1105,7 +1285,12 @@ mod tests { // user's layout, not a link out of the tree. std::os::unix::fs::symlink(&site, dir.path().join("site-link")).unwrap(); assert_eq!( - shared_store_of_patch_dirs(&dir.path().join("site-link"), ["real/x.py"]).await, + shared_store_of_patch_dirs( + crate::utils::repo_root::ReadScope::Workspace, + &dir.path().join("site-link"), + ["real/x.py"] + ) + .await, None ); } diff --git a/crates/socket-patch-core/src/patch/store_copies.rs b/crates/socket-patch-core/src/patch/store_copies.rs index 2a0c8fe22..4d1d7fa5a 100644 --- a/crates/socket-patch-core/src/patch/store_copies.rs +++ b/crates/socket-patch-core/src/patch/store_copies.rs @@ -43,7 +43,12 @@ pub(crate) trait CopyFold: Sized { /// other pnpm/vlt store copy of the same package, folding each copy in. /// Copies are visited even when the primary was already done: that is the /// state a single-copy run leaves behind (done primary, untouched twin). -pub(crate) async fn fan_out(package_key: &str, pkg_path: &Path, engine: F) -> R +pub(crate) async fn fan_out( + scope: crate::utils::repo_root::ReadScope, + package_key: &str, + pkg_path: &Path, + engine: F, +) -> R where R: CopyFold, F: Fn(PathBuf) -> Fut, @@ -53,7 +58,9 @@ where // Only npm purls can name pnpm or vlt store copies; everything else // skips the (already cheap) discovery outright. if result.success() && package_key.starts_with("pkg:npm/") { - for copy in crate::crawlers::npm_crawler::find_store_peer_variant_copies(pkg_path).await { + for copy in + crate::crawlers::npm_crawler::find_store_peer_variant_copies(scope, pkg_path).await + { let copy_result = engine(copy.clone()).await; fold(&mut result, ©, copy_result); } @@ -172,6 +179,7 @@ mod regression_tests { async fn apply_reports_a_write_to_an_unpatched_twin_copy() { let (_root, primary, copies, blobs, files) = pnpm_twins(PATCHED, ORIGINAL).await; let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/foo@1.0.0", &primary, &files, @@ -215,6 +223,7 @@ mod regression_tests { .await .unwrap(); let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/foo@1.0.0", &primary, &files, @@ -243,6 +252,7 @@ mod regression_tests { async fn apply_dry_run_carries_an_unpatched_twin_verify_record() { let (_root, primary, copies, blobs, files) = pnpm_twins(PATCHED, ORIGINAL).await; let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/foo@1.0.0", &primary, &files, @@ -266,6 +276,7 @@ mod regression_tests { async fn apply_over_patched_twins_stays_already_patched() { let (_root, primary, _copies, blobs, files) = pnpm_twins(PATCHED, PATCHED).await; let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, "pkg:npm/foo@1.0.0", &primary, &files, @@ -290,8 +301,15 @@ mod regression_tests { #[tokio::test] async fn rollback_reports_a_restore_of_a_patched_twin_copy() { let (_root, primary, copies, blobs, files) = pnpm_twins(ORIGINAL, PATCHED).await; - let result = - rollback_package_patch("pkg:npm/foo@1.0.0", &primary, &files, &blobs, false).await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/foo@1.0.0", + &primary, + &files, + &blobs, + false, + ) + .await; assert!(result.success, "{:?}", result.error); assert_eq!(read(&copies[1].join("index.js")).await, ORIGINAL); @@ -315,8 +333,15 @@ mod regression_tests { #[tokio::test] async fn rollback_over_original_twins_stays_already_original() { let (_root, primary, _copies, blobs, files) = pnpm_twins(ORIGINAL, ORIGINAL).await; - let result = - rollback_package_patch("pkg:npm/foo@1.0.0", &primary, &files, &blobs, false).await; + let result = rollback_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/foo@1.0.0", + &primary, + &files, + &blobs, + false, + ) + .await; assert!(result.success, "{:?}", result.error); assert!(result.files_rolled_back.is_empty()); assert_eq!(result.files_verified.len(), 2); diff --git a/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs b/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs index 43744c509..93a34775c 100644 --- a/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs +++ b/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs @@ -380,7 +380,7 @@ async fn global_crawl_reads_a_package_outside_the_repository() { global_prefix: None, }; assert_eq!(local.read_scope(), ReadScope::Workspace); - let found = crate::crawlers::npm_crawler::NpmCrawler + let found = crate::crawlers::npm_crawler::NpmCrawler::for_options(&options) .crawl_all(&options) .await; assert!( diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 8afbbb1e1..9cfb2900d 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -190,7 +190,7 @@ async fn read_package_json_empty_version_returns_none() { #[serial_test::parallel] fn npm_crawler_new_and_default_construct_cleanly() { let _a = NpmCrawler::new(); - let _b = NpmCrawler; + let _b = NpmCrawler::default(); } // ── get_node_modules_paths ───────────────────────────────────── @@ -204,7 +204,7 @@ async fn get_node_modules_paths_global_prefix_passthrough() { let custom = tmp.path().join("custom-nm"); tokio::fs::create_dir_all(&custom).await.unwrap(); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let opts = CrawlerOptions { cwd: tmp.path().to_path_buf(), global: false, @@ -222,7 +222,7 @@ async fn get_node_modules_paths_global_prefix_passthrough() { #[serial_test::parallel] async fn get_node_modules_paths_global_mode_no_prefix() { let tmp = tempfile::tempdir().unwrap(); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let opts = CrawlerOptions { cwd: tmp.path().to_path_buf(), global: true, @@ -671,7 +671,7 @@ async fn find_by_purls_unscoped_package() { let nm = tmp.path().join("node_modules"); stage_npm_pkg(&nm, "lodash", "4.17.21").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/lodash@4.17.21".to_string()]) .await @@ -703,7 +703,7 @@ async fn find_by_purls_scoped_package() { let nm = tmp.path().join("node_modules"); stage_npm_pkg(&nm, "@types/node", "20.0.0").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/@types/node@20.0.0".to_string()]) .await @@ -730,7 +730,7 @@ async fn find_by_purls_version_mismatch_returns_empty() { let nm = tmp.path().join("node_modules"); stage_npm_pkg(&nm, "lodash", "4.17.21").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/lodash@99.99.99".to_string()]) .await @@ -751,7 +751,7 @@ async fn find_by_purls_resolves_qualified_purl_keyed_by_input() { let nm = tmp.path().join("node_modules"); stage_npm_pkg(&nm, "lodash", "4.17.21").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let qualified = "pkg:npm/lodash@4.17.21?extension=tgz".to_string(); let result = crawler .find_by_purls(&nm, std::slice::from_ref(&qualified)) @@ -786,7 +786,7 @@ async fn find_by_purls_qualifier_containing_at_does_not_corrupt_version() { stage_npm_pkg(&nm, "foo", "1.0.0").await; stage_npm_pkg(&nm, "@types/node", "20.0.0").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let unscoped_q = "pkg:npm/foo@1.0.0?vcs_url=git@github.com:x/y.git".to_string(); let scoped_q = "pkg:npm/@types/node@20.0.0?maintainer=a@b.com".to_string(); let result = crawler @@ -818,7 +818,7 @@ async fn find_by_purls_qualifier_containing_at_does_not_corrupt_version() { async fn find_by_purls_purl_without_at_skipped() { let tmp = tempfile::tempdir().unwrap(); let nm = tmp.path().join("node_modules"); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/lodash".to_string()]) .await @@ -833,7 +833,7 @@ async fn find_by_purls_purl_without_at_skipped() { async fn find_by_purls_purl_with_empty_version_skipped() { let tmp = tempfile::tempdir().unwrap(); let nm = tmp.path().join("node_modules"); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/lodash@".to_string()]) .await @@ -848,7 +848,7 @@ async fn find_by_purls_purl_with_empty_version_skipped() { async fn find_by_purls_scoped_purl_without_slash_skipped() { let tmp = tempfile::tempdir().unwrap(); let nm = tmp.path().join("node_modules"); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/@foo@1.0".to_string()]) .await @@ -863,7 +863,7 @@ async fn find_by_purls_scoped_purl_without_slash_skipped() { async fn find_by_purls_scoped_purl_with_empty_name_skipped() { let tmp = tempfile::tempdir().unwrap(); let nm = tmp.path().join("node_modules"); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/@scope/@1.0".to_string()]) .await @@ -875,7 +875,7 @@ async fn find_by_purls_scoped_purl_with_empty_name_skipped() { #[serial_test::parallel] async fn find_by_purls_invalid_purl_skipped() { let tmp = tempfile::tempdir().unwrap(); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(tmp.path(), &["pkg:not-npm/foo@1.0".to_string()]) .await @@ -893,7 +893,7 @@ async fn crawl_all_discovers_unscoped_and_scoped() { stage_npm_pkg(&nm, "lodash", "4.17.21").await; stage_npm_pkg(&nm, "@types/node", "20.0.0").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let opts = options_at(tmp.path()); let result = crawler.crawl_all(&opts).await; assert_eq!( @@ -932,7 +932,7 @@ async fn crawl_all_skips_dirs_without_package_json() { .unwrap(); // No package.json — must be skipped. - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let opts = options_at(tmp.path()); let result = crawler.crawl_all(&opts).await; assert!(result.is_empty()); @@ -949,7 +949,7 @@ async fn crawl_all_recurses_into_workspace_packages() { let pkg_dir = tmp.path().join("packages").join("ws-a"); stage_npm_pkg(&pkg_dir.join("node_modules"), "lodash", "4.17.21").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let opts = options_at(tmp.path()); let result = crawler.crawl_all(&opts).await; let lodash = result @@ -1026,7 +1026,7 @@ async fn crawl_all_finds_declared_members_with_skip_dir_names() { expected.push(stage_member(root, member).await); } - let result = NpmCrawler.crawl_all(&options_at(root)).await; + let result = NpmCrawler::new().crawl_all(&options_at(root)).await; let mut got: Vec = result.iter().map(|p| p.path.clone()).collect(); got.sort(); expected.sort(); @@ -1079,7 +1079,7 @@ async fn crawl_all_finds_declared_members_in_hidden_dirs() { expected.push(stage_member(root, member).await); } - let result = NpmCrawler.crawl_all(&options_at(root)).await; + let result = NpmCrawler::new().crawl_all(&options_at(root)).await; let mut got: Vec = result.iter().map(|p| p.path.clone()).collect(); got.sort(); expected.sort(); @@ -1122,7 +1122,7 @@ async fn crawl_all_still_skips_undeclared_hidden_dirs() { ) .await; - let result = NpmCrawler.crawl_all(&options_at(root)).await; + let result = NpmCrawler::new().crawl_all(&options_at(root)).await; let got: Vec = result.iter().map(|p| p.path.clone()).collect(); assert_eq!(got, vec![member]); } @@ -1145,7 +1145,7 @@ async fn crawl_all_finds_skip_dir_members_of_a_nested_workspace() { stage_npm_pkg(&web.join("node_modules"), "ms", "2.1.3").await; let member = stage_member(root, "web/packages/dist").await; - let result = NpmCrawler.crawl_all(&options_at(root)).await; + let result = NpmCrawler::new().crawl_all(&options_at(root)).await; let mut got: Vec = result.iter().map(|p| p.path.clone()).collect(); got.sort(); assert_eq!(got, vec![web.join("node_modules").join("ms"), member]); @@ -1176,7 +1176,7 @@ async fn crawl_all_member_walk_keeps_the_walk_rules() { .unwrap(); let member = stage_member(root, "packages/Build").await; - let result = NpmCrawler.crawl_all(&options_at(root)).await; + let result = NpmCrawler::new().crawl_all(&options_at(root)).await; let got: Vec = result.iter().map(|p| p.path.clone()).collect(); assert_eq!(got, vec![member]); } @@ -1203,7 +1203,7 @@ async fn crawl_all_finds_members_of_a_tagged_scan_root() { .unwrap(); let member = stage_member(root, "apps/build").await; - let result = NpmCrawler.crawl_all(&options_at(root)).await; + let result = NpmCrawler::new().crawl_all(&options_at(root)).await; let got: Vec = result.iter().map(|p| p.path.clone()).collect(); assert_eq!(got, vec![member]); } @@ -1239,7 +1239,7 @@ async fn crawl_all_still_skips_undeclared_skip_dirs() { // Declared by `build/*` but no package.json. stage_npm_pkg(&root.join("build/out/node_modules"), "not-a-member", "1.0").await; - let result = NpmCrawler.crawl_all(&options_at(root)).await; + let result = NpmCrawler::new().crawl_all(&options_at(root)).await; let got: Vec = result.iter().map(|p| p.path.clone()).collect(); assert_eq!(got, vec![member]); } @@ -1269,7 +1269,7 @@ async fn crawl_all_skips_hidden_and_skip_dirs() { ) .await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let opts = options_at(tmp.path()); let result = crawler.crawl_all(&opts).await; let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect(); @@ -1308,7 +1308,7 @@ async fn crawl_all_handles_unreadable_node_modules() { stage_npm_pkg(&nm, "would-be-found", "1.0.0").await; common::chmod_unreadable(&nm); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let opts = options_at(tmp.path()); let result = crawler.crawl_all(&opts).await; common::chmod_readable(&nm); @@ -1345,7 +1345,7 @@ async fn crawl_all_handles_unreadable_workspace_dir() { stage_npm_pkg(&blocked.join("node_modules"), "hidden", "2.0.0").await; common::chmod_unreadable(&blocked); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let opts = options_at(tmp.path()); let result = crawler.crawl_all(&opts).await; common::chmod_readable(&blocked); @@ -1407,7 +1407,7 @@ async fn crawl_all_handles_nested_and_messy_scope_dir() { ) .await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let opts = options_at(tmp.path()); let result = crawler.crawl_all(&opts).await; @@ -1466,7 +1466,7 @@ async fn crawl_all_discovers_deeply_nested_transitive_deps() { stage_npm_pkg(&b_nm, "c", "3.0.0").await; stage_npm_pkg(&c_nm, "d", "4.0.0").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler.crawl_all(&options_at(tmp.path())).await; let ver = |n: &str| -> Option<&str> { @@ -1502,7 +1502,7 @@ async fn crawl_all_skips_dirs_with_corrupt_package_json() { .await .unwrap(); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let opts = options_at(tmp.path()); let result = crawler.crawl_all(&opts).await; assert!(result.is_empty()); @@ -1540,7 +1540,7 @@ async fn crawl_all_does_not_recurse_through_symlinked_nested_package() { // ...containing a SYMLINK to the out-of-tree store package. symlink(&linked_pkg, host_nm.join("linked-pkg")).unwrap(); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let opts = options_at(tmp.path()); let result = crawler.crawl_all(&opts).await; let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect(); @@ -1586,7 +1586,7 @@ async fn read_package_json_tolerates_utf8_bom() { ); // The production symptom: the package must be visible to scan… - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let crawled = crawler.crawl_all(&options_at(tmp.path())).await; assert_eq!( crawled.len(), @@ -1627,7 +1627,7 @@ async fn find_by_purls_rejects_alias_dir_with_matching_version() { .await .unwrap(); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/foo@1.0.0".to_string()]) .await @@ -1681,7 +1681,7 @@ async fn find_by_purls_resolves_nested_only_install() { // Nested scoped package. stage_npm_pkg(&a_nm, "@s/d", "1.0.0").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let purls = vec![ "pkg:npm/b@2.0.0".to_string(), "pkg:npm/c@5.0.0".to_string(), @@ -1754,7 +1754,7 @@ async fn read_package_json_rejects_fifo_without_hanging() { }; assert_eq!(direct, None, "a FIFO is not a valid package.json"); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let Ok(crawled) = tokio::time::timeout(deadline, crawler.crawl_all(&options_at(tmp.path()))).await else { @@ -1894,7 +1894,7 @@ async fn find_by_purls_resolves_pnpm_virtual_store_transitives() { let nm = stage_pnpm_isolated_tree(tmp.path()).await; let store = nm.join(".pnpm"); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let purls = vec![ "pkg:npm/mkdirp@0.5.5".to_string(), "pkg:npm/minimist@1.2.8".to_string(), @@ -1977,7 +1977,7 @@ async fn crawl_all_inventories_pnpm_virtual_store_exactly_once() { let nm = stage_pnpm_isolated_tree(tmp.path()).await; let store = nm.join(".pnpm"); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler.crawl_all(&options_at(tmp.path())).await; let purls: Vec<&str> = result.iter().map(|p| p.purl.as_str()).collect(); @@ -2046,7 +2046,7 @@ async fn find_by_purls_fallback_pass_probes_store_entries_decoding_to_other_name ) .await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let purls = vec![ "pkg:npm/wanted@1.0.0".to_string(), "pkg:npm/@s/wanted@1.0.0".to_string(), @@ -2084,7 +2084,7 @@ async fn find_by_purls_resolves_pnpm_store_entry_with_peer_suffix() { let entry_nm = store.join("wanted@1.0.0(peer@2.0.0)").join("node_modules"); stage_npm_pkg(&entry_nm, "wanted", "1.0.0").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/wanted@1.0.0".to_string()]) .await @@ -2113,7 +2113,7 @@ async fn find_by_purls_probes_undecodable_pnpm_store_entry() { .join("node_modules"); stage_npm_pkg(&entry_nm, "wanted2", "1.0.0").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/wanted2@1.0.0".to_string()]) .await @@ -2152,7 +2152,7 @@ async fn crawl_all_inventories_bundled_dep_under_seen_store_entry() { // Root-linked direct dep (importer pass inventories it first). symlink(host_store_nm.join("host"), nm.join("host")).unwrap(); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler.crawl_all(&options_at(tmp.path())).await; let host = result @@ -2194,7 +2194,7 @@ async fn find_by_purls_returns_root_and_nested_duplicate_root_first() { stage_npm_pkg(&nm, "dup", "1.0.0").await; stage_npm_pkg(&nm.join("a").join("node_modules"), "dup", "1.0.0").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/dup@1.0.0".to_string()]) .await @@ -2330,7 +2330,7 @@ async fn find_by_purls_resolves_pnpm4_nested_store_transitives() { let nm = stage_pnpm4_nested_tree(tmp.path()).await; let host = nm.join(".pnpm/registry.npmjs.org"); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let purls = vec![ "pkg:npm/mkdirp@0.5.5".to_string(), "pkg:npm/minimist@1.2.8".to_string(), @@ -2396,7 +2396,7 @@ async fn crawl_all_inventories_pnpm4_nested_store_exactly_once() { let nm = stage_pnpm4_nested_tree(tmp.path()).await; let host = nm.join(".pnpm/registry.npmjs.org"); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler.crawl_all(&options_at(tmp.path())).await; let purls: Vec<&str> = result.iter().map(|p| p.purl.as_str()).collect(); @@ -2520,7 +2520,7 @@ async fn find_by_purls_resolves_pnpm3_legacy_registry_store_transitive() { let nm = stage_pnpm3_legacy_tree(tmp.path()).await; let store = nm.join(".registry.npmjs.org"); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let purls = vec![ "pkg:npm/mkdirp@0.5.5".to_string(), "pkg:npm/minimist@1.2.8".to_string(), @@ -2561,7 +2561,7 @@ async fn crawl_all_inventories_pnpm3_legacy_registry_store_exactly_once() { let nm = stage_pnpm3_legacy_tree(tmp.path()).await; let store = nm.join(".registry.npmjs.org"); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler.crawl_all(&options_at(tmp.path())).await; let purls: Vec<&str> = result.iter().map(|p| p.purl.as_str()).collect(); @@ -2656,7 +2656,7 @@ async fn apply_and_rollback_reach_every_pnpm_peer_variant_copy() { ); // Resolve the primary exactly the way apply's dispatcher does. - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let resolved = crawler .find_by_purls(&nm, &["pkg:npm/foo@1.0.0".to_string()]) .await @@ -2673,6 +2673,7 @@ async fn apply_and_rollback_reach_every_pnpm_peer_variant_copy() { mem_blobs: None, }; let result = apply_package_patch( + socket_patch_core::utils::repo_root::ReadScope::Workspace, "pkg:npm/foo@1.0.0", &primary, &files, @@ -2702,7 +2703,15 @@ async fn apply_and_rollback_reach_every_pnpm_peer_variant_copy() { ); // Rollback restores every copy too. - let rb = rollback_package_patch("pkg:npm/foo@1.0.0", &primary, &files, &blobs, false).await; + let rb = rollback_package_patch( + socket_patch_core::utils::repo_root::ReadScope::Workspace, + "pkg:npm/foo@1.0.0", + &primary, + &files, + &blobs, + false, + ) + .await; assert!(rb.success, "rollback must succeed: {:?}", rb.error); for entry_nm in &variants { let bytes = tokio::fs::read(entry_nm.join("foo").join("index.js")) @@ -2777,6 +2786,7 @@ async fn apply_heals_unpatched_pnpm_twin_when_primary_already_patched() { mem_blobs: None, }; let result = apply_package_patch( + socket_patch_core::utils::repo_root::ReadScope::Workspace, "pkg:npm/foo@1.0.0", &nm.join("foo"), &files, @@ -2813,7 +2823,7 @@ async fn find_by_purls_resolves_bundled_only_target_via_fallback_pass() { // nested node_modules. stage_npm_pkg(&host_nm.join("host").join("node_modules"), "leaf", "2.0.0").await; - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let result = crawler .find_by_purls(&nm, &["pkg:npm/leaf@2.0.0".to_string()]) .await @@ -2878,7 +2888,7 @@ async fn find_by_purls_returns_bundled_copy_of_an_already_found_target() { }; let purl = "pkg:npm/left-pad@1.3.0".to_string(); - let result = NpmCrawler + let result = NpmCrawler::new() .find_by_purls(&nm, std::slice::from_ref(&purl)) .await .unwrap(); @@ -2948,7 +2958,7 @@ fn find_by_purls_bounds_bundled_store_cycles_and_keeps_linked_copies() { stage_npm_pkg(&entry_nm, "host-c", "1.0.0").await; std::os::unix::fs::symlink(&linked, entry_nm.join("host-c/node_modules")).unwrap(); let purl = "pkg:npm/target@1.0.0".to_string(); - let found = NpmCrawler + let found = NpmCrawler::new() .find_by_purls(&nm, std::slice::from_ref(&purl)) .await .unwrap(); @@ -2985,7 +2995,7 @@ async fn find_by_purls_preserves_importer_mode_after_store_visit() { stage_npm_pkg(&host_nm, "host", "1.0.0").await; std::os::unix::fs::symlink(&opaque_nm, host_nm.join("host/node_modules")).unwrap(); let purl = "pkg:npm/only-linked@1.0.0".to_string(); - let found = NpmCrawler + let found = NpmCrawler::new() .find_by_purls(&nm, std::slice::from_ref(&purl)) .await .unwrap(); @@ -3198,7 +3208,7 @@ async fn find_by_purls_resolves_vlt_store_transitives() { .keys() .map(|key| nm.join(key)) .collect(); - let result = NpmCrawler.find_by_purls(&nm, &purls).await.unwrap(); + let result = NpmCrawler::new().find_by_purls(&nm, &purls).await.unwrap(); for purl in &purls { let found = result .get(purl) @@ -3274,7 +3284,7 @@ async fn crawl_all_inventories_vlt_store_exactly_once() { let nm = stage_vlt_tree(tmp.path(), &listing).await; let copies = vlt_real_copies(tmp.path(), &listing); - let result = NpmCrawler.crawl_all(&options_at(tmp.path())).await; + let result = NpmCrawler::new().crawl_all(&options_at(tmp.path())).await; let mut purls: Vec<&str> = result.iter().map(|p| p.purl.as_str()).collect(); purls.sort_unstable(); let before = purls.len(); @@ -3339,7 +3349,7 @@ async fn find_by_purls_resolves_vlt_store_entry_with_peer_extra() { if vlt_importer_key(&listing, tmp.path(), &purl).is_some() { continue; } - let result = NpmCrawler + let result = NpmCrawler::new() .find_by_purls(&nm, std::slice::from_ref(&purl)) .await .unwrap(); @@ -3380,7 +3390,7 @@ async fn find_by_purls_probes_undecodable_vlt_store_entry() { let find = |purl: &'static str| { let nm = nm.clone(); async move { - NpmCrawler + NpmCrawler::new() .find_by_purls(&nm, &[purl.to_string()]) .await .unwrap() @@ -3473,7 +3483,7 @@ async fn find_by_purls_skips_vlt_dependency_links_and_importer_resolved_copies() .iter() .map(|p| p.to_string()) .collect(); - let result = NpmCrawler.find_by_purls(&nm, &purls).await.unwrap(); + let result = NpmCrawler::new().find_by_purls(&nm, &purls).await.unwrap(); let paths = |purl: &str| -> Vec { result .get(purl) @@ -3513,7 +3523,7 @@ async fn find_by_purls_returns_same_version_git_copy_as_its_own_primary() { std::os::unix::fs::symlink(".vlt/~npm~tap@1.0.0/node_modules/tap", nm.join("tap")).unwrap(); let purl = "pkg:npm/tap@1.0.0".to_string(); - let result = NpmCrawler + let result = NpmCrawler::new() .find_by_purls(&nm, std::slice::from_ref(&purl)) .await .unwrap(); @@ -3524,9 +3534,12 @@ async fn find_by_purls_returns_same_version_git_copy_as_its_own_primary() { .collect::>(), vec![nm.join("tap"), git_copy] ); - assert!(find_store_peer_variant_copies(&nm.join("tap")) - .await - .is_empty()); + assert!(find_store_peer_variant_copies( + socket_patch_core::utils::repo_root::ReadScope::Workspace, + &nm.join("tap") + ) + .await + .is_empty()); } /// Only a REAL `node_modules/.vlt` directory is a store: one that links to @@ -3551,12 +3564,12 @@ async fn find_by_purls_and_crawl_all_never_follow_a_linked_vlt_store() { std::os::unix::fs::symlink(outside.path(), nm.join(".vlt")).unwrap(); let purl = "pkg:npm/x@1.0.0".to_string(); - let result = NpmCrawler + let result = NpmCrawler::new() .find_by_purls(&nm, std::slice::from_ref(&purl)) .await .unwrap(); assert!(result.is_empty(), "got {result:?}"); - let scanned = NpmCrawler.crawl_all(&options_at(tmp.path())).await; + let scanned = NpmCrawler::new().crawl_all(&options_at(tmp.path())).await; assert!(!scanned.iter().any(|p| p.purl == purl), "got {scanned:?}"); } @@ -3627,10 +3640,10 @@ async fn find_by_purls_and_crawl_all_skip_vlt_hoist_dir_and_vlt_json_and_delete_ "pkg:npm/bin-decoy@9.9.9", ]; let purls: Vec = decoys.iter().map(|p| p.to_string()).collect(); - let result = NpmCrawler.find_by_purls(&nm, &purls).await.unwrap(); + let result = NpmCrawler::new().find_by_purls(&nm, &purls).await.unwrap(); assert!(result.is_empty(), "no decoy resolves; got {result:?}"); - let scanned = NpmCrawler.crawl_all(&options_at(tmp.path())).await; + let scanned = NpmCrawler::new().crawl_all(&options_at(tmp.path())).await; for decoy in decoys { assert!( !scanned.iter().any(|p| p.purl == decoy), @@ -3656,7 +3669,7 @@ async fn crawl_all_inventories_vlt_workspace_root_store_once() { let listing = vlt_listing("1.0.0-rc.22-workspace"); let nm = stage_vlt_tree(tmp.path(), &listing).await; - let mut roots = NpmCrawler + let mut roots = NpmCrawler::new() .get_node_modules_paths(&options_at(tmp.path())) .await .unwrap(); @@ -3670,7 +3683,7 @@ async fn crawl_all_inventories_vlt_workspace_root_store_once() { ] ); - let result = NpmCrawler.crawl_all(&options_at(tmp.path())).await; + let result = NpmCrawler::new().crawl_all(&options_at(tmp.path())).await; let mut purls: Vec<&str> = result.iter().map(|p| p.purl.as_str()).collect(); purls.sort_unstable(); let copies = vlt_real_copies(tmp.path(), &listing); @@ -3734,6 +3747,7 @@ async fn apply_index_js( mem_blobs: None, }; apply_package_patch( + socket_patch_core::utils::repo_root::ReadScope::Workspace, purl, primary, &patch.files, @@ -3837,8 +3851,15 @@ async fn apply_and_rollback_reach_every_vlt_variant_copy() { } assert_eq!(tokio::fs::read(&cas).await.unwrap(), ORIGINAL); - let rb = - rollback_package_patch(case.purl, &primary, &patch.files, &patch.blobs, false).await; + let rb = rollback_package_patch( + socket_patch_core::utils::repo_root::ReadScope::Workspace, + case.purl, + &primary, + &patch.files, + &patch.blobs, + false, + ) + .await; assert!(rb.success, "{}: {:?}", case.tree, rb.error); for copy in &copies { assert_eq!( @@ -4126,7 +4147,11 @@ async fn find_store_peer_variant_copies_probe_gates() { ) .await; - let copies = find_store_peer_variant_copies(&primary).await; + let copies = find_store_peer_variant_copies( + socket_patch_core::utils::repo_root::ReadScope::Workspace, + &primary, + ) + .await; let got: std::collections::HashSet<_> = copies.iter().cloned().collect(); let want: std::collections::HashSet<_> = [twin.clone(), undecodable_copy.clone()] @@ -4162,7 +4187,11 @@ async fn find_store_peer_variant_copies_unreadable_primary_returns_empty() { ) .await; - let copies = find_store_peer_variant_copies(&primary).await; + let copies = find_store_peer_variant_copies( + socket_patch_core::utils::repo_root::ReadScope::Workspace, + &primary, + ) + .await; assert!( copies.is_empty(), "unreadable primary identity ⇒ no twins reported; got {copies:?}" @@ -4202,7 +4231,11 @@ async fn find_store_peer_variant_copies_vlt_member_link_and_git_copy() { ) .unwrap(); - let copies = find_store_peer_variant_copies(&member_nm.join("foo")).await; + let copies = find_store_peer_variant_copies( + socket_patch_core::utils::repo_root::ReadScope::Workspace, + &member_nm.join("foo"), + ) + .await; assert_eq!( copies .iter() @@ -4228,7 +4261,12 @@ async fn find_store_peer_variant_copies_vlt_unreadable_primary_returns_empty() { "1.0.0", ) .await; - assert!(find_store_peer_variant_copies(&primary).await.is_empty()); + assert!(find_store_peer_variant_copies( + socket_patch_core::utils::repo_root::ReadScope::Workspace, + &primary + ) + .await + .is_empty()); } // ── pnpm<=3 legacy store: stray node_modules + depth-1 home ──── @@ -4536,7 +4574,7 @@ async fn deno_npm_cache_prefix_crawls_name_version_layout() { global: false, global_prefix: Some(registry.clone()), }; - let mut purls: Vec = NpmCrawler + let mut purls: Vec = NpmCrawler::new() .crawl_all(&opts) .await .into_iter() @@ -4574,7 +4612,10 @@ async fn deno_npm_cache_find_by_purls_returns_every_copy() { "pkg:npm/left-pad@1.3.0".to_string(), "pkg:npm/absent@1.0.0".to_string(), ]; - let found = NpmCrawler.find_by_purls(®istry, &purls).await.unwrap(); + let found = NpmCrawler::new() + .find_by_purls(®istry, &purls) + .await + .unwrap(); assert_eq!( found_paths(&found, "pkg:npm/is-odd@3.0.1"), vec![plain, copy] @@ -4597,7 +4638,10 @@ async fn deno_npm_cache_find_by_purls_rejects_traversal() { stage_deno_npm_pkg(tmp.path(), "evil", "1.0.0", "1.0.0").await; let purls = vec!["pkg:npm/..%2F..%2Fevil@1.0.0".to_string()]; - let found = NpmCrawler.find_by_purls(®istry, &purls).await.unwrap(); + let found = NpmCrawler::new() + .find_by_purls(®istry, &purls) + .await + .unwrap(); assert!(found.values().all(|copies| copies.is_empty()), "{found:?}"); } @@ -4629,8 +4673,11 @@ async fn global_mode_includes_deno_npm_cache() { global: true, global_prefix: None, }; - let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); - let crawled = NpmCrawler.crawl_all(&opts).await; + let roots = NpmCrawler::new() + .get_node_modules_paths(&opts) + .await + .unwrap(); + let crawled = NpmCrawler::new().crawl_all(&opts).await; assert!(roots.contains(®istry), "{roots:?}"); assert!( @@ -4655,14 +4702,14 @@ async fn non_deno_root_under_npm_dir_keeps_node_modules_walk() { global: false, global_prefix: Some(deps.clone()), }; - let purls: Vec = NpmCrawler + let purls: Vec = NpmCrawler::new() .crawl_all(&opts) .await .into_iter() .map(|p| p.purl) .collect(); assert_eq!(purls, vec!["pkg:npm/left-pad@1.3.0"]); - let found = NpmCrawler + let found = NpmCrawler::new() .find_by_purls(&deps, &["pkg:npm/left-pad@1.3.0".to_string()]) .await .unwrap(); @@ -4677,7 +4724,7 @@ async fn deno_npm_cache_find_by_purls_records_each_copy_once() { let registry = tmp.path().join("npm").join("registry.npmjs.org"); let plain = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; let purl = "pkg:npm/is-odd@3.0.1".to_string(); - let found = NpmCrawler + let found = NpmCrawler::new() .find_by_purls(®istry, &[purl.clone(), purl.clone()]) .await .unwrap(); @@ -4703,7 +4750,7 @@ async fn deno_npm_cache_find_by_purls_resolves_encoded_capital_name() { .await .unwrap(); let purl = "pkg:npm/JSONStream@1.3.5".to_string(); - let found = NpmCrawler + let found = NpmCrawler::new() .find_by_purls(®istry, std::slice::from_ref(&purl)) .await .unwrap(); @@ -4731,7 +4778,7 @@ async fn deno_npm_cache_nested_registry_prefix_crawls_and_resolves() { global: false, global_prefix: Some(registry.clone()), }; - let purls: Vec = NpmCrawler + let purls: Vec = NpmCrawler::new() .crawl_all(&opts) .await .into_iter() @@ -4739,7 +4786,7 @@ async fn deno_npm_cache_nested_registry_prefix_crawls_and_resolves() { .collect(); assert_eq!(purls, vec!["pkg:npm/is-odd@3.0.1"]); let purl = "pkg:npm/is-odd@3.0.1".to_string(); - let found = NpmCrawler + let found = NpmCrawler::new() .find_by_purls(®istry, std::slice::from_ref(&purl)) .await .unwrap(); @@ -4774,7 +4821,10 @@ async fn global_mode_includes_nested_deno_npm_registry() { global: true, global_prefix: None, }; - let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let roots = NpmCrawler::new() + .get_node_modules_paths(&opts) + .await + .unwrap(); assert!(roots.contains(&plain), "{roots:?}"); assert!(roots.contains(&nested), "{roots:?}"); assert!( @@ -4804,7 +4854,7 @@ async fn deno_npm_cache_without_packument_is_still_found() { .await .unwrap(); let purl = "pkg:npm/is-odd@3.0.1".to_string(); - let found = NpmCrawler + let found = NpmCrawler::new() .find_by_purls(®istry, std::slice::from_ref(&purl)) .await .unwrap(); @@ -4815,7 +4865,7 @@ async fn deno_npm_cache_without_packument_is_still_found() { let deps = tmp.path().join("npm").join("deps"); stage_npm_pkg(&deps, "rxjs", "7.8.1").await; stage_npm_pkg(&deps.join("rxjs"), "7.8.1", "0.0.0").await; - let found = NpmCrawler + let found = NpmCrawler::new() .find_by_purls(&deps, &["pkg:npm/rxjs@7.8.1".to_string()]) .await .unwrap(); @@ -4855,7 +4905,10 @@ async fn global_mode_finds_registry_nested_under_another() { global: true, global_prefix: None, }; - let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let roots = NpmCrawler::new() + .get_node_modules_paths(&opts) + .await + .unwrap(); let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); } @@ -4889,7 +4942,10 @@ async fn global_mode_finds_path_registry_behind_same_named_package() { global: true, global_prefix: None, }; - let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let roots = NpmCrawler::new() + .get_node_modules_paths(&opts) + .await + .unwrap(); let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); } @@ -4922,7 +4978,10 @@ async fn global_mode_finds_one_segment_registry_under_root_registry() { global: true, global_prefix: None, }; - let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let roots = NpmCrawler::new() + .get_node_modules_paths(&opts) + .await + .unwrap(); let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); } @@ -4973,11 +5032,14 @@ async fn global_mode_finds_one_segment_registry_behind_same_named_package() { global: true, global_prefix: None, }; - let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let roots = NpmCrawler::new() + .get_node_modules_paths(&opts) + .await + .unwrap(); let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); assert_eq!(deno_roots, vec![&host, &nested], "{nested_pkg}: {roots:?}"); - let mut crawled: Vec<(String, PathBuf)> = NpmCrawler + let mut crawled: Vec<(String, PathBuf)> = NpmCrawler::new() .crawl_all(&opts) .await .into_iter() @@ -4993,7 +5055,7 @@ async fn global_mode_finds_one_segment_registry_behind_same_named_package() { expected.sort(); assert_eq!(crawled, expected, "{nested_pkg}"); - let found = NpmCrawler + let found = NpmCrawler::new() .find_by_purls(&nested, &[foo_purl.clone()]) .await .unwrap(); @@ -5031,10 +5093,13 @@ async fn deno_dir_and_npm_dir_prefixes_expand_to_registry_dirs() { global: false, global_prefix: Some(prefix.clone()), }; - let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let roots = NpmCrawler::new() + .get_node_modules_paths(&opts) + .await + .unwrap(); assert_eq!(roots, vec![nested.clone(), public.clone()], "{prefix:?}"); - let mut crawled: Vec<(String, PathBuf)> = NpmCrawler + let mut crawled: Vec<(String, PathBuf)> = NpmCrawler::new() .crawl_all(&opts) .await .into_iter() @@ -5067,9 +5132,12 @@ async fn prefix_with_npm_package_child_keeps_node_modules_walk() { global: false, global_prefix: Some(prefix.clone()), }; - let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let roots = NpmCrawler::new() + .get_node_modules_paths(&opts) + .await + .unwrap(); assert_eq!(roots, vec![prefix.clone()]); - let mut purls: Vec = NpmCrawler + let mut purls: Vec = NpmCrawler::new() .crawl_all(&opts) .await .into_iter() @@ -5102,7 +5170,10 @@ async fn npm_global_prefix_named_npm_is_not_deno_cache() { global: false, global_prefix: Some(prefix.clone()), }; - let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let roots = NpmCrawler::new() + .get_node_modules_paths(&opts) + .await + .unwrap(); assert_eq!(roots, vec![prefix.clone()], "{prefix:?}"); } } @@ -5123,7 +5194,10 @@ async fn relative_deno_npm_dir_prefix_expands() { global: false, global_prefix: Some(PathBuf::from(".")), }; - let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let roots = NpmCrawler::new() + .get_node_modules_paths(&opts) + .await + .unwrap(); assert_eq!(roots.len(), 1, "{roots:?}"); assert!(roots[0].ends_with("registry.npmjs.org"), "{roots:?}"); } diff --git a/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs b/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs index 37e9085b1..bc776d99c 100644 --- a/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs +++ b/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs @@ -68,7 +68,7 @@ async fn npm_crawler_find_by_purls_with_empty_purls_returns_empty_map() { .await .unwrap(); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); // Positive control: the package IS discoverable on this exact path, // so an empty result below can ONLY be caused by the empty PURL list. @@ -105,7 +105,7 @@ async fn npm_crawler_find_by_purls_with_nonexistent_node_modules_returns_empty() .await .unwrap(); - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); let purl = "pkg:npm/lodash@4.17.21".to_string(); // Positive control: same PURL resolves against the real tree. @@ -129,7 +129,7 @@ async fn npm_crawler_find_by_purls_with_nonexistent_node_modules_returns_empty() #[tokio::test] async fn npm_crawler_crawl_all_with_no_packages_returns_empty() { - let crawler = NpmCrawler; + let crawler = NpmCrawler::default(); // Positive control: a populated local node_modules yields the package. let populated = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs index 29e251336..e280bce1a 100644 --- a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs +++ b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs @@ -492,7 +492,7 @@ async fn bun_global_dir_falls_back_to_bun_env_resolution() { let value = vars.iter().find(|(n, _)| *n == name).map(|(_, v)| v); case_env.set(name, value.map(|v| v.as_os_str())); } - let paths = NpmCrawler + let paths = NpmCrawler::new() .get_node_modules_paths(&CrawlerOptions { cwd: l.proj.clone(), global: true, From e8e26faec7ba97f64fc6c4aee70d095bc96f0464 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 23:10:24 -0600 Subject: [PATCH 06/10] Fix the review findings on the repository-root bound - Resolve paths physically (canonicalize, else component by component), so `link/../x` climbs from the link's target as the OS does. - Check every project-file read on its real path, not only climbing or absolute names: an in-tree name through an outward symlink is refused. - Agent-mode cargo: `apply` / `apply --check` refuse Cargo patches when a `.cargo/config(.toml)` above the repository may replace crates.io (`cargo_config_outside_root`); build-cache invalidation names that file, whose target dir was not read. - `with_store_peer_variant_copies` takes the run's scope, so a global `apply --check` sees the copies a global apply writes. - go.work / go native vendor / NuGet parent configs use upward-lookup semantics: without a checkout, as before; drop the test `.git` dirs that only worked around it. - An ancestor `pnpm-workspace.yaml` at or above home never governs. - Drive-absolute pnpm <= 8 specifiers are machine-absolute on every OS. - `NpmCrawler` keeps one scope source (its own, from its options). - Look up the ancestor uv config once; compute the go native vendor and vex node-resolution bounds once per check / package. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 8 +- crates/socket-patch-cli/src/commands/apply.rs | 112 +++++++++++++ .../src/commands/vex_consumed.rs | 22 ++- .../src/ecosystem_dispatch.rs | 3 +- .../tests/vendor/vendor_failure_paths.rs | 7 +- .../src/crawlers/npm_crawler.rs | 86 +++++++--- .../src/crawlers/npm_crawler/oracle.rs | 2 +- crates/socket-patch-core/src/hosted/engine.rs | 2 +- .../src/patch/redirect/upstream/npm.rs | 2 - .../src/utils/cargo_build_cache.rs | 46 ++++++ .../src/utils/pnpm_workspace.rs | 36 +++++ .../src/utils/python_script.rs | 11 +- .../socket-patch-core/src/utils/repo_root.rs | 152 ++++++++++++++---- .../src/utils/repo_root_upward_tests.rs | 6 +- .../src/vendor/go_consumer_sync.rs | 18 +-- .../src/vendor/go_mod_edit.rs | 4 - .../src/vendor/go_native_vendor.rs | 11 +- .../src/vendor/go_workspace.rs | 21 ++- .../src/vendor/nuget_config.rs | 4 +- .../src/vendor/nuget_feed.rs | 2 - crates/socket-patch-core/src/vendor/path.rs | 30 +++- .../socket-patch-core/src/vendor/pypi_lock.rs | 2 +- .../src/vendor/pypi_requirements.rs | 9 +- .../socket-patch-core/src/vendor/pypi_uv.rs | 2 +- .../src/vex/discover/golang.rs | 8 +- crates/socket-patch-core/src/vex/verify.rs | 47 +++--- .../tests/crawler_npm_e2e.rs | 114 +++++++------ .../tests/global_probe_spawn_e2e.rs | 2 +- 28 files changed, 570 insertions(+), 199 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 70a63c683..3aa14a7fb 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -908,13 +908,13 @@ Not covered: the agent-mode blob cache and manifest are checked from `.socket` d **Workspace runs only.** The bound applies to project-scoped runs (the default `--cwd` project). A global run (`--global` / `-g`, or `--global-prefix`, on any command) reads anywhere on the system, so it can scan global `node_modules`, `site-packages`, `~/.m2`, the cargo and Go caches, and their settings wherever they live: nothing in this section applies to it. The mode is fixed once per run from those flags and passed to every bounded read (core `utils::repo_root::ReadScope`: `Workspace` or `Global`; `ReadBoundary::Workspace { root }` or `ReadBoundary::Unbounded`). It is never inferred from the filesystem. Hosted and vendored mode refuse global runs anyway, and VEX discovery and verification do not run in them. -VEX discovery, the lock inventory, hosted mode and vendored mode never read a PROJECT file outside the repository root: the git checkout enclosing the project (the nearest `.git`, by the same rules as the repository lookup), or the project directory itself when there is none. One check (core `utils::repo_root::ReadBoundary`) decides it; a level that resolves outside through `..` or a symlink counts as outside. A refused read behaves like a missing file to a reader; a writer refuses with the reason (`… is outside the repository root …; socket-patch does not read project files outside it`). Machine-level tool configuration (the user `~/.npmrc` / `.yarnrc.yml` / `NuGet.Config`, the `GOENV` file, `$CARGO_HOME`, module and package caches) is not a project file and is read as before. +VEX discovery, the lock inventory, hosted mode and vendored mode never read a PROJECT file outside the repository root: the git checkout enclosing the project (the nearest `.git`, by the same rules as the repository lookup), or the project directory itself when there is none. One check (core `utils::repo_root::ReadBoundary`) decides it on the REAL path: every project-file read is checked, an in-tree name included, and a level that resolves outside through `..` or a symlink (a `..` after a link climbs from the link's target, as the OS resolves it) counts as outside. A refused read behaves like a missing file to a reader; a writer refuses with the reason (`… is outside the repository root …; socket-patch does not read project files outside it`). Machine-level tool configuration (the user `~/.npmrc` / `.yarnrc.yml` / `NuGet.Config`, the `GOENV` file, `$CARGO_HOME`, module and package caches) is not a project file and is read as before. -- **Go**: a `go.work` outside the root (an ancestor's, or one an absolute `GOWORK` names) is only noticed, never read: the workspace is invalid, so vendored and hosted writes refuse with the Go workspace refusal and discovery withholds every Go ref. Inside the root, a `use` member or a `replace` directory outside it reads as missing, so the selection it would decide is unknown. +- **Go** (an upward lookup: without a checkout, as before): a `go.work` outside the root (an ancestor's, or one an absolute `GOWORK` names) is only noticed, never read: the workspace is invalid, so vendored and hosted writes refuse with the Go workspace refusal and discovery withholds every Go ref. Inside the root, a `use` member or a `replace` directory outside it reads as missing, so the selection it would decide is unknown. - **Requirements includes**: a `-r` include outside the root is never read. Discovery and the lock inventory see it as missing; vendored mode refuses `pypi_requirements_outside_root`, since the pins pip installs from it are unknowable. -- **NuGet parent configs**: a `nuget.config` in a directory above the root is never read. Vendoring refuses (`… also merges , which is outside the repository root …`) and hosted mode skips each NuGet dep with `redirect_nuget_inherited_config_outside_root`, writing nothing for it. +- **NuGet parent configs** (an upward lookup: without a checkout, as before): a `nuget.config` in a directory above the root is never read. Vendoring refuses (`… also merges , which is outside the repository root …`) and hosted mode skips each NuGet dep with `redirect_nuget_inherited_config_outside_root`, writing nothing for it. - **Relative and absolute references read through the project view** (yarn `file:` / `portal:` / `link:` copies, include chains, any `../` path a lockfile names) and the hosted upstream restore's staged reads: outside the root they read as missing, and the restore refuses. -- **Upward lookups** (a workspace root or settings file the package manager searches the project's ancestors for): inside a git checkout none reads above the repository root. When one of the files the lookup would read exists above it (a stat, never a read, and never in the home directory or above), the tool may honor it, so writers refuse and readers withhold: a lockless npm-family member or a member's own npm/Bun/vlt lock under a `package.json`, `pnpm-workspace.yaml` or `vlt.json` above (`redirect_workspace_lockfile_elsewhere` in hosted mode, `vendor_lockfile_missing` in vendored mode, unattested in discovery); the nearest `pnpm-workspace.yaml` above counts as governing (`redirect_pnpm_settings_elsewhere`, `vendor_pnpm_settings_elsewhere`, and a `gitBranchLockfile` that may be on); a uv workspace `pyproject.toml` above (`redirect_workspace_lockfile_elsewhere`, `pypi_uv_workspace_unsupported`) or uv settings above (`pypi_uv_ancestor_settings`; hosted `redirect_uv_project_unsupported` / `redirect_uv_script_unsupported` / `redirect_uv_lock_unsupported`); a `Cargo.toml` above (`cargo_manifest_not_workspace_root`) or a `.cargo/config(.toml)` above (`user_authored_patch_entry` in vendored mode; hosted mode refuses the run with `redirect_cargo_config_outside_root`); a `.yarnrc.yml` above (its `nodeLinker` reads as unknown, so PnP; no `pnpmStoreFolder` store is admitted; the Berry upstream restore refuses the registry lookup); a yarn classic `.yarnrc` / `.npmrc` above, or a workspace member linked from outside the repository (`redirect_yarn_classic_offline_mirror`, naming the file or member); a `pom.xml`, Gradle settings or `build.sbt` above (`not_build_root`). Gradle settings above are not analysed by the crawler, an `injectEnvironmentFiles` dotenv file outside the repository (other than beside the home rc) injects nothing, a cargo `[patch]` path outside it reads as unknown (`user_authored_patch_entry`), and agent mode's build-cache invalidation stays inside it. Without a git checkout every upward lookup walks on as before. +- **Upward lookups** (a workspace root or settings file the package manager searches the project's ancestors for): inside a git checkout none reads above the repository root. When one of the files the lookup would read exists above it (a stat, never a read, and never in the home directory or above), the tool may honor it, so writers refuse and readers withhold: a lockless npm-family member or a member's own npm/Bun/vlt lock under a `package.json`, `pnpm-workspace.yaml` or `vlt.json` above (`redirect_workspace_lockfile_elsewhere` in hosted mode, `vendor_lockfile_missing` in vendored mode, unattested in discovery); the nearest `pnpm-workspace.yaml` above (but never one in the home directory or above it) counts as governing (`redirect_pnpm_settings_elsewhere`, `vendor_pnpm_settings_elsewhere`, and a `gitBranchLockfile` that may be on); a uv workspace `pyproject.toml` above (`redirect_workspace_lockfile_elsewhere`, `pypi_uv_workspace_unsupported`) or uv settings above (`pypi_uv_ancestor_settings`; hosted `redirect_uv_project_unsupported` / `redirect_uv_script_unsupported` / `redirect_uv_lock_unsupported`); a `Cargo.toml` above (`cargo_manifest_not_workspace_root`) or a `.cargo/config(.toml)` above (`user_authored_patch_entry` in vendored mode; hosted mode refuses the run with `redirect_cargo_config_outside_root`; agent-mode `apply` and `apply --check` refuse a run with Cargo patches with `cargo_config_outside_root`, naming the file, since it may replace crates.io while apply would patch the registry cache, and the cargo build-cache invalidation warns `cargo_build_cache_stale` that a `build.target-dir` / `build.build-dir` set there was not read); a `.yarnrc.yml` above (its `nodeLinker` reads as unknown, so PnP; no `pnpmStoreFolder` store is admitted; the Berry upstream restore refuses the registry lookup); a yarn classic `.yarnrc` / `.npmrc` above, or a workspace member linked from outside the repository (`redirect_yarn_classic_offline_mirror`, naming the file or member); a `pom.xml`, Gradle settings or `build.sbt` above (`not_build_root`). Gradle settings above are not analysed by the crawler, an `injectEnvironmentFiles` dotenv file outside the repository (other than beside the home rc) injects nothing, a cargo `[patch]` path outside it reads as unknown (`user_authored_patch_entry`), and agent mode's build-cache invalidation stays inside it. Without a git checkout every upward lookup walks on as before. - **Vendored ownership**: writers, reverters and the GC claim a lockfile reference only when it is root-anchored into THIS project's `.socket/vendor/` (`./.socket/vendor/…`, or `../` from a file below the root back to exactly the root). A sibling checkout's `../.socket/vendor///…`, even for the same patch uuid, is never rewritten, reverted or deleted; the revert leaves it with `vendor_lock_entry_drifted` and keeps the artifact. The one exception is pnpm <= 8, which records override specifiers as machine-absolute paths itself: an absolute `…/.socket/vendor/npm//` naming the entry's own uuid and leaf is still the entry's. ## Vendor command contract diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 881e56de1..8b218c4f4 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -478,6 +478,10 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { { return refuse_yarn_pnp(args); } + // The same cargo gate `apply` runs (see [`cargo_config_outside_root`]). + if let Some(why) = cargo_config_outside_root(args, &tree) { + return refuse_cargo_config_outside_root(args, &why, &[]); + } let mut in_sync: Vec = Vec::new(); let mut not_installed: Vec = Vec::new(); if !tree.patches.is_empty() { @@ -948,6 +952,52 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent /// True when the manifest records at least one npm patch — the only kind a /// PnP layout can block (a polyglot repo's pypi/gem/go patches live outside /// `node_modules` and apply fine). +/// The refusal code of [`cargo_config_outside_root`]. +const CARGO_CONFIG_OUTSIDE_ROOT: &str = "cargo_config_outside_root"; + +/// Why a workspace apply cannot patch this project's Cargo crates: a +/// `.cargo/config` / `config.toml` sits above the repository root (stat +/// only; it is never read), and cargo merges it into the build, so it may +/// replace crates.io with another source while apply patches the shared +/// registry cache. `None` for a global run, a run without Cargo patches +/// or a project that is no Cargo project. +fn cargo_config_outside_root(args: &ApplyArgs, manifest: &PatchManifest) -> Option { + let cwd = &args.common.cwd; + if !eco_in_local_scope(&args.common, Ecosystem::Cargo) + || !manifest + .patches + .keys() + .any(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Cargo)) + || !(cwd.join("Cargo.toml").is_file() || cwd.join("Cargo.lock").is_file()) + { + return None; + } + socket_patch_core::vendor::cargo_config::config_above_repo(cwd).map(|why| { + format!( + "{why}: it may replace crates.io with another source, so the registry copy apply \ + patches may not be what cargo builds; move the settings into the repository's \ + .cargo/config.toml (or run with --global to patch the registry cache anyway)" + ) + }) +} + +/// Print the [`cargo_config_outside_root`] refusal and return the exit +/// code. +fn refuse_cargo_config_outside_root(args: &ApplyArgs, why: &str, skipped: &[PatchEvent]) -> i32 { + if args.common.json { + let mut env = Envelope::new(Command::Apply); + env.dry_run = args.common.dry_run; + for skip in skipped { + env.record(skip.clone()); + } + env.mark_error(EnvelopeError::new(CARGO_CONFIG_OUTSIDE_ROOT, why)); + println!("{}", env.to_pretty_json()); + } else { + eprintln!("Error: {why}"); + } + 1 +} + fn manifest_targets_npm(manifest: &PatchManifest) -> bool { manifest .patches @@ -1388,6 +1438,16 @@ pub(crate) async fn run_locked( // informational note; the substantive safety is core's rename-over // write (`utils::fs::atomic_write_bytes` never touches the store's // shared inode). + // A cargo config above the repository root is never read in a + // workspace run; it may move crates.io elsewhere, so the Cargo patches + // refuse instead of patching the registry cache blind. + if let Some(why) = cargo_config_outside_root(&args, &manifest) { + let code = refuse_cargo_config_outside_root(&args, &why, &user_patch_skips); + return ApplyRunReport { + skipped: user_patch_skips, + ..ApplyRunReport::run_failure(code, CARGO_CONFIG_OUTSIDE_ROOT, why) + }; + } match detect_npm_pkg_manager(args.common.read_scope(), &args.common.cwd) { NpmPkgManager::YarnBerryPnP => { if eco_in_local_scope(&args.common, Ecosystem::Npm) && manifest_targets_npm(&manifest) { @@ -3749,6 +3809,58 @@ mod tests { AppliedVia as CoreAppliedVia, ApplyResult, VerifyResult, VerifyStatus, }; + /// Review finding 3: a workspace apply of Cargo patches refuses when a + /// `.cargo/config.toml` above the repository root (never read) may + /// replace crates.io; a global run, or one without it, does not. + #[test] + fn cargo_patches_refuse_under_a_cargo_config_above_the_repository() { + #[derive(clap::Parser)] + struct Wrap { + #[command(flatten)] + args: ApplyArgs, + } + let tmp = tempfile::tempdir().unwrap(); + let outer = std::fs::canonicalize(tmp.path()).unwrap(); + let app = outer.join("repo"); + std::fs::create_dir_all(app.join(".git")).unwrap(); + std::fs::write(app.join("Cargo.toml"), "[package]\nname = \"app\"\n").unwrap(); + let record: socket_patch_core::manifest::schema::PatchRecord = + serde_json::from_value(serde_json::json!({ + "uuid": "11111111-1111-4111-8111-111111111111", + "exportedAt": "", + "files": {}, + "vulnerabilities": {}, + "description": "", + "license": "", + "tier": "free", + })) + .unwrap(); + let mut manifest = PatchManifest::default(); + manifest + .patches + .insert("pkg:cargo/serde@1.0.0".to_string(), record); + let parse = |extra: &[&str]| { + let cwd = app.display().to_string(); + let mut argv = vec!["t", "--cwd", cwd.as_str()]; + argv.extend_from_slice(extra); + ::try_parse_from(argv).unwrap().args + }; + assert_eq!(cargo_config_outside_root(&parse(&[]), &manifest), None); + std::fs::create_dir_all(outer.join(".cargo")).unwrap(); + std::fs::write( + outer.join(".cargo/config.toml"), + "[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n", + ) + .unwrap(); + let why = cargo_config_outside_root(&parse(&[]), &manifest).unwrap(); + assert!(why.contains(".cargo/config.toml"), "{why}"); + assert!(why.contains("outside the repository"), "{why}"); + assert_eq!( + cargo_config_outside_root(&parse(&["--global"]), &manifest), + None + ); + } + /// A new-file collision's empty expected hash reads `(new file)` in the /// verbose summary; a real hash is shown as is. #[test] diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index 6b84f9d5b..eb184a5d6 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -105,7 +105,7 @@ pub(crate) async fn hosted_consumed_copies( // Without installed copies the identity fallback's have not // had their store variants enumerated yet. if npm.contains(&purl) && installed.get(purl).is_none_or(Vec::is_empty) { - paths = with_store_peer_variant_copies(paths).await; + paths = with_store_peer_variant_copies(options.read_scope(), paths).await; } out.insert( purl.clone(), @@ -441,9 +441,12 @@ mod tests { static VARIANT_INPUTS: std::cell::RefCell>>; } - pub(super) async fn recording_store_variants(paths: Vec) -> Vec { + pub(super) async fn recording_store_variants( + scope: socket_patch_core::utils::repo_root::ReadScope, + paths: Vec, + ) -> Vec { let _ = VARIANT_INPUTS.try_with(|calls| calls.borrow_mut().push(paths.clone())); - socket_patch_core::crawlers::npm_crawler::with_store_peer_variant_copies(paths).await + socket_patch_core::crawlers::npm_crawler::with_store_peer_variant_copies(scope, paths).await } #[cfg(unix)] @@ -782,7 +785,11 @@ mod tests { nm.join("left-pad"), ) .unwrap(); - let mut got = with_store_peer_variant_copies(vec![nm.join("left-pad")]).await; + let mut got = with_store_peer_variant_copies( + socket_patch_core::utils::repo_root::ReadScope::Workspace, + vec![nm.join("left-pad")], + ) + .await; got.sort(); let mut want = vec![ nm.join("left-pad"), @@ -791,7 +798,12 @@ mod tests { ]; want.sort(); assert_eq!(got, want); - assert!(with_store_peer_variant_copies(Vec::new()).await.is_empty()); + assert!(with_store_peer_variant_copies( + socket_patch_core::utils::repo_root::ReadScope::Workspace, + Vec::new() + ) + .await + .is_empty()); } /// vlt: every importer entry is a link into the `.vlt` store (the alias diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index dfb444bb8..45bb53aac 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -1115,7 +1115,8 @@ pub async fn find_manifest_package_copies_reusing( // includes them (#603). for (purl, paths) in copies.iter_mut() { if purl.starts_with("pkg:npm/") { - *paths = with_store_peer_variant_copies(std::mem::take(paths)).await; + *paths = + with_store_peer_variant_copies(common.read_scope(), std::mem::take(paths)).await; } } // A Bun store entry nothing links any more (Bun never prunes `.bun`) diff --git a/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs b/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs index 7379a963e..db5b10f07 100644 --- a/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs +++ b/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs @@ -794,8 +794,11 @@ async fn vendor_refuses_a_symlinked_lock_instead_of_replacing_it() { } let link = fx.root().join(linked); let original = std::fs::read(&link).unwrap(); - let shared = tempfile::tempdir().unwrap(); - let target = shared.path().join(linked); + // The link target stays inside the project: one outside the + // repository root is never read at all (a refusal of its own). + let shared = fx.root().join("shared"); + std::fs::create_dir_all(&shared).unwrap(); + let target = shared.join(linked); std::fs::rename(&link, &target).unwrap(); std::os::unix::fs::symlink(&target, &link).unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index c0dae8842..28109e7a0 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -2714,6 +2714,17 @@ impl NpmCrawler { Self::with_scope(options.read_scope()) } + /// The one scope source is the crawler's own: a crawler built for a + /// workspace run must never crawl a global run's options (its + /// lookups would be bounded where the run is not). + fn debug_assert_scope_of(&self, options: &CrawlerOptions) { + debug_assert!( + self.scope == crate::utils::repo_root::ReadScope::Global + || options.read_scope() == crate::utils::repo_root::ReadScope::Workspace, + "a workspace NpmCrawler crawls global options; build it with NpmCrawler::for_options" + ); + } + /// The run's read scope. pub fn scope(&self) -> crate::utils::repo_root::ReadScope { self.scope @@ -2732,8 +2743,10 @@ impl NpmCrawler { &self, options: &CrawlerOptions, ) -> Result, std::io::Error> { + self.debug_assert_scope_of(options); let options = options.clone(); - Ok(run_walk(move || Self::node_modules_paths_sync(&options)).await) + let scope = self.scope; + Ok(run_walk(move || Self::node_modules_paths_sync(scope, &options)).await) } /// Crawl all discovered `node_modules` and return every package found. @@ -2770,12 +2783,17 @@ impl NpmCrawler { &self, options: &CrawlerOptions, ) -> (Vec, Vec) { + self.debug_assert_scope_of(options); let options = options.clone(); - run_walk(move || Self::crawl_all_sync(&options)).await + let scope = self.scope; + run_walk(move || Self::crawl_all_sync(scope, &options)).await } - fn crawl_all_sync(options: &CrawlerOptions) -> (Vec, Vec) { - let nm_paths = Self::node_modules_paths_sync(options); + fn crawl_all_sync( + scope: crate::utils::repo_root::ReadScope, + options: &CrawlerOptions, + ) -> (Vec, Vec) { + let nm_paths = Self::node_modules_paths_sync(scope, options); // Yarn's relocated pnpm-linker store (#859), walked once, from the // first root of its project, after that root's own entries like // the stores found by name. @@ -2783,7 +2801,7 @@ impl NpmCrawler { let roots: Vec<(&PathBuf, Option)> = nm_paths .iter() .map(|nm| { - let store = relocated_yarn_pnpm_store_sync(options.read_scope(), nm) + let store = relocated_yarn_pnpm_store_sync(scope, nm) .filter(|store| seen_stores.insert(store.clone())); (nm, store) }) @@ -3455,7 +3473,10 @@ impl NpmCrawler { // ------------------------------------------------------------------ /// Blocking body of [`Self::get_node_modules_paths`]. - fn node_modules_paths_sync(options: &CrawlerOptions) -> Vec { + fn node_modules_paths_sync( + scope: crate::utils::repo_root::ReadScope, + options: &CrawlerOptions, + ) -> Vec { if options.global || options.global_prefix.is_some() { if let Some(ref custom) = options.global_prefix { if is_deno_npm_cache_root(custom) { @@ -3467,10 +3488,10 @@ impl NpmCrawler { } return pnpm_isolated_global_install_roots(custom); } - return NpmCrawler::for_options(options).get_global_node_modules_paths(); + return NpmCrawler::with_scope(scope).get_global_node_modules_paths(); } - Self::find_local_node_modules_dirs(options.read_scope(), &options.cwd) + Self::find_local_node_modules_dirs(scope, &options.cwd) } /// Find `node_modules` directories within the project root. @@ -4737,7 +4758,10 @@ async fn find_store_peer_variant_copies_reusing( /// to apply's [`find_store_peer_variant_copies`] fan-out, but each variant /// is what some dependent loads, so a check of "every installed copy" /// (`vex`) must see them too. -pub async fn with_store_peer_variant_copies(paths: Vec) -> Vec { +pub async fn with_store_peer_variant_copies( + scope: crate::utils::repo_root::ReadScope, + paths: Vec, +) -> Vec { let mut seen: HashSet = HashSet::new(); for path in &paths { seen.insert(tokio::fs::canonicalize(path).await.unwrap_or(path.clone())); @@ -4747,14 +4771,7 @@ pub async fn with_store_peer_variant_copies(paths: Vec) -> Vec // by a store's first scan. Sharing scan state therefore loses no copy. let mut scanned = HashSet::new(); for path in &paths { - // `vex`'s every-copy check: a workspace run. - for copy in find_store_peer_variant_copies_reusing( - crate::utils::repo_root::ReadScope::Workspace, - path, - &mut scanned, - ) - .await - { + for copy in find_store_peer_variant_copies_reusing(scope, path, &mut scanned).await { let canonical = tokio::fs::canonicalize(©).await.unwrap_or(copy.clone()); if seen.insert(canonical) { out.push(copy); @@ -4905,7 +4922,11 @@ mod tests { async fn tracked_store_expansion(paths: Vec) -> (Vec, Vec) { VARIANT_STORE_SCANS .scope(std::cell::RefCell::new(Vec::new()), async { - let expanded = with_store_peer_variant_copies(paths).await; + let expanded = with_store_peer_variant_copies( + crate::utils::repo_root::ReadScope::Workspace, + paths, + ) + .await; let scans = VARIANT_STORE_SCANS.with(|scans| scans.borrow().clone()); (expanded, scans) }) @@ -6707,7 +6728,11 @@ mod tests { assert_eq!(variants, want); // VEX's every-installed-copy set sees the alias entries too. - let mut all = with_store_peer_variant_copies(vec![nm.join("left-pad")]).await; + let mut all = with_store_peer_variant_copies( + crate::utils::repo_root::ReadScope::Workspace, + vec![nm.join("left-pad")], + ) + .await; all.sort(); let mut want = vec![nm.join("left-pad"), alias.clone(), scoped_alias.clone()]; want.sort(); @@ -8239,6 +8264,17 @@ mod tests { find_store_peer_variant_copies(ReadScope::Global, &number).await, vec![number_twin.clone()] ); + // Review finding 4: `apply --check` (and `vex`) expand copies with + // the run's scope, so a global check sees what a global apply + // writes. + assert_eq!( + with_store_peer_variant_copies(ReadScope::Global, vec![number.clone()]).await, + vec![number.clone(), number_twin.clone()] + ); + assert_eq!( + with_store_peer_variant_copies(ReadScope::Workspace, vec![number.clone()]).await, + vec![number.clone()] + ); assert_eq!( crate::patch::shared_store::shared_store_of(ReadScope::Global, &nm.join("is-odd")) .await, @@ -9172,7 +9208,10 @@ mod tests { // Neither a stray file nor a dir without `node_modules` is a root. std::fs::write(v11.join("stray"), "").unwrap(); std::fs::create_dir_all(v11.join("empty")).unwrap(); - let roots = NpmCrawler::node_modules_paths_sync(&global_prefix_options(&v11)); + let roots = NpmCrawler::node_modules_paths_sync( + crate::utils::repo_root::ReadScope::Global, + &global_prefix_options(&v11), + ); assert_eq!(roots, vec![a, b, c]); } @@ -9194,7 +9233,10 @@ mod tests { write_pnpm_global_install(&nm_v2, "aaa", true, true); std::fs::write(nm_v2.join(".modules.yaml"), "layoutVersion: 5\n").unwrap(); for root in [legacy, npm, v1, nm_v2] { - let roots = NpmCrawler::node_modules_paths_sync(&global_prefix_options(&root)); + let roots = NpmCrawler::node_modules_paths_sync( + crate::utils::repo_root::ReadScope::Global, + &global_prefix_options(&root), + ); assert_eq!(roots, vec![root]); } } @@ -9212,7 +9254,7 @@ mod tests { let v11 = tmp.path().join("global").join("v11"); let a = write_pnpm_global_install(&v11, "aaa", direct_first, true); let b = write_pnpm_global_install(&v11, "bbb", !direct_first, true); - let crawler = NpmCrawler::new(); + let crawler = NpmCrawler::with_scope(crate::utils::repo_root::ReadScope::Global); let purl = "pkg:npm/left-pad@1.3.0".to_string(); let mut real: Vec = Vec::new(); for root in crawler diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs b/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs index 3fe7c73f4..e77f41025 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs @@ -79,7 +79,7 @@ impl LegacyNpmCrawler { let mut seen = HashSet::new(); let nm_paths = if options.global || options.global_prefix.is_some() { - NpmCrawler::new() + NpmCrawler::for_options(options) .get_node_modules_paths(options) .await .unwrap_or_default() diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index a346e5083..03fdfc11f 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -899,7 +899,7 @@ pub async fn read_candidate_files( ); } else if let Some(why) = view .disk_root_reading(std::iter::empty::<&std::path::Path>()) - .and_then(crate::utils::python_script::uv_config_above_repo) + .and_then(|root| crate::utils::python_script::uv_config_above_repo(root, None)) { out.files.insert( crate::patch::redirect::UV_ANCESTOR_OUTSIDE_KEY.to_string(), diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 534858115..709fb0da1 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -4459,8 +4459,6 @@ mod tests { use super::{pnpm_settings_prefix, read_sibling}; let dir = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = std::fs::canonicalize(dir.path()).unwrap(); write( &ws, diff --git a/crates/socket-patch-core/src/utils/cargo_build_cache.rs b/crates/socket-patch-core/src/utils/cargo_build_cache.rs index eaa11dc6b..116888c1b 100644 --- a/crates/socket-patch-core/src/utils/cargo_build_cache.rs +++ b/crates/socket-patch-core/src/utils/cargo_build_cache.rs @@ -359,6 +359,26 @@ pub fn invalidate_project<'a>( ), )); } + // A workspace run never reads a cargo config above the repository + // root, so a `build.target-dir` / `build.build-dir` it sets is unknown: + // say which file, rather than report the invalidation complete. + let above = match scope { + crate::utils::repo_root::ReadScope::Workspace => { + crate::vendor::cargo_config::config_above_repo(cwd) + } + crate::utils::repo_root::ReadScope::Global => None, + }; + if let Some(why) = above { + warnings.push(( + STALE_WARNING.to_string(), + format!( + "{why}: a `build.target-dir` or `build.build-dir` it sets was not read, so \ + compiled copies there were not invalidated; the next build may link the stale \ + code. Run `cargo clean -p ` for: {}", + names.join(", ") + ), + )); + } warnings } @@ -396,6 +416,32 @@ mod tests { ); } + /// Review finding 3: a target dir set by a cargo config above the + /// repository root is never read in a workspace run, so the + /// invalidation says so instead of reporting itself complete. + #[test] + fn a_config_above_the_repository_is_named_when_its_target_dir_is_unread() { + use crate::utils::repo_root::ReadScope; + let tmp = tempfile::tempdir().unwrap(); + let outer = std::fs::canonicalize(tmp.path()).unwrap(); + let app = outer.join("repo"); + std::fs::create_dir_all(app.join(".git")).unwrap(); + std::fs::write(app.join("Cargo.toml"), "[package]\nname = \"app\"\n").unwrap(); + let purls = ["pkg:cargo/serde@1.0.0"]; + assert!(invalidate_project(ReadScope::Workspace, &app, purls).is_empty()); + std::fs::create_dir_all(outer.join(".cargo")).unwrap(); + std::fs::write( + outer.join(".cargo/config.toml"), + "[build]\ntarget-dir = \"/elsewhere/target\"\n", + ) + .unwrap(); + let warnings = invalidate_project(ReadScope::Workspace, &app, purls); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].1.contains(".cargo/config.toml"), "{warnings:?}"); + // A global run reads it as before. + assert!(invalidate_project(ReadScope::Global, &app, purls).is_empty()); + } + fn mkfp(root: &Path, rel: &str) -> PathBuf { let p = root.join(rel); std::fs::create_dir_all(&p).unwrap(); diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 466ed550a..3dbd6202c 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -995,7 +995,17 @@ fn governing_file(view: &ProjectView<'_>) -> Option { /// canonical, minus Windows' verbatim prefix (see /// [`without_verbatim_prefix`]), because refusals and warnings show it to /// the user. +/// +/// A file above the repository root (a workspace run's upward bound) is +/// never read and fails closed as governing, unless it sits in the home +/// directory or above it, which never governs a checkout (the home rule +/// of [`crate::utils::repo_root::ReadBoundary::marker_above`]). pub fn governing_workspace_file(project_root: &Path) -> Option { + governing_workspace_file_with(project_root, crate::utils::repo_root::home().as_deref()) +} + +/// [`governing_workspace_file`] with the home directory injected. +fn governing_workspace_file_with(project_root: &Path, home: Option<&Path>) -> Option { if std::fs::symlink_metadata(project_root.join(PNPM_WORKSPACE)).is_ok() { return None; } @@ -1014,6 +1024,9 @@ pub fn governing_workspace_file(project_root: &Path) -> Option { &canonical, &file, ) { + if crate::utils::repo_root::at_or_above_home(dir, home) { + return None; + } return Some(without_verbatim_prefix(file)); } let rel: Vec = canonical @@ -1069,6 +1082,29 @@ pub fn without_verbatim_prefix(path: PathBuf) -> PathBuf { #[cfg(test)] mod tests { use super::*; + + /// Review finding 6: a `pnpm-workspace.yaml` above the repository + /// root fails closed as governing (unread), except in the home + /// directory or above it, which never governs a checkout. + #[test] + fn a_workspace_file_above_the_repository_honours_the_home_rule() { + let tmp = tempfile::tempdir().unwrap(); + let outer = std::fs::canonicalize(tmp.path()).unwrap(); + let app = outer.join("repo/app"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::create_dir_all(outer.join("repo/.git")).unwrap(); + let file = outer.join(PNPM_WORKSPACE); + std::fs::write(&file, "packages:\n - other\n").unwrap(); + assert_eq!( + governing_workspace_file_with(&app, None), + Some(without_verbatim_prefix(file.clone())) + ); + assert_eq!(governing_workspace_file_with(&app, Some(&outer)), None); + assert_eq!( + governing_workspace_file_with(&app, Some(Path::new("/nonexistent/home"))), + Some(without_verbatim_prefix(file)) + ); + } use crate::formats::pnpm::workspace::package_globs; /// An environment lookup over `vars` alone. diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index a9e225394..9ff311e5d 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -509,12 +509,15 @@ pub fn find_ancestor_uv_config(root: &Path) -> Option { /// a `uv.toml` or `pyproject.toml` exists above it (stat only, never /// read), which uv may apply. Writers refuse on it rather than wire what /// those settings may reject. -pub fn uv_config_above_repo(root: &Path) -> Option { - let bound = - crate::utils::repo_root::upward_bound(crate::utils::repo_root::ReadScope::Workspace, root)?; - if find_ancestor_uv_config(root).is_some() { +/// +/// `ancestor` is the caller's [`find_ancestor_uv_config`] answer, never +/// looked up twice: one found inside the repository governs instead. +pub fn uv_config_above_repo(root: &Path, ancestor: Option<&UvConfig>) -> Option { + if ancestor.is_some() { return None; } + let bound = + crate::utils::repo_root::upward_bound(crate::utils::repo_root::ReadScope::Workspace, root)?; let marker = bound.marker_above(&[UV_TOML, "pyproject.toml"])?; Some(format!( "uv may apply the settings in {}: {}", diff --git a/crates/socket-patch-core/src/utils/repo_root.rs b/crates/socket-patch-core/src/utils/repo_root.rs index 1df420e3a..ad3ccea1e 100644 --- a/crates/socket-patch-core/src/utils/repo_root.rs +++ b/crates/socket-patch-core/src/utils/repo_root.rs @@ -183,6 +183,14 @@ impl ReadBoundary { } } + /// The boundary of an UPWARD lookup from `start` (see [`upward_bound`]) + /// as a [`ReadBoundary`]: the enclosing checkout in a workspace run, + /// [`ReadBoundary::Unbounded`] in a global run or outside any checkout, + /// where the lookup reads as it always has. + pub fn upward(scope: ReadScope, start: &Path) -> Self { + upward_bound(scope, start).unwrap_or(ReadBoundary::Unbounded) + } + /// The boundary directory; `None` when unbounded. pub fn root(&self) -> Option<&Path> { match self { @@ -224,7 +232,7 @@ impl ReadBoundary { let home = home(); root.ancestors() .skip(1) - .take_while(|dir| home.as_deref().is_none_or(|home| !home.starts_with(dir))) + .take_while(|dir| !at_or_above_home(dir, home.as_deref())) .flat_map(|dir| names.iter().map(move |name| dir.join(name))) .find(|path| std::fs::metadata(path).is_ok()) } @@ -246,6 +254,12 @@ impl ReadBoundary { } } +/// Whether `dir` is the home directory or one of its ancestors: never +/// read as a marker above a checkout (git's own home rule). +pub(crate) fn at_or_above_home(dir: &Path, home: Option<&Path>) -> bool { + home.is_some_and(|home| home.starts_with(dir)) +} + /// The bound of an UPWARD lookup from `start` (a workspace root, an /// inherited config, a settings file the package manager searches its /// ancestors for) in a `scope` run: in a [`ReadScope::Workspace`] run @@ -271,27 +285,33 @@ pub fn upward_readable(scope: ReadScope, start: &Path, path: &Path) -> bool { } /// `project_root.join(rel)` for a project file named by a root-relative -/// `rel` that may climb (`../shared/requirements.txt`) or be absolute; -/// `Err` of kind `NotFound` (a refused read reads as a missing file) -/// carrying [`ReadBoundary::refusal`] when, in a `scope` run, it lies -/// outside the boundary ([`ReadBoundary::of`]). A plain in-tree `rel` (no -/// `..`, not absolute) is never refused, and costs no filesystem lookups. +/// `rel` (which may climb, `../shared/requirements.txt`, be absolute, or +/// pass through a symlinked level); `Err` of kind `NotFound` (a refused +/// read reads as a missing file) carrying [`ReadBoundary::refusal`] when, +/// in a [`ReadScope::Workspace`] run, its REAL path lies outside the +/// boundary ([`ReadBoundary::of`]). Every name is checked: an in-tree +/// spelling through a link out of the repository is refused like a `..`. +/// A path that resolves inside the project itself (the common case) needs +/// no repository lookup. pub fn bounded_project_path( scope: ReadScope, project_root: &Path, rel: &str, ) -> std::io::Result { let path = project_root.join(rel); - let climbs = - crate::utils::relpath::is_anchored(rel) || rel.split(['/', '\\']).any(|seg| seg == ".."); - if climbs { - let bound = ReadBoundary::of(scope, project_root); - if !bound.contains(&path) { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - OutsideRepoRoot(bound.refusal(&path)), - )); - } + if scope == ReadScope::Global { + return Ok(path); + } + let real = canonical_or_lexical(&path); + if real.starts_with(canonical_or_lexical(project_root)) { + return Ok(path); + } + let bound = ReadBoundary::of(scope, project_root); + if !bound.contains(&real) { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + OutsideRepoRoot(bound.refusal(&path)), + )); } Ok(path) } @@ -316,26 +336,38 @@ pub fn is_refused_read(e: &std::io::Error) -> bool { .is_some_and(|inner| inner.is::()) } -/// `path` absolute, `..` folded lexically, with its deepest existing level -/// canonicalized (the rest re-appended as spelled). +/// `path` made absolute and resolved the way the OS resolves it, one +/// component at a time: every existing level is canonicalized (a symlink +/// is followed) before the next component applies, so a `..` climbs from +/// the real directory a link points at, never from the link's spelling. +/// Components past the deepest existing level fold lexically (nothing on +/// disk can redirect them). fn canonical_or_lexical(path: &Path) -> PathBuf { - let abs = crate::utils::relpath::absolute_lexically(path); - let mut tail = Vec::new(); - let mut probe: &Path = &abs; - loop { - if let Ok(canon) = std::fs::canonicalize(probe) { - let mut out = canon; - out.extend(tail.iter().rev()); - return out; - } - match (probe.parent(), probe.file_name()) { - (Some(parent), Some(name)) => { - tail.push(name.to_os_string()); - probe = parent; + use std::path::Component; + let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()); + // The OS resolves an existing path physically in one call. + if let Ok(real) = std::fs::canonicalize(&abs) { + return real; + } + let mut out = PathBuf::new(); + for component in abs.components() { + match component { + Component::Prefix(_) | Component::RootDir => out.push(component.as_os_str()), + Component::CurDir => {} + // `out` is canonical while it exists: its parent is the + // physical one. + Component::ParentDir => { + out.pop(); + } + Component::Normal(name) => { + out.push(name); + if let Ok(real) = std::fs::canonicalize(&out) { + out = real; + } } - _ => return abs, } } + out } /// The checkout AT the home directory that a lookup from `start` stopped @@ -551,6 +583,62 @@ mod tests { assert!(bound.contains(&repo.join("requirements.txt"))); } + /// Review finding 1: a `..` after a symlinked level climbs from the + /// link's TARGET, as the OS resolves it, never from the link's + /// spelling. + #[cfg(unix)] + #[test] + fn dot_dot_after_a_link_resolves_physically() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let repo = base.join("repo"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::create_dir_all(base.join("outside/dir")).unwrap(); + fs::write(base.join("outside/secret.txt"), "x").unwrap(); + fs::write(repo.join("secret.txt"), "in").unwrap(); + std::os::unix::fs::symlink(base.join("outside/dir"), repo.join("link")).unwrap(); + let bound = ReadBoundary::of(ReadScope::Workspace, &repo); + assert!(!bound.contains(&repo.join("link/../secret.txt"))); + assert!(!bound.contains(&repo.join("link/../missing/x.txt"))); + assert!(bound.contains(&repo.join("sub/../secret.txt"))); + let err = bounded_project_path(ReadScope::Workspace, &repo, "link/../secret.txt"); + assert!(err.is_err_and(|e| is_refused_read(&e))); + } + + /// Review finding 2: an in-tree name read through a symlinked level + /// that points out of the repository is refused too, not only a + /// climbing (`..`) or absolute one. + #[cfg(unix)] + #[test] + fn in_tree_names_through_an_outward_link_are_refused() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let repo = base.join("repo"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::create_dir_all(base.join("other")).unwrap(); + fs::write(base.join("other/base.txt"), "six==1.16.0\n").unwrap(); + std::os::unix::fs::symlink(base.join("other"), repo.join("shared")).unwrap(); + let refused = bounded_project_path(ReadScope::Workspace, &repo, "shared/base.txt"); + assert!(refused.is_err_and(|e| is_refused_read(&e))); + assert!(bounded_project_path(ReadScope::Workspace, &repo, "requirements.txt").is_ok()); + assert!(bounded_project_path(ReadScope::Global, &repo, "shared/base.txt").is_ok()); + // The project view (discovery, the lock inventory, the + // requirements walks) reads through it: missing to a reader. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let view = crate::vendor::lock_inventory::ProjectView::Disk(&repo); + let read = rt.block_on(view.read_text("shared/base.txt")).unwrap_err(); + assert!(is_refused_read(&read)); + let snap = crate::vendor::lock_inventory::DiskSnapshot::new(&repo); + let snapshot = crate::vendor::lock_inventory::ProjectView::Snapshot(&snap); + let read = rt + .block_on(snapshot.read_text("shared/base.txt")) + .unwrap_err(); + assert!(is_refused_read(&read)); + } + #[test] fn walk_stops_at_ceiling_dirs() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs b/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs index 93a34775c..f7f22b82c 100644 --- a/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs +++ b/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs @@ -173,7 +173,11 @@ fn uv_settings_above_the_repository() { let l = layout(git); write(&l.outer.join("uv.toml"), "[pip]\nno-binary = [\"six\"]\n"); assert_eq!(find_ancestor_uv_config(&l.app).is_some(), !git); - assert_eq!(uv_config_above_repo(&l.app).is_some(), git); + let ancestor = find_ancestor_uv_config(&l.app); + assert_eq!( + uv_config_above_repo(&l.app, ancestor.as_ref()).is_some(), + git + ); } } diff --git a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs index 3d5f6eb78..6a0075e2d 100644 --- a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs +++ b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs @@ -545,6 +545,11 @@ async fn verify_native_vendor_with( replaces: &replaces, known, command, + // A project's Go check: a workspace run. + bound: crate::utils::repo_root::ReadBoundary::upward( + crate::utils::repo_root::ReadScope::Workspace, + project_root, + ), }, record, ) @@ -975,8 +980,6 @@ mod tests { #[tokio::test] async fn workspace_modules_txt_is_checked_against_every_member() { let dir = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let root = dir.path(); consumer(root, REPLACE); write( @@ -1042,8 +1045,6 @@ mod tests { ("../a", COPY), ] { let dir = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path().join("ws"); let member = ws.join(use_dir); // How go spells a path relative to the go.work directory. @@ -1088,7 +1089,6 @@ mod tests { // has the same spelling as `a`'s recorded one but another target, // so the line is stale and `b`'s is not recorded. let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path().join("ws"); consumer(&ws.join("a"), ""); consumer(&ws.join("b"), REPLACE); @@ -1191,8 +1191,6 @@ mod tests { #[tokio::test] async fn go_work_replace_is_judged_by_where_it_points() { let dir = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let (app, b) = (ws.join("app"), ws.join("b")); consumer(&app, REPLACE); @@ -1222,8 +1220,6 @@ mod tests { #[tokio::test] async fn go_work_that_does_not_use_the_project_makes_the_replace_inert() { let dir = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let root = ws.join("app"); consumer(&root, REPLACE); @@ -1254,8 +1250,6 @@ mod tests { #[tokio::test] async fn go_work_member_matches_through_a_symlinked_workspace() { let dir = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let real = dir.path().join("real"); std::fs::create_dir(&real).unwrap(); let ws = dir.path().join("link"); @@ -1276,8 +1270,6 @@ mod tests { #[tokio::test] async fn member_replaces_of_one_module_version_must_agree() { let dir = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let (a, b) = (ws.join("a"), ws.join("b")); consumer(&a, REPLACE); diff --git a/crates/socket-patch-core/src/vendor/go_mod_edit.rs b/crates/socket-patch-core/src/vendor/go_mod_edit.rs index 54cb9029c..6bffc966f 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -1920,8 +1920,6 @@ replace ( #[tokio::test] async fn test_ensure_refuses_a_replace_go_work_defeats() { let dir = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let root = ws.join("app"); let go_mod = "module example.com/app\n\ngo 1.21\n\nrequire example.com/m v1.0.0\n"; @@ -1974,8 +1972,6 @@ replace ( #[tokio::test] async fn test_symlinked_replace_host_is_refused() { let dir = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let root = &dir.path().join("ws"); for m in ["a", "b"] { std::fs::create_dir_all(root.join(m)).unwrap(); diff --git a/crates/socket-patch-core/src/vendor/go_native_vendor.rs b/crates/socket-patch-core/src/vendor/go_native_vendor.rs index 9cbeb796d..28fb23a77 100644 --- a/crates/socket-patch-core/src/vendor/go_native_vendor.rs +++ b/crates/socket-patch-core/src/vendor/go_native_vendor.rs @@ -30,6 +30,9 @@ pub(super) struct Target<'a> { pub known: &'a HashMap, /// `go mod vendor` or `go work vendor`. pub command: &'static str, + /// The boundary replacement directories are read within, computed + /// once per check ([`crate::utils::repo_root::ReadBoundary::upward`]). + pub bound: crate::utils::repo_root::ReadBoundary, } /// Read expected text from an authenticated blob or replacement copy. Never @@ -52,12 +55,7 @@ async fn expected_text(t: &Target<'_>, name: &str, hash: &str) -> Option if let Some(path) = &entry.path { // A replacement directory outside the repository is never read. let file = t.root.join(path).join(name); - if crate::utils::repo_root::ReadBoundary::of( - crate::utils::repo_root::ReadScope::Workspace, - t.root, - ) - .contains(&file) - { + if t.bound.contains(&file) { paths.push(file); } } else if let (Some(cache), Some(target), Some(ver)) = ( @@ -994,6 +992,7 @@ mod tests { replaces: &[], known: &known, command: "go mod vendor", + bound: crate::utils::repo_root::ReadBoundary::Unbounded, }; let rec = record(&[("lib.go", &hash)]); assert_eq!(verify(&target("example.com/testonly"), &rec).await, Ok(())); diff --git a/crates/socket-patch-core/src/vendor/go_workspace.rs b/crates/socket-patch-core/src/vendor/go_workspace.rs index 1a99eeba7..bff82b4d4 100644 --- a/crates/socket-patch-core/src/vendor/go_workspace.rs +++ b/crates/socket-patch-core/src/vendor/go_workspace.rs @@ -93,7 +93,7 @@ fn resolve_scoped( gowork: Option<&str>, ) -> GoWork { let bounded = |file: PathBuf| { - let bound = crate::utils::repo_root::ReadBoundary::of(scope, project_root); + let bound = crate::utils::repo_root::ReadBoundary::upward(scope, project_root); if bound.contains(&file) { GoWork::File(file) } else { @@ -270,7 +270,7 @@ pub async fn load_with(project_root: &Path, gowork: Option<&str>) -> Loaded { GoWork::File(work_file) => { load_file( work_file, - &crate::utils::repo_root::ReadBoundary::of( + &crate::utils::repo_root::ReadBoundary::upward( crate::utils::repo_root::ReadScope::Workspace, project_root, ), @@ -695,7 +695,7 @@ impl GoWorkspace { /// [`GoWorkspace::read_graph`] against the module cache `cache`. pub async fn read_graph_from(&mut self, cache: Option<&Path>) { let replaces = self.replace_sources(); - let bound = crate::utils::repo_root::ReadBoundary::of( + let bound = crate::utils::repo_root::ReadBoundary::upward( crate::utils::repo_root::ReadScope::Workspace, self.work_dir(), ); @@ -1184,7 +1184,7 @@ pub async fn module_roots(root: &Path) -> Vec { } match load_file( root.join("go.work"), - &crate::utils::repo_root::ReadBoundary::of( + &crate::utils::repo_root::ReadBoundary::upward( crate::utils::repo_root::ReadScope::Workspace, root, ), @@ -1311,8 +1311,6 @@ mod tests { #[test] fn gowork_resolves_as_go_does() { let tmp = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let project = tmp.path().join("ws").join("app"); std::fs::create_dir_all(&project).unwrap(); for unset in [None, Some(""), Some("auto")] { @@ -1375,9 +1373,12 @@ mod tests { resolve_with(&project, Some(&elsewhere)), GoWork::Invalid(why) if why.contains("outside the repository root") )); - // Without a repository the project itself is the root. + // Without a checkout the lookup walks on as before. std::fs::remove_dir(repo.join(".git")).unwrap(); - assert!(matches!(resolve_with(&project, None), GoWork::Invalid(_))); + assert_eq!( + resolve_with(&project, None), + GoWork::File(tmp.path().join("go.work")) + ); std::fs::create_dir_all(repo.join(".git")).unwrap(); // The repository's own go.work: a member outside it, and a replace @@ -1416,8 +1417,6 @@ mod tests { #[test] fn goflags_modfile_conflicts_unless_it_names_the_own_go_mod() { let tmp = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let project = tmp.path().join("app"); write(tmp.path(), "app/go.mod", "module example.com/app\n"); let conflict = |goflags: &str, gowork: Option<&str>| { @@ -1506,8 +1505,6 @@ mod tests { #[tokio::test] async fn selection_spans_every_member() { let tmp = tempfile::tempdir().unwrap(); - // The repository every path of the fixture lives in. - std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = &tmp.path().join("ws"); let cache = &tmp.path().join("cache"); for v in ["v1.0.0", "v1.1.0"] { diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 921c5478b..f0cfdb71e 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -158,7 +158,7 @@ pub(crate) async fn inherited_source_keys_traced( )], ..NugetConfig::default() })); - let bound = crate::utils::repo_root::ReadBoundary::of( + let bound = crate::utils::repo_root::ReadBoundary::upward( crate::utils::repo_root::ReadScope::Workspace, project_root, ); @@ -567,7 +567,6 @@ mod tests { .unwrap(); let project = tmp.path().join("repo/app"); std::fs::create_dir_all(&project).unwrap(); - std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap(); USER_CONFIG.with(|c| *c.borrow_mut() = Some(user.clone())); assert_eq!(inherited_source_keys(&project).await, ["nuget.org", "corp"]); // A repo-root config adds its own feed. @@ -599,7 +598,6 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path().join("repo/app"); std::fs::create_dir_all(&project).unwrap(); - std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap(); assert!(!super::inherited_sources(&project).await.mapped); std::fs::write( tmp.path().join("repo/nuget.config"), diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 98ef840c1..4cebf3f63 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -5250,8 +5250,6 @@ mod tests { async fn vendor_keeps_a_parent_directorys_feed_routable() { let (dir, blobs, installed, record) = fixture(true, None).await; let outer = dir.path(); - // The parent is inside the repository. - tokio::fs::create_dir_all(outer.join(".git")).await.unwrap(); tokio::fs::write( outer.join("nuget.config"), "\n \n \n \n\n", diff --git a/crates/socket-patch-core/src/vendor/path.rs b/crates/socket-patch-core/src/vendor/path.rs index 96163fbcf..87284618a 100644 --- a/crates/socket-patch-core/src/vendor/path.rs +++ b/crates/socket-patch-core/src/vendor/path.rs @@ -177,7 +177,15 @@ pub fn parse_vendor_path_or_machine_absolute(s: &str) -> Option let norm = normalize_vendor_spelling(s); let idx = vendor_anchor_index(&norm)?; let prefix = &norm[..idx]; - let absolute = crate::utils::relpath::is_anchored(prefix) + // A drive-absolute spelling (`C:/…`, backslashes already folded) + // is absolute whichever OS reads the lock: one written on Windows + // reverts on Linux CI too. + let drive = prefix + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphabetic) + && prefix.get(1..3) == Some(":/"); + let absolute = (crate::utils::relpath::is_anchored(prefix) || drive) && !prefix.split('/').any(|seg| matches!(seg, "." | "..")); if !absolute { return None; @@ -816,6 +824,26 @@ mod tests { let abs = format!("/home/ci/app/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); assert!(parse_vendor_path_or_machine_absolute(&abs).is_some()); assert!(parse_vendor_path_or_machine_absolute(&format!("file:{abs}")).is_some()); + // Review finding 7: a Windows checkout's drive-absolute specifier + // reads as machine-absolute on every OS (a lock written on Windows + // reverts on Linux CI); a drive path with `..` still does not. + for windows in [ + format!("C:\\Users\\ci\\app\\.socket\\vendor\\npm\\{UUID}\\left-pad-1.3.0.tgz"), + format!("file:D:/ci/app/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"), + ] { + assert!( + parse_vendor_path_or_machine_absolute(&windows).is_some_and(|p| p.uuid == UUID), + "{windows}" + ); + } + assert!(parse_vendor_path_or_machine_absolute(&format!( + "C:/ci/app/../other/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz" + )) + .is_none()); + assert!(parse_vendor_path_or_machine_absolute(&format!( + "app/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz" + )) + .is_none()); assert!(parse_vendor_path_or_machine_absolute(&format!( "/home/ci/app/../other/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz" )) diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index ed31271a1..2b12d09fc 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -196,7 +196,7 @@ fn ancestor_settings_unknown( (pyproject, uv_toml, ancestor): &(Option, Option, Option), ) -> Option { (ancestor.is_none() && !root_governs_script(pyproject.as_deref(), uv_toml.as_deref())) - .then(|| crate::utils::python_script::uv_config_above_repo(root)) + .then(|| crate::utils::python_script::uv_config_above_repo(root, ancestor.as_ref())) .flatten() } diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index a27b57d6a..ede1b5835 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -4357,12 +4357,13 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn symlinked_requirements_refuses_wire_and_revert_without_writing() { - // wire: the planned root file is a link. + // wire: the planned root file is a link (to a file inside the + // project, so the read itself is in bounds). let outer = tempfile::tempdir().unwrap(); - let real = outer.path().join("real.txt"); - tokio::fs::write(&real, "six==1.16.0\n").await.unwrap(); let root = outer.path().join("proj"); tokio::fs::create_dir_all(&root).await.unwrap(); + let real = root.join("real.txt"); + tokio::fs::write(&real, "six==1.16.0\n").await.unwrap(); std::os::unix::fs::symlink(&real, root.join("requirements.txt")).unwrap(); let err = wire_requirements(&root, "six", "1.16.0", REL_WHEEL, SHA) .await @@ -4380,7 +4381,7 @@ mod tests { .await .unwrap(); let wired = read_root(tmp.path()).await; - let target = outer.path().join("wired.txt"); + let target = tmp.path().join("wired.txt"); tokio::fs::write(&target, &wired).await.unwrap(); tokio::fs::remove_file(tmp.path().join("requirements.txt")) .await diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 581e259e7..0cc2f0cb1 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -235,7 +235,7 @@ pub(super) async fn load_uv_project(root: &Path) -> Result Option { crate::utils::fs::read_regular_to_string_sync(&pkg_path.join("package.json")).ok()?; let pkg: serde_json::Value = serde_json::from_str(&text).ok()?; let real = std::fs::canonicalize(&pkg_path).ok()?; + // Once per package, not per dependency. + let bound = crate::utils::repo_root::ReadBoundary::upward( + crate::utils::repo_root::ReadScope::Workspace, + &real, + ); let map = |field: &str| pkg.get(field).and_then(serde_json::Value::as_object); let optional = map("optionalDependencies"); let overrides = project_overrides(&project); @@ -577,14 +582,16 @@ async fn unresolved_npm_dependency(pkg_path: &Path) -> Option { required .chain(optional) .filter(|((name, _), _)| crate::vendor::npm_common::is_safe_npm_name(name)) - .find(|((name, spec), optional)| match node_resolve(&real, name) { - None => !optional, - Some(_) if forces(&overrides, owner, name) => false, - Some(copy) => spec - .as_str() - .and_then(declared_npm_range) - .is_some_and(|range| !copy_admitted(©, range)), - }) + .find( + |((name, spec), optional)| match node_resolve(&bound, &real, name) { + None => !optional, + Some(_) if forces(&overrides, owner, name) => false, + Some(copy) => spec + .as_str() + .and_then(declared_npm_range) + .is_some_and(|range| !copy_admitted(©, range)), + }, + ) .map(|((name, _), _)| name.clone()) }) .await @@ -689,18 +696,16 @@ fn forces(overrides: &[(Option, String)], owner: Option<&str>, name: &st /// /// Inside a git checkout a copy above its root is never read: `None`, so /// the dependency is unresolved rather than verified from outside. -fn node_resolve(from: &Path, name: &str) -> Option { +fn node_resolve( + bound: &crate::utils::repo_root::ReadBoundary, + from: &Path, + name: &str, +) -> Option { from.ancestors() .filter(|dir| dir.file_name().is_none_or(|n| n != "node_modules")) .map(|dir| dir.join("node_modules").join(name)) .find(|copy| copy.exists()) - .filter(|copy| { - crate::utils::repo_root::upward_readable( - crate::utils::repo_root::ReadScope::Workspace, - from, - copy, - ) - }) + .filter(|copy| bound.contains(copy)) } /// The registry semver range of a `dependencies` value: the value itself, @@ -784,10 +789,16 @@ mod tests { std::fs::create_dir_all(&from).unwrap(); std::fs::create_dir_all(outer.join("node_modules/dep")).unwrap(); std::fs::create_dir_all(outer.join("repo/.git")).unwrap(); - assert_eq!(node_resolve(&from, "dep"), None); + let bound = |from: &Path| { + crate::utils::repo_root::ReadBoundary::upward( + crate::utils::repo_root::ReadScope::Workspace, + from, + ) + }; + assert_eq!(node_resolve(&bound(&from), &from, "dep"), None); std::fs::remove_dir(outer.join("repo/.git")).unwrap(); assert_eq!( - node_resolve(&from, "dep"), + node_resolve(&bound(&from), &from, "dep"), Some(outer.join("node_modules/dep")) ); } diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 9cfb2900d..3c8438c9c 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -204,7 +204,7 @@ async fn get_node_modules_paths_global_prefix_passthrough() { let custom = tmp.path().join("custom-nm"); tokio::fs::create_dir_all(&custom).await.unwrap(); - let crawler = NpmCrawler::default(); + let crawler = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global); let opts = CrawlerOptions { cwd: tmp.path().to_path_buf(), global: false, @@ -222,7 +222,7 @@ async fn get_node_modules_paths_global_prefix_passthrough() { #[serial_test::parallel] async fn get_node_modules_paths_global_mode_no_prefix() { let tmp = tempfile::tempdir().unwrap(); - let crawler = NpmCrawler::default(); + let crawler = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global); let opts = CrawlerOptions { cwd: tmp.path().to_path_buf(), global: true, @@ -4574,12 +4574,13 @@ async fn deno_npm_cache_prefix_crawls_name_version_layout() { global: false, global_prefix: Some(registry.clone()), }; - let mut purls: Vec = NpmCrawler::new() - .crawl_all(&opts) - .await - .into_iter() - .map(|p| p.purl) - .collect(); + let mut purls: Vec = + NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); purls.sort(); assert_eq!( purls, @@ -4673,11 +4674,13 @@ async fn global_mode_includes_deno_npm_cache() { global: true, global_prefix: None, }; - let roots = NpmCrawler::new() + let roots = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&opts) .await .unwrap(); - let crawled = NpmCrawler::new().crawl_all(&opts).await; + let crawled = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .crawl_all(&opts) + .await; assert!(roots.contains(®istry), "{roots:?}"); assert!( @@ -4702,14 +4705,15 @@ async fn non_deno_root_under_npm_dir_keeps_node_modules_walk() { global: false, global_prefix: Some(deps.clone()), }; - let purls: Vec = NpmCrawler::new() - .crawl_all(&opts) - .await - .into_iter() - .map(|p| p.purl) - .collect(); + let purls: Vec = + NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); assert_eq!(purls, vec!["pkg:npm/left-pad@1.3.0"]); - let found = NpmCrawler::new() + let found = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .find_by_purls(&deps, &["pkg:npm/left-pad@1.3.0".to_string()]) .await .unwrap(); @@ -4778,15 +4782,16 @@ async fn deno_npm_cache_nested_registry_prefix_crawls_and_resolves() { global: false, global_prefix: Some(registry.clone()), }; - let purls: Vec = NpmCrawler::new() - .crawl_all(&opts) - .await - .into_iter() - .map(|p| p.purl) - .collect(); + let purls: Vec = + NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); assert_eq!(purls, vec!["pkg:npm/is-odd@3.0.1"]); let purl = "pkg:npm/is-odd@3.0.1".to_string(); - let found = NpmCrawler::new() + let found = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .find_by_purls(®istry, std::slice::from_ref(&purl)) .await .unwrap(); @@ -4821,7 +4826,7 @@ async fn global_mode_includes_nested_deno_npm_registry() { global: true, global_prefix: None, }; - let roots = NpmCrawler::new() + let roots = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&opts) .await .unwrap(); @@ -4905,7 +4910,7 @@ async fn global_mode_finds_registry_nested_under_another() { global: true, global_prefix: None, }; - let roots = NpmCrawler::new() + let roots = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&opts) .await .unwrap(); @@ -4942,7 +4947,7 @@ async fn global_mode_finds_path_registry_behind_same_named_package() { global: true, global_prefix: None, }; - let roots = NpmCrawler::new() + let roots = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&opts) .await .unwrap(); @@ -4978,7 +4983,7 @@ async fn global_mode_finds_one_segment_registry_under_root_registry() { global: true, global_prefix: None, }; - let roots = NpmCrawler::new() + let roots = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&opts) .await .unwrap(); @@ -5032,20 +5037,21 @@ async fn global_mode_finds_one_segment_registry_behind_same_named_package() { global: true, global_prefix: None, }; - let roots = NpmCrawler::new() + let roots = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&opts) .await .unwrap(); let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); assert_eq!(deno_roots, vec![&host, &nested], "{nested_pkg}: {roots:?}"); - let mut crawled: Vec<(String, PathBuf)> = NpmCrawler::new() - .crawl_all(&opts) - .await - .into_iter() - .filter(|p| p.path.starts_with(&deno_dir)) - .map(|p| (p.purl, p.path)) - .collect(); + let mut crawled: Vec<(String, PathBuf)> = + NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .crawl_all(&opts) + .await + .into_iter() + .filter(|p| p.path.starts_with(&deno_dir)) + .map(|p| (p.purl, p.path)) + .collect(); crawled.sort(); let foo_purl = format!("pkg:npm/{nested_pkg}@1.0.0"); let mut expected = vec![ @@ -5055,7 +5061,7 @@ async fn global_mode_finds_one_segment_registry_behind_same_named_package() { expected.sort(); assert_eq!(crawled, expected, "{nested_pkg}"); - let found = NpmCrawler::new() + let found = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .find_by_purls(&nested, &[foo_purl.clone()]) .await .unwrap(); @@ -5093,18 +5099,19 @@ async fn deno_dir_and_npm_dir_prefixes_expand_to_registry_dirs() { global: false, global_prefix: Some(prefix.clone()), }; - let roots = NpmCrawler::new() + let roots = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&opts) .await .unwrap(); assert_eq!(roots, vec![nested.clone(), public.clone()], "{prefix:?}"); - let mut crawled: Vec<(String, PathBuf)> = NpmCrawler::new() - .crawl_all(&opts) - .await - .into_iter() - .map(|p| (p.purl, p.path)) - .collect(); + let mut crawled: Vec<(String, PathBuf)> = + NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .crawl_all(&opts) + .await + .into_iter() + .map(|p| (p.purl, p.path)) + .collect(); crawled.sort(); assert_eq!( crawled, @@ -5132,17 +5139,18 @@ async fn prefix_with_npm_package_child_keeps_node_modules_walk() { global: false, global_prefix: Some(prefix.clone()), }; - let roots = NpmCrawler::new() + let roots = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&opts) .await .unwrap(); assert_eq!(roots, vec![prefix.clone()]); - let mut purls: Vec = NpmCrawler::new() - .crawl_all(&opts) - .await - .into_iter() - .map(|p| p.purl) - .collect(); + let mut purls: Vec = + NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); purls.sort(); assert_eq!(purls, vec!["pkg:npm/left-pad@1.3.0", "pkg:npm/npm@10.9.0"]); } @@ -5170,7 +5178,7 @@ async fn npm_global_prefix_named_npm_is_not_deno_cache() { global: false, global_prefix: Some(prefix.clone()), }; - let roots = NpmCrawler::new() + let roots = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&opts) .await .unwrap(); @@ -5194,7 +5202,7 @@ async fn relative_deno_npm_dir_prefix_expands() { global: false, global_prefix: Some(PathBuf::from(".")), }; - let roots = NpmCrawler::new() + let roots = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&opts) .await .unwrap(); diff --git a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs index e280bce1a..2fc3aa3e6 100644 --- a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs +++ b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs @@ -492,7 +492,7 @@ async fn bun_global_dir_falls_back_to_bun_env_resolution() { let value = vars.iter().find(|(n, _)| *n == name).map(|(_, v)| v); case_env.set(name, value.map(|v| v.as_os_str())); } - let paths = NpmCrawler::new() + let paths = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&CrawlerOptions { cwd: l.proj.clone(), global: true, From 82505212179b45240248b0213d1f517da2f96872 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 14:38:15 +0000 Subject: [PATCH 07/10] Refuse out-of-root symlinked hosted targets; keep fixtures in the repo The repository-root bound made a hosted candidate lock symlinked out of the root (uv.lock, package-lock.json, vlt-lock.json -> ../shared/) read as missing on disk, so `scan --mode hosted` exited 0 having silently skipped a lock the package manager still honors. A disk read the bound refuses on a symlinked candidate now lands in `symlinked_reads`, and the guard checks those on every view, so the run refuses with redirect_symlinked_file_unsupported as before. Three e2e fixtures relied on reading a file outside any repository: - yarn-classic vendor symlinked lock: keep the link target inside the project, as vendor_failure_paths already does; - yarn-classic `file:../ext/lpdir` copy and the pip `-r ../shared/base.txt` include: make the temp dir the repository, so the sibling stays outside the project but inside the repository (the monorepo layout these tests model). Out-of-repository includes are covered by pypi_requirements::tests::includes_outside_the_repository_are_never_read. Co-Authored-By: Claude --- .../tests/e2e_redirect_yarn_classic_build.rs | 4 +++ .../tests/e2e_vendor_yarn_classic_build.rs | 4 ++- .../tests/mode_migration_pypi.rs | 4 +++ crates/socket-patch-core/src/hosted/engine.rs | 25 +++++++++++++------ 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs index e38536c43..c3578f492 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs @@ -1558,6 +1558,10 @@ async fn classic_outside_root_copy_under_another_name_is_named_and_not_attested( return; } let tmp = tempfile::tempdir().unwrap(); + // The copy sits outside the project but inside its repository (a + // monorepo sibling): a project file outside the repository root is + // never read at all, so its name could not be read from it. + std::fs::create_dir(tmp.path().join(".git")).unwrap(); let proj = tmp.path().join("proj"); let copy = tmp.path().join("ext").join("lpdir"); std::fs::create_dir_all(©).unwrap(); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs index b3e73b995..f51c2ad7d 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs @@ -1051,7 +1051,9 @@ fn yarn_classic_vendor_refuses_a_symlinked_lock() { let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join("proj"); - let shared = tmp.path().join("shared"); + // The link target stays inside the project: one outside the + // repository root is never read at all (a refusal of its own). + let shared = proj.join("shared"); std::fs::create_dir_all(&proj).unwrap(); std::fs::create_dir_all(&shared).unwrap(); std::fs::write( diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 0e35333b4..be710c300 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -3346,6 +3346,10 @@ async fn pip_lock_vendor_refuses_an_env_templated_requirements_pin() { #[tokio::test] async fn pip_lock_beside_an_out_of_root_include_pin_refuses() { let (tmp, root) = project(); + // The include sits outside the project but inside its repository, so + // vendor reads its pins: one outside the repository root is never read + // (`pypi_requirements::tests::includes_outside_the_repository_are_never_read`). + std::fs::create_dir(tmp.path().join(".git")).unwrap(); stage_pip_lock(&root); let shared = tmp.path().join("shared"); std::fs::create_dir_all(&shared).unwrap(); diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 03fdfc11f..2a918665e 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -347,10 +347,11 @@ pub struct CandidateFiles { /// The workspace members' own locks among `files`, read beside the /// root's when the workspace turned the shared lock off (#492). pub pnpm_member_lock_keys: Vec, - /// In memory only: candidate files the disk flow reads through a - /// symbolic link. Their bytes are unknown here, so a project whose - /// candidates could rewrite one is refused like the disk symlink guard - /// refuses the write. + /// In memory: candidate files the disk flow reads through a symbolic + /// link. On disk: candidate files linked out of the repository root, + /// whose reads the bound refused. Their bytes are unknown, so a project + /// whose candidates could rewrite one is refused like the disk symlink + /// guard refuses the write. pub symlinked_reads: Vec, /// In memory only: candidate files that exist without content; a /// project whose candidates could rewrite (or whose rewrite depends on) @@ -401,6 +402,14 @@ impl CandidateFiles { self.undecodable_reads.push(rel.to_string()); None } + // A candidate linked out of the repository root: the bound + // refuses the read, but the package manager still honors + // the file, so it is a symlinked rewrite target the guard + // refuses, never an absent one. + Err(e) if crate::utils::repo_root::is_refused_read(&e) && view.is_symlink(rel) => { + self.symlinked_reads.push(rel.to_string()); + None + } Err(_) => None, }, ProjectView::Memory(project) => { @@ -3645,9 +3654,8 @@ pub fn guard( .iter() .map(|c| c.dep.ecosystem.as_str()) .collect(); - let ProjectView::Memory(project) = view else { - return None; - }; + // In memory, every linked candidate; on disk, those linked out of the + // repository root, whose reads the bound refused. if let Some(linked) = done .symlinked_reads .iter() @@ -3655,6 +3663,9 @@ pub fn guard( { return Some(symlink_refusal(linked)); } + let ProjectView::Memory(project) = view else { + return None; + }; done.unreadable_reads .iter() .find(|rel| { From 230cba3b3b93059ab2717da9ab3ac7807f011a7b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 14:48:47 +0000 Subject: [PATCH 08/10] Refuse hosted candidates reached through an outward directory link Address Bugbot on 8250521: the hosted arm recorded a bound-refused candidate only when the file itself was a link, so a candidate whose real path left the repository through a directory link on its path (`sub -> ../outside/sub`, then `sub/package-lock.json`) still read as missing and hosted skipped a lock the package manager follows. Every bound-refused candidate read is now recorded (candidate names never climb, so only a link can take one out of the root), and the guard names the directory-link case in its own message under the same code. Co-Authored-By: Claude --- crates/socket-patch-core/src/hosted/engine.rs | 74 +++++++++++++++++-- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 2a918665e..48b3fa6b0 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -167,6 +167,24 @@ pub fn symlink_refusal(linked: &str) -> Refusal { } } +/// The refusal for a candidate read through a link: the file itself +/// ([`symlink_refusal`]), or, on disk, a directory on its path that links +/// out of the repository root, so a rewrite would land outside it. +fn linked_read_refusal(view: &ProjectView<'_>, rel: &str) -> Refusal { + if matches!(view, ProjectView::Memory(_)) || view.is_symlink(rel) { + return symlink_refusal(rel); + } + Refusal { + code: SYMLINK_REFUSAL.to_string(), + message: format!( + "{rel} is reached through a symbolic link that leaves the repository root; \ + socket-patch does not read or rewrite project files outside it — replace the \ + link with a regular directory (or run socket-patch in the directory it points \ + to) and re-run; nothing was written" + ), + } +} + /// The refusal for a symlinked `bun.lockb` (checked before any takeover /// changes wiring). pub fn bun_lockb_symlink_refusal() -> Refusal { @@ -402,11 +420,13 @@ impl CandidateFiles { self.undecodable_reads.push(rel.to_string()); None } - // A candidate linked out of the repository root: the bound - // refuses the read, but the package manager still honors - // the file, so it is a symlinked rewrite target the guard - // refuses, never an absent one. - Err(e) if crate::utils::repo_root::is_refused_read(&e) && view.is_symlink(rel) => { + // A candidate whose real path leaves the repository root + // (the file itself, or a directory on its path, is a link + // out of it; a candidate name never climbs): the bound + // refuses the read, but the package manager still follows + // the link, so the guard refuses it, never takes it for + // absent. + Err(e) if crate::utils::repo_root::is_refused_read(&e) => { self.symlinked_reads.push(rel.to_string()); None } @@ -3654,14 +3674,14 @@ pub fn guard( .iter() .map(|c| c.dep.ecosystem.as_str()) .collect(); - // In memory, every linked candidate; on disk, those linked out of the - // repository root, whose reads the bound refused. + // In memory, every linked candidate; on disk, those whose real path + // leaves the repository root, whose reads the bound refused. if let Some(linked) = done .symlinked_reads .iter() .find(|rel| file_ecosystem(rel).is_some_and(|eco| candidate_ecosystems.contains(eco))) { - return Some(symlink_refusal(linked)); + return Some(linked_read_refusal(view, linked)); } let ProjectView::Memory(project) = view else { return None; @@ -3726,6 +3746,44 @@ mod tests { ); } + /// A disk candidate whose real path leaves the repository root, as + /// the file itself or through a directory link on its path, is + /// recorded as a linked read (refused by the guard), never as absent. + #[cfg(unix)] + #[tokio::test] + async fn disk_candidates_linked_out_of_the_repository_are_refused() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + let outside = tmp.path().join("outside"); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + std::fs::create_dir_all(outside.join("sub")).unwrap(); + std::fs::write(outside.join("uv.lock"), "version = 1\n").unwrap(); + std::fs::write(outside.join("sub/package-lock.json"), "{}\n").unwrap(); + std::os::unix::fs::symlink(outside.join("uv.lock"), repo.join("uv.lock")).unwrap(); + std::os::unix::fs::symlink(outside.join("sub"), repo.join("sub")).unwrap(); + let view = ProjectView::Disk(&repo); + let mut out = CandidateFiles::default(); + for rel in ["uv.lock", "sub/package-lock.json"] { + assert!(!out.read(&view, &BTreeSet::new(), rel).await, "{rel}"); + } + assert_eq!(out.symlinked_reads, ["uv.lock", "sub/package-lock.json"]); + assert!(out.files.is_empty()); + let file = linked_read_refusal(&view, "uv.lock"); + assert_eq!(file.code, SYMLINK_REFUSAL); + assert!( + file.message.contains("uv.lock is a symbolic link"), + "{}", + file.message + ); + let dir = linked_read_refusal(&view, "sub/package-lock.json"); + assert_eq!(dir.code, SYMLINK_REFUSAL); + assert!( + dir.message.contains("leaves the repository root"), + "{}", + dir.message + ); + } + fn no_cargo_outer() -> CratesIoSource { CratesIoSource::Registry } From f28eaefcf95b7e39b645e44c3f49016ad94f1fe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 14:59:52 +0000 Subject: [PATCH 09/10] Revert "Refuse hosted candidates reached through an outward directory link" This reverts commit 230cba3. Refusing every bound-refused candidate read also refuses install-state reads such as node_modules/.modules.yaml when node_modules links out of the repository, which broke the vlt heal tests (in_process_redirect vlt::scan_redirect_vlt_heal_undeterminable_*, vlt::scan_redirect_vlt_heal_invalidation_failure_warns). The directory- link case Bugbot raised needs the refusal limited to rewrite targets (locks and manifests), not install state; left for a follow-up round. Co-Authored-By: Claude --- crates/socket-patch-core/src/hosted/engine.rs | 74 ++----------------- 1 file changed, 8 insertions(+), 66 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 48b3fa6b0..2a918665e 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -167,24 +167,6 @@ pub fn symlink_refusal(linked: &str) -> Refusal { } } -/// The refusal for a candidate read through a link: the file itself -/// ([`symlink_refusal`]), or, on disk, a directory on its path that links -/// out of the repository root, so a rewrite would land outside it. -fn linked_read_refusal(view: &ProjectView<'_>, rel: &str) -> Refusal { - if matches!(view, ProjectView::Memory(_)) || view.is_symlink(rel) { - return symlink_refusal(rel); - } - Refusal { - code: SYMLINK_REFUSAL.to_string(), - message: format!( - "{rel} is reached through a symbolic link that leaves the repository root; \ - socket-patch does not read or rewrite project files outside it — replace the \ - link with a regular directory (or run socket-patch in the directory it points \ - to) and re-run; nothing was written" - ), - } -} - /// The refusal for a symlinked `bun.lockb` (checked before any takeover /// changes wiring). pub fn bun_lockb_symlink_refusal() -> Refusal { @@ -420,13 +402,11 @@ impl CandidateFiles { self.undecodable_reads.push(rel.to_string()); None } - // A candidate whose real path leaves the repository root - // (the file itself, or a directory on its path, is a link - // out of it; a candidate name never climbs): the bound - // refuses the read, but the package manager still follows - // the link, so the guard refuses it, never takes it for - // absent. - Err(e) if crate::utils::repo_root::is_refused_read(&e) => { + // A candidate linked out of the repository root: the bound + // refuses the read, but the package manager still honors + // the file, so it is a symlinked rewrite target the guard + // refuses, never an absent one. + Err(e) if crate::utils::repo_root::is_refused_read(&e) && view.is_symlink(rel) => { self.symlinked_reads.push(rel.to_string()); None } @@ -3674,14 +3654,14 @@ pub fn guard( .iter() .map(|c| c.dep.ecosystem.as_str()) .collect(); - // In memory, every linked candidate; on disk, those whose real path - // leaves the repository root, whose reads the bound refused. + // In memory, every linked candidate; on disk, those linked out of the + // repository root, whose reads the bound refused. if let Some(linked) = done .symlinked_reads .iter() .find(|rel| file_ecosystem(rel).is_some_and(|eco| candidate_ecosystems.contains(eco))) { - return Some(linked_read_refusal(view, linked)); + return Some(symlink_refusal(linked)); } let ProjectView::Memory(project) = view else { return None; @@ -3746,44 +3726,6 @@ mod tests { ); } - /// A disk candidate whose real path leaves the repository root, as - /// the file itself or through a directory link on its path, is - /// recorded as a linked read (refused by the guard), never as absent. - #[cfg(unix)] - #[tokio::test] - async fn disk_candidates_linked_out_of_the_repository_are_refused() { - let tmp = tempfile::tempdir().unwrap(); - let repo = tmp.path().join("repo"); - let outside = tmp.path().join("outside"); - std::fs::create_dir_all(repo.join(".git")).unwrap(); - std::fs::create_dir_all(outside.join("sub")).unwrap(); - std::fs::write(outside.join("uv.lock"), "version = 1\n").unwrap(); - std::fs::write(outside.join("sub/package-lock.json"), "{}\n").unwrap(); - std::os::unix::fs::symlink(outside.join("uv.lock"), repo.join("uv.lock")).unwrap(); - std::os::unix::fs::symlink(outside.join("sub"), repo.join("sub")).unwrap(); - let view = ProjectView::Disk(&repo); - let mut out = CandidateFiles::default(); - for rel in ["uv.lock", "sub/package-lock.json"] { - assert!(!out.read(&view, &BTreeSet::new(), rel).await, "{rel}"); - } - assert_eq!(out.symlinked_reads, ["uv.lock", "sub/package-lock.json"]); - assert!(out.files.is_empty()); - let file = linked_read_refusal(&view, "uv.lock"); - assert_eq!(file.code, SYMLINK_REFUSAL); - assert!( - file.message.contains("uv.lock is a symbolic link"), - "{}", - file.message - ); - let dir = linked_read_refusal(&view, "sub/package-lock.json"); - assert_eq!(dir.code, SYMLINK_REFUSAL); - assert!( - dir.message.contains("leaves the repository root"), - "{}", - dir.message - ); - } - fn no_cargo_outer() -> CratesIoSource { CratesIoSource::Registry } From 25e03e4bbefc0f1b615bfd759b88b81ebfc070bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 23:10:48 +0000 Subject: [PATCH 10/10] Refuse hosted rewrite targets reached through an outward dir link Re-applies 230cba3 (reverted in f28eaef because it also refused install state such as `node_modules/.modules.yaml` behind a linked `node_modules`, breaking the vlt heal tests), with the refusal limited to rewrite targets: a bound-refused candidate read counts as a linked read when the file itself is a link, or when it is not under a `node_modules/` segment. A lock or manifest whose real path leaves the repository through a directory link (`sub -> ../outside/sub`, then `sub/package-lock.json`) is refused by the guard instead of read as absent while the package manager still follows the link. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-core/src/hosted/engine.rs | 80 +++++++++++++++++-- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 2a918665e..cede6784b 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -167,6 +167,24 @@ pub fn symlink_refusal(linked: &str) -> Refusal { } } +/// The refusal for a candidate read through a link: the file itself +/// ([`symlink_refusal`]), or, on disk, a directory on its path that links +/// out of the repository root, so a rewrite would land outside it. +fn linked_read_refusal(view: &ProjectView<'_>, rel: &str) -> Refusal { + if matches!(view, ProjectView::Memory(_)) || view.is_symlink(rel) { + return symlink_refusal(rel); + } + Refusal { + code: SYMLINK_REFUSAL.to_string(), + message: format!( + "{rel} is reached through a symbolic link that leaves the repository root; \ + socket-patch does not read or rewrite project files outside it — replace the \ + link with a regular directory (or run socket-patch in the directory it points \ + to) and re-run; nothing was written" + ), + } +} + /// The refusal for a symlinked `bun.lockb` (checked before any takeover /// changes wiring). pub fn bun_lockb_symlink_refusal() -> Refusal { @@ -402,11 +420,19 @@ impl CandidateFiles { self.undecodable_reads.push(rel.to_string()); None } - // A candidate linked out of the repository root: the bound - // refuses the read, but the package manager still honors - // the file, so it is a symlinked rewrite target the guard - // refuses, never an absent one. - Err(e) if crate::utils::repo_root::is_refused_read(&e) && view.is_symlink(rel) => { + // A candidate whose real path leaves the repository root + // (the file itself, or a directory on its path, is a link + // out of it; a candidate name never climbs): the bound + // refuses the read, but the package manager still follows + // the link, so the guard refuses it, never takes it for + // absent. Install state under `node_modules/` (a linked + // `node_modules` is a common layout) is no rewrite target: + // only a link of the file itself counts there. + Err(e) + if crate::utils::repo_root::is_refused_read(&e) + && (view.is_symlink(rel) + || !rel.split('/').any(|segment| segment == "node_modules")) => + { self.symlinked_reads.push(rel.to_string()); None } @@ -3654,14 +3680,14 @@ pub fn guard( .iter() .map(|c| c.dep.ecosystem.as_str()) .collect(); - // In memory, every linked candidate; on disk, those linked out of the - // repository root, whose reads the bound refused. + // In memory, every linked candidate; on disk, those whose real path + // leaves the repository root, whose reads the bound refused. if let Some(linked) = done .symlinked_reads .iter() .find(|rel| file_ecosystem(rel).is_some_and(|eco| candidate_ecosystems.contains(eco))) { - return Some(symlink_refusal(linked)); + return Some(linked_read_refusal(view, linked)); } let ProjectView::Memory(project) = view else { return None; @@ -3726,6 +3752,44 @@ mod tests { ); } + /// A disk candidate whose real path leaves the repository root, as + /// the file itself or through a directory link on its path, is + /// recorded as a linked read (refused by the guard), never as absent. + #[cfg(unix)] + #[tokio::test] + async fn disk_candidates_linked_out_of_the_repository_are_refused() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + let outside = tmp.path().join("outside"); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + std::fs::create_dir_all(outside.join("sub")).unwrap(); + std::fs::write(outside.join("uv.lock"), "version = 1\n").unwrap(); + std::fs::write(outside.join("sub/package-lock.json"), "{}\n").unwrap(); + std::os::unix::fs::symlink(outside.join("uv.lock"), repo.join("uv.lock")).unwrap(); + std::os::unix::fs::symlink(outside.join("sub"), repo.join("sub")).unwrap(); + let view = ProjectView::Disk(&repo); + let mut out = CandidateFiles::default(); + for rel in ["uv.lock", "sub/package-lock.json"] { + assert!(!out.read(&view, &BTreeSet::new(), rel).await, "{rel}"); + } + assert_eq!(out.symlinked_reads, ["uv.lock", "sub/package-lock.json"]); + assert!(out.files.is_empty()); + let file = linked_read_refusal(&view, "uv.lock"); + assert_eq!(file.code, SYMLINK_REFUSAL); + assert!( + file.message.contains("uv.lock is a symbolic link"), + "{}", + file.message + ); + let dir = linked_read_refusal(&view, "sub/package-lock.json"); + assert_eq!(dir.code, SYMLINK_REFUSAL); + assert!( + dir.message.contains("leaves the repository root"), + "{}", + dir.message + ); + } + fn no_cargo_outer() -> CratesIoSource { CratesIoSource::Registry }