diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 566fea319..19cc35481 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1882,7 +1882,9 @@ const DENO_NPM_REGISTRY_MAX_DEPTH: usize = 8; /// [`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 +/// can sit there, not a package's copy); a package dir whose other +/// subdirs hold packages is itself a registry one path segment down +/// (#1784). `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 { @@ -1945,20 +1947,30 @@ fn deno_npm_registry_dirs(npm: &Path) -> Vec { // (`/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 { + // the path's first segment, `/api/`) holds version + // dirs of its own, so only its other subdirs are walked. + // When those subdirs are packages themselves + // (`https://host/npm/` caches `/npm//` beside + // the root registry's package `npm`), the package dir is + // also a registry (#1784), whose own version dirs (those + // of the outer package) are skipped. + for name in children.into_iter().filter(|name| !name.starts_with('@')) { + if is_deno_version_dir(&dir, &name) { + continue; + } + let child = dir.join(name); + if !is_deno_package_dir(&child) { next.push(child); + continue; + } + let others: Vec = sorted_subdirs_sync(&child) + .into_iter() + .filter(|name| !is_deno_version_dir(&child, name)) + .collect(); + if holds_deno_packages_among(&child, &others) { + next.push(child); + } else { + next.extend(others.into_iter().map(|name| child.join(name))); } } roots.push(dir); @@ -2003,8 +2015,13 @@ pub(super) fn is_deno_npm_cache_root(root: &Path) -> bool { /// 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); + holds_deno_packages_among(root, &sorted_subdirs_sync(root)) +} + +/// [`holds_deno_packages`] over only the subdirs `names` of `root`. +fn holds_deno_packages_among(root: &Path, names: &[String]) -> bool { + names.iter().any(|name| { + let dir = root.join(name); if name.starts_with('@') { sorted_subdirs_sync(&dir) .into_iter() @@ -2015,6 +2032,16 @@ fn holds_deno_packages(root: &Path) -> bool { }) } +/// Whether `parent/name` is a version dir of a package in Deno's npm +/// cache (`/` or `_/`, named from a digit, with the +/// package's `package.json`) rather than a package dir: a package named +/// from a digit (`7zip-bin`) has no `package.json` of its own. Only a +/// digit-named dir is stat'ed. +fn is_deno_version_dir(parent: &Path, name: &str) -> bool { + name.starts_with(|c: char| c.is_ascii_digit()) + && parent.join(name).join("package.json").is_file() +} + /// 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 @@ -2026,10 +2053,9 @@ fn is_deno_package_dir(dir: &Path) -> bool { 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() - }) + || sorted_subdirs_sync(dir) + .into_iter() + .any(|version| is_deno_version_dir(dir, &version)) } /// The copy index of the Deno npm cache dir `dir_name` when it holds @@ -2066,6 +2092,12 @@ fn sorted_subdirs_sync(dir: &Path) -> Vec { fn deno_npm_cache_package_dirs(root: &Path) -> Vec { let mut dirs = Vec::new(); for name in sorted_subdirs_sync(root) { + // A registry that is also a package dir of the registry above it + // (#1784) holds that package's version dirs, which are not + // packages of its own. + if is_deno_version_dir(root, &name) { + continue; + } if name.starts_with('@') { let scope = root.join(&name); dirs.extend( diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 9475badde..880c016a1 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -4712,6 +4712,84 @@ async fn global_mode_finds_one_segment_registry_under_root_registry() { assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); } +/// #1784: a one-segment path registry (`https://host/npm/` → +/// `/npm//`) stays a root when the host-root registry has also +/// cached a package named like that segment (`/npm//`), +/// whether the nested registry's packages are plain or scoped, and the +/// scan reports each registry's packages once, from its own dir. +#[tokio::test] +#[serial_test::serial] +async fn global_mode_finds_one_segment_registry_behind_same_named_package() { + 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")); + for nested_pkg in ["foo", "@scope/foo"] { + 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"); + let npm_pkg = stage_deno_npm_pkg(&host, "npm", "10.9.0", "10.9.0").await; + // The outer package's own content: a nested manifest and a + // fixture tree laid out like a registry, neither of which is a + // package or registry of `/npm`. + stage_npm_pkg(&npm_pkg.join("node_modules"), "abbrev", "2.0.0").await; + tokio::fs::create_dir_all(npm_pkg.join("dist")) + .await + .unwrap(); + tokio::fs::write( + npm_pkg.join("dist").join("package.json"), + r#"{"name":"npm-dist","version":"0.0.1"}"#, + ) + .await + .unwrap(); + stage_deno_npm_pkg(&npm_pkg.join("fixtures"), "bar", "2.0.0", "2.0.0").await; + let foo = stage_deno_npm_pkg(&nested, nested_pkg, "1.0.0", "1.0.0").await; + + 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], "{nested_pkg}: {roots:?}"); + + let mut crawled: Vec<(String, PathBuf)> = NpmCrawler + .crawl_all(&opts) + .await + .into_iter() + .filter(|p| p.path.starts_with(&deno_dir)) + .map(|p| (p.purl, p.path)) + .collect(); + crawled.sort(); + let foo_purl = format!("pkg:npm/{nested_pkg}@1.0.0"); + let mut expected = vec![ + ("pkg:npm/npm@10.9.0".to_string(), npm_pkg.clone()), + (foo_purl.clone(), foo.clone()), + ]; + expected.sort(); + assert_eq!(crawled, expected, "{nested_pkg}"); + + let found = NpmCrawler + .find_by_purls(&nested, &[foo_purl.clone()]) + .await + .unwrap(); + assert_eq!( + found_paths(&found, &foo_purl), + vec![foo.clone()], + "{nested_pkg}" + ); + } +} + /// #444: a `--global-prefix` naming `$DENO_DIR` or `$DENO_DIR/npm` (a parent /// of Deno's npm cache registry dirs) expands to every registry dir below /// it, the same ones `-g` finds, so the scan sees the packages