diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index c3deb5985..52ece9654 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -279,6 +279,8 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **pnpm 11+ global installs are one root each.** pnpm 11+ gives every `pnpm add -g` its own install, `$PNPM_HOME/global/v//node_modules` (each with its own `.pnpm`), and `pnpm root -g` prints their parent. `--global` and a `--global-prefix` naming such a directory (a `v` dir that is not itself a `node_modules` and has at least one child install carrying pnpm's `.modules.yaml`) crawl every child `node_modules` as a separate root, so `apply`, `rollback`, `scan` and `vex` see every install's copy of a package, not just the first install's (#435). One physical copy two installs both link is still visited once. Any other prefix (pnpm <= 10's `global/5/node_modules`, npm's) is crawled as the single root it names. +**Deno's npm cache is a global root (additive/MINOR, #444).** `deno install -g` tools load their npm dependencies from Deno's shared npm cache, `$DENO_DIR/npm////`, where `` is the registry's host, plus one dir per path segment for a registry served below a path (`https://host/api/npm/` → `/api/npm`) (scoped: `@scope/name//`; `DENO_DIR` defaults to Deno's per-platform cache dir). `--global` crawls every such registry dir under `$DENO_DIR/npm` (all but `jsr.io`) beside the npm, pnpm, yarn and bun roots, and a `--global-prefix` naming one (a dir below one named `npm`, other than `node_modules` and `jsr.io`, holding some package dir in Deno's layout: no `package.json` of its own, and a cached `registry.json` or a version dir with a `package.json`) is read in that layout instead of as a `node_modules` tree, so `scan`, `apply`, `rollback` and `vex` act on the copy Deno runs. Every copy Deno made of a `name@version` for a different peer-dependency resolution (`_/`) is a copy of it, matched by its `package.json`. The Deno (JSR) crawler finds nothing under such a prefix. + **scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts. **Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the project's lockfiles pin ≥ 1 hosted patch: `{ mode, records: [{purl, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"`. **v5.0 (MAJOR shape change)**: hosted mode keeps no ledger, so `records` lists the hosted pins lockfile discovery finds (one per `(purl, uuid)`, the same discovery `vex` uses: a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` origin), and the v4 `ledger` and `records[].ledgerKey` keys are gone. Each record's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the join is a plain string compare. `wiringLive` is the subset of those pins among this run's *counted* purls (post-`--ecosystems`-filter) — computed once per run, the same set that feeds `hosted_wiring_retained`. A record missing from `wiringLive` is still wired; it just was not crawled/queried this run (an `--ecosystems` filter, a zero discovery). The key is omitted when no lockfile pins a hosted patch (and under `--global`), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A pre-v5 `.socket/vendor/redirect-state.json` is not read. Hosted-mode runs carry their own `redirect` payload and `details.mode: "hosted"` events instead, and vendored-mode runs carry the takeover warnings (their takeovers may restore pins mid-run) — neither duplicates a pre-run snapshot that could go stale. diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index d2e4dc542..b9693c715 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -1006,3 +1006,61 @@ fn rollback_dispatch_branch_deno() { }; assert_rollback_restored(root, "deno", &fixture); } + +/// #444: a package `deno install -g` tools load lives in Deno's npm cache, +/// `$DENO_DIR/npm/registry.npmjs.org///`. Both `--global` +/// (which now discovers that cache from `DENO_DIR`) and a `--global-prefix` +/// naming the registry dir must reach it; before the fix each reported the +/// package not installed. +fn deno_npm_cache_fixture( + root: &Path, + scope_args: impl FnOnce(&Path) -> Vec, +) -> RollbackFixture { + let deno_dir = root.join("deno-dir"); + let registry = deno_dir.join("npm").join("registry.npmjs.org"); + let pkg = registry.join("__deno_global_dispatch__").join("1.0.0"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + r#"{"name":"__deno_global_dispatch__","version":"1.0.0"}"#, + ) + .unwrap(); + // Deno caches the package's packument beside its version dirs. + std::fs::write(pkg.parent().unwrap().join("registry.json"), "{}").unwrap(); + let verify_file = pkg.join("index.js"); + std::fs::write(&verify_file, PATCHED).unwrap(); + let purl = "pkg:npm/__deno_global_dispatch__@1.0.0"; + write_rollback_manifest(root, purl, "package/index.js"); + RollbackFixture { + purl: purl.to_string(), + verify_file, + envs: vec![( + "DENO_DIR".to_string(), + deno_dir.to_str().unwrap().to_string(), + )], + scope_args: scope_args(®istry), + } +} + +#[test] +fn rollback_dispatch_deno_npm_cache_global() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_root_package_json(root); + let fixture = deno_npm_cache_fixture(root, |_| vec!["--global".to_string()]); + assert_rollback_restored(root, "npm", &fixture); +} + +#[test] +fn rollback_dispatch_deno_npm_cache_global_prefix() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_root_package_json(root); + let fixture = deno_npm_cache_fixture(root, |registry| { + vec![ + "--global-prefix".to_string(), + registry.to_str().unwrap().to_string(), + ] + }); + assert_rollback_restored(root, "npm", &fixture); +} diff --git a/crates/socket-patch-core/src/crawlers/deno_crawler.rs b/crates/socket-patch-core/src/crawlers/deno_crawler.rs index ca72d2a94..a7bd78f1c 100644 --- a/crates/socket-patch-core/src/crawlers/deno_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/deno_crawler.rs @@ -60,8 +60,9 @@ impl DenoCrawler { /// Get the JSR cache root paths to scan. /// - /// With `--global-prefix`, returns the prefix directly; otherwise - /// resolves `$DENO_DIR/npm/jsr.io/`. + /// With `--global-prefix`, returns the prefix directly (nothing for a + /// registry dir of Deno's npm cache, which holds npm packages); + /// otherwise resolves `$DENO_DIR/npm/jsr.io/`. /// /// In local mode, only returns paths when the cwd looks like a /// Deno project (`deno.json`, `deno.jsonc`, or `deno.lock` @@ -71,6 +72,13 @@ impl DenoCrawler { options: &CrawlerOptions, ) -> Result, std::io::Error> { if let Some(ref custom) = options.global_prefix { + // Deno's npm cache (`$DENO_DIR/npm/registry.npmjs.org`) holds + // npm packages, which the npm crawler reads there; walked as a + // JSR root, its `@scope/name/` dirs would read as + // bogus `pkg:jsr/` purls. + if super::npm_crawler::is_deno_npm_cache_root(custom) { + return Ok(Vec::new()); + } return Ok(vec![custom.clone()]); } if !options.global && !is_deno_project(&options.cwd).await { @@ -336,7 +344,7 @@ async fn is_deno_project(cwd: &Path) -> bool { /// * Linux/other Unix: `$XDG_CACHE_HOME/deno`, else `$HOME/.cache/deno`. /// * Windows: `%LOCALAPPDATA%\deno` (falling back to `~\.cache\deno` /// if LOCALAPPDATA isn't set). -fn deno_dir() -> Option { +pub(super) fn deno_dir() -> Option { if let Ok(d) = std::env::var("DENO_DIR") { if !d.is_empty() { return Some(PathBuf::from(d)); @@ -422,6 +430,41 @@ mod tests { let _b = DenoCrawler::new(); } + /// A `--global-prefix` naming Deno's npm cache registry dir is the npm + /// crawler's (#444): its scoped `@types/node/20.0.0` must not also + /// surface as `pkg:jsr/@types/node@20.0.0`. A JSR tree under a dir + /// named `npm` (no `registry.json`) is still walked. + #[tokio::test] + async fn crawl_all_skips_deno_npm_cache_prefix() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + stage(®istry, "@types", "node", "20.0.0").await; + tokio::fs::write(registry.join("@types/node/registry.json"), b"{}") + .await + .unwrap(); + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: Some(registry), + }; + assert!(DenoCrawler.crawl_all(&opts).await.is_empty()); + + let mirror = tmp.path().join("npm").join("jsr-mirror"); + stage(&mirror, "@std", "path", "1.0.8").await; + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: Some(mirror), + }; + let purls: Vec = DenoCrawler + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); + assert_eq!(purls, vec!["pkg:jsr/@std/path@1.0.8"]); + } + #[tokio::test] async fn crawl_all_empty_cache_returns_empty() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index cde06377b..0540dec2d 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1866,6 +1866,180 @@ fn list_dir_sync(path: &Path) -> Listing { } } +/// How many levels below `$DENO_DIR/npm` a registry dir may sit. Deno +/// maps a registry URL to `[_]` plus one dir per path segment, +/// so `https://host/api/npm/` lands three levels down; this leaves room +/// for longer paths without walking the cache's package dirs. +const DENO_NPM_REGISTRY_MAX_DEPTH: usize = 8; + +/// The registry dirs of Deno's npm cache (#444): every dir under +/// `$DENO_DIR/npm` that holds packages in Deno's layout +/// ([`holds_deno_packages`]), `` for a registry at a host's root +/// (`registry.npmjs.org`) and `/` for one served below a +/// path (`https://host/api/npm/` → `/api/npm`, as Artifactory, +/// CodeArtifact and GitLab registries are). `deno install -g` tools load +/// their npm dependencies from there, in Deno's own layout (see +/// [`is_deno_npm_cache_root`]), so these are global roots beside the npm, +/// pnpm, yarn and bun `node_modules`. Below a registry dir only the +/// non-version subdirs of its children are walked (a nested registry +/// can sit there, not a package's copy), and `jsr.io` is skipped: it is the +/// Deno crawler's JSR root, not an npm registry. Sorted, for a stable +/// scan order. +fn deno_npm_cache_roots() -> Vec { + let Some(npm) = super::deno_crawler::deno_dir().map(|dir| dir.join("npm")) else { + return Vec::new(); + }; + let mut roots = Vec::new(); + let mut level: Vec = sorted_subdirs_sync(&npm) + .into_iter() + .filter(|name| name != "jsr.io") + .map(|name| npm.join(name)) + .collect(); + for _ in 0..DENO_NPM_REGISTRY_MAX_DEPTH { + let mut next = Vec::new(); + for dir in level { + let children = sorted_subdirs_sync(&dir); + if holds_deno_packages(&dir) { + // A registry dir's children are mostly its packages, but the + // same host can also serve a registry below a path + // (`/npm//` or `/api/npm//` beside + // `//`). A child that is not a package is walked + // like any other dir; one that is (a package named like + // the path's first segment, `/api/`) holds only + // version dirs (named from a digit) of its own, so its + // other subdirs are walked. + for child in children.into_iter().filter(|name| !name.starts_with('@')) { + let child = dir.join(child); + if is_deno_package_dir(&child) { + next.extend( + sorted_subdirs_sync(&child) + .into_iter() + .filter(|name| !name.starts_with(|c: char| c.is_ascii_digit())) + .map(|name| child.join(name)), + ); + } else { + next.push(child); + } + } + roots.push(dir); + } else { + next.extend(children.into_iter().map(|name| dir.join(name))); + } + } + if next.is_empty() { + break; + } + level = next; + } + roots.sort(); + roots +} + +/// Whether `root` is a registry dir of Deno's npm cache +/// (`$DENO_DIR/npm/registry.npmjs.org`, or a nested +/// `$DENO_DIR/npm//` for a registry served below a path) +/// rather than a `node_modules` tree: a dir below one named `npm`, other +/// than a `node_modules` and Deno's `jsr.io` root, with at least one +/// package dir in Deno's layout ([`is_deno_package_dir`]). A +/// `node_modules` package dir has its own `package.json`, so a project +/// root that merely sits under a dir named `npm` keeps its usual walk. Deno lays a package +/// out at `//` (scoped: `@scope/name//`), and each +/// further copy it makes for a different peer-dependency resolution at +/// `_/`. A relative `--global-prefix` is judged by its +/// absolute spelling. +pub(super) fn is_deno_npm_cache_root(root: &Path) -> bool { + let root = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf()); + let named = root + .file_name() + .is_some_and(|name| name != "node_modules" && name != "jsr.io") + && root + .ancestors() + .skip(1) + .any(|dir| dir.file_name().is_some_and(|name| name == "npm")); + named && holds_deno_packages(&root) +} + +/// Whether some package dir of `root` (`` or `@scope/`) is laid +/// out the way Deno's npm cache lays one out ([`is_deno_package_dir`]), +/// stopping at the first that is. +fn holds_deno_packages(root: &Path) -> bool { + sorted_subdirs_sync(root).into_iter().any(|name| { + let dir = root.join(&name); + if name.starts_with('@') { + sorted_subdirs_sync(&dir) + .into_iter() + .any(|child| is_deno_package_dir(&dir.join(child))) + } else { + is_deno_package_dir(&dir) + } + }) +} + +/// Whether `dir` is a package dir of Deno's npm cache: it has no +/// `package.json` of its own (a `node_modules` package dir always does) +/// and holds Deno's cached packument (`registry.json`) or a version dir +/// (a name starting with a digit) with a `package.json`. Deno skips the +/// packument when a `deno.lock` already pins the tarball, so the version +/// dirs alone must count. +fn is_deno_package_dir(dir: &Path) -> bool { + if dir.join("package.json").exists() { + return false; + } + dir.join("registry.json").is_file() + || sorted_subdirs_sync(dir).into_iter().any(|version| { + version.starts_with(|c: char| c.is_ascii_digit()) + && dir.join(version).join("package.json").is_file() + }) +} + +/// The copy index of the Deno npm cache dir `dir_name` when it holds +/// `version`: 0 for ``, `n` for `_`, `None` for a dir +/// of any other version. +fn deno_npm_copy_index(dir_name: &str, version: &str) -> Option { + let rest = dir_name.strip_prefix(version)?; + if rest.is_empty() { + return Some(0); + } + let digits = rest.strip_prefix('_')?; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +/// The non-hidden subdirectories of `dir`, sorted by name. +fn sorted_subdirs_sync(dir: &Path) -> Vec { + let mut names: Vec = list_dir_sync(dir) + .entries + .into_iter() + .filter(|entry| { + !entry.name_str.starts_with('.') && entry.file_type.is_some_and(|t| t.is_dir()) + }) + .map(|entry| entry.name_str) + .collect(); + names.sort(); + names +} + +/// Every package dir (`` or `@scope/`) of the Deno npm cache +/// registry dir `root`. +fn deno_npm_cache_package_dirs(root: &Path) -> Vec { + let mut dirs = Vec::new(); + for name in sorted_subdirs_sync(root) { + if name.starts_with('@') { + let scope = root.join(&name); + dirs.extend( + sorted_subdirs_sync(&scope) + .into_iter() + .map(|n| scope.join(n)), + ); + } else { + dirs.push(root.join(name)); + } + } + dirs +} + /// The `node_modules` roots a global prefix stands for. pnpm 11+ gives /// every `pnpm add -g` its own install, `$PNPM_HOME/global/v11//`, /// each with its own `node_modules` (and its own `.pnpm` on pnpm 12, or @@ -2427,6 +2601,11 @@ impl NpmCrawler { }) .collect(); let gathered: Vec> = par_map(roots, |(nm_path, store)| { + // A Deno npm cache root (#444) has its own layout and no + // relocated store (it is not a `node_modules`). + if is_deno_npm_cache_root(nm_path) { + return Self::gather_deno_npm_cache(nm_path); + } let mut events = Self::gather_node_modules(nm_path, None, false); if let Some(store) = store { let entries = Self::list_npm_store_entries_sync(&store, true); @@ -2504,6 +2683,11 @@ impl NpmCrawler { }); } + if is_deno_npm_cache_root(node_modules_path) { + let root = node_modules_path.to_path_buf(); + return Ok(run_walk(move || Self::resolve_deno_npm_cache(&root, pending)).await); + } + // Both passes run as one walk-pool task: each visited dir is // listed once, and that listing both bounds which targets are // probed there and drives the descent (see @@ -3075,6 +3259,12 @@ impl NpmCrawler { } } + // Deno's npm cache, last: a copy another manager's global root + // also holds keeps being reported there. + for root in deno_npm_cache_roots() { + add(root); + } + paths } @@ -3086,6 +3276,9 @@ impl NpmCrawler { fn node_modules_paths_sync(options: &CrawlerOptions) -> Vec { if options.global || options.global_prefix.is_some() { if let Some(ref custom) = options.global_prefix { + if is_deno_npm_cache_root(custom) { + return vec![custom.clone()]; + } return pnpm_isolated_global_install_roots(custom); } return NpmCrawler.get_global_node_modules_paths(); @@ -3525,6 +3718,104 @@ impl NpmCrawler { }) } + /// Gather every package in the Deno npm cache registry dir `root` + /// (`//`, see [`is_deno_npm_cache_root`]), each + /// identified by its own package.json like any other package. A + /// `_` peer-dependency copy is read only when no + /// `` dir sits beside it: the scan keeps one copy per purl, + /// and that one comes first. + fn gather_deno_npm_cache(root: &Path) -> Vec { + par_map(deno_npm_cache_package_dirs(root), |pkg_dir| { + let dirs = sorted_subdirs_sync(&pkg_dir); + let is_extra_copy = |dir: &String| { + dir.rsplit_once('_').is_some_and(|(base, n)| { + !n.is_empty() + && n.bytes().all(|b| b.is_ascii_digit()) + && dirs.binary_search_by(|d| d.as_str().cmp(base)).is_ok() + }) + }; + dirs.iter() + .filter(|dir| !is_extra_copy(dir)) + .map(|dir| { + let path = pkg_dir.join(dir); + ScanEvent::Package { + identity: read_package_json_sync(&path.join("package.json")), + path, + entry_key: None, + } + }) + .collect::>() + }) + .into_iter() + .flatten() + .collect() + } + + /// [`Self::find_by_purls`] over the Deno npm cache registry dir `root`: + /// every copy of each target, `/` first, then its + /// `_` peer-dependency copies in index order, each kept + /// only when its package.json names the target and recorded once. + /// Deno stores a name with capitals under an encoded `_<...>` dir at + /// the registry root, so a target with one that the direct lookup + /// misses is matched by package.json identity against those dirs only. + fn resolve_deno_npm_cache( + root: &Path, + targets: Vec, + ) -> HashMap> { + let is_target = |path: &Path, target: &Target| { + read_package_json_sync(&path.join("package.json")) + .is_some_and(|(name, version)| name == target.dir_key && version == target.version) + }; + let mut result: HashMap> = HashMap::new(); + let mut record = |path: PathBuf, target: &Target| { + let copies = result.entry(target.purl.clone()).or_default(); + if !copies.iter().any(|c| c.path == path) { + copies.push(CrawledPackage { + name: target.name.clone(), + version: target.version.clone(), + namespace: target.namespace.clone(), + purl: target.purl.clone(), + path, + }); + } + }; + let mut unmatched = Vec::new(); + for target in targets { + let pkg_dir = root.join(&target.dir_key); + let mut copies: Vec<(u64, String)> = sorted_subdirs_sync(&pkg_dir) + .into_iter() + .filter_map(|dir| Some((deno_npm_copy_index(&dir, &target.version)?, dir))) + .collect(); + copies.sort(); + let mut found = false; + for (_, dir) in copies { + let path = pkg_dir.join(dir); + if is_target(&path, &target) { + record(path, &target); + found = true; + } + } + if !found && target.dir_key.bytes().any(|b| b.is_ascii_uppercase()) { + unmatched.push(target); + } + } + if !unmatched.is_empty() { + for encoded in sorted_subdirs_sync(root) + .into_iter() + .filter(|name| name.starts_with('_')) + { + let pkg_dir = root.join(encoded); + for dir in sorted_subdirs_sync(&pkg_dir) { + let path = pkg_dir.join(dir); + for target in unmatched.iter().filter(|t| is_target(&path, t)) { + record(path.clone(), target); + } + } + } + } + result + } + /// Replay gathered [`ScanEvent`]s in order against `seen`, exactly as /// the sequential walk's `check_package` calls would have: a package /// is recorded only if its package.json parsed and its PURL is new. diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index dda4d2ca4..c9de98a80 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -4272,3 +4272,442 @@ async fn apply_breaks_hardlink_to_shared_store_file() { assert_eq!(target_meta.nlink(), 1); } } + +// ── Deno's npm cache (#444) ──────────────────────────────────── + +/// Stage a package in Deno's npm cache layout: +/// `///package.json`, where `` is the version +/// (or `_` for a peer-dependency copy). `name` may include a +/// `@scope/` prefix. +async fn stage_deno_npm_pkg(registry: &Path, name: &str, dir: &str, version: &str) -> PathBuf { + let pkg_dir = registry.join(name).join(dir); + tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); + let pkg_json = format!(r#"{{"name":"{name}","version":"{version}"}}"#); + tokio::fs::write(pkg_dir.join("package.json"), pkg_json) + .await + .unwrap(); + tokio::fs::write(registry.join(name).join("registry.json"), "{}") + .await + .unwrap(); + pkg_dir +} + +/// The copy each purl resolves to, as paths. +fn found_paths( + found: &HashMap>, + purl: &str, +) -> Vec { + found + .get(purl) + .map(|copies| copies.iter().map(|c| c.path.clone()).collect()) + .unwrap_or_default() +} + +/// #444: a `--global-prefix` naming Deno's npm cache registry dir +/// (`$DENO_DIR/npm/registry.npmjs.org`) is read in Deno's +/// `/` layout, not as a `node_modules` tree, so the scan +/// finds the packages `deno install -g` tools run. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_prefix_crawls_name_version_layout() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + stage_deno_npm_pkg(®istry, "is-number", "6.0.0", "6.0.0").await; + stage_deno_npm_pkg(®istry, "@types/node", "20.0.0", "20.0.0").await; + + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: false, + global_prefix: Some(registry.clone()), + }; + let mut purls: Vec = NpmCrawler + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); + purls.sort(); + assert_eq!( + purls, + vec![ + "pkg:npm/@types/node@20.0.0", + "pkg:npm/is-number@6.0.0", + "pkg:npm/is-odd@3.0.1", + ] + ); +} + +/// #444: apply / rollback / vex resolve a purl in Deno's npm cache to its +/// `/` dir, plus every `_` peer-dependency copy +/// Deno made of it, and to nothing whose package.json says otherwise. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_find_by_purls_returns_every_copy() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + let plain = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + let copy = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1_1", "3.0.1").await; + stage_deno_npm_pkg(®istry, "is-odd", "3.0.10", "3.0.10").await; + let scoped = stage_deno_npm_pkg(®istry, "@types/node", "20.0.0", "20.0.0").await; + // A dir spelled like the version whose package.json disagrees is not it. + stage_deno_npm_pkg(®istry, "left-pad", "1.3.0", "9.9.9").await; + + let purls = vec![ + "pkg:npm/is-odd@3.0.1".to_string(), + "pkg:npm/%40types/node@20.0.0".to_string(), + "pkg:npm/left-pad@1.3.0".to_string(), + "pkg:npm/absent@1.0.0".to_string(), + ]; + let found = NpmCrawler.find_by_purls(®istry, &purls).await.unwrap(); + assert_eq!( + found_paths(&found, "pkg:npm/is-odd@3.0.1"), + vec![plain, copy] + ); + assert_eq!( + found_paths(&found, "pkg:npm/%40types/node@20.0.0"), + vec![scoped] + ); + assert!(found_paths(&found, "pkg:npm/left-pad@1.3.0").is_empty()); + assert!(found_paths(&found, "pkg:npm/absent@1.0.0").is_empty()); +} + +/// #444: a traversal purl never escapes Deno's npm cache root. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_find_by_purls_rejects_traversal() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + stage_deno_npm_pkg(tmp.path(), "evil", "1.0.0", "1.0.0").await; + + let purls = vec!["pkg:npm/..%2F..%2Fevil@1.0.0".to_string()]; + let found = NpmCrawler.find_by_purls(®istry, &purls).await.unwrap(); + assert!(found.values().all(|copies| copies.is_empty()), "{found:?}"); +} + +/// #444: `-g` discovers `$DENO_DIR/npm/` beside the npm, pnpm, +/// yarn and bun global roots, so `scan -g` sees the packages +/// `deno install -g` tools load. +#[tokio::test] +#[serial_test::serial] +async fn global_mode_includes_deno_npm_cache() { + let tmp = tempfile::tempdir().unwrap(); + let deno_dir = tmp.path().join("deno"); + let registry = deno_dir.join("npm").join("registry.npmjs.org"); + let pkg = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + + /// Restores `DENO_DIR` however the test ends. + struct DenoDirGuard(Option); + impl Drop for DenoDirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("DENO_DIR", v), + None => std::env::remove_var("DENO_DIR"), + } + } + } + let _guard = DenoDirGuard(std::env::var_os("DENO_DIR")); + std::env::set_var("DENO_DIR", &deno_dir); + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let crawled = NpmCrawler.crawl_all(&opts).await; + + assert!(roots.contains(®istry), "{roots:?}"); + assert!( + crawled + .iter() + .any(|p| p.purl == "pkg:npm/is-odd@3.0.1" && p.path == pkg), + "{crawled:?}" + ); +} + +/// #444 review: a root that merely sits under a dir named `npm` (a +/// project checked out at `~/src/npm` with a custom modules folder) has no +/// Deno packument, so it keeps the `node_modules` walk. +#[tokio::test] +#[serial_test::parallel] +async fn non_deno_root_under_npm_dir_keeps_node_modules_walk() { + let tmp = tempfile::tempdir().unwrap(); + let deps = tmp.path().join("npm").join("deps"); + stage_npm_pkg(&deps, "left-pad", "1.3.0").await; + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: false, + global_prefix: Some(deps.clone()), + }; + let purls: Vec = NpmCrawler + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); + assert_eq!(purls, vec!["pkg:npm/left-pad@1.3.0"]); + let found = NpmCrawler + .find_by_purls(&deps, &["pkg:npm/left-pad@1.3.0".to_string()]) + .await + .unwrap(); + assert_eq!(found_paths(&found, "pkg:npm/left-pad@1.3.0").len(), 1); +} + +/// #444 review: a purl listed twice resolves each Deno cache copy once. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_find_by_purls_records_each_copy_once() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + let plain = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + let purl = "pkg:npm/is-odd@3.0.1".to_string(); + let found = NpmCrawler + .find_by_purls(®istry, &[purl.clone(), purl.clone()]) + .await + .unwrap(); + assert_eq!(found_paths(&found, &purl), vec![plain]); +} + +/// #444: Deno stores a name with capitals under an encoded `_<...>` dir; +/// the purl still resolves to it by its package.json. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_find_by_purls_resolves_encoded_capital_name() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + let pkg = registry.join("_jjjvatsuojxgc3nonu").join("1.3.5"); + tokio::fs::create_dir_all(&pkg).await.unwrap(); + tokio::fs::write( + pkg.join("package.json"), + r#"{"name":"JSONStream","version":"1.3.5"}"#, + ) + .await + .unwrap(); + tokio::fs::write(pkg.parent().unwrap().join("registry.json"), "{}") + .await + .unwrap(); + let purl = "pkg:npm/JSONStream@1.3.5".to_string(); + let found = NpmCrawler + .find_by_purls(®istry, std::slice::from_ref(&purl)) + .await + .unwrap(); + assert_eq!(found_paths(&found, &purl), vec![pkg]); +} + +/// #444 (Bugbot): Deno maps a registry URL with a path +/// (`https://host/api/npm/`, as Artifactory, CodeArtifact and GitLab +/// serve) to nested dirs, `$DENO_DIR/npm//api/npm///` +/// (checked against Deno 2.9.7). A `--global-prefix` naming that nested +/// registry dir is still read in Deno's layout. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_nested_registry_prefix_crawls_and_resolves() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp + .path() + .join("npm") + .join("registry.example.com") + .join("api") + .join("npm"); + let pkg = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: false, + global_prefix: Some(registry.clone()), + }; + let purls: Vec = NpmCrawler + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); + assert_eq!(purls, vec!["pkg:npm/is-odd@3.0.1"]); + let purl = "pkg:npm/is-odd@3.0.1".to_string(); + let found = NpmCrawler + .find_by_purls(®istry, std::slice::from_ref(&purl)) + .await + .unwrap(); + assert_eq!(found_paths(&found, &purl), vec![pkg]); +} + +/// #444 (Bugbot): `-g` finds a nested registry dir under `$DENO_DIR/npm` +/// beside a plain one. +#[tokio::test] +#[serial_test::serial] +async fn global_mode_includes_nested_deno_npm_registry() { + let tmp = tempfile::tempdir().unwrap(); + let deno_dir = tmp.path().join("deno"); + let plain = deno_dir.join("npm").join("registry.npmjs.org"); + let nested = deno_dir.join("npm").join("4873").join("api").join("npm"); + stage_deno_npm_pkg(&plain, "is-odd", "3.0.1", "3.0.1").await; + stage_deno_npm_pkg(&nested, "is-number", "6.0.0", "6.0.0").await; + + struct DenoDirGuard(Option); + impl Drop for DenoDirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("DENO_DIR", v), + None => std::env::remove_var("DENO_DIR"), + } + } + } + let _guard = DenoDirGuard(std::env::var_os("DENO_DIR")); + std::env::set_var("DENO_DIR", &deno_dir); + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + assert!(roots.contains(&plain), "{roots:?}"); + assert!(roots.contains(&nested), "{roots:?}"); + assert!( + !roots + .iter() + .any(|r| r.ends_with("4873") || r.ends_with("api")), + "intermediate dirs are not roots: {roots:?}" + ); +} + +/// #444: when a global tool's `deno.lock` already pins the tarball, Deno +/// downloads only the tarball and writes no `registry.json` (checked +/// against Deno 2.9.7). Such a registry dir is still Deno's cache, both +/// under `-g` and as a `--global-prefix`, while a `node_modules` tree +/// whose package dirs have their own `package.json` is not. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_without_packument_is_still_found() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + let pkg = registry.join("is-odd").join("3.0.1"); + tokio::fs::create_dir_all(&pkg).await.unwrap(); + tokio::fs::write( + pkg.join("package.json"), + r#"{"name":"is-odd","version":"3.0.1"}"#, + ) + .await + .unwrap(); + let purl = "pkg:npm/is-odd@3.0.1".to_string(); + let found = NpmCrawler + .find_by_purls(®istry, std::slice::from_ref(&purl)) + .await + .unwrap(); + assert_eq!(found_paths(&found, &purl), vec![pkg]); + + // A node_modules-shaped root under a dir named npm, whose package + // ships a version-like subdir with a package.json, keeps its walk. + let deps = tmp.path().join("npm").join("deps"); + stage_npm_pkg(&deps, "rxjs", "7.8.1").await; + stage_npm_pkg(&deps.join("rxjs"), "7.8.1", "0.0.0").await; + let found = NpmCrawler + .find_by_purls(&deps, &["pkg:npm/rxjs@7.8.1".to_string()]) + .await + .unwrap(); + assert_eq!( + found_paths(&found, "pkg:npm/rxjs@7.8.1"), + vec![deps.join("rxjs")] + ); +} + +/// #444 (Bugbot): one host can hold a registry at its root and another +/// below a path (`npm///` beside `npm//api/npm//`); +/// `-g` finds both, without taking a package dir as a nested registry. +#[tokio::test] +#[serial_test::serial] +async fn global_mode_finds_registry_nested_under_another() { + let tmp = tempfile::tempdir().unwrap(); + let deno_dir = tmp.path().join("deno"); + let host = deno_dir.join("npm").join("registry.example.com"); + let nested = host.join("api").join("npm"); + stage_deno_npm_pkg(&host, "is-odd", "3.0.1", "3.0.1").await; + stage_deno_npm_pkg(&host, "@types/node", "20.0.0", "20.0.0").await; + stage_deno_npm_pkg(&nested, "is-number", "6.0.0", "6.0.0").await; + + struct DenoDirGuard(Option); + impl Drop for DenoDirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("DENO_DIR", v), + None => std::env::remove_var("DENO_DIR"), + } + } + } + let _guard = DenoDirGuard(std::env::var_os("DENO_DIR")); + std::env::set_var("DENO_DIR", &deno_dir); + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); + assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); +} + +/// #444 (Bugbot): a host-root package named like the first segment of a +/// path registry on the same host (`/api//` beside +/// `/api/npm//`) does not hide that registry from `-g`. +#[tokio::test] +#[serial_test::serial] +async fn global_mode_finds_path_registry_behind_same_named_package() { + let tmp = tempfile::tempdir().unwrap(); + let deno_dir = tmp.path().join("deno"); + let host = deno_dir.join("npm").join("registry.example.com"); + let nested = host.join("api").join("npm"); + stage_deno_npm_pkg(&host, "api", "1.0.0", "1.0.0").await; + stage_deno_npm_pkg(&nested, "is-number", "6.0.0", "6.0.0").await; + + struct DenoDirGuard(Option); + impl Drop for DenoDirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("DENO_DIR", v), + None => std::env::remove_var("DENO_DIR"), + } + } + } + let _guard = DenoDirGuard(std::env::var_os("DENO_DIR")); + std::env::set_var("DENO_DIR", &deno_dir); + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); + assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); +} + +/// #444 (Bugbot): a registry one path segment below a root registry on the +/// same host (`https://host/npm/` → `/npm//`) is itself a root. +#[tokio::test] +#[serial_test::serial] +async fn global_mode_finds_one_segment_registry_under_root_registry() { + let tmp = tempfile::tempdir().unwrap(); + let deno_dir = tmp.path().join("deno"); + let host = deno_dir.join("npm").join("registry.example.com"); + let nested = host.join("npm"); + stage_deno_npm_pkg(&host, "is-odd", "3.0.1", "3.0.1").await; + stage_deno_npm_pkg(&nested, "is-number", "6.0.0", "6.0.0").await; + + struct DenoDirGuard(Option); + impl Drop for DenoDirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("DENO_DIR", v), + None => std::env::remove_var("DENO_DIR"), + } + } + } + let _guard = DenoDirGuard(std::env::var_os("DENO_DIR")); + std::env::set_var("DENO_DIR", &deno_dir); + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); + assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); +}