From e899ef923208993cbbc489cc8bcf4d0467957246 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:06:34 +0000 Subject: [PATCH 1/3] Start refactor for #1216 Assisted-by: Claude Code:claude-opus-5-5 From 487b965b7a34b32b64d1f0ff8e18080d989844e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:19:09 +0000 Subject: [PATCH 2/3] Share the JSR lookup and deno.lock section reader Move find_by_purls' per-purl cache lookup into one locate() and the version-dependent deno.lock section lookup (v2 npm.packages, v3 packages.
, v4/v5 top-level) out of VEX discovery into deno_crawler::lock_section, so the project crawl can reuse both. No behavior change. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/deno_crawler.rs | 107 +++++++++++++----- .../src/vex/discover/deno.rs | 14 +-- 2 files changed, 82 insertions(+), 39 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/deno_crawler.rs b/crates/socket-patch-core/src/crawlers/deno_crawler.rs index 2881f000a..8823d1642 100644 --- a/crates/socket-patch-core/src/crawlers/deno_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/deno_crawler.rs @@ -41,6 +41,8 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use serde_json::{Map, Value}; + use super::types::{CrawledPackage, CrawlerOptions}; use crate::patch::path_safety; use crate::utils::fs::{home_dir, is_dir}; @@ -108,38 +110,9 @@ impl DenoCrawler { let mut result: HashMap = HashMap::new(); for purl in purls { - let Some(((scope, name), version)) = crate::utils::purl::parse_jsr_purl(purl) else { - continue; - }; - // SECURITY: scope/name/version come straight from the (untrusted) - // manifest PURL and are joined onto the cache root below. A real - // JSR coordinate is a single path segment, so reject any that - // could traverse out of the cache (`..`/`.`, a separator, NUL). - // The parser percent-decodes components, so these guards see the - // decoded form — `%2e%2e` cannot smuggle a traversal past them. - // Unlike the cargo/npm crawlers there is no content check to catch - // a bogus path, and jsr patches in place — so fail closed here. - if !(is_safe_jsr_component(&scope) - && is_safe_jsr_component(&name) - && is_safe_jsr_component(&version)) - { - continue; + if let Some(pkg) = locate(jsr_cache_path, purl).await { + result.insert(purl.clone(), pkg); } - // Cache layout: //// - let pkg_dir = jsr_cache_path.join(&*scope).join(&*name).join(&*version); - if !is_dir(&pkg_dir).await { - continue; - } - result.insert( - purl.clone(), - CrawledPackage { - name: name.to_string(), - version: version.to_string(), - namespace: Some(scope.to_string()), - purl: purl.clone(), - path: pkg_dir, - }, - ); } Ok(result) @@ -152,6 +125,52 @@ impl Default for DenoCrawler { } } +/// The cache directory of JSR `purl` under `jsr_cache_path`, reported +/// under `purl`: the lookup behind [`DenoCrawler::find_by_purls`]. `None` +/// for a non-`pkg:jsr/` purl, an unsafe coordinate, or a version directory +/// that is not there. +async fn locate(jsr_cache_path: &Path, purl: &str) -> Option { + let ((scope, name), version) = crate::utils::purl::parse_jsr_purl(purl)?; + // SECURITY: scope/name/version come straight from the (untrusted) + // manifest PURL and are joined onto the cache + // root below. A real JSR coordinate is a single path segment, so reject + // any that could traverse out of the cache (`..`/`.`, a separator, + // NUL). The parser percent-decodes components, so these guards see the + // decoded form — `%2e%2e` cannot smuggle a traversal past them. + // Unlike the cargo/npm crawlers there is no content check to catch + // a bogus path, and jsr patches in place — so fail closed here. + if !(is_safe_jsr_component(&scope) + && is_safe_jsr_component(&name) + && is_safe_jsr_component(&version)) + { + return None; + } + // Cache layout: //// + let pkg_dir = jsr_cache_path.join(&*scope).join(&*name).join(&*version); + if !is_dir(&pkg_dir).await { + return None; + } + Some(CrawledPackage { + name: name.to_string(), + version: version.to_string(), + namespace: Some(scope.to_string()), + purl: purl.to_string(), + path: pkg_dir, + }) +} + +/// The `section` (`npm` or `jsr`) package map of a parsed `deno.lock`, in +/// any lockfile version: top-level in versions 4 and 5, `packages.
` +/// in version 3 and `
.packages` in version 2. The one reader of +/// the lock's layout, shared with VEX discovery (`vex::discover::deno`). +pub(crate) fn lock_section<'a>(lock: &'a Value, section: &str) -> Option<&'a Map> { + let top = lock.get(section); + top.and_then(|s| s.get("packages")) + .or(top) + .or_else(|| lock.get("packages").and_then(|p| p.get(section))) + .and_then(Value::as_object) +} + /// Walk `/@///` and emit a /// `CrawledPackage` per (scope, name, version) tuple found. async fn scan_jsr_cache(root: &Path, seen: &mut HashSet, out: &mut Vec) { @@ -502,6 +521,32 @@ mod tests { assert!(crawler.crawl_all(&opts).await.is_empty()); } + /// The shared section reader answers every lockfile version for both + /// callers: the crawl's `jsr` scope and VEX discovery's `npm` keys. + #[test] + fn lock_section_reads_every_lock_version() { + let keys = |text: &str, section: &str| -> Vec { + let lock: Value = serde_json::from_str(text).unwrap(); + lock_section(&lock, section) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default() + }; + // v4 / v5: top-level sections. + let v4 = r#"{"version":"4","npm":{"left-pad@1.3.0":{}},"jsr":{"@std/path@1.0.0":{}}}"#; + assert_eq!(keys(v4, "npm"), ["left-pad@1.3.0"]); + assert_eq!(keys(v4, "jsr"), ["@std/path@1.0.0"]); + // v3: under `packages`. + let v3 = r#"{"version":"3","packages":{"npm":{"left-pad@1.3.0":{}},"jsr":{"@std/path@1.0.0":{}}}}"#; + assert_eq!(keys(v3, "npm"), ["left-pad@1.3.0"]); + assert_eq!(keys(v3, "jsr"), ["@std/path@1.0.0"]); + // v2: `npm.packages`; there is no jsr section. + let v2 = r#"{"version":"2","npm":{"specifiers":{},"packages":{"left-pad@1.3.0":{}}}}"#; + assert_eq!(keys(v2, "npm"), ["left-pad@1.3.0"]); + assert!(keys(v2, "jsr").is_empty()); + // A non-object section is no section. + assert!(keys(r#"{"version":"4","jsr":[]}"#, "jsr").is_empty()); + } + /// Unit contract for the coordinate gate: real scope/name/version /// components pass; a `:` is rejected because a Windows drive-relative /// component (`C:evil`) joins as an absolute path under `Path::join`. diff --git a/crates/socket-patch-core/src/vex/discover/deno.rs b/crates/socket-patch-core/src/vex/discover/deno.rs index 92fdb939e..3f17ab52c 100644 --- a/crates/socket-patch-core/src/vex/discover/deno.rs +++ b/crates/socket-patch-core/src/vex/discover/deno.rs @@ -31,6 +31,8 @@ use serde_json::Value; use std::path::PathBuf; +use crate::crawlers::deno_crawler::lock_section; + use super::{ canonical_base_purl, npm_purl, parse_json, CopyTarget, DiscoverCtx, Discovery, UnattestedKind, UnwiredCopy, @@ -59,15 +61,11 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { } } -/// The keys of `deno.lock`'s npm package map, in any lockfile version. +/// The keys of `deno.lock`'s npm package map, in any lockfile version +/// ([`lock_section`]). fn deno_npm_keys(lock: &Value) -> Vec<&str> { - let npm = lock.get("npm"); - let map = npm - .and_then(|n| n.get("packages")) - .or(npm) - .or_else(|| lock.get("packages").and_then(|p| p.get("npm"))) - .and_then(Value::as_object); - map.map(|m| m.keys().map(String::as_str).collect()) + lock_section(lock, "npm") + .map(|m| m.keys().map(String::as_str).collect()) .unwrap_or_default() } From a3e5f30c1547769665231c27692dc7c6522b077b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:19:17 +0000 Subject: [PATCH 3/3] Scope the Deno project crawl to deno.lock In project mode, crawl_all walked the whole JSR cache, so scan, agent apply and VEX saw every JSR package any project on the machine had cached. A project with a deno.lock now gets only the packages its lock's jsr section records, each looked up in the cache; a lock with no jsr section records none. --global, --global-prefix and projects without a usable deno.lock still walk the cache. On a 3,000-package cache with 140 locked packages the crawl drops from 48 ms to 7.5 ms (debug build). Fixes #1216. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/deno_crawler.rs | 180 +++++++++++++++++- 1 file changed, 172 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/deno_crawler.rs b/crates/socket-patch-core/src/crawlers/deno_crawler.rs index 8823d1642..80c2ac948 100644 --- a/crates/socket-patch-core/src/crawlers/deno_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/deno_crawler.rs @@ -28,7 +28,9 @@ //! scannable JSR-shaped trees, and (b) any future Deno that //! adopts a stable scope/name/version layout (or a third-party //! tool that materializes JSR packages this way) gets picked -//! up automatically. +//! up automatically. In a project with a `deno.lock`, only the +//! lock's `jsr` entries are looked up in that layout; the cache is +//! not walked. //! //! In the meantime, `socket-patch scan --global --ecosystems //! deno --global-prefix ` is what real users would invoke @@ -84,16 +86,44 @@ impl DenoCrawler { } } - /// Crawl JSR cache(s) and return every `pkg:jsr/...` package - /// present. JSR cache layout is + /// Crawl JSR cache(s) and return the `pkg:jsr/...` packages present. + /// JSR cache layout is /// `/@///`. + /// + /// A local project with a `deno.lock` only gets the JSR packages its + /// lock records, each looked up in the cache ([`jsr_lock_scope`], + /// [`locate`]), so a package another project left in the cache is + /// never crawled and the cache tree is not walked. Everything else + /// (`--global`, `--global-prefix`, no or unreadable `deno.lock`) walks + /// the whole cache. pub async fn crawl_all(&self, options: &CrawlerOptions) -> Vec { let mut packages = Vec::new(); let mut seen = HashSet::new(); let cache_paths = self.get_jsr_cache_paths(options).await.unwrap_or_default(); + if cache_paths.is_empty() { + return packages; + } + let scope = if options.global || options.global_prefix.is_some() { + None + } else { + jsr_lock_scope(&options.cwd).await + }; for cache_path in &cache_paths { - scan_jsr_cache(cache_path, &mut seen, &mut packages).await; + match &scope { + Some(locked) => { + for purl in locked { + if seen.contains(purl) { + continue; + } + if let Some(pkg) = locate(cache_path, purl).await { + seen.insert(pkg.purl.clone()); + packages.push(pkg); + } + } + } + None => scan_jsr_cache(cache_path, &mut seen, &mut packages).await, + } } packages @@ -126,13 +156,13 @@ impl Default for DenoCrawler { } /// The cache directory of JSR `purl` under `jsr_cache_path`, reported -/// under `purl`: the lookup behind [`DenoCrawler::find_by_purls`]. `None` -/// for a non-`pkg:jsr/` purl, an unsafe coordinate, or a version directory -/// that is not there. +/// under `purl`: the one lookup behind [`DenoCrawler::find_by_purls`] and +/// the `deno.lock`-scoped crawl. `None` for a non-`pkg:jsr/` purl, an +/// unsafe coordinate, or a version directory that is not there. async fn locate(jsr_cache_path: &Path, purl: &str) -> Option { let ((scope, name), version) = crate::utils::purl::parse_jsr_purl(purl)?; // SECURITY: scope/name/version come straight from the (untrusted) - // manifest PURL and are joined onto the cache + // manifest PURL or project `deno.lock` and are joined onto the cache // root below. A real JSR coordinate is a single path segment, so reject // any that could traverse out of the cache (`..`/`.`, a separator, // NUL). The parser percent-decodes components, so these guards see the @@ -159,6 +189,24 @@ async fn locate(jsr_cache_path: &Path, purl: &str) -> Option { }) } +/// The `pkg:jsr/` purl of every key of `/deno.lock`'s `jsr` section +/// (`@/@`), in lock order. A lock without a `jsr` +/// section records no JSR package (`Some` of nothing). `None` when there +/// is no readable `deno.lock`, it is not JSON, or it carries no `version` +/// (not a lock Deno wrote); the crawl then walks the whole cache. +async fn jsr_lock_scope(cwd: &Path) -> Option> { + let text = crate::utils::fs::read_regular_to_string(&cwd.join("deno.lock")) + .await + .ok()?; + let lock: Value = serde_json::from_str(&text).ok()?; + lock.get("version")?; + Some( + lock_section(&lock, "jsr") + .map(|jsr| jsr.keys().map(|key| format!("pkg:jsr/{key}")).collect()) + .unwrap_or_default(), + ) +} + /// The `section` (`npm` or `jsr`) package map of a parsed `deno.lock`, in /// any lockfile version: top-level in versions 4 and 5, `packages.
` /// in version 3 and `
.packages` in version 2. The one reader of @@ -521,6 +569,122 @@ mod tests { assert!(crawler.crawl_all(&opts).await.is_empty()); } + // ── deno.lock-scoped project crawl ───────────────────────────── + + /// A project at a fresh tempdir with `deno.lock` = `lock`, and a + /// `DENO_DIR` whose JSR cache holds `@std/path@0.220.0` (locked in the + /// fixtures below) plus `@std/fs@1.0.0` and `@other/x@2.0.0` (never + /// locked). Returns the purls `crawl_all` reports, sorted. + async fn crawl_project_with_lock(lock: &str, global: bool) -> Vec { + let project = tempfile::tempdir().unwrap(); + tokio::fs::write(project.path().join("deno.lock"), lock) + .await + .unwrap(); + let deno_home = tempfile::tempdir().unwrap(); + let jsr = deno_home.path().join("npm").join("jsr.io"); + stage(&jsr, "@std", "path", "0.220.0").await; + stage(&jsr, "@std", "fs", "1.0.0").await; + stage(&jsr, "@other", "x", "2.0.0").await; + let _g = EnvGuard::set("DENO_DIR", deno_home.path().to_str().unwrap()); + + let opts = CrawlerOptions { + cwd: project.path().to_path_buf(), + global, + global_prefix: None, + }; + let mut purls: Vec = DenoCrawler + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); + purls.sort(); + purls + } + + const WHOLE_CACHE: [&str; 3] = [ + "pkg:jsr/@other/x@2.0.0", + "pkg:jsr/@std/fs@1.0.0", + "pkg:jsr/@std/path@0.220.0", + ]; + + #[tokio::test] + #[serial_test::serial] + async fn crawl_all_local_lock_scopes_to_locked_jsr_packages() { + // v4/v5 (top-level `jsr`) and v3 (`packages.jsr`): only the locked + // package is reported; the other cached packages are not. + for lock in [ + r#"{"version":"5","jsr":{"@std/path@0.220.0":{"integrity":"a"}}}"#, + r#"{"version":"4","jsr":{"@std/path@0.220.0":{"integrity":"a"}}}"#, + r#"{"version":"3","packages":{"jsr":{"@std/path@0.220.0":{"integrity":"a"}}}}"#, + ] { + assert_eq!( + crawl_project_with_lock(lock, false).await, + vec!["pkg:jsr/@std/path@0.220.0"], + "{lock}" + ); + } + } + + #[tokio::test] + #[serial_test::serial] + async fn crawl_all_local_lock_without_jsr_reports_nothing() { + // An npm-only project resolves no JSR package at all. + let lock = r#"{"version":"4","npm":{"left-pad@1.3.0":{"integrity":"x"}}}"#; + assert!(crawl_project_with_lock(lock, false).await.is_empty()); + } + + #[tokio::test] + #[serial_test::serial] + async fn crawl_all_local_lock_skips_uncached_and_unsafe_entries() { + // A locked package missing from the cache, and a key whose version + // would traverse out of it, are skipped; the cached one is found. + let lock = r#"{"version":"4","jsr":{ + "@std/path@0.220.0":{}, + "@std/path@9.9.9":{}, + "@std/fs@../../@other/x/2.0.0":{} + }}"#; + assert_eq!( + crawl_project_with_lock(lock, false).await, + vec!["pkg:jsr/@std/path@0.220.0"] + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn crawl_all_walks_without_a_usable_lock_or_in_global_mode() { + let scoped = r#"{"version":"4","jsr":{"@std/path@0.220.0":{}}}"#; + // `--global` ignores the project's lock. + assert_eq!(crawl_project_with_lock(scoped, true).await, WHOLE_CACHE); + // Not JSON, or JSON without a `version` (no lock Deno wrote): walk. + assert_eq!( + crawl_project_with_lock("not json", false).await, + WHOLE_CACHE + ); + assert_eq!(crawl_project_with_lock("{}", false).await, WHOLE_CACHE); + } + + #[tokio::test] + #[serial_test::serial] + async fn crawl_all_local_without_lock_walks_the_cache() { + // A `deno.json`-only project has no resolution to scope by. + let project = tempfile::tempdir().unwrap(); + tokio::fs::write(project.path().join("deno.json"), b"{}") + .await + .unwrap(); + let deno_home = tempfile::tempdir().unwrap(); + let jsr = deno_home.path().join("npm").join("jsr.io"); + stage(&jsr, "@std", "path", "0.220.0").await; + stage(&jsr, "@std", "fs", "1.0.0").await; + let _g = EnvGuard::set("DENO_DIR", deno_home.path().to_str().unwrap()); + let opts = CrawlerOptions { + cwd: project.path().to_path_buf(), + global: false, + global_prefix: None, + }; + assert_eq!(DenoCrawler.crawl_all(&opts).await.len(), 2); + } + /// The shared section reader answers every lockfile version for both /// callers: the crawl's `jsr` scope and VEX discovery's `npm` keys. #[test]