diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..3aa14a7fb 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,19 @@ 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 + +**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 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** (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** (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 (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 `vendor` converts the hosted patches pinned in the project's lockfiles into committable 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..8b218c4f4 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -471,13 +471,17 @@ 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) { 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 @@ -1061,7 +1111,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 +1438,17 @@ 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) { + // 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) { let code = refuse_yarn_pnp_with_skips(&args, &user_patch_skips); @@ -1512,6 +1572,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()), ) @@ -2712,6 +2773,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, @@ -2735,6 +2797,7 @@ async fn apply_patches_inner( applied.result } else { apply_package_patch( + args.common.read_scope(), variant_purl, pkg_path, &patch.files, @@ -2892,6 +2955,7 @@ async fn apply_patches_inner( } None => { apply_package_patch( + args.common.read_scope(), purl, pkg_path, &patch.files, @@ -3423,6 +3487,7 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied { } attempted = true; let mut result = apply_package_patch( + args.common.read_scope(), variant, &dir, &files, @@ -3443,6 +3508,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, @@ -3743,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/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 78ac53026..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, @@ -3400,6 +3403,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() @@ -3817,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/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-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 8c0ec1b2a..45bb53aac 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) } @@ -1107,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`) @@ -1209,10 +1218,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. @@ -1384,7 +1397,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!( @@ -1400,7 +1417,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!( @@ -2554,11 +2574,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); @@ -2804,7 +2827,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-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-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/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 fdab8df73..40109037c 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -900,13 +900,20 @@ 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(); } 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(scope, cwd, up)) + { roots.push(up.to_path_buf()); } } @@ -932,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 { @@ -959,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..28109e7a0 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), @@ -2665,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. /// @@ -2688,9 +2698,36 @@ 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 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 } // ------------------------------------------------------------------ @@ -2706,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. @@ -2744,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. @@ -2757,7 +2801,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(scope, nm) .filter(|store| seen_stores.insert(store.clone())); (nm, store) }) @@ -2855,6 +2899,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(); @@ -2864,8 +2909,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 @@ -2877,7 +2927,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 @@ -2918,6 +2974,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>, @@ -2932,11 +2989,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(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 @@ -3415,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) { @@ -3427,16 +3488,19 @@ impl NpmCrawler { } return pnpm_isolated_global_install_roots(custom); } - return NpmCrawler.get_global_node_modules_paths(); + return NpmCrawler::with_scope(scope).get_global_node_modules_paths(); } - Self::find_local_node_modules_dirs(&options.cwd) + Self::find_local_node_modules_dirs(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); @@ -3458,7 +3522,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(nm)) + .filter_map(|nm| relocated_yarn_pnpm_store_sync(scope, nm)) .filter_map(|store| std::fs::canonicalize(store).ok()) .collect(); if !stores.is_empty() { @@ -4456,8 +4520,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 @@ -4465,6 +4532,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 { @@ -4571,7 +4639,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(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)) }) @@ -4690,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())); @@ -4700,7 +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 { - for copy in find_store_peer_variant_copies_reusing(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); @@ -4851,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) }) @@ -5968,7 +6043,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); } @@ -6287,7 +6367,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 @@ -6330,7 +6414,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(); @@ -6429,7 +6517,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(), @@ -6581,7 +6673,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()] ); } @@ -6624,14 +6717,22 @@ 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(); 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(); @@ -6736,9 +6837,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" ); } @@ -6779,7 +6883,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:?}" @@ -6807,7 +6915,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:?}" @@ -6881,13 +6993,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}" ); @@ -6920,7 +7038,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 @@ -7282,7 +7404,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 @@ -7772,7 +7898,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()] ); @@ -8066,11 +8193,133 @@ 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()] + ); + // 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, + 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' @@ -8126,7 +8375,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 +8390,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 +8402,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" ); } @@ -8824,7 +9078,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 [ @@ -8893,7 +9150,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)) @@ -8948,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]); } @@ -8970,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]); } } @@ -8988,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/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index 419e9e5d3..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 @@ -239,9 +249,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(self.scope, 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 { @@ -271,6 +298,7 @@ impl YarnEnv { home: None, vars: Some(Default::default()), env_files: Default::default(), + scope: crate::utils::repo_root::ReadScope::Workspace, } } @@ -352,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 { @@ -361,13 +392,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(env.scope, &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 +437,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(env.scope, &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 { @@ -409,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; } @@ -449,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, ) @@ -529,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 @@ -875,6 +942,7 @@ mod tests { home: None, vars: Some(Default::default()), env_files: Default::default(), + scope: crate::utils::repo_root::ReadScope::Workspace, } } @@ -1045,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] @@ -1060,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] @@ -1075,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 @@ -1084,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] @@ -1092,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 ); } @@ -1105,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 ); } @@ -1115,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] @@ -1123,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 @@ -1132,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 @@ -1144,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 @@ -1156,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 ); } @@ -1171,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 ); } @@ -1186,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 ); } @@ -1200,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 @@ -1211,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 @@ -1224,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 @@ -1237,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 @@ -1252,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 ); } @@ -1268,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 ); } @@ -1284,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 ); } @@ -1315,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 @@ -1330,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 ); } @@ -1345,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 ); } @@ -1357,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 @@ -1369,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 @@ -1380,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 @@ -1396,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] @@ -1411,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 @@ -1420,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] @@ -1436,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 ); } @@ -1458,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}" ); @@ -1471,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 @@ -1481,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 cc8e8eca4..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, @@ -124,7 +125,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(scope, 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())); @@ -278,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, @@ -298,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/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/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 09901663b..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 { @@ -347,10 +365,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 +420,22 @@ 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. 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 + } Err(_) => None, }, ProjectView::Memory(project) => { @@ -807,6 +842,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, + ); + } } } @@ -891,6 +932,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(|root| crate::utils::python_script::uv_config_above_repo(root, None)) + { + out.files.insert( + crate::patch::redirect::UV_ANCESTOR_OUTSIDE_KEY.to_string(), + why, + ); } } @@ -2775,12 +2824,22 @@ 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( + 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) } 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))) { @@ -3621,16 +3680,18 @@ 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 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; + }; done.unreadable_reads .iter() .find(|rel| { @@ -3691,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 } @@ -4403,6 +4502,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/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 205008fa7..72f8967bd 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,13 @@ 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( + 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)), TrustPlan::AlreadyTrue | TrustPlan::UserSet(_) @@ -244,7 +264,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 +310,12 @@ async fn pnpm_lock_elsewhere(root: &Path) -> Option { .unwrap_or_else(|_| root.to_path_buf()); let mut workspace = None; + 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. + 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 +527,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 +575,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(crate::utils::repo_root::ReadScope::Workspace, 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(crate::utils::repo_root::ReadScope::Workspace, 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 +856,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(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; + } let Ok(text) = read_regular_to_string(&ancestor.join("package.json")).await else { continue; }; @@ -1031,7 +1120,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(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; + } let Some((groups, source)) = vlt_workspace_groups(ancestor).await else { continue; }; 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/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0c778ac74..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 { @@ -7069,6 +7073,11 @@ pub const NUGET_INHERITED_SOURCES_KEY: &str = "\n\n \n \n\n"; @@ -7083,6 +7092,12 @@ const EMPTY_NUGET_CONFIG: &str = "\n>(), + ["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..67a74773e 100644 --- a/crates/socket-patch-core/src/patch/redirect/staged.rs +++ b/crates/socket-patch-core/src/patch/redirect/staged.rs @@ -25,7 +25,30 @@ 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. 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( + crate::utils::repo_root::ReadScope::Workspace, + 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( + 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)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(format!("read {rel}: {e}")), @@ -103,3 +126,38 @@ 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}"); + } + // 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 1bb703280..709fb0da1 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,21 @@ 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( + 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)) + }); + 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()) @@ -656,11 +672,18 @@ 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, } } 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 +1935,32 @@ 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( + crate::utils::repo_root::ReadScope::Workspace, + 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..4cb6df9e3 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -264,12 +264,28 @@ 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( + crate::utils::repo_root::ReadScope::Workspace, + 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 +348,17 @@ 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( + crate::utils::repo_root::ReadScope::Workspace, + 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 +647,10 @@ 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(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() @@ -648,6 +675,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 +715,33 @@ 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( + crate::utils::repo_root::ReadScope::Workspace, + 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().unwrap_or(Path::new("/")).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/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 3634b0816..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,7 +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(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)) @@ -526,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()); } @@ -535,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 @@ -557,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 @@ -578,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") @@ -586,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)] @@ -600,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 @@ -610,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 @@ -649,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, @@ -657,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)); } } @@ -672,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", @@ -687,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}" + ); } } @@ -705,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 @@ -751,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)); } } @@ -805,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 @@ -822,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"); @@ -865,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()); } @@ -927,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}" ); @@ -938,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" ); @@ -975,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() @@ -988,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) ); } @@ -1041,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 @@ -1062,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 ); } @@ -1086,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 ); @@ -1102,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/cargo_build_cache.rs b/crates/socket-patch-core/src/utils/cargo_build_cache.rs index e4b3668c4..116888c1b 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(); @@ -123,13 +125,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`. -fn workspace_root(cwd: &Path) -> PathBuf { - for dir in cwd.ancestors() { +/// +/// 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(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() { 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 @@ -155,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(); @@ -309,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)> { @@ -322,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 { @@ -347,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 } @@ -362,6 +394,54 @@ 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(crate::utils::repo_root::ReadScope::Workspace, &app), + app + ); + std::fs::remove_dir(outer.join("repo/.git")).unwrap(); + assert_eq!( + workspace_root(crate::utils::repo_root::ReadScope::Workspace, &app), + outer + ); + } + + /// 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(); @@ -442,7 +522,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()] @@ -477,9 +562,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); @@ -495,7 +590,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, @@ -511,7 +611,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"))); @@ -520,7 +625,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. @@ -529,7 +639,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 8671daab2..3dbd6202c 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -795,17 +795,38 @@ 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( + 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")) + .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() { @@ -974,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; } @@ -985,6 +1016,19 @@ 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( + crate::utils::repo_root::ReadScope::Workspace, + &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 .strip_prefix(dir) .ok()? @@ -1038,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 cafa232de..9ff311e5d 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -479,8 +479,17 @@ 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(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; + } 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 +504,28 @@ 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. +/// +/// `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 {}: {}", + 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 504268698..ad3ccea1e 100644 --- a/crates/socket-patch-core/src/utils/repo_root.rs +++ b/crates/socket-patch-core/src/utils/repo_root.rs @@ -124,6 +124,252 @@ pub fn find_git_repo(start: &Path) -> Option { repo_at_end_of(search_dirs(start)) } +/// 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 +/// `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 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 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 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 { + ReadBoundary::Workspace { root } => Some(root), + ReadBoundary::Unbounded => None, + } + } + + /// 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 { + 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. + 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.shown_root() + ) + } + + /// The nearest file named one of `names` in a directory ABOVE the + /// 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(); + root.ancestors() + .skip(1) + .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()) + } + + /// 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.shown_root() + ) + } + + fn shown_root(&self) -> std::path::Display<'_> { + self.root().unwrap_or(Path::new("/")).display() + } +} + +/// 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 +/// 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` 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` (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); + 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) +} + +/// 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` 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 { + 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; + } + } + } + } + out +} + /// 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. @@ -197,6 +443,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::*; @@ -270,6 +520,125 @@ 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(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"))); + 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(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(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(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 + /// 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(ReadScope::Workspace, &repo); + assert!(!bound.contains(&repo.join("link/requirements.txt"))); + 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 new file mode 100644 index 000000000..f7f22b82c --- /dev/null +++ b/crates/socket-patch-core/src/utils/repo_root_upward_tests.rs @@ -0,0 +1,394 @@ +//! 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(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"); + write(&l.outer.join("package.json"), "not json"); + assert_eq!( + bound.marker_above(&["package.json"]), + Some(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(ReadScope::Workspace, &plain.app), None); + assert!(upward_readable( + ReadScope::Workspace, + &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); + let ancestor = find_ancestor_uv_config(&l.app); + assert_eq!( + uv_config_above_repo(&l.app, ancestor.as_ref()).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( + ReadScope::Workspace, + &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(ReadScope::Workspace, &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(), + scope: ReadScope::Workspace, + } +} + +#[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())); + } + } +} + +/// 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::for_options(&options) + .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 77e56eae6..f1dc1f263 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,17 @@ 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(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"]) { + 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 +93,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 +119,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..03a884b6d 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,19 @@ 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(crate::utils::repo_root::ReadScope::Workspace, &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 +1562,15 @@ 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( + crate::utils::repo_root::ReadScope::Workspace, + 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 +1743,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 +1974,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..a68e51620 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -157,6 +157,23 @@ 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( + 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: {}", + 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 +182,13 @@ 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(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))) .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/go_consumer_sync.rs b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs index e4db0b3e8..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, ) 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..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 @@ -50,7 +53,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 t.bound.contains(&file) { + paths.push(file); + } } else if let (Some(cache), Some(target), Some(ver)) = ( GoCrawler::get_gomodcache(), &entry.rhs_module, @@ -985,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 2c2218035..bff82b4d4 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, @@ -57,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(), ) @@ -67,18 +80,44 @@ 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::upward(scope, 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 +267,22 @@ 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::upward( + crate::utils::repo_root::ReadScope::Workspace, + 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 +302,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 +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::upward( + 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. @@ -666,7 +726,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 +1085,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 +1112,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 +1182,15 @@ 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::upward( + crate::utils::repo_root::ReadScope::Workspace, + root, + ), + ) + .await + { Loaded::Workspace(ws) => ws .members_within(root) .into_iter() @@ -1271,6 +1345,73 @@ 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 checkout the lookup walks on as before. + std::fs::remove_dir(repo.join(".git")).unwrap(); + 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 + // 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] 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..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}; @@ -440,7 +439,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/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index c55ec4549..1753cd841 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,16 @@ 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( + crate::utils::repo_root::ReadScope::Workspace, + 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 +743,60 @@ 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( + crate::utils::repo_root::ReadScope::Workspace, + 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( + crate::utils::repo_root::ReadScope::Workspace, + 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( + crate::utils::repo_root::ReadScope::Workspace, + root, + rel, + )?) + .await + } ProjectView::Memory(project) => project.read_bytes(rel), ProjectView::Snapshot(snap) => snap.read_bytes(rel).await, } @@ -859,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 0f320ad8b..33626f829 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -335,8 +335,29 @@ 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( + 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 + // 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 +1711,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/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/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/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 0aa773fd2..f0cfdb71e 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,11 @@ pub(crate) async fn inherited_source_keys_traced( )], ..NugetConfig::default() })); + let bound = crate::utils::repo_root::ReadBoundary::upward( + 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(); for dir in ancestors { @@ -147,6 +173,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 +194,7 @@ pub(crate) async fn inherited_source_keys_traced( Inherited { keys: effective_source_keys(&chain), mapped, + outside, }, touched, ) @@ -574,6 +607,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..4cebf3f63 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; @@ -5267,6 +5271,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/path.rs b/crates/socket-patch-core/src/vendor/path.rs index 993d8b5d3..87284618a 100644 --- a/crates/socket-patch-core/src/vendor/path.rs +++ b/crates/socket-patch-core/src/vendor/path.rs @@ -134,34 +134,167 @@ 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]; + // 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; + } + 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 +770,90 @@ 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()); + // 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" + )) + .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 +863,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/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 597720df8..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); @@ -4156,6 +4152,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 +4211,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 +4232,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 +4254,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_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 726314b21..2b12d09fc 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, ancestor.as_ref())) + .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_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index 490ef46ec..ede1b5835 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 @@ -4292,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 @@ -4315,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 c00b33934..0cc2f0cb1 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 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; @@ -3330,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 { diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index e7fc3af3e..197e7be54 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -564,6 +564,11 @@ async fn unresolved_npm_dependency(pkg_path: &Path) -> 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 @@ -686,11 +693,19 @@ fn forces(overrides: &[(Option, 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. -fn node_resolve(from: &Path, name: &str) -> Option { +/// +/// 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( + 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| bound.contains(copy)) } /// The registry semver range of a `dependencies` value: the value itself, @@ -764,6 +779,30 @@ 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(); + 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(&bound(&from), &from, "dep"), + Some(outer.join("node_modules/dep")) + ); + } + fn record_with_one_file(after_hash: &str) -> PatchRecord { let mut files = HashMap::new(); files.insert( diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 8afbbb1e1..3c8438c9c 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::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; + let crawler = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global); 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,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 - .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, @@ -4574,7 +4613,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 +4639,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 +4674,13 @@ 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::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .get_node_modules_paths(&opts) + .await + .unwrap(); + let crawled = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .crawl_all(&opts) + .await; assert!(roots.contains(®istry), "{roots:?}"); assert!( @@ -4655,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 - .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 + 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(); @@ -4677,7 +4728,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 +4754,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,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 - .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 + let found = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .find_by_purls(®istry, std::slice::from_ref(&purl)) .await .unwrap(); @@ -4774,7 +4826,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::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .get_node_modules_paths(&opts) + .await + .unwrap(); assert!(roots.contains(&plain), "{roots:?}"); assert!(roots.contains(&nested), "{roots:?}"); assert!( @@ -4804,7 +4859,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 +4870,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 +4910,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::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], "{roots:?}"); } @@ -4889,7 +4947,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::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], "{roots:?}"); } @@ -4922,7 +4983,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::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], "{roots:?}"); } @@ -4973,17 +5037,21 @@ 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::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 - .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![ @@ -4993,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 + let found = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .find_by_purls(&nested, &[foo_purl.clone()]) .await .unwrap(); @@ -5031,15 +5099,19 @@ 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::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 - .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, @@ -5067,14 +5139,18 @@ 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(); - assert_eq!(roots, vec![prefix.clone()]); - let mut purls: Vec = NpmCrawler - .crawl_all(&opts) + let roots = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .get_node_modules_paths(&opts) .await - .into_iter() - .map(|p| p.purl) - .collect(); + .unwrap(); + assert_eq!(roots, vec![prefix.clone()]); + 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"]); } @@ -5102,7 +5178,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::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .get_node_modules_paths(&opts) + .await + .unwrap(); assert_eq!(roots, vec![prefix.clone()], "{prefix:?}"); } } @@ -5123,7 +5202,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::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) + .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..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 + let paths = NpmCrawler::with_scope(socket_patch_core::utils::repo_root::ReadScope::Global) .get_node_modules_paths(&CrawlerOptions { cwd: l.proj.clone(), global: true,