2828//! scannable JSR-shaped trees, and (b) any future Deno that
2929//! adopts a stable scope/name/version layout (or a third-party
3030//! tool that materializes JSR packages this way) gets picked
31- //! up automatically.
31+ //! up automatically. In a project with a `deno.lock`, only the
32+ //! lock's `jsr` entries are looked up in that layout; the cache is
33+ //! not walked.
3234//!
3335//! In the meantime, `socket-patch scan --global --ecosystems
3436//! deno --global-prefix <path>` is what real users would invoke
@@ -84,16 +86,44 @@ impl DenoCrawler {
8486 }
8587 }
8688
87- /// Crawl JSR cache(s) and return every `pkg:jsr/...` package
88- /// present. JSR cache layout is
89+ /// Crawl JSR cache(s) and return the `pkg:jsr/...` packages present.
90+ /// JSR cache layout is
8991 /// `<root>/@<scope>/<name>/<version>/<package contents>`.
92+ ///
93+ /// A local project with a `deno.lock` only gets the JSR packages its
94+ /// lock records, each looked up in the cache ([`jsr_lock_scope`],
95+ /// [`locate`]), so a package another project left in the cache is
96+ /// never crawled and the cache tree is not walked. Everything else
97+ /// (`--global`, `--global-prefix`, no or unreadable `deno.lock`) walks
98+ /// the whole cache.
9099 pub async fn crawl_all ( & self , options : & CrawlerOptions ) -> Vec < CrawledPackage > {
91100 let mut packages = Vec :: new ( ) ;
92101 let mut seen = HashSet :: new ( ) ;
93102
94103 let cache_paths = self . get_jsr_cache_paths ( options) . await . unwrap_or_default ( ) ;
104+ if cache_paths. is_empty ( ) {
105+ return packages;
106+ }
107+ let scope = if options. global || options. global_prefix . is_some ( ) {
108+ None
109+ } else {
110+ jsr_lock_scope ( & options. cwd ) . await
111+ } ;
95112 for cache_path in & cache_paths {
96- scan_jsr_cache ( cache_path, & mut seen, & mut packages) . await ;
113+ match & scope {
114+ Some ( locked) => {
115+ for purl in locked {
116+ if seen. contains ( purl) {
117+ continue ;
118+ }
119+ if let Some ( pkg) = locate ( cache_path, purl) . await {
120+ seen. insert ( pkg. purl . clone ( ) ) ;
121+ packages. push ( pkg) ;
122+ }
123+ }
124+ }
125+ None => scan_jsr_cache ( cache_path, & mut seen, & mut packages) . await ,
126+ }
97127 }
98128
99129 packages
@@ -126,13 +156,13 @@ impl Default for DenoCrawler {
126156}
127157
128158/// The cache directory of JSR `purl` under `jsr_cache_path`, reported
129- /// under `purl`: the lookup behind [`DenoCrawler::find_by_purls`]. `None`
130- /// for a non-`pkg:jsr/` purl, an unsafe coordinate, or a version directory
131- /// that is not there.
159+ /// under `purl`: the one lookup behind [`DenoCrawler::find_by_purls`] and
160+ /// the `deno.lock`-scoped crawl. `None` for a non-`pkg:jsr/` purl, an
161+ /// unsafe coordinate, or a version directory that is not there.
132162async fn locate ( jsr_cache_path : & Path , purl : & str ) -> Option < CrawledPackage > {
133163 let ( ( scope, name) , version) = crate :: utils:: purl:: parse_jsr_purl ( purl) ?;
134164 // SECURITY: scope/name/version come straight from the (untrusted)
135- // manifest PURL and are joined onto the cache
165+ // manifest PURL or project `deno.lock` and are joined onto the cache
136166 // root below. A real JSR coordinate is a single path segment, so reject
137167 // any that could traverse out of the cache (`..`/`.`, a separator,
138168 // 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<CrawledPackage> {
159189 } )
160190}
161191
192+ /// The `pkg:jsr/` purl of every key of `<cwd>/deno.lock`'s `jsr` section
193+ /// (`@<scope>/<name>@<version>`), in lock order. A lock without a `jsr`
194+ /// section records no JSR package (`Some` of nothing). `None` when there
195+ /// is no readable `deno.lock`, it is not JSON, or it carries no `version`
196+ /// (not a lock Deno wrote); the crawl then walks the whole cache.
197+ async fn jsr_lock_scope ( cwd : & Path ) -> Option < Vec < String > > {
198+ let text = crate :: utils:: fs:: read_regular_to_string ( & cwd. join ( "deno.lock" ) )
199+ . await
200+ . ok ( ) ?;
201+ let lock: Value = serde_json:: from_str ( & text) . ok ( ) ?;
202+ lock. get ( "version" ) ?;
203+ Some (
204+ lock_section ( & lock, "jsr" )
205+ . map ( |jsr| jsr. keys ( ) . map ( |key| format ! ( "pkg:jsr/{key}" ) ) . collect ( ) )
206+ . unwrap_or_default ( ) ,
207+ )
208+ }
209+
162210/// The `section` (`npm` or `jsr`) package map of a parsed `deno.lock`, in
163211/// any lockfile version: top-level in versions 4 and 5, `packages.<section>`
164212/// in version 3 and `<section>.packages` in version 2. The one reader of
@@ -521,6 +569,122 @@ mod tests {
521569 assert ! ( crawler. crawl_all( & opts) . await . is_empty( ) ) ;
522570 }
523571
572+ // ── deno.lock-scoped project crawl ─────────────────────────────
573+
574+ /// A project at a fresh tempdir with `deno.lock` = `lock`, and a
575+ /// `DENO_DIR` whose JSR cache holds `@std/path@0.220.0` (locked in the
576+ /// fixtures below) plus `@std/fs@1.0.0` and `@other/x@2.0.0` (never
577+ /// locked). Returns the purls `crawl_all` reports, sorted.
578+ async fn crawl_project_with_lock ( lock : & str , global : bool ) -> Vec < String > {
579+ let project = tempfile:: tempdir ( ) . unwrap ( ) ;
580+ tokio:: fs:: write ( project. path ( ) . join ( "deno.lock" ) , lock)
581+ . await
582+ . unwrap ( ) ;
583+ let deno_home = tempfile:: tempdir ( ) . unwrap ( ) ;
584+ let jsr = deno_home. path ( ) . join ( "npm" ) . join ( "jsr.io" ) ;
585+ stage ( & jsr, "@std" , "path" , "0.220.0" ) . await ;
586+ stage ( & jsr, "@std" , "fs" , "1.0.0" ) . await ;
587+ stage ( & jsr, "@other" , "x" , "2.0.0" ) . await ;
588+ let _g = EnvGuard :: set ( "DENO_DIR" , deno_home. path ( ) . to_str ( ) . unwrap ( ) ) ;
589+
590+ let opts = CrawlerOptions {
591+ cwd : project. path ( ) . to_path_buf ( ) ,
592+ global,
593+ global_prefix : None ,
594+ } ;
595+ let mut purls: Vec < String > = DenoCrawler
596+ . crawl_all ( & opts)
597+ . await
598+ . into_iter ( )
599+ . map ( |p| p. purl )
600+ . collect ( ) ;
601+ purls. sort ( ) ;
602+ purls
603+ }
604+
605+ const WHOLE_CACHE : [ & str ; 3 ] = [
606+ "pkg:jsr/@other/x@2.0.0" ,
607+ "pkg:jsr/@std/fs@1.0.0" ,
608+ "pkg:jsr/@std/path@0.220.0" ,
609+ ] ;
610+
611+ #[ tokio:: test]
612+ #[ serial_test:: serial]
613+ async fn crawl_all_local_lock_scopes_to_locked_jsr_packages ( ) {
614+ // v4/v5 (top-level `jsr`) and v3 (`packages.jsr`): only the locked
615+ // package is reported; the other cached packages are not.
616+ for lock in [
617+ r#"{"version":"5","jsr":{"@std/path@0.220.0":{"integrity":"a"}}}"# ,
618+ r#"{"version":"4","jsr":{"@std/path@0.220.0":{"integrity":"a"}}}"# ,
619+ r#"{"version":"3","packages":{"jsr":{"@std/path@0.220.0":{"integrity":"a"}}}}"# ,
620+ ] {
621+ assert_eq ! (
622+ crawl_project_with_lock( lock, false ) . await ,
623+ vec![ "pkg:jsr/@std/path@0.220.0" ] ,
624+ "{lock}"
625+ ) ;
626+ }
627+ }
628+
629+ #[ tokio:: test]
630+ #[ serial_test:: serial]
631+ async fn crawl_all_local_lock_without_jsr_reports_nothing ( ) {
632+ // An npm-only project resolves no JSR package at all.
633+ let lock = r#"{"version":"4","npm":{"left-pad@1.3.0":{"integrity":"x"}}}"# ;
634+ assert ! ( crawl_project_with_lock( lock, false ) . await . is_empty( ) ) ;
635+ }
636+
637+ #[ tokio:: test]
638+ #[ serial_test:: serial]
639+ async fn crawl_all_local_lock_skips_uncached_and_unsafe_entries ( ) {
640+ // A locked package missing from the cache, and a key whose version
641+ // would traverse out of it, are skipped; the cached one is found.
642+ let lock = r#"{"version":"4","jsr":{
643+ "@std/path@0.220.0":{},
644+ "@std/path@9.9.9":{},
645+ "@std/fs@../../@other/x/2.0.0":{}
646+ }}"# ;
647+ assert_eq ! (
648+ crawl_project_with_lock( lock, false ) . await ,
649+ vec![ "pkg:jsr/@std/path@0.220.0" ]
650+ ) ;
651+ }
652+
653+ #[ tokio:: test]
654+ #[ serial_test:: serial]
655+ async fn crawl_all_walks_without_a_usable_lock_or_in_global_mode ( ) {
656+ let scoped = r#"{"version":"4","jsr":{"@std/path@0.220.0":{}}}"# ;
657+ // `--global` ignores the project's lock.
658+ assert_eq ! ( crawl_project_with_lock( scoped, true ) . await , WHOLE_CACHE ) ;
659+ // Not JSON, or JSON without a `version` (no lock Deno wrote): walk.
660+ assert_eq ! (
661+ crawl_project_with_lock( "not json" , false ) . await ,
662+ WHOLE_CACHE
663+ ) ;
664+ assert_eq ! ( crawl_project_with_lock( "{}" , false ) . await , WHOLE_CACHE ) ;
665+ }
666+
667+ #[ tokio:: test]
668+ #[ serial_test:: serial]
669+ async fn crawl_all_local_without_lock_walks_the_cache ( ) {
670+ // A `deno.json`-only project has no resolution to scope by.
671+ let project = tempfile:: tempdir ( ) . unwrap ( ) ;
672+ tokio:: fs:: write ( project. path ( ) . join ( "deno.json" ) , b"{}" )
673+ . await
674+ . unwrap ( ) ;
675+ let deno_home = tempfile:: tempdir ( ) . unwrap ( ) ;
676+ let jsr = deno_home. path ( ) . join ( "npm" ) . join ( "jsr.io" ) ;
677+ stage ( & jsr, "@std" , "path" , "0.220.0" ) . await ;
678+ stage ( & jsr, "@std" , "fs" , "1.0.0" ) . await ;
679+ let _g = EnvGuard :: set ( "DENO_DIR" , deno_home. path ( ) . to_str ( ) . unwrap ( ) ) ;
680+ let opts = CrawlerOptions {
681+ cwd : project. path ( ) . to_path_buf ( ) ,
682+ global : false ,
683+ global_prefix : None ,
684+ } ;
685+ assert_eq ! ( DenoCrawler . crawl_all( & opts) . await . len( ) , 2 ) ;
686+ }
687+
524688 /// The shared section reader answers every lockfile version for both
525689 /// callers: the crawl's `jsr` scope and VEX discovery's `npm` keys.
526690 #[ test]
0 commit comments