diff --git a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs index 7cd4cb6b..c19dadf9 100644 --- a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs +++ b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs @@ -299,6 +299,330 @@ async fn pnpm_install_then_apply_patches_file() { assert_patched(&real, &patched, &before_hash, &after_hash); } +// --------------------------------------------------------------------------- +// pnpm isolated linker: transitive-only dependency in the virtual store +// --------------------------------------------------------------------------- + +/// Under pnpm's isolated linker a *transitive-only* dependency has no +/// importer-root entry at all: `node_modules/` does not exist, and the +/// only physical install lives at `node_modules/.pnpm//node_modules/` +/// — runtime-loaded, yet invisible to any walk that skips the hidden `.pnpm` +/// virtual store (apply reported `package_not_installed` on pnpm 7–12). +/// mkdirp@0.5.5 depends on minimist, giving a real pnpm install with exactly +/// that shape. Apply must resolve minimist inside the store and patch the +/// canonical file — while a sibling project sharing the same store stays +/// pristine: the file is hardlink-imported, so only a CoW break (not an +/// in-place write) keeps the store and every other consumer untouched. +#[tokio::test] +#[serial] +async fn pnpm_transitive_only_dep_apply_patches_virtual_store() { + if !has("pnpm") { + println!("SKIP: pnpm not on PATH"); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let store = tmp.path().join("store"); + + let stage_project = |name: &str| -> std::path::PathBuf { + let proj = tmp.path().join(name); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + format!( + r#"{{ "name": "{name}", "version": "0.0.0", "dependencies": {{ "mkdirp": "0.5.5" }} }}"# + ), + ) + .unwrap(); + proj + }; + let proj_a = stage_project("pnpm-iso-a"); + let proj_b = stage_project("pnpm-iso-b"); + + for proj in [&proj_a, &proj_b] { + // Both projects share one store; hardlink import (instead of the + // APFS-clone default) makes each project file share the store + // file's inode — the layout the CoW assertions below are about. + // CLI flags, not project `.npmrc`: pnpm 11 no longer reads these + // settings from `.npmrc` (silently — config get returns undefined). + let out = pm_command("pnpm", &["npm_config_"]) + .args([ + "install", + "--silent", + "--no-frozen-lockfile", + "--store-dir", + store.to_str().unwrap(), + "--config.package-import-method=hardlink", + ]) + .current_dir(proj) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .expect("pnpm install"); + if !out.status.success() { + println!( + "SKIP: pnpm install failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + return; + } + } + + // Premise: minimist is transitive-only — no importer-root entry (not + // even a symlink). If pnpm ever hoisted it, this test would silently + // stop exercising the virtual-store path and must say so. + assert!( + std::fs::symlink_metadata(proj_a.join("node_modules/minimist")).is_err(), + "pnpm test premise broken: minimist appeared at the importer root; \ + the transitive-only virtual-store path is not being exercised" + ); + + // The lock-resolved minimist lives next to the real mkdirp inside its + // own store entry; canonicalize resolves that (possibly symlinked) + // sibling to its physical store home. + let locate = |proj: &Path| -> std::path::PathBuf { + let mkdirp_real = + std::fs::canonicalize(proj.join("node_modules/mkdirp")).expect("canonicalize mkdirp"); + std::fs::canonicalize(mkdirp_real.parent().unwrap().join("minimist")) + .expect("minimist must be installed beside mkdirp in its store entry") + }; + let minimist_a = locate(&proj_a); + assert!( + minimist_a.components().any(|c| c.as_os_str() == ".pnpm"), + "premise: minimist's canonical home must be inside the virtual store: {minimist_a:?}" + ); + let meta: serde_json::Value = + serde_json::from_slice(&std::fs::read(minimist_a.join("package.json")).unwrap()).unwrap(); + let version = meta["version"].as_str().expect("version field").to_string(); + + let target = minimist_a.join("index.js"); + let original = std::fs::read(&target).expect("read minimist index.js"); + let before_hash = git_sha256(&original); + let mut patched = original.clone(); + patched.extend_from_slice(b"\n// SOCKET-PATCH-PNPM-TRANSITIVE-MARKER\n"); + let after_hash = git_sha256(&patched); + + // The sibling project's canonical copy of the same file. + let target_b = locate(&proj_b).join("index.js"); + assert_eq!( + std::fs::read(&target_b).unwrap(), + original, + "both projects must start from identical store-imported bytes" + ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!( + std::fs::metadata(&target).unwrap().ino(), + std::fs::metadata(&target_b).unwrap().ino(), + "hardlink-import premise: both projects' copies must share the store inode" + ); + } + + let socket = proj_a.join(".socket"); + write_manifest( + &socket, + &format!("pkg:npm/minimist@{version}"), + &before_hash, + &after_hash, + ); + let blobs = socket.join("blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + std::fs::write(blobs.join(&after_hash), &patched).unwrap(); + + let code = apply_run(default_apply(&proj_a)).await; + assert_eq!( + code, 0, + "apply must resolve the transitive-only dep inside .pnpm and succeed" + ); + assert_patched(&target, &patched, &before_hash, &after_hash); + + // CoW safety: the sibling project sharing the store is untouched, and + // the patched file no longer shares the store inode. + let after_b = std::fs::read(&target_b).unwrap(); + assert_eq!( + git_sha256(&after_b), + before_hash, + "sibling project sharing the store must keep the original bytes" + ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_ne!( + std::fs::metadata(&target).unwrap().ino(), + std::fs::metadata(&target_b).unwrap().ino(), + "apply must break the hardlink (CoW) instead of writing through the store inode" + ); + } +} + +// --------------------------------------------------------------------------- +// pnpm 4 (legacy NESTED virtual store): transitive-only dependency +// --------------------------------------------------------------------------- + +/// REAL `corepack pnpm@4.14.4` install (pnpm 4 runs fine on modern Node). +/// Its layoutVersion-3 virtual store is nested by registry host — +/// `.pnpm/registry.npmjs.org///node_modules/` — not +/// the flat `.pnpm/@` shape of pnpm 6+, and the flat-entry +/// walk was blind to it: apply exited 0 claiming success while the +/// transitive dep's file was never written (empirically confirmed on a +/// captured pnpm 4.14.4 tree). mkdirp@0.5.5 depends on minimist, giving a +/// real install with exactly that shape. Two projects share one store via +/// hardlink import, so the CoW asserts prove apply broke the link instead +/// of writing through the store inode. +#[tokio::test] +#[serial] +async fn pnpm4_nested_store_transitive_only_dep_apply_patches_file() { + if !has_corepack_pm("pnpm@4.14.4") { + println!("SKIP: corepack pnpm@4.14.4 unavailable"); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let store = tmp.path().join("store"); + + let stage_project = |name: &str| -> std::path::PathBuf { + let proj = tmp.path().join(name); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + format!( + r#"{{ "name": "{name}", "version": "0.0.0", "dependencies": {{ "mkdirp": "0.5.5" }} }}"# + ), + ) + .unwrap(); + proj + }; + let proj_a = stage_project("pnpm4-nested-a"); + let proj_b = stage_project("pnpm4-nested-b"); + + for proj in [&proj_a, &proj_b] { + // `--store-dir` + `--package-import-method hardlink` are the pnpm 4 + // spellings (verified against `pnpm@4.14.4 install --help`); both + // projects share the store so the file is hardlink-imported and the + // CoW assertions below have teeth. + let out = pm_command("corepack", &["npm_config_"]) + .args([ + "pnpm@4.14.4", + "install", + "--store-dir", + store.to_str().unwrap(), + "--package-import-method", + "hardlink", + ]) + .current_dir(proj) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .expect("corepack pnpm@4.14.4 install"); + if !out.status.success() { + println!( + "SKIP: pnpm@4.14.4 install failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + return; + } + } + + // Layout premises — if any of these drift the test would silently stop + // exercising the nested-store path and must say so instead: + // the store is nested by registry host (layoutVersion 3), … + assert!( + proj_a + .join("node_modules/.pnpm/registry.npmjs.org") + .is_dir(), + "pnpm 4 premise broken: no nested `.pnpm/registry.npmjs.org` host dir" + ); + // … and minimist is transitive-only: no importer-root entry at all. + assert!( + std::fs::symlink_metadata(proj_a.join("node_modules/minimist")).is_err(), + "pnpm 4 premise broken: minimist appeared at the importer root; \ + the transitive-only nested-store path is not being exercised" + ); + + // The lock-resolved minimist lives next to the real mkdirp inside its + // own store entry; canonicalize resolves that sibling symlink to its + // physical nested-store home. + let locate = |proj: &Path| -> std::path::PathBuf { + let mkdirp_real = + std::fs::canonicalize(proj.join("node_modules/mkdirp")).expect("canonicalize mkdirp"); + std::fs::canonicalize(mkdirp_real.parent().unwrap().join("minimist")) + .expect("minimist must be installed beside mkdirp in its store entry") + }; + let minimist_a = locate(&proj_a); + assert!( + minimist_a + .components() + .any(|c| c.as_os_str() == "registry.npmjs.org"), + "premise: minimist's canonical home must be inside the NESTED store: {minimist_a:?}" + ); + let meta: serde_json::Value = + serde_json::from_slice(&std::fs::read(minimist_a.join("package.json")).unwrap()).unwrap(); + let version = meta["version"].as_str().expect("version field").to_string(); + + let target = minimist_a.join("index.js"); + let original = std::fs::read(&target).expect("read minimist index.js"); + let before_hash = git_sha256(&original); + let mut patched = original.clone(); + patched.extend_from_slice(b"\n// SOCKET-PATCH-PNPM4-NESTED-MARKER\n"); + let after_hash = git_sha256(&patched); + + // The sibling project's canonical copy of the same file. + let target_b = locate(&proj_b).join("index.js"); + assert_eq!( + std::fs::read(&target_b).unwrap(), + original, + "both projects must start from identical store-imported bytes" + ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!( + std::fs::metadata(&target).unwrap().ino(), + std::fs::metadata(&target_b).unwrap().ino(), + "hardlink-import premise: both projects' copies must share the store inode" + ); + } + + let socket = proj_a.join(".socket"); + write_manifest( + &socket, + &format!("pkg:npm/minimist@{version}"), + &before_hash, + &after_hash, + ); + let blobs = socket.join("blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + std::fs::write(blobs.join(&after_hash), &patched).unwrap(); + + let code = apply_run(default_apply(&proj_a)).await; + assert_eq!( + code, 0, + "apply must resolve the transitive-only dep inside the nested \ + `.pnpm/registry.npmjs.org` store and succeed" + ); + assert_patched(&target, &patched, &before_hash, &after_hash); + + // CoW safety: the sibling project sharing the store is untouched, and + // the patched file no longer shares the store inode. + let after_b = std::fs::read(&target_b).unwrap(); + assert_eq!( + git_sha256(&after_b), + before_hash, + "sibling project sharing the store must keep the original bytes" + ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_ne!( + std::fs::metadata(&target).unwrap().ino(), + std::fs::metadata(&target_b).unwrap().ino(), + "apply must break the hardlink (CoW) instead of writing through the store inode" + ); + } +} + // --------------------------------------------------------------------------- // Monorepo workspace (npm workspaces) // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index af4e74e1..42d783fb 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -89,6 +89,108 @@ pub fn build_npm_purl(namespace: Option<&str>, name: &str, version: &str) -> Str } } +// --------------------------------------------------------------------------- +// Helper: decode a pnpm virtual-store entry directory name +// --------------------------------------------------------------------------- + +/// Decode a `.pnpm` virtual-store entry directory name into the +/// `(package_name, version)` it advertises. +/// +/// Store entry names follow `@` where: +/// - a scoped name's `/` is written as `+` (`@scope+leaf@2.0.0`), +/// - pnpm 9+ appends peer/qualifier suffixes in parentheses +/// (`foo@1.0.0(bar@2.0.0)(@babel+core@7.21.0)`), +/// - pnpm 6–8 appended peer suffixes after `_` (`foo@1.0.0_bar@2.0.0`), +/// - over-long names are truncated (the cut can land ANYWHERE, even +/// mid-name or mid-version) and end in `_`. +/// +/// Returns `None` for anything that does not cleanly parse as +/// `name@X.Y.Z…`: store metadata files (`lock.yaml`), git/URL dependency +/// entries (`foo@github.com+user+repo@` — a sha is not a semver +/// triple), truncated long-name dirs, and names containing a literal `_` +/// (indistinguishable from a legacy peer suffix). Callers MUST treat +/// `None` as "identity unknowable from the dir name", not "no package +/// here", and keep such entries probeable/scannable. A `Some` can still +/// be a truncation artifact (a cut that happens to land after a +/// `name@X.Y.Z` prefix is undetectable), so the decoded pair is +/// advisory: resolution authority stays with the package.json probe. +pub fn decode_pnpm_store_entry_name(entry_name: &str) -> Option<(String, String)> { + // pnpm 9+ peer/qualifier suffix: everything from the first `(`. + let base = &entry_name[..entry_name.find('(').unwrap_or(entry_name.len())]; + // Legacy (pnpm 6–8) `_` peer suffix, doubling as the long-name + // truncation hash separator. Real package names may contain `_` too + // — those then fail the version parse below and fall to the + // conservative `None` path, which is the safe direction. + let base = &base[..base.find('_').unwrap_or(base.len())]; + + let at = base.rfind('@')?; + // `at == 0` would leave an empty name (`@1.0.0`). + if at == 0 { + return None; + } + let version = &base[at + 1..]; + if !is_semver_triple(version) { + return None; + } + // Scope escaping: `/` in the (possibly scoped) name is written `+`. + // `+` cannot appear in a real npm name, so a bare replace is exact. + let name = base[..at].replace('+', "/"); + Some((name, version.to_string())) +} + +/// Whether `v` starts with a numeric `MAJOR.MINOR.PATCH` triple +/// (pre-release/build tails allowed). Registry versions — the only kind +/// pnpm writes into decodable store entry names — always do; git shas +/// and URL fragments never do, so requiring the triple keeps those +/// entries on `decode_pnpm_store_entry_name`'s conservative `None` path. +fn is_semver_triple(v: &str) -> bool { + let mut parts = v.splitn(3, '.'); + let (Some(major), Some(minor), Some(rest)) = (parts.next(), parts.next(), parts.next()) else { + return false; + }; + let patch = &rest[..rest.find(['-', '+']).unwrap_or(rest.len())]; + let all_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + all_digits(major) && all_digits(minor) && all_digits(patch) +} + +// --------------------------------------------------------------------------- +// Helpers: pnpm virtual-store layout knowledge +// --------------------------------------------------------------------------- + +/// Maximum directory depth probed below a *nested* virtual-store host dir. +/// pnpm 4/5 (layoutVersion 3) nest store entries by registry host — +/// `.pnpm////node_modules/` — and +/// pnpm <=3 (layoutVersion <=2) use the same shape directly under a hidden +/// `node_modules/.` dir (both confirmed against captured +/// real installs). Relative to the host dir the deepest package home is +/// `@scope//` — three levels. +const NESTED_STORE_MAX_DEPTH: usize = 3; + +/// Upper bound on directories visited while descending one nested-store +/// host, so a corrupted (or adversarial) tree cannot turn the bounded +/// descent into an unbounded readdir storm. A real store holds one dir per +/// scope/name and one per version — orders of magnitude below this. +/// Hitting the cap can only make the walk miss packages (fail toward "not +/// installed", the same answer the pre-descent code gave for every nested +/// entry), never patch the wrong one: the package.json probe stays the +/// authority. +const NESTED_STORE_MAX_DIRS: usize = 16_384; + +/// Whether a hidden `node_modules` child is a pnpm <=3 virtual store. +/// Before the `.pnpm` dir existed (layoutVersion <=2: pnpm 1/2/3) the +/// store lived at `node_modules/.` — `.registry.npmjs.org` +/// for the default registry (confirmed byte-for-byte in captured pnpm +/// 1.x/2.x/3.8 trees, whose `.modules.yaml` names +/// `registries.default: https://registry.npmjs.org/`). Matching the +/// `.registry.` prefix also covers other `registry.*` hosts while never +/// mistaking unrelated hidden dirs (`.bin`, `.cache`, `.git`) for a store; +/// a custom registry on a host not starting with `registry.` would need +/// its own entry here — deliberately NOT "any hidden dir", which would +/// walk arbitrary tool caches. +fn is_legacy_pnpm_store_dir_name(name: &str) -> bool { + name.starts_with(".registry.") +} + // --------------------------------------------------------------------------- // Global prefix detection helpers // --------------------------------------------------------------------------- @@ -276,6 +378,48 @@ fn find_node_dirs_sync(base: &Path, segments: &[&str]) -> Vec { /// NPM ecosystem crawler for discovering packages in `node_modules`. pub struct NpmCrawler; +/// One still-unresolved `find_by_purls` lookup. +/// +/// `purl` is the *verbatim* caller-supplied PURL, including any +/// `?qualifiers`. The result map is keyed by this exact string: the +/// dispatcher drives npm with `passthrough_purls` + `merge_first_wins`, +/// so it looks results back up under the PURL it handed in. Keying by a +/// reconstructed/stripped PURL silently loses every qualified PURL +/// (e.g. `pkg:npm/foo@1.0.0?vcs_url=...`). +struct Target { + namespace: Option, + name: String, + version: String, + purl: String, + /// Install dir relative to a `node_modules` root + /// (`@scope/name` or `name`) — which is also exactly what the + /// package.json `name` field must say for this dir to BE that + /// package. + dir_key: String, +} + +/// Which kind of `node_modules` directory a scan pass is walking — the one +/// traversal-policy bit that differs between them. +#[derive(Clone, Copy)] +enum ScanPolicy<'a> { + /// An importer's or package's `node_modules`: symlinked entries are + /// recorded (pnpm links direct deps; `npm link` targets) but never + /// traversed into, and a `.pnpm` child is the virtual store, scanned + /// in a deferred pass. + Importer, + /// One pnpm virtual-store entry's `node_modules`: only REAL + /// directories are inventoried — a symlinked entry here is the + /// package's dependency pointing at a sibling `.pnpm` store entry, + /// which is inventoried via that entry; following it would record the + /// same package under a path owned by a different store entry. + /// `identity_seen` optionally carries the entry's own package name + /// (what the store dir name decodes to) when its name@version is + /// already inventoried — the importer pass wins the `seen` dedup for + /// every root-linked direct dep — so that child's package.json is not + /// read a second time; everything below it is still scanned. + StoreEntry { identity_seen: Option<&'a str> }, +} + impl NpmCrawler { /// Create a new `NpmCrawler`. pub fn new() -> Self { @@ -316,7 +460,7 @@ impl NpmCrawler { .unwrap_or_default(); for nm_path in &nm_paths { - let found = Self::scan_node_modules(nm_path, &mut seen).await; + let found = Self::scan_node_modules(nm_path, &mut seen, ScanPolicy::Importer).await; packages.extend(found); } @@ -335,24 +479,6 @@ impl NpmCrawler { ) -> Result, std::io::Error> { let mut result: HashMap = HashMap::new(); - // `purl` is the *verbatim* caller-supplied PURL, including any - // `?qualifiers`. The result map is keyed by this exact string: the - // dispatcher drives npm with `passthrough_purls` + `merge_first_wins`, - // so it looks results back up under the PURL it handed in. Keying by a - // reconstructed/stripped PURL silently loses every qualified PURL - // (e.g. `pkg:npm/foo@1.0.0?vcs_url=...`). - struct Target { - namespace: Option, - name: String, - version: String, - purl: String, - /// Install dir relative to a `node_modules` root - /// (`@scope/name` or `name`) — which is also exactly what the - /// package.json `name` field must say for this dir to BE that - /// package. - dir_key: String, - } - let mut pending: Vec = Vec::new(); for purl in purls { let Some((namespace, name, version)) = Self::parse_purl_components(purl) else { @@ -386,14 +512,51 @@ impl NpmCrawler { }); } - // Probe trees breadth-first: the root `node_modules` first (so a - // root-level install always wins), then — only while targets remain - // unresolved — each nested `node_modules`. npm nests a conflicting - // version under the dependent package, so a patched version can - // exist *only* nested; CLI_CONTRACT ("Deeply nested transitive - // dependencies are fully supported") promises those are patched - // identically to direct deps, and `crawl_all` (scan) already - // discovers them at unbounded depth. + // Pass 1 — filtered: `.pnpm` virtual-store entries are enqueued + // only when their dir name decodes to a still-pending target's + // name (a manifest routinely lists packages that simply aren't + // 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).await; + + // Pass 2 — unfiltered fallback, only for targets pass 1 could not + // resolve: a target can physically exist ONLY inside another + // package's store entry (a bundled dependency at + // `.pnpm/host@1.0.0/node_modules/host/node_modules/`), + // whose entry name decodes to the HOST's name — the pass-1 filter + // skips it, leaving an installed, scan-visible package invisible + // to apply (fail-open: apply reported it not installed). Probe + // 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).await; + } + + Ok(result) + } + + /// One breadth-first resolution pass over the tree rooted at + /// `node_modules_path`: the root `node_modules` first (so a root-level + /// install always wins), then — only while targets remain unresolved — + /// each nested `node_modules`. npm nests a conflicting version under + /// the dependent package, so a patched version can exist *only* + /// nested; CLI_CONTRACT ("Deeply nested transitive dependencies are + /// fully supported") promises those are patched identically to direct + /// deps, and `crawl_all` (scan) already discovers them at unbounded + /// depth. + /// + /// Resolved targets land in `result`; the still-unresolved remainder + /// is returned. `filter_store_entries` selects whether pnpm + /// virtual-store entries are bounded by the pending-name filter (pass + /// 1) or all probed (the pass-2 fallback) — see `find_by_purls`. + async fn resolve_pending_targets( + node_modules_path: &Path, + mut pending: Vec, + result: &mut HashMap, + filter_store_entries: bool, + ) -> Vec { let mut queue: VecDeque = VecDeque::from([node_modules_path.to_path_buf()]); while let Some(nm_path) = queue.pop_front() { if pending.is_empty() { @@ -429,11 +592,17 @@ impl NpmCrawler { } pending = unresolved; if !pending.is_empty() { - Self::collect_nested_node_modules(&nm_path, &mut queue).await; + // The still-unresolved names bound which `.pnpm` store + // entries are worth enqueuing (see the store branch of + // `collect_nested_node_modules`). Rebuilt per level: + // targets resolved at shallower depths drop out. + let pending_names: HashSet<&str> = + pending.iter().map(|t| t.dir_key.as_str()).collect(); + let filter = filter_store_entries.then_some(&pending_names); + Self::collect_nested_node_modules(&nm_path, filter, &mut queue).await; } } - - Ok(result) + pending } /// Append the `node_modules` dirs living one level below `nm_path` @@ -441,11 +610,61 @@ impl NpmCrawler { /// Mirrors `scan_node_modules`' traversal policy: hidden entries are /// skipped and symlinked packages are never traversed — a symlink here /// points into pnpm's content-addressed store or an `npm link` target - /// outside the project. - async fn collect_nested_node_modules(nm_path: &Path, queue: &mut VecDeque) { + /// outside the project. The one exception is pnpm's `.pnpm` virtual + /// store (see below); `pending_names` — `Some(the still-unresolved + /// targets' full package names)` — bounds which store entries get + /// enqueued, while `None` (the pass-2 fallback of `find_by_purls`) + /// enqueues every store entry. + async fn collect_nested_node_modules( + nm_path: &Path, + pending_names: Option<&HashSet<&str>>, + queue: &mut VecDeque, + ) { for entry in crate::utils::fs::list_dir_entries(nm_path).await { let name = entry.file_name(); let name_str = name.to_string_lossy(); + // pnpm's virtual store. Under the isolated linker the store is + // the ONLY physical home of transitive dependencies: the + // importer's node_modules holds symlinks for direct deps only, + // so a transitive-only target (installed at + // `.pnpm//node_modules/`, runtime-loaded) is + // unreachable through the symlink-free walk above — invisible + // to apply despite being importable. Probe REAL store entries' + // `node_modules`; the name+version match in `find_by_purls` + // keeps aliases and multi-version store entries distinct, and + // BFS order guarantees a root-linked install has already been + // probed (and removed from `pending`) before these are + // dequeued, so a package is never resolved twice. + if name_str == ".pnpm" { + let Some(file_type) = crate::utils::fs::entry_file_type(&entry).await else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let store_path = nm_path.join(&name); + let entries = Self::list_pnpm_store_entries(&store_path).await; + Self::enqueue_pending_store_entries(entries, pending_names, queue); + continue; + } + // pnpm <=3: the virtual store is a hidden `.` dir + // (there is no `.pnpm` at all) with the same + // transitive-only-deps property, so it gets the same probing. + // Must run before the generic hidden-entry skip below, which + // would otherwise swallow it — leaving every transitive-only + // install unpatchable on those layouts. + if is_legacy_pnpm_store_dir_name(&name_str) { + let Some(file_type) = crate::utils::fs::entry_file_type(&entry).await else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let mut entries = Vec::new(); + Self::collect_nested_store_entries(&nm_path.join(&name), &mut entries).await; + Self::enqueue_pending_store_entries(entries, pending_names, queue); + continue; + } if name_str.starts_with('.') || name_str == "node_modules" { continue; } @@ -483,6 +702,40 @@ impl NpmCrawler { } } + /// Enqueue virtual-store entries that can still hold a pending target. + /// A manifest routinely lists packages that simply aren't installed + /// here, and probing every entry of a large monorepo store for them + /// would add a readdir+stat storm to every apply/rollback run. The + /// entry name advertises the entry's package, so filter by PENDING + /// NAME only — the version is deliberately NOT matched at this stage + /// (dir-name versions can carry peer/build decorations; the + /// package.json probe stays the authority). An undecodable name + /// (truncated/hash-suffixed dirs, git/URL deps, `_`-bearing names) + /// reveals nothing about what's inside, so it stays probeable. + /// + /// `pending_names = None` disables the filter entirely: the entry name + /// only advertises the entry's OWN package, so a target present solely + /// as a bundled dependency INSIDE another package's entry hides behind + /// a non-matching name — `find_by_purls`' pass-2 fallback probes every + /// entry for exactly those. Both enumerators only yield entries whose + /// `node_modules` exists, so no re-stat here. + fn enqueue_pending_store_entries( + entries: Vec<(String, PathBuf)>, + pending_names: Option<&HashSet<&str>>, + queue: &mut VecDeque, + ) { + for (entry_name, entry_nm) in entries { + if let Some(filter) = pending_names { + if let Some((entry_pkg, _version)) = decode_pnpm_store_entry_name(&entry_name) { + if !filter.contains(entry_pkg.as_str()) { + continue; + } + } + } + queue.push_back(entry_nm); + } + } + // ------------------------------------------------------------------ // Private helpers – global paths // ------------------------------------------------------------------ @@ -616,18 +869,63 @@ impl NpmCrawler { // ------------------------------------------------------------------ /// Scan a `node_modules` directory, returning all valid packages found. - /// Recurses into each package's own nested `node_modules`. + /// Recurses into each package's own nested `node_modules`. The one + /// policy bit distinguishing an importer/package tree from a pnpm + /// virtual-store entry is carried by [`ScanPolicy`]. fn scan_node_modules<'a>( node_modules_path: &'a Path, seen: &'a mut HashSet, + policy: ScanPolicy<'a>, ) -> std::pin::Pin> + 'a>> { Box::pin(async move { let mut results = Vec::new(); + let mut pnpm_store: Option = None; + let mut legacy_stores: Vec = Vec::new(); + let (store_entry, identity_seen) = match policy { + ScanPolicy::Importer => (false, None), + ScanPolicy::StoreEntry { identity_seen } => (true, identity_seen), + }; for entry in crate::utils::fs::list_dir_entries(node_modules_path).await { let name = entry.file_name(); let name_str = name.to_string_lossy().to_string(); + // pnpm's virtual store: under the isolated linker it is the + // ONLY physical home of transitive dependencies (the + // importer's node_modules symlinks direct deps only), so + // skipping it as just-another-hidden-dir leaves every + // transitive-only install invisible to scan. Deferred until + // after this loop so root-level entries are inventoried + // first and win the `seen` name@version dedup at their + // importer-root paths. (A store entry's own children never + // include a nested `.pnpm`; under `StoreEntry` policy the + // name falls through to the hidden-entry skip below.) + if !store_entry && name_str == ".pnpm" { + let Some(file_type) = crate::utils::fs::entry_file_type(&entry).await else { + continue; + }; + if file_type.is_dir() { + pnpm_store = Some(node_modules_path.join(&name_str)); + } + continue; + } + + // pnpm <=3 virtual store (a hidden `.` dir; + // no `.pnpm` exists on those layouts): same + // transitive-only-home property, same deferred scan so + // root-level entries win the `seen` dedup. Must run before + // the hidden-entry skip below, which would otherwise leave + // every transitive-only install invisible to scan. + if !store_entry && is_legacy_pnpm_store_dir_name(&name_str) { + let Some(file_type) = crate::utils::fs::entry_file_type(&entry).await else { + continue; + }; + if file_type.is_dir() { + legacy_stores.push(node_modules_path.join(&name_str)); + } + continue; + } + // Skip hidden files and node_modules if name_str.starts_with('.') || name_str == "node_modules" { continue; @@ -637,8 +935,15 @@ impl NpmCrawler { continue; }; - // Allow both directories and symlinks (pnpm uses symlinks) - if !file_type.is_dir() && !file_type.is_symlink() { + // Importer trees allow both directories and symlinks (pnpm + // links direct deps); a store entry accepts REAL dirs only + // (see `ScanPolicy::StoreEntry`). + let acceptable = if store_entry { + file_type.is_dir() + } else { + file_type.is_dir() || file_type.is_symlink() + }; + if !acceptable { continue; } @@ -646,36 +951,215 @@ impl NpmCrawler { if name_str.starts_with('@') { // Scoped packages - let scoped = Self::scan_scoped_packages(&entry_path, seen).await; + let scoped = Self::scan_scoped_packages(&entry_path, seen, policy).await; results.extend(scoped); } else { - // Regular package - if let Some(pkg) = Self::check_package(&entry_path, seen).await { - results.push(pkg); + // Regular package. `identity_seen` marks this exact dir + // as already inventoried by the importer pass — skip + // the redundant package.json read, but still descend + // below: bundled dependencies are real dirs nested + // inside the package itself (pnpm cannot link them + // out), physically present only here. + if identity_seen != Some(name_str.as_str()) { + if let Some(pkg) = Self::check_package(&entry_path, seen).await { + results.push(pkg); + } } // Recurse into nested node_modules only for real // directories (not symlinks). Following a symlink here // would walk into pnpm's content-addressed store (or an // `npm link` target outside the project). if file_type.is_dir() { - let nested = - Self::scan_node_modules(&entry_path.join("node_modules"), seen).await; + let nested = Self::scan_node_modules( + &entry_path.join("node_modules"), + seen, + ScanPolicy::Importer, + ) + .await; results.extend(nested); } } } + if let Some(store_path) = pnpm_store { + let entries = Self::list_pnpm_store_entries(&store_path).await; + results.extend(Self::scan_store_entries(entries, seen).await); + } + for store_path in legacy_stores { + let mut entries = Vec::new(); + Self::collect_nested_store_entries(&store_path, &mut entries).await; + results.extend(Self::scan_store_entries(entries, seen).await); + } + results }) } - /// Scan a scoped packages directory (`@scope/`). + /// Enumerate pnpm virtual-store (`node_modules/.pnpm`) entries, + /// yielding `(entry_name, /node_modules)` for every entry whose + /// `node_modules` actually exists. The child literally named + /// `node_modules` is pnpm's internal hoist dir (nothing but symlinks + /// into sibling entries) and hidden children are store metadata — both + /// skipped. A REAL directory child with a `node_modules` of its own is + /// a flat (pnpm 6+) entry; one *without* is the pnpm 4/5 nested layout + /// — the child is a registry-host dir + /// (`.pnpm////node_modules/`), so + /// treating it as an empty entry silently hid every transitive-only + /// install (apply exited 0 claiming success with nothing written) — + /// descend it instead. Shared by the resolver + /// (`collect_nested_node_modules`) and the scan pass + /// (`scan_store_entries` callers) so the store-layout policy lives + /// once. + async fn list_pnpm_store_entries(store_path: &Path) -> Vec<(String, PathBuf)> { + let mut entries = Vec::new(); + for entry in crate::utils::fs::list_dir_entries(store_path).await { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with('.') || name_str == "node_modules" { + continue; + } + let Some(file_type) = crate::utils::fs::entry_file_type(&entry).await else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let entry_path = store_path.join(&name); + let entry_nm = entry_path.join("node_modules"); + if is_dir(&entry_nm).await { + entries.push((name_str.into_owned(), entry_nm)); + } else { + Self::collect_nested_store_entries(&entry_path, &mut entries).await; + } + } + entries + } + + /// Descend a *nested* virtual-store host dir, yielding + /// `(name@version, /node_modules)` for each package home + /// found. Covers the two pre-flat layouts (both confirmed against + /// captured real installs): + /// - pnpm 4/5: `.pnpm//…` — called on a `.pnpm` child + /// that has no `node_modules` of its own; + /// - pnpm <=3: `node_modules/./…` — called on the + /// hidden store root directly. + /// + /// Below the host, path components are registry coordinates (`@scope`, + /// name, version), NOT package dirs, so the importer-walk hidden-name + /// skip does not apply here — but symlinks are never traversed (a link + /// inside the store points at a sibling entry or out of tree, and + /// following one could cycle), and both depth and total fan-out are + /// bounded. Each found dir's host-relative path is synthesized into + /// the flat `name@version` entry-name form so downstream consumers + /// (the pending-name filter, the `identity_seen` dedup) treat nested + /// and flat entries identically; a shape that doesn't fit stays an + /// undecodable — always-probed — name, the conservative direction. + async fn collect_nested_store_entries(host_path: &Path, entries: &mut Vec<(String, PathBuf)>) { + let mut remaining = NESTED_STORE_MAX_DIRS; + let mut queue: VecDeque<(PathBuf, String, usize)> = + VecDeque::from([(host_path.to_path_buf(), String::new(), 0)]); + while let Some((dir, rel, depth)) = queue.pop_front() { + for entry in crate::utils::fs::list_dir_entries(&dir).await { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + // A `node_modules` here belongs to a parent entry (already + // yielded), never a name/version coordinate. + if name_str == "node_modules" { + continue; + } + let Some(file_type) = crate::utils::fs::entry_file_type(&entry).await else { + continue; + }; + if !file_type.is_dir() { + continue; + } + if remaining == 0 { + return; + } + remaining -= 1; + let child = dir.join(&name); + let child_rel = if rel.is_empty() { + name_str.into_owned() + } else { + format!("{rel}/{name_str}") + }; + let child_nm = child.join("node_modules"); + if is_dir(&child_nm).await { + // `//node_modules` — a package home. + // Anything deeper belongs to that package's own tree, + // which the store-entry scan walks itself. + let entry_name = match child_rel.rsplit_once('/') { + Some((pkg, version)) => format!("{pkg}@{version}"), + // Directly under the host there is no name/version + // split; the raw component stays the entry name + // (undecodable ⇒ probed). + None => child_rel, + }; + entries.push((entry_name, child_nm)); + continue; + } + if depth + 1 < NESTED_STORE_MAX_DEPTH { + queue.push_back((child, child_rel, depth + 1)); + } + } + } + } + + /// Inventory the packages under each virtual-store entry's + /// `node_modules` (entries come from `list_pnpm_store_entries` or + /// `collect_nested_store_entries`). An entry whose name decodes to a + /// name@version the importer pass already inventoried (every + /// root-linked direct dep) skips the redundant package.json re-read + /// via `identity_seen` — the entry is still walked, because + /// bundled/injected dependencies are real dirs that physically live + /// only inside the store entry. + async fn scan_store_entries( + entries: Vec<(String, PathBuf)>, + seen: &mut HashSet, + ) -> Vec { + let mut results = Vec::new(); + + for (entry_name, entry_nm) in entries { + let identity_seen = decode_pnpm_store_entry_name(&entry_name) + .filter(|(full_name, version)| { + let (ns, bare) = parse_package_name(full_name); + seen.contains(&build_npm_purl(ns.as_deref(), &bare, version)) + }) + .map(|(full_name, _version)| full_name); + let found = Self::scan_node_modules( + &entry_nm, + seen, + ScanPolicy::StoreEntry { + identity_seen: identity_seen.as_deref(), + }, + ) + .await; + results.extend(found); + } + + results + } + + /// Scan a scoped packages directory (`@scope/`). `policy` carries the + /// caller's traversal rules (see [`ScanPolicy`]); nested `node_modules` + /// below a scoped package are always regular importer-style trees. fn scan_scoped_packages<'a>( scope_path: &'a Path, seen: &'a mut HashSet, + policy: ScanPolicy<'a>, ) -> std::pin::Pin> + 'a>> { Box::pin(async move { let mut results = Vec::new(); + let (store_entry, identity_seen) = match policy { + ScanPolicy::Importer => (false, None), + ScanPolicy::StoreEntry { identity_seen } => (true, identity_seen), + }; + // `identity_seen` names the full `@scope/name`; this dir is the + // `@scope` half. + let scope_name = scope_path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); for entry in crate::utils::fs::list_dir_entries(scope_path).await { let name = entry.file_name(); @@ -689,19 +1173,32 @@ impl NpmCrawler { continue; }; - if !file_type.is_dir() && !file_type.is_symlink() { + let acceptable = if store_entry { + file_type.is_dir() + } else { + file_type.is_dir() || file_type.is_symlink() + }; + if !acceptable { continue; } let pkg_path = scope_path.join(&name_str); - if let Some(pkg) = Self::check_package(&pkg_path, seen).await { - results.push(pkg); + let already_inventoried = + identity_seen.is_some_and(|full| full == format!("{scope_name}/{name_str}")); + if !already_inventoried { + if let Some(pkg) = Self::check_package(&pkg_path, seen).await { + results.push(pkg); + } } // Nested node_modules only for real directories if file_type.is_dir() { - let nested = - Self::scan_node_modules(&pkg_path.join("node_modules"), seen).await; + let nested = Self::scan_node_modules( + &pkg_path.join("node_modules"), + seen, + ScanPolicy::Importer, + ) + .await; results.extend(nested); } } @@ -786,6 +1283,124 @@ impl Default for NpmCrawler { } } +// --------------------------------------------------------------------------- +// pnpm peer-variant duplicate discovery (used by the apply engine) +// --------------------------------------------------------------------------- + +/// Find every OTHER physical copy of the package installed at `pkg_path` +/// inside the pnpm virtual store(s) reachable from it. +/// +/// pnpm materializes one store copy PER PEER COMBINATION: +/// `.pnpm/foo@1.0.0(react@17…)/` and `.pnpm/foo@1.0.0(react@18…)/` are +/// both real directories holding the same `foo@1.0.0`, and each is +/// runtime-loaded by whichever importer resolves to it. The purl-keyed +/// resolver hands apply exactly ONE primary path (root-install-wins), so +/// the apply engine calls this to fan every write out to the remaining +/// physical copies — patching (or restoring) only one of them would leave +/// a live vulnerable copy behind while reporting success (fail-open). +/// +/// Discovery, all bounded and read-only: +/// 1. Candidate stores come from the ancestor chains of `pkg_path` AND of +/// its canonicalized form (the root-linked primary is a symlink into +/// the store, and in a workspace the store lives beside the ROOT +/// `node_modules`, on the canonical chain only): any ancestor named +/// `.pnpm`, plus any `node_modules` ancestor's `.pnpm` child. Non-pnpm +/// layouts (npm/yarn trees, cargo/go/vendor dirs) have neither and +/// return early — this is the cheap common case. pnpm <=3 legacy +/// stores are keyed by plain `name/version` and cannot hold +/// peer-variant duplicates, so they are deliberately not probed. +/// 2. Store entries are enumerated with the shared layout walker +/// (`list_pnpm_store_entries`, flat + nested); an entry whose name +/// decodes to a DIFFERENT name@version is skipped, an undecodable name +/// stays probeable (decode is advisory), and the package.json probe is +/// the authority — exactly the resolver's contract. +/// 3. Only REAL directories count (a symlink inside a store entry is +/// another entry's copy, already yielded via that entry), the copy +/// `pkg_path` itself canonicalizes to is excluded, and results are +/// deduped by canonical path. +/// +/// The returned paths are the copies' package roots (each in its own +/// 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_pnpm_peer_variant_copies(pkg_path: &Path) -> Vec { + // 1. Candidate stores from both ancestor chains (cheap stats only — + // no file reads until a store is actually found). + let canonical_pkg = tokio::fs::canonicalize(pkg_path).await.ok(); + let mut stores: Vec = Vec::new(); + let mut seen_stores: HashSet = HashSet::new(); + let chains = [Some(pkg_path), canonical_pkg.as_deref()]; + for start in chains.into_iter().flatten() { + let mut cur = start.parent(); + while let Some(dir) = cur { + match dir.file_name().map(|n| n.to_string_lossy()) { + Some(name) if name == ".pnpm" => { + if seen_stores.insert(dir.to_path_buf()) { + stores.push(dir.to_path_buf()); + } + } + Some(name) if name == "node_modules" => { + let store = dir.join(".pnpm"); + if is_dir(&store).await && seen_stores.insert(store.clone()) { + stores.push(store); + } + } + _ => {} + } + cur = dir.parent(); + } + } + if stores.is_empty() { + return Vec::new(); + } + + // 2. The primary's identity — the package.json is the authority, same + // as the resolver. Unreadable/invalid ⇒ no safe way to identify + // twins ⇒ none reported (the primary itself is still handled by + // the caller). + let Some((full_name, version)) = read_package_json(&pkg_path.join("package.json")).await else { + return Vec::new(); + }; + + let mut copies: Vec = Vec::new(); + let mut seen_copies: HashSet = HashSet::new(); + for store in stores { + for (entry_name, entry_nm) in NpmCrawler::list_pnpm_store_entries(&store).await { + // Fast advertisement filter; undecodable names stay probeable. + if let Some((n, v)) = decode_pnpm_store_entry_name(&entry_name) { + if n != full_name || v != version { + continue; + } + } + // `full_name` may be scoped (`@s/n`) — Path::join handles the + // two-segment relative form. + let candidate = entry_nm.join(&full_name); + // Real dirs only: a symlink here is another entry's physical + // copy, reached via that entry. + let Ok(meta) = tokio::fs::symlink_metadata(&candidate).await else { + continue; + }; + if !meta.is_dir() { + continue; + } + match read_package_json(&candidate.join("package.json")).await { + Some((n, v)) if n == full_name && v == version => {} + _ => continue, + } + let canon = tokio::fs::canonicalize(&candidate) + .await + .unwrap_or_else(|_| candidate.clone()); + if canonical_pkg.as_ref() == Some(&canon) { + continue; + } + if seen_copies.insert(canon) { + copies.push(candidate); + } + } + } + copies +} + // --------------------------------------------------------------------------- // Utility // --------------------------------------------------------------------------- @@ -1345,6 +1960,124 @@ mod tests { assert!(!is_safe_npm_component("c:")); } + // ── decode_pnpm_store_entry_name ─────────────────────────────── + + /// Helper: decode and unwrap into owned strings for terse asserts. + fn decoded(entry: &str) -> Option<(String, String)> { + decode_pnpm_store_entry_name(entry) + } + + #[test] + fn test_decode_pnpm_store_entry_plain() { + assert_eq!( + decoded("mkdirp@0.5.5"), + Some(("mkdirp".into(), "0.5.5".into())) + ); + } + + #[test] + fn test_decode_pnpm_store_entry_scoped_plus_escape() { + assert_eq!( + decoded("@scope+leaf@2.0.0"), + Some(("@scope/leaf".into(), "2.0.0".into())) + ); + } + + #[test] + fn test_decode_pnpm_store_entry_v9_peer_parens() { + // Single and stacked peer suffixes, including a scoped peer whose + // own name carries `@`/`+` — everything from the first `(` goes. + assert_eq!( + decoded("foo@1.0.0(bar@2.0.0)"), + Some(("foo".into(), "1.0.0".into())) + ); + assert_eq!( + decoded("foo@1.0.0(bar@2.0.0)(@babel+core@7.21.0)"), + Some(("foo".into(), "1.0.0".into())) + ); + assert_eq!( + decoded("@scope+leaf@2.0.0(@peer+dep@3.0.0)"), + Some(("@scope/leaf".into(), "2.0.0".into())) + ); + } + + #[test] + fn test_decode_pnpm_store_entry_legacy_underscore_suffix() { + // pnpm 6–8 peer suffix — everything from the first `_` goes, even + // when the suffix itself carries `@version` fragments that would + // otherwise confuse the rfind('@') split. + assert_eq!( + decoded("foo@1.0.0_bar@2.0.0"), + Some(("foo".into(), "1.0.0".into())) + ); + assert_eq!( + decoded("@scope+name@1.0.0_@peer+dep@2.0.0"), + Some(("@scope/name".into(), "1.0.0".into())) + ); + } + + #[test] + fn test_decode_pnpm_store_entry_prerelease_version() { + assert_eq!( + decoded("foo@1.0.0-rc.1(bar@2.0.0)"), + Some(("foo".into(), "1.0.0-rc.1".into())) + ); + } + + /// pnpm truncates over-long dir names ANYWHERE and appends `_`. + /// A cut mid-name leaves no `@X.Y.Z` tail → None (conservative: the + /// entry stays probeable). A cut that lands after a `name@X.Y.Z` + /// prefix is an undetectable artifact — it decodes, pinned here so + /// the contract ("decoded is advisory, probe is authority") is + /// explicit. A cut mid-version (`@1.2`) fails the semver-triple + /// check → None. + #[test] + fn test_decode_pnpm_store_entry_truncation_hash_tail() { + assert_eq!(decoded("some-truncated-name-prefix_abc123def456"), None); + assert_eq!(decoded("foo@1.2_abc123def456"), None); + assert_eq!( + decoded("foo@1.2.3_abc123def456"), + Some(("foo".into(), "1.2.3".into())), + "truncation after a full name@X.Y.Z prefix is indistinguishable \ + from a legacy peer suffix — decodes, and that is acceptable \ + because the package.json probe stays the authority" + ); + } + + /// Names that are not registry package entries at all. + #[test] + fn test_decode_pnpm_store_entry_non_package_names() { + // Store metadata file. + assert_eq!(decoded("lock.yaml"), None); + // The internal hoist dir (also skipped by the enumerator). + assert_eq!(decoded("node_modules"), None); + // No version at all. + assert_eq!(decoded("foo"), None); + // Empty name half. + assert_eq!(decoded("@1.0.0"), None); + // Empty version half. + assert_eq!(decoded("foo@"), None); + } + + /// A real npm name containing `_` is indistinguishable from a legacy + /// peer suffix, so it must fall to the conservative None (the entry + /// stays reachable via the fallback rule). + #[test] + fn test_decode_pnpm_store_entry_underscore_name_falls_back() { + assert_eq!(decoded("lodash._baseclone@1.0.0"), None); + } + + /// Git/URL dependency entries carry a sha or URL fragment where the + /// version would be — not a semver triple → None → still probeable. + #[test] + fn test_decode_pnpm_store_entry_git_url_deps_fall_back() { + assert_eq!(decoded("foo@github.com+user+repo@4a3b2c1d9e8f"), None); + assert_eq!( + decoded("foo@https+++codeload.github.com+x+tar.gz+abc"), + None + ); + } + /// A PURL whose version is not the one on disk must be skipped, while a /// sibling PURL for the installed version is kept. #[tokio::test] diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index e30eb176..24579c72 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -373,7 +373,14 @@ pub async fn select_installed_variants( /// set on new files to honor the read-only-by-default policy. /// /// Writes the patched content and verifies the resulting hash. -pub(crate) async fn apply_file_patch( +/// +/// This variant writes to exactly the one package root it is given. +/// External write paths (rollback's restore) go through +/// [`apply_file_patch`], which additionally fans the write out to every +/// pnpm peer-variant store copy of the package; `apply_package_patch` +/// handles those copies itself at package level (with full per-copy +/// verification) and therefore uses this single-copy variant directly. +pub(crate) async fn apply_file_patch_at( pkg_path: &Path, file_name: &str, patched_content: &[u8], @@ -478,6 +485,31 @@ pub(crate) async fn apply_file_patch( Ok(()) } +/// [`apply_file_patch_at`] plus pnpm peer-variant fan-out: after the write +/// to `pkg_path` succeeds, the same hash-verified bytes are written to +/// every OTHER physical store copy of the package +/// (`.pnpm/@(peerA…)/…` vs `(peerB…)/…` are distinct real +/// dirs, each runtime-loaded). This is the write path rollback's restore +/// uses, so rolling back a patch restores every copy the apply reached — +/// restoring only the resolver's single primary would leave a +/// still-patched twin behind. Each copy goes through the full hardened +/// pipeline (atomic stage+rename, per-copy hardlink break, permission +/// restore); a failed copy propagates as an error — fail closed, never +/// "done" with a copy left divergent. Non-pnpm layouts discover no copies +/// and behave exactly as before. +pub(crate) async fn apply_file_patch( + pkg_path: &Path, + file_name: &str, + patched_content: &[u8], + expected_hash: &str, +) -> Result<(), std::io::Error> { + apply_file_patch_at(pkg_path, file_name, patched_content, expected_hash).await?; + for copy in crate::crawlers::npm_crawler::find_pnpm_peer_variant_copies(pkg_path).await { + apply_file_patch_at(©, file_name, patched_content, expected_hash).await?; + } + Ok(()) +} + /// Guard that temporarily grants owner-write on a directory so the /// stage+rename write path can create and move files inside it, then /// restores the directory's original mode. @@ -652,6 +684,21 @@ async fn chown_blocking( /// diff-archive lookup (the corresponding `sources.packages_path` / /// `sources.diffs_path` must also be set). Pass `None` to restrict the /// pipeline to per-file blobs only — equivalent to pre-2.2 behavior. +/// +/// For npm packages, one on-disk `pkg_path` is not necessarily the only +/// physical home of `package@version`: pnpm materializes a separate +/// virtual-store copy per peer-dependency combination +/// (`.pnpm/foo@1.0.0(react@17…)/` and `…(react@18…)/` are both real, +/// runtime-loaded dirs), and the purl-keyed resolver hands apply exactly +/// one primary path. After the primary succeeds, the same verify+patch +/// pipeline is re-run against every other physical copy — including when +/// the primary is AlreadyPatched, which is precisely the state a +/// single-copy apply left behind (patched primary, vulnerable twin). A +/// failed copy fails the whole result: claiming the CVE fixed while a +/// physical copy remains unpatched is the fail-open this closes. The +/// primary's per-file records are what the returned `ApplyResult` +/// carries (the envelope shape is unchanged); copies contribute only +/// success/error state. pub async fn apply_package_patch( package_key: &str, pkg_path: &Path, @@ -660,6 +707,46 @@ pub async fn apply_package_patch( uuid: Option<&str>, dry_run: bool, policy: MismatchPolicy, +) -> ApplyResult { + let mut result = + apply_package_patch_at(package_key, pkg_path, files, sources, uuid, dry_run, policy).await; + // Only npm purls can name pnpm 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_pnpm_peer_variant_copies(pkg_path).await { + let copy_result = + apply_package_patch_at(package_key, ©, files, sources, uuid, dry_run, policy) + .await; + if !copy_result.success { + result.success = false; + let copy_err = copy_result + .error + .unwrap_or_else(|| "unknown error".to_string()); + let note = format!( + "pnpm store copy {} failed to patch: {}", + copy.display(), + copy_err + ); + result.error = Some(match result.error.take() { + Some(prev) => format!("{prev}; {note}"), + None => note, + }); + } + } + } + result +} + +/// 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( + package_key: &str, + pkg_path: &Path, + files: &HashMap, + sources: &PatchSources<'_>, + uuid: Option<&str>, + dry_run: bool, + policy: MismatchPolicy, ) -> ApplyResult { let mut result = ApplyResult { package_key: package_key.to_string(), @@ -850,8 +937,11 @@ pub async fn apply_package_patch( } }; + // Single-copy write: the public `apply_package_patch` wrapper fans + // out to pnpm peer-variant copies itself, with per-copy + // verification. if let Err(e) = - apply_file_patch(pkg_path, file_name, &patched_content, &file_info.after_hash).await + apply_file_patch_at(pkg_path, file_name, &patched_content, &file_info.after_hash).await { result.error = Some(e.to_string()); return result; @@ -927,7 +1017,9 @@ async fn try_apply_from_archive( if compute_git_sha256_from_bytes(bytes) != file_info.after_hash { return false; } - apply_file_patch(pkg_path, file_name, bytes, &file_info.after_hash) + // Single-copy write: `apply_package_patch` fans out to pnpm + // peer-variant copies itself, with per-copy verification. + apply_file_patch_at(pkg_path, file_name, bytes, &file_info.after_hash) .await .is_ok() } @@ -985,7 +1077,9 @@ async fn try_apply_from_diff( if compute_git_sha256_from_bytes(&patched) != file_info.after_hash { return false; } - apply_file_patch(pkg_path, file_name, &patched, &file_info.after_hash) + // Single-copy write: `apply_package_patch` fans out to pnpm + // peer-variant copies itself, with per-copy verification. + apply_file_patch_at(pkg_path, file_name, &patched, &file_info.after_hash) .await .is_ok() } diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 7e8c25a1..2b835700 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -1281,6 +1281,398 @@ async fn read_package_json_rejects_fifo_without_hanging() { ); } +// ── pnpm isolated-linker virtual store (.pnpm) ───────────────── + +/// Hand-build the tree `pnpm install` (isolated linker) produces for a +/// project depending on mkdirp@0.5.5 (whose dep is minimist), plus a second +/// minimist version and a scoped transitive package: +/// +/// ```text +/// node_modules/ +/// mkdirp -> .pnpm/mkdirp@0.5.5/node_modules/mkdirp (direct dep) +/// .pnpm/ +/// mkdirp@0.5.5/node_modules/ +/// mkdirp/ (real dir) +/// minimist -> ../../minimist@1.2.8/node_modules/minimist +/// minimist@1.2.8/node_modules/minimist/ (real dir) +/// minimist@0.0.8/node_modules/minimist/ (real dir) +/// @scope+leaf@2.0.0/node_modules/@scope/leaf/ (real dir) +/// node_modules/ (internal hoist dir) +/// .hidden-meta@1.0.0/… (store metadata) +/// ``` +/// +/// minimist (both versions) and @scope/leaf are *transitive-only*: their +/// sole physical home is the hidden `.pnpm` virtual store, with no +/// importer-root entry at all — yet they are runtime-loaded. Decoy packages +/// are planted where the traversal must NOT look (the internal hoist dir, +/// a hidden store child) so a policy regression turns the tests red. +#[cfg(unix)] +async fn stage_pnpm_isolated_tree(root: &Path) -> std::path::PathBuf { + use std::os::unix::fs::symlink; + + let nm = root.join("node_modules"); + let store = nm.join(".pnpm"); + + stage_npm_pkg( + &store.join("mkdirp@0.5.5").join("node_modules"), + "mkdirp", + "0.5.5", + ) + .await; + stage_npm_pkg( + &store.join("minimist@1.2.8").join("node_modules"), + "minimist", + "1.2.8", + ) + .await; + stage_npm_pkg( + &store.join("minimist@0.0.8").join("node_modules"), + "minimist", + "0.0.8", + ) + .await; + stage_npm_pkg( + &store.join("@scope+leaf@2.0.0").join("node_modules"), + "@scope/leaf", + "2.0.0", + ) + .await; + + // mkdirp's dependency: a sibling symlink inside its own store entry. + symlink( + store.join("minimist@1.2.8/node_modules/minimist"), + store.join("mkdirp@0.5.5/node_modules/minimist"), + ) + .unwrap(); + + // Importer root: the direct dep is a symlink into the store. + symlink( + store.join("mkdirp@0.5.5/node_modules/mkdirp"), + nm.join("mkdirp"), + ) + .unwrap(); + + // pnpm's internal hoist dir `.pnpm/node_modules`: symlinks into sibling + // entries. A REAL decoy package is planted where the traversal would + // land if the hoist-dir skip were dropped. + let hoist = store.join("node_modules"); + tokio::fs::create_dir_all(&hoist).await.unwrap(); + symlink( + store.join("minimist@1.2.8/node_modules/minimist"), + hoist.join("minimist"), + ) + .unwrap(); + stage_npm_pkg(&hoist.join("node_modules"), "hoist-decoy", "9.9.9").await; + + // Hidden store child (metadata): must never be probed. + stage_npm_pkg( + &store.join(".hidden-meta@1.0.0").join("node_modules"), + "hidden-decoy", + "9.9.9", + ) + .await; + // A plain file at the store top level (pnpm writes lock.yaml here). + tokio::fs::write(store.join("lock.yaml"), b"lockfileVersion: 9\n") + .await + .unwrap(); + + nm +} + +/// Regression (pnpm 7–12, empirically confirmed): a transitive-only +/// dependency living solely at `.pnpm//node_modules/` was invisible +/// to `find_by_purls` — apply reported `package_not_installed` for a package +/// that is installed and runtime-loaded. The virtual store must be probed; +/// the name@version match keeps two store versions of one package distinct; +/// the root-linked direct dep must still resolve at its importer-root path +/// (BFS root-first); and the hoist-dir/hidden decoys must stay unreachable. +#[cfg(unix)] +#[tokio::test] +#[serial_test::parallel] +async fn find_by_purls_resolves_pnpm_virtual_store_transitives() { + let tmp = tempfile::tempdir().unwrap(); + let nm = stage_pnpm_isolated_tree(tmp.path()).await; + let store = nm.join(".pnpm"); + + let crawler = NpmCrawler; + let purls = vec![ + "pkg:npm/mkdirp@0.5.5".to_string(), + "pkg:npm/minimist@1.2.8".to_string(), + "pkg:npm/minimist@0.0.8".to_string(), + "pkg:npm/@scope/leaf@2.0.0".to_string(), + "pkg:npm/hoist-decoy@9.9.9".to_string(), + "pkg:npm/hidden-decoy@9.9.9".to_string(), + ]; + let result = crawler.find_by_purls(&nm, &purls).await.unwrap(); + + // The direct dep resolves at the importer root (probed before any + // .pnpm entry is dequeued), not at its store home. + let mkdirp = result + .get("pkg:npm/mkdirp@0.5.5") + .expect("root-linked direct dep must resolve"); + assert_eq!( + mkdirp.path, + nm.join("mkdirp"), + "root-linked install must win over the store copy" + ); + + // Transitive-only: reachable only via the virtual store. It may be + // found through mkdirp's sibling symlink or its own store entry — + // both name the same physical package. + let m1 = result + .get("pkg:npm/minimist@1.2.8") + .expect("transitive-only minimist@1.2.8 must resolve via .pnpm"); + assert_eq!( + std::fs::canonicalize(&m1.path).unwrap(), + std::fs::canonicalize(store.join("minimist@1.2.8/node_modules/minimist")).unwrap(), + "resolved path must be the store's physical minimist@1.2.8" + ); + + // Second store version of the same package stays distinct. + let m0 = result + .get("pkg:npm/minimist@0.0.8") + .expect("second store version must resolve independently"); + assert_eq!( + m0.path, + store.join("minimist@0.0.8/node_modules/minimist"), + "version match must bind each purl to its own store entry" + ); + + // Scoped transitive-only package. + let leaf = result + .get("pkg:npm/@scope/leaf@2.0.0") + .expect("scoped transitive-only package must resolve via .pnpm"); + assert_eq!( + leaf.path, + store.join("@scope+leaf@2.0.0/node_modules/@scope/leaf") + ); + + // The hoist dir and hidden store children are never probed. + assert!( + !result.contains_key("pkg:npm/hoist-decoy@9.9.9"), + "`.pnpm/node_modules` (internal hoist dir) must not be probed" + ); + assert!( + !result.contains_key("pkg:npm/hidden-decoy@9.9.9"), + "hidden store children must not be probed" + ); + assert_eq!(result.len(), 4, "exactly the four real packages resolve"); +} + +/// Scan twin of the resolver regression: `crawl_all` skipped `.pnpm` as +/// just-another-hidden-dir, so a transitive-only install never reached the +/// batch API request. Each package must be inventoried exactly once (the +/// root pass wins the `seen` dedup for the root-linked direct dep; store +/// entries are accepted only as real dirs so a sibling symlink cannot +/// double-count), both store versions surface, and the decoys stay out. +#[cfg(unix)] +#[tokio::test] +#[serial_test::parallel] +async fn crawl_all_inventories_pnpm_virtual_store_exactly_once() { + let tmp = tempfile::tempdir().unwrap(); + let nm = stage_pnpm_isolated_tree(tmp.path()).await; + let store = nm.join(".pnpm"); + + let crawler = NpmCrawler; + let result = crawler.crawl_all(&options_at(tmp.path())).await; + + let purls: Vec<&str> = result.iter().map(|p| p.purl.as_str()).collect(); + assert_eq!( + result.len(), + 4, + "exactly the four real packages, each once — no symlink double-hits, \ + no hoist/hidden decoys; got {purls:?}" + ); + + let by_purl = |purl: &str| -> &socket_patch_core::crawlers::types::CrawledPackage { + result + .iter() + .find(|p| p.purl == purl) + .unwrap_or_else(|| panic!("{purl} must be inventoried; got {purls:?}")) + }; + + // Root-linked direct dep is recorded at its importer-root path (the + // root pass runs before the deferred store pass). + assert_eq!(by_purl("pkg:npm/mkdirp@0.5.5").path, nm.join("mkdirp")); + // Transitive-only packages are recorded at their physical store homes. + assert_eq!( + by_purl("pkg:npm/minimist@1.2.8").path, + store.join("minimist@1.2.8/node_modules/minimist"), + "store entry must be recorded at its real dir, not a sibling symlink" + ); + assert_eq!( + by_purl("pkg:npm/minimist@0.0.8").path, + store.join("minimist@0.0.8/node_modules/minimist") + ); + let leaf = by_purl("pkg:npm/@scope/leaf@2.0.0"); + assert_eq!(leaf.namespace.as_deref(), Some("@scope")); + assert_eq!( + leaf.path, + store.join("@scope+leaf@2.0.0/node_modules/@scope/leaf") + ); +} + +/// Two-pass contract: the filtered pass skips a store entry whose dir +/// name decodes to a non-pending package (perf: a manifest routinely +/// lists packages that simply aren't installed), but a target physically +/// present ONLY inside such an entry must still resolve — the unfiltered +/// fallback pass probes every entry for the leftovers. Pre-fix the filter +/// was final, leaving these installed, scan-visible packages invisible to +/// apply (fail-open). +#[tokio::test] +#[serial_test::parallel] +async fn find_by_purls_fallback_pass_probes_store_entries_decoding_to_other_names() { + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + let store = nm.join(".pnpm"); + + // Entry advertises `decoy@1.0.0`, but its node_modules holds the + // pending target `wanted@1.0.0` — reachable only via the fallback. + stage_npm_pkg( + &store.join("decoy@1.0.0").join("node_modules"), + "wanted", + "1.0.0", + ) + .await; + // Scoped twin. + stage_npm_pkg( + &store.join("@other+decoy@1.0.0").join("node_modules"), + "@s/wanted", + "1.0.0", + ) + .await; + + let crawler = NpmCrawler; + let purls = vec![ + "pkg:npm/wanted@1.0.0".to_string(), + "pkg:npm/@s/wanted@1.0.0".to_string(), + ]; + let result = crawler.find_by_purls(&nm, &purls).await.unwrap(); + + assert_eq!( + result.get("pkg:npm/wanted@1.0.0").map(|p| p.path.clone()), + Some(store.join("decoy@1.0.0/node_modules/wanted")), + "a target hidden behind another entry's name must resolve via the \ + fallback pass; got {result:?}" + ); + assert_eq!( + result + .get("pkg:npm/@s/wanted@1.0.0") + .map(|p| p.path.clone()), + Some(store.join("@other+decoy@1.0.0/node_modules/@s/wanted")), + "scoped twin must resolve via the fallback pass; got {result:?}" + ); +} + +/// The name filter's positive half: an entry whose (peer-suffixed) dir +/// name decodes to a pending target's name IS enqueued and its package +/// resolves. Uses a pnpm-9 paren peer suffix so the decode path — not a +/// literal string match — is what admits the entry. +#[tokio::test] +#[serial_test::parallel] +async fn find_by_purls_resolves_pnpm_store_entry_with_peer_suffix() { + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + let store = nm.join(".pnpm"); + + 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 result = crawler + .find_by_purls(&nm, &["pkg:npm/wanted@1.0.0".to_string()]) + .await + .unwrap(); + + let pkg = result + .get("pkg:npm/wanted@1.0.0") + .expect("peer-suffixed store entry for a pending name must be probed"); + assert_eq!(pkg.path, entry_nm.join("wanted")); +} + +/// The conservative fallback: pnpm truncates over-long dir names (the cut +/// can land anywhere) and appends `_`, so such an entry's identity +/// is unknowable from its name — it must STAY probeable, and a pending +/// target living inside it must resolve. +#[tokio::test] +#[serial_test::parallel] +async fn find_by_purls_probes_undecodable_pnpm_store_entry() { + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + let store = nm.join(".pnpm"); + + // No `@X.Y.Z` tail survives the `_` strip → undecodable. + let entry_nm = store + .join("wanted-with-a-very-long-truncated_abc123def456") + .join("node_modules"); + stage_npm_pkg(&entry_nm, "wanted2", "1.0.0").await; + + let crawler = NpmCrawler; + let result = crawler + .find_by_purls(&nm, &["pkg:npm/wanted2@1.0.0".to_string()]) + .await + .unwrap(); + + let pkg = result + .get("pkg:npm/wanted2@1.0.0") + .expect("undecodable (truncated/hash-suffixed) store entries must remain probeable"); + assert_eq!(pkg.path, entry_nm.join("wanted2")); +} + +/// The store pass skips re-reading a root-linked direct dep's own +/// package.json (its decoded name@version already won the `seen` dedup at +/// the importer root), but must still walk INTO the entry: bundled +/// dependencies are real dirs nested inside the package itself, physically +/// present only there. +#[cfg(unix)] +#[tokio::test] +#[serial_test::parallel] +async fn crawl_all_inventories_bundled_dep_under_seen_store_entry() { + use std::os::unix::fs::symlink; + + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + let store = nm.join(".pnpm"); + + let host_store_nm = store.join("host@1.0.0").join("node_modules"); + stage_npm_pkg(&host_store_nm, "host", "1.0.0").await; + // Bundled dep: a REAL dir inside the package's own node_modules. + stage_npm_pkg( + &host_store_nm.join("host").join("node_modules"), + "bundled", + "3.3.3", + ) + .await; + // Root-linked direct dep (importer pass inventories it first). + symlink(host_store_nm.join("host"), nm.join("host")).unwrap(); + + let crawler = NpmCrawler; + let result = crawler.crawl_all(&options_at(tmp.path())).await; + + let host = result + .iter() + .find(|p| p.name == "host") + .expect("root-linked host must be inventoried"); + assert_eq!( + host.path, + nm.join("host"), + "importer-root path wins the seen dedup" + ); + let bundled = result + .iter() + .find(|p| p.name == "bundled") + .expect("bundled dep must be inventoried via the store walk even when its host's identity is already seen"); + assert_eq!( + bundled.path, + host_store_nm + .join("host") + .join("node_modules") + .join("bundled") + ); + let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(result.len(), 2, "exactly host + bundled; got {names:?}"); +} + /// When the same `name@version` exists at the root *and* nested, the root /// copy must win (shallowest-first), preserving the pre-existing behavior /// for everything resolvable at the root. @@ -1304,3 +1696,619 @@ async fn find_by_purls_prefers_root_copy_over_nested_duplicate() { "root copy must be preferred over the nested duplicate" ); } + +// ── pnpm 4/5 NESTED virtual store (.pnpm//…) ──── + +/// Byte-accurate replica of a captured real `pnpm@4.14.4` install +/// (layoutVersion 3, and the same shape synthetic pnpm-5 trees showed): +/// store entries are nested by registry host — +/// `.pnpm/registry.npmjs.org///node_modules/` — so +/// the `.pnpm` child (`registry.npmjs.org`) has NO `node_modules` of its +/// own and the flat-entry walk finds nothing behind it: +/// +/// ```text +/// node_modules/ +/// mkdirp -> .pnpm/registry.npmjs.org/mkdirp/0.5.5/node_modules/mkdirp +/// .pnpm/ +/// lock.yaml +/// node_modules/minimist -> … (internal hoist dir) +/// registry.npmjs.org/ +/// mkdirp/0.5.5/node_modules/ +/// mkdirp/ (real dir) +/// minimist -> ../../../minimist/1.2.8/node_modules/minimist +/// minimist/1.2.8/node_modules/minimist/ (real dir, TRANSITIVE-ONLY) +/// @scope/leaf/2.0.0/node_modules/@scope/leaf/ (real, transitive-only) +/// decoy/1.0.0/node_modules/wanted/ (advertises decoy, holds wanted) +/// loop -> ../.. (symlink cycle bait) +/// ``` +#[cfg(unix)] +async fn stage_pnpm4_nested_tree(root: &Path) -> std::path::PathBuf { + use std::os::unix::fs::symlink; + + let nm = root.join("node_modules"); + let store = nm.join(".pnpm"); + let host = store.join("registry.npmjs.org"); + + stage_npm_pkg(&host.join("mkdirp/0.5.5/node_modules"), "mkdirp", "0.5.5").await; + stage_npm_pkg( + &host.join("minimist/1.2.8/node_modules"), + "minimist", + "1.2.8", + ) + .await; + // Scoped transitive-only package: one nesting level deeper + // (host/@scope/name/version), the deepest shape the layout produces. + stage_npm_pkg( + &host.join("@scope/leaf/2.0.0/node_modules"), + "@scope/leaf", + "2.0.0", + ) + .await; + + // mkdirp's dependency: a sibling symlink inside its own store entry + // (relative, exactly as captured). + symlink( + Path::new("../../../minimist/1.2.8/node_modules/minimist"), + host.join("mkdirp/0.5.5/node_modules/minimist"), + ) + .unwrap(); + + // Importer root: the direct dep is a symlink into the nested store. + symlink( + Path::new(".pnpm/registry.npmjs.org/mkdirp/0.5.5/node_modules/mkdirp"), + nm.join("mkdirp"), + ) + .unwrap(); + + // pnpm 4's internal hoist dir `.pnpm/node_modules` (captured: symlinks + // into the nested entries), plus a REAL decoy planted where the walk + // would land if the hoist-dir skip were dropped. + let hoist = store.join("node_modules"); + tokio::fs::create_dir_all(&hoist).await.unwrap(); + symlink( + host.join("minimist/1.2.8/node_modules/minimist"), + hoist.join("minimist"), + ) + .unwrap(); + stage_npm_pkg(&hoist.join("node_modules"), "hoist-decoy", "9.9.9").await; + + // Store metadata file at the top level (pnpm 4 writes lock.yaml). + tokio::fs::write(store.join("lock.yaml"), b"lockfileVersion: 5.1\n") + .await + .unwrap(); + + // A nested entry advertising `decoy/1.0.0` whose INNER package is + // `wanted@1.0.0`. The filtered pass skips it (the entry name is the + // advertisement, exactly like a flat `decoy@1.0.0` entry); the + // unfiltered fallback pass must still find the inner package. + stage_npm_pkg(&host.join("decoy/1.0.0/node_modules"), "wanted", "1.0.0").await; + + // Symlink cycle inside the nested store: a link back up the tree. The + // descent must never traverse symlinks — following this one would + // recurse forever, so the tests *terminating* is itself the guard. + symlink(Path::new("../.."), host.join("loop")).unwrap(); + + nm +} + +/// Regression (empirically confirmed on a real pnpm 4.14.4 tree, same on +/// synthetic pnpm-5): the `.pnpm` walk handled FLAT `name@version` entries +/// only. The nested `registry.npmjs.org` child has no `node_modules`, the +/// conservative fallback enqueued exactly `.pnpm//node_modules` +/// (one level), so a transitive-only dep was silently skipped — apply +/// exited 0 claiming success while the file was never written. The nested +/// descent must find it; a target hidden behind another entry's advertised +/// name resolves via the unfiltered fallback pass; the cycle symlink must +/// not hang the walk. +#[cfg(unix)] +#[tokio::test] +#[serial_test::parallel] +async fn find_by_purls_resolves_pnpm4_nested_store_transitives() { + let tmp = tempfile::tempdir().unwrap(); + let nm = stage_pnpm4_nested_tree(tmp.path()).await; + let host = nm.join(".pnpm/registry.npmjs.org"); + + let crawler = NpmCrawler; + let purls = vec![ + "pkg:npm/mkdirp@0.5.5".to_string(), + "pkg:npm/minimist@1.2.8".to_string(), + "pkg:npm/@scope/leaf@2.0.0".to_string(), + "pkg:npm/wanted@1.0.0".to_string(), + "pkg:npm/hoist-decoy@9.9.9".to_string(), + ]; + let result = crawler.find_by_purls(&nm, &purls).await.unwrap(); + + // Root-linked direct dep still wins at the importer root. + assert_eq!( + result.get("pkg:npm/mkdirp@0.5.5").map(|p| p.path.clone()), + Some(nm.join("mkdirp")), + "root-linked install must win over the store copy" + ); + // Transitive-only dep: physically present ONLY in the nested store. + let minimist = result + .get("pkg:npm/minimist@1.2.8") + .expect("transitive-only dep must resolve through the nested (pnpm 4/5) store layout"); + assert_eq!( + minimist.path, + host.join("minimist/1.2.8/node_modules/minimist") + ); + // Scoped transitive-only dep: the deepest nesting the layout produces. + let leaf = result + .get("pkg:npm/@scope/leaf@2.0.0") + .expect("scoped transitive-only dep must resolve through the nested store layout"); + assert_eq!( + leaf.path, + host.join("@scope/leaf/2.0.0/node_modules/@scope/leaf") + ); + // The nested entry advertising a different name is skipped by the + // filtered pass, but its inner package — the only physical home of + // `wanted@1.0.0` — must still resolve via the unfiltered fallback. + assert_eq!( + result.get("pkg:npm/wanted@1.0.0").map(|p| p.path.clone()), + Some(host.join("decoy/1.0.0/node_modules/wanted")), + "a target hidden behind a nested entry's advertised name must \ + resolve via the fallback pass" + ); + // The hoist dir stays unreachable even in the unfiltered pass (it is + // skipped as store plumbing, not by the pending-name filter). + assert!( + !result.contains_key("pkg:npm/hoist-decoy@9.9.9"), + "`.pnpm/node_modules` (internal hoist dir) must not be probed" + ); + assert_eq!(result.len(), 4); +} + +/// Scan twin: `crawl_all` must inventory the nested-store packages exactly +/// once each. (`wanted` IS inventoried here — scan has no pending filter +/// and the package is genuinely installed; only the resolver's name filter +/// treats the advertising entry name as authoritative.) +#[cfg(unix)] +#[tokio::test] +#[serial_test::parallel] +async fn crawl_all_inventories_pnpm4_nested_store_exactly_once() { + let tmp = tempfile::tempdir().unwrap(); + let nm = stage_pnpm4_nested_tree(tmp.path()).await; + let host = nm.join(".pnpm/registry.npmjs.org"); + + let crawler = NpmCrawler; + let result = crawler.crawl_all(&options_at(tmp.path())).await; + + let purls: Vec<&str> = result.iter().map(|p| p.purl.as_str()).collect(); + assert_eq!( + result.len(), + 4, + "mkdirp + minimist + @scope/leaf + wanted, each exactly once — no \ + symlink double-hits, no hoist decoy; got {purls:?}" + ); + let by_purl = |purl: &str| -> &socket_patch_core::crawlers::types::CrawledPackage { + result + .iter() + .find(|p| p.purl == purl) + .unwrap_or_else(|| panic!("{purl} must be inventoried; got {purls:?}")) + }; + // Root pass wins the seen dedup for the root-linked direct dep. + assert_eq!(by_purl("pkg:npm/mkdirp@0.5.5").path, nm.join("mkdirp")); + // Transitive-only packages surface at their physical store homes. + assert_eq!( + by_purl("pkg:npm/minimist@1.2.8").path, + host.join("minimist/1.2.8/node_modules/minimist") + ); + assert_eq!( + by_purl("pkg:npm/@scope/leaf@2.0.0").path, + host.join("@scope/leaf/2.0.0/node_modules/@scope/leaf") + ); + assert_eq!( + by_purl("pkg:npm/wanted@1.0.0").path, + host.join("decoy/1.0.0/node_modules/wanted") + ); + assert!( + !purls.contains(&"pkg:npm/hoist-decoy@9.9.9"), + "the internal hoist dir must not be scanned" + ); +} + +// ── pnpm <=3 virtual store (node_modules/.registry.npmjs.org) ── + +/// Byte-accurate replica of a captured real `pnpm@3.8.1` install +/// (layoutVersion 2 — pnpm 1.x and 2.x produce the identical shape): the +/// virtual store is a hidden `.registry.npmjs.org` dir directly under +/// `node_modules`, with NO `.pnpm` anywhere: +/// +/// ```text +/// node_modules/ +/// .modules.yaml, .pnpm-lock.yaml (metadata FILES) +/// mkdirp -> .registry.npmjs.org/mkdirp/0.5.5/node_modules/mkdirp +/// .registry.npmjs.org/ +/// mkdirp/0.5.5/node_modules/ +/// mkdirp/ (real dir) +/// minimist -> ../../../minimist/1.2.8/node_modules/minimist +/// minimist/1.2.8/node_modules/minimist/ (real dir, TRANSITIVE-ONLY) +/// loop -> .. (symlink cycle bait) +/// .not-a-store/wanted3/1.0.0/node_modules/wanted3/ (hidden decoy) +/// ``` +/// +/// The `.not-a-store` decoy pins the recognition rule: only +/// `.registry.*`-named hidden dirs are virtual stores; other hidden dirs +/// (caches, tool state) must stay skipped. +#[cfg(unix)] +async fn stage_pnpm3_legacy_tree(root: &Path) -> std::path::PathBuf { + use std::os::unix::fs::symlink; + + let nm = root.join("node_modules"); + let store = nm.join(".registry.npmjs.org"); + + stage_npm_pkg(&store.join("mkdirp/0.5.5/node_modules"), "mkdirp", "0.5.5").await; + stage_npm_pkg( + &store.join("minimist/1.2.8/node_modules"), + "minimist", + "1.2.8", + ) + .await; + + // mkdirp's dependency: relative sibling symlink, exactly as captured. + symlink( + Path::new("../../../minimist/1.2.8/node_modules/minimist"), + store.join("mkdirp/0.5.5/node_modules/minimist"), + ) + .unwrap(); + + // Importer root: relative symlink into the hidden store (as captured). + symlink( + Path::new(".registry.npmjs.org/mkdirp/0.5.5/node_modules/mkdirp"), + nm.join("mkdirp"), + ) + .unwrap(); + + // Metadata FILES at the node_modules root, as captured. + tokio::fs::write(nm.join(".modules.yaml"), b"layoutVersion: 2\n") + .await + .unwrap(); + tokio::fs::write(nm.join(".pnpm-lock.yaml"), b"shrinkwrapVersion: 4\n") + .await + .unwrap(); + + // Symlink cycle inside the store: never traversed (see pnpm4 stager). + symlink(Path::new(".."), store.join("loop")).unwrap(); + + // Hidden dir that is NOT a `.registry.*` store: must stay invisible. + stage_npm_pkg( + &nm.join(".not-a-store/wanted3/1.0.0/node_modules"), + "wanted3", + "1.0.0", + ) + .await; + + nm +} + +/// Regression (empirically confirmed on a real pnpm 3.8.1 tree; pnpm 1/2 +/// captures show the identical layout): pre-`.pnpm` pnpm hides the virtual +/// store at `node_modules/.registry.npmjs.org`, which the walk skipped as +/// just-another-hidden-dir — a transitive-only dep was unresolvable, apply +/// exited 0 claiming success with nothing written. +#[cfg(unix)] +#[tokio::test] +#[serial_test::parallel] +async fn find_by_purls_resolves_pnpm3_legacy_registry_store_transitive() { + let tmp = tempfile::tempdir().unwrap(); + let nm = stage_pnpm3_legacy_tree(tmp.path()).await; + let store = nm.join(".registry.npmjs.org"); + + let crawler = NpmCrawler; + let purls = vec![ + "pkg:npm/mkdirp@0.5.5".to_string(), + "pkg:npm/minimist@1.2.8".to_string(), + "pkg:npm/wanted3@1.0.0".to_string(), + ]; + let result = crawler.find_by_purls(&nm, &purls).await.unwrap(); + + assert_eq!( + result.get("pkg:npm/mkdirp@0.5.5").map(|p| p.path.clone()), + Some(nm.join("mkdirp")), + "root-linked install must win over the store copy" + ); + let minimist = result + .get("pkg:npm/minimist@1.2.8") + .expect("transitive-only dep must resolve through the pnpm<=3 `.registry.*` store"); + assert_eq!( + minimist.path, + store.join("minimist/1.2.8/node_modules/minimist") + ); + assert!( + !result.contains_key("pkg:npm/wanted3@1.0.0"), + "hidden dirs that are not `.registry.*` stores must stay unprobed" + ); + assert_eq!(result.len(), 2); +} + +/// Scan twin: `crawl_all` must inventory the legacy store's packages +/// exactly once each — the root pass wins for the root-linked direct dep, +/// the transitive-only dep surfaces at its physical store home, and +/// non-store hidden dirs stay out of the inventory. +#[cfg(unix)] +#[tokio::test] +#[serial_test::parallel] +async fn crawl_all_inventories_pnpm3_legacy_registry_store_exactly_once() { + let tmp = tempfile::tempdir().unwrap(); + let nm = stage_pnpm3_legacy_tree(tmp.path()).await; + let store = nm.join(".registry.npmjs.org"); + + let crawler = NpmCrawler; + let result = crawler.crawl_all(&options_at(tmp.path())).await; + + let purls: Vec<&str> = result.iter().map(|p| p.purl.as_str()).collect(); + assert_eq!( + result.len(), + 2, + "exactly mkdirp + minimist, each once; got {purls:?}" + ); + let by_purl = |purl: &str| -> &socket_patch_core::crawlers::types::CrawledPackage { + result + .iter() + .find(|p| p.purl == purl) + .unwrap_or_else(|| panic!("{purl} must be inventoried; got {purls:?}")) + }; + assert_eq!(by_purl("pkg:npm/mkdirp@0.5.5").path, nm.join("mkdirp")); + assert_eq!( + by_purl("pkg:npm/minimist@1.2.8").path, + store.join("minimist/1.2.8/node_modules/minimist"), + "transitive-only dep must be inventoried at its physical store home" + ); + assert!( + !purls.contains(&"pkg:npm/wanted3@1.0.0"), + "hidden dirs that are not `.registry.*` stores must stay unscanned" + ); +} + +// ── pnpm peer-variant duplicates & bundled-only targets ──────── + +/// Regression (adversarial review, CONFIRMED): pnpm materializes one +/// physical store copy PER PEER COMBINATION — `.pnpm/foo@1.0.0(react@17…)/` +/// and `.pnpm/foo@1.0.0(react@18…)/` are both real dirs holding the same +/// `foo@1.0.0`. The resolver hands apply ONE primary path (root-linked +/// install wins), and apply used to patch only that copy — exiting 0 +/// claiming the CVE fixed while the twin stayed vulnerable and +/// runtime-loaded. Apply must patch EVERY physical copy, rollback must +/// restore every copy, and the copy-on-write break must protect the shared +/// content-store inode per copy. +#[cfg(unix)] +#[tokio::test] +#[serial_test::parallel] +async fn apply_and_rollback_reach_every_pnpm_peer_variant_copy() { + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + use socket_patch_core::manifest::schema::PatchFileInfo; + use socket_patch_core::patch::apply::{apply_package_patch, MismatchPolicy, PatchSources}; + use socket_patch_core::patch::rollback::rollback_package_patch; + use std::os::unix::fs::symlink; + + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + let store = nm.join(".pnpm"); + + let original: &[u8] = b"module.exports = 'vulnerable';\n"; + let patched: &[u8] = b"module.exports = 'fixed';\n"; + let before_hash = compute_git_sha256_from_bytes(original); + let after_hash = compute_git_sha256_from_bytes(patched); + + // The content-addressable "global store" inode both copies hardlink to + // (pnpm's default layout). It must NEVER be mutated by a patch. + let cas = tmp.path().join("cas-index.js"); + tokio::fs::write(&cas, original).await.unwrap(); + + let variants = [ + store.join("foo@1.0.0(react@17.0.2)").join("node_modules"), + store.join("foo@1.0.0(react@18.2.0)").join("node_modules"), + ]; + for entry_nm in &variants { + let pkg = entry_nm.join("foo"); + tokio::fs::create_dir_all(&pkg).await.unwrap(); + tokio::fs::write( + pkg.join("package.json"), + r#"{"name":"foo","version":"1.0.0"}"#, + ) + .await + .unwrap(); + std::fs::hard_link(&cas, pkg.join("index.js")).unwrap(); + } + // Importer root: direct dep symlinks to ONE of the variants. + symlink(variants[0].join("foo"), nm.join("foo")).unwrap(); + + let blobs = tmp.path().join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write(blobs.join(&after_hash), patched) + .await + .unwrap(); + tokio::fs::write(blobs.join(&before_hash), original) + .await + .unwrap(); + + let mut files = std::collections::HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: before_hash.clone(), + after_hash: after_hash.clone(), + }, + ); + + // Resolve the primary exactly the way apply's dispatcher does. + let crawler = NpmCrawler; + let resolved = crawler + .find_by_purls(&nm, &["pkg:npm/foo@1.0.0".to_string()]) + .await + .unwrap(); + let primary = resolved + .get("pkg:npm/foo@1.0.0") + .expect("root-linked copy must resolve") + .path + .clone(); + assert_eq!(primary, nm.join("foo"), "root install stays the primary"); + + let sources = PatchSources { + blobs_path: &blobs, + packages_path: None, + diffs_path: None, + mem_blobs: None, + }; + let result = apply_package_patch( + "pkg:npm/foo@1.0.0", + &primary, + &files, + &sources, + None, + false, + MismatchPolicy::Warn, + ) + .await; + assert!(result.success, "apply must succeed: {:?}", result.error); + + for entry_nm in &variants { + let bytes = tokio::fs::read(entry_nm.join("foo").join("index.js")) + .await + .unwrap(); + assert_eq!( + bytes, + patched, + "EVERY physical peer-variant copy must be patched, not just the \ + root-linked one ({})", + entry_nm.display() + ); + } + assert_eq!( + tokio::fs::read(&cas).await.unwrap(), + original, + "copy-on-write: the shared content-store inode must never be mutated" + ); + + // Rollback restores every copy too. + let rb = rollback_package_patch("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")) + .await + .unwrap(); + assert_eq!( + bytes, + original, + "rollback must restore EVERY physical copy ({})", + entry_nm.display() + ); + } + assert_eq!( + tokio::fs::read(&cas).await.unwrap(), + original, + "copy-on-write must hold on the rollback write path too" + ); +} + +/// Healing half of the peer-variant regression: a pre-fix apply left the +/// primary copy patched and the twin vulnerable. Re-running apply reports +/// the primary AlreadyPatched — and must STILL patch the lagging twin +/// instead of early-returning success. +#[cfg(unix)] +#[tokio::test] +#[serial_test::parallel] +async fn apply_heals_unpatched_pnpm_twin_when_primary_already_patched() { + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + use socket_patch_core::manifest::schema::PatchFileInfo; + use socket_patch_core::patch::apply::{apply_package_patch, MismatchPolicy, PatchSources}; + use std::os::unix::fs::symlink; + + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + let store = nm.join(".pnpm"); + + let original: &[u8] = b"module.exports = 'vulnerable';\n"; + let patched: &[u8] = b"module.exports = 'fixed';\n"; + let before_hash = compute_git_sha256_from_bytes(original); + let after_hash = compute_git_sha256_from_bytes(patched); + + let primary_entry = store.join("foo@1.0.0(react@17.0.2)").join("node_modules"); + let twin_entry = store.join("foo@1.0.0(react@18.2.0)").join("node_modules"); + for (entry_nm, content) in [(&primary_entry, patched), (&twin_entry, original)] { + let pkg = entry_nm.join("foo"); + tokio::fs::create_dir_all(&pkg).await.unwrap(); + tokio::fs::write( + pkg.join("package.json"), + r#"{"name":"foo","version":"1.0.0"}"#, + ) + .await + .unwrap(); + tokio::fs::write(pkg.join("index.js"), content) + .await + .unwrap(); + } + symlink(primary_entry.join("foo"), nm.join("foo")).unwrap(); + + let blobs = tmp.path().join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write(blobs.join(&after_hash), patched) + .await + .unwrap(); + + let mut files = std::collections::HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash, + after_hash, + }, + ); + let sources = PatchSources { + blobs_path: &blobs, + packages_path: None, + diffs_path: None, + mem_blobs: None, + }; + let result = apply_package_patch( + "pkg:npm/foo@1.0.0", + &nm.join("foo"), + &files, + &sources, + None, + false, + MismatchPolicy::Warn, + ) + .await; + assert!(result.success, "apply must succeed: {:?}", result.error); + assert_eq!( + tokio::fs::read(twin_entry.join("foo").join("index.js")) + .await + .unwrap(), + patched, + "an AlreadyPatched primary must not mask a still-vulnerable twin" + ); +} + +/// Regression (adversarial review, CONFIRMED): a target that exists ONLY +/// as a bundled dependency inside another package's store entry +/// (`.pnpm/host@1.0.0/node_modules/host/node_modules/leaf`) was invisible +/// to the resolver — the pending-name filter skipped the `host@1.0.0` +/// entry — while `crawl_all` (scan) listed it. After the filtered pass +/// leaves targets unresolved, an unfiltered fallback pass must find them. +#[tokio::test] +#[serial_test::parallel] +async fn find_by_purls_resolves_bundled_only_target_via_fallback_pass() { + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + let store = nm.join(".pnpm"); + + let host_nm = store.join("host@1.0.0").join("node_modules"); + stage_npm_pkg(&host_nm, "host", "1.0.0").await; + // Bundled dep: a real dir physically present ONLY inside host's own + // nested node_modules. + stage_npm_pkg(&host_nm.join("host").join("node_modules"), "leaf", "2.0.0").await; + + let crawler = NpmCrawler; + let result = crawler + .find_by_purls(&nm, &["pkg:npm/leaf@2.0.0".to_string()]) + .await + .unwrap(); + let leaf = result + .get("pkg:npm/leaf@2.0.0") + .expect("bundled-only target must resolve via the unfiltered fallback pass"); + assert_eq!( + leaf.path, + host_nm.join("host").join("node_modules").join("leaf") + ); +}