4040use std:: collections:: { BTreeMap , HashMap } ;
4141use std:: path:: { Path , PathBuf } ;
4242
43+ #[ cfg( not( test) ) ]
4344use socket_patch_core:: crawlers:: npm_crawler:: with_store_peer_variant_copies;
4445use socket_patch_core:: crawlers:: {
4546 CargoCrawler , CrawlerOptions , Ecosystem , GoCrawler , MavenCrawler , NpmCrawler ,
@@ -50,6 +51,8 @@ use socket_patch_core::vendor::go_mod_edit::{
5051} ;
5152use socket_patch_core:: vendor:: lock_inventory:: LockIntegrity ;
5253use socket_patch_core:: vex:: HostedCopies ;
54+ #[ cfg( test) ]
55+ use tests:: recording_store_variants as with_store_peer_variant_copies;
5356
5457use crate :: args:: GlobalArgs ;
5558use crate :: commands:: vex_sources:: HostedWiring ;
@@ -61,8 +64,9 @@ use crate::ecosystem_dispatch::{
6164/// module docs), under the same crawler options and `--ecosystems` scope as
6265/// the installed-tree lookup. `installed` is that lookup's every-copy
6366/// result ([`crate::ecosystem_dispatch::find_manifest_package_copies_reusing`] over
64- /// the record view, which holds every hosted purl): the shared-location
65- /// ecosystems read it instead of crawling the tree a second time. `prior`
67+ /// the record view, which holds every hosted purl), including npm store
68+ /// variants. The shared-location ecosystems read it instead of crawling
69+ /// the tree a second time. `prior`
6670/// (embedded hosted `scan --vex` only) is scan's npm crawl of the same
6771/// tree: the alias walk takes its `node_modules` roots and the identity
6872/// fallback its packages instead of walking the tree again.
@@ -107,9 +111,33 @@ pub(crate) async fn hosted_consumed_copies(
107111 let npm: Vec < & String > = shared. get ( & Ecosystem :: Npm ) . into_iter ( ) . flatten ( ) . collect ( ) ;
108112 for purl in shared. values ( ) . flatten ( ) {
109113 let mut paths = all. remove ( purl) . unwrap_or_default ( ) ;
110- paths. extend ( aliases. remove ( purl) . unwrap_or_default ( ) ) ;
111- if npm. contains ( & purl) {
114+ let extra = aliases. remove ( purl) . unwrap_or_default ( ) ;
115+ if npm. contains ( & purl) && installed. get ( purl) . is_some_and ( |p| !p. is_empty ( ) ) {
116+ // The installed lookup already expanded these copies.
117+ // Expanding its N variants again scans the store N times.
118+ // Only aliases are new; expand them before merging so a
119+ // different alias/store can still contribute more copies.
120+ if !extra. is_empty ( ) {
121+ let added = with_store_peer_variant_copies ( extra) . await ;
122+ let mut seen = std:: collections:: HashSet :: new ( ) ;
123+ for path in & paths {
124+ seen. insert ( tokio:: fs:: canonicalize ( path) . await . unwrap_or ( path. clone ( ) ) ) ;
125+ }
126+ for path in added {
127+ let canonical =
128+ tokio:: fs:: canonicalize ( & path) . await . unwrap_or ( path. clone ( ) ) ;
129+ if seen. insert ( canonical) {
130+ paths. push ( path) ;
131+ }
132+ }
133+ }
134+ } else if npm. contains ( & purl) {
135+ // No installed copies: the identity fallback and aliases
136+ // have not had their store variants enumerated yet.
137+ paths. extend ( extra) ;
112138 paths = with_store_peer_variant_copies ( paths) . await ;
139+ } else {
140+ paths. extend ( extra) ;
113141 }
114142 out. insert (
115143 purl. clone ( ) ,
@@ -576,6 +604,208 @@ async fn maven_copies(options: &CrawlerOptions, purl: &str, wiring: &HostedWirin
576604mod tests {
577605 use super :: * ;
578606
607+ tokio:: task_local! {
608+ // Observe real expansion work only in the regression's own task;
609+ // concurrent tests keep calling the production helper normally.
610+ static VARIANT_INPUTS : std:: cell:: RefCell <Vec <Vec <PathBuf >>>;
611+ }
612+
613+ pub ( super ) async fn recording_store_variants ( paths : Vec < PathBuf > ) -> Vec < PathBuf > {
614+ let _ = VARIANT_INPUTS . try_with ( |calls| calls. borrow_mut ( ) . push ( paths. clone ( ) ) ) ;
615+ socket_patch_core:: crawlers:: npm_crawler:: with_store_peer_variant_copies ( paths) . await
616+ }
617+
618+ #[ cfg( unix) ]
619+ async fn tracked_npm_hosted (
620+ common : & GlobalArgs ,
621+ installed : & HashMap < String , Vec < PathBuf > > ,
622+ ) -> ( Vec < PathBuf > , Vec < Vec < PathBuf > > ) {
623+ let purl = "pkg:npm/left-pad@1.3.0" . to_string ( ) ;
624+ let hosted = BTreeMap :: from ( [ (
625+ purl. clone ( ) ,
626+ HostedWiring {
627+ uuid : "11111111-1111-4111-8111-111111111111" . to_string ( ) ,
628+ refs : Vec :: new ( ) ,
629+ } ,
630+ ) ] ) ;
631+ VARIANT_INPUTS
632+ . scope ( std:: cell:: RefCell :: new ( Vec :: new ( ) ) , async {
633+ let mut found = hosted_consumed_copies ( common, & hosted, installed, None ) . await ;
634+ let paths = found. remove ( & purl) . unwrap ( ) . paths ;
635+ let calls = VARIANT_INPUTS . with ( |inputs| inputs. borrow ( ) . clone ( ) ) ;
636+ ( paths, calls)
637+ } )
638+ . await
639+ }
640+
641+ #[ cfg( unix) ]
642+ fn peer_copies ( store : & Path , count : usize ) -> Vec < PathBuf > {
643+ ( 0 ..count)
644+ . map ( |i| {
645+ let path = store. join ( format ! (
646+ "left-pad@1.3.0(peer@1.0.{i})/node_modules/left-pad"
647+ ) ) ;
648+ pkg ( & path, "left-pad" , "1.3.0" ) ;
649+ path
650+ } )
651+ . collect ( )
652+ }
653+
654+ #[ cfg( unix) ]
655+ #[ tokio:: test]
656+ async fn hosted_reuses_expanded_npm_copies_and_merges_alias_variants ( ) {
657+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
658+ let nm = tmp. path ( ) . canonicalize ( ) . unwrap ( ) . join ( "node_modules" ) ;
659+ let peers = peer_copies ( & nm. join ( ".pnpm" ) , 8 ) ;
660+ std:: os:: unix:: fs:: symlink ( & peers[ 0 ] , nm. join ( "left-pad" ) ) . unwrap ( ) ;
661+ let common = GlobalArgs {
662+ cwd : tmp. path ( ) . canonicalize ( ) . unwrap ( ) ,
663+ ecosystems : Some ( vec ! [ "npm" . to_string( ) ] ) ,
664+ ..GlobalArgs :: default ( )
665+ } ;
666+ let purl = "pkg:npm/left-pad@1.3.0" . to_string ( ) ;
667+ let installed = crate :: ecosystem_dispatch:: find_manifest_package_copies_reusing (
668+ std:: slice:: from_ref ( & purl) ,
669+ & common,
670+ true ,
671+ None ,
672+ )
673+ . await ;
674+ assert_eq ! ( installed[ & purl] . len( ) , peers. len( ) ) ;
675+ let ( paths, calls) = tracked_npm_hosted ( & common, & installed) . await ;
676+ assert_eq ! ( paths, installed[ & purl] ) ;
677+ assert ! (
678+ calls. is_empty( ) ,
679+ "already-expanded copies were rescanned: {calls:?}"
680+ ) ;
681+
682+ // A real alias is absent from the name-keyed installed set. Its
683+ // store variants overlap that set canonically, including the
684+ // importer link's physical copy; keep the alias once and preserve
685+ // the original importer-first path choices.
686+ let alias = nm. join ( "lp" ) ;
687+ pkg ( & alias, "left-pad" , "1.3.0" ) ;
688+ let ( paths, calls) = tracked_npm_hosted ( & common, & installed) . await ;
689+ assert_eq ! ( calls, vec![ vec![ alias. clone( ) ] ] ) ;
690+ let mut expected = installed[ & purl] . clone ( ) ;
691+ expected. push ( alias) ;
692+ assert_eq ! ( paths, expected) ;
693+
694+ // An alias beneath a real nested host can reach another store.
695+ // The installed root copy makes the name-keyed resolver skip
696+ // those peers, so alias expansion must still add them even when
697+ // installed copies are already present.
698+ let host = nm. join ( "host" ) ;
699+ pkg ( & host, "host" , "1.0.0" ) ;
700+ let host_nm = host. join ( "node_modules" ) ;
701+ let nested_peers = peer_copies ( & host_nm. join ( ".pnpm" ) , 2 ) ;
702+ let nested_alias = host_nm. join ( "lp" ) ;
703+ pkg ( & nested_alias, "left-pad" , "1.3.0" ) ;
704+ let installed_again = crate :: ecosystem_dispatch:: find_manifest_package_copies_reusing (
705+ std:: slice:: from_ref ( & purl) ,
706+ & common,
707+ true ,
708+ None ,
709+ )
710+ . await ;
711+ assert_eq ! ( installed_again, installed) ;
712+ let ( paths, calls) = tracked_npm_hosted ( & common, & installed_again) . await ;
713+ assert_eq ! ( calls. len( ) , 1 ) ;
714+ let mut inputs = calls[ 0 ] . clone ( ) ;
715+ inputs. sort ( ) ;
716+ let mut aliases = vec ! [ nm. join( "lp" ) , nested_alias. clone( ) ] ;
717+ aliases. sort ( ) ;
718+ assert_eq ! ( inputs, aliases) ;
719+ assert_eq ! ( & paths[ ..installed[ & purl] . len( ) ] , installed[ & purl] ) ;
720+ expected. push ( nested_alias) ;
721+ expected. extend ( nested_peers) ;
722+ let mut actual = paths. clone ( ) ;
723+ actual. sort ( ) ;
724+ expected. sort ( ) ;
725+ assert_eq ! ( actual, expected) ;
726+ assert_eq ! (
727+ paths
728+ . iter( )
729+ . map( |path| path. canonicalize( ) . unwrap( ) )
730+ . collect:: <std:: collections:: HashSet <_>>( )
731+ . len( ) ,
732+ paths. len( )
733+ ) ;
734+ }
735+
736+ #[ cfg( unix) ]
737+ #[ tokio:: test]
738+ async fn hosted_expands_alias_only_copies ( ) {
739+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
740+ let store = tmp
741+ . path ( )
742+ . canonicalize ( )
743+ . unwrap ( )
744+ . join ( "node_modules/.pnpm" ) ;
745+ let peers = peer_copies ( & store, 2 ) ;
746+ // Run within a store package whose nested dependency is an alias.
747+ // The sibling peer copies are outside its project-root search.
748+ let root = store. join ( "host@1.0.0/node_modules/host" ) ;
749+ let alias = root. join ( "node_modules/lp" ) ;
750+ pkg ( & alias, "left-pad" , "1.3.0" ) ;
751+ let common = GlobalArgs {
752+ cwd : root,
753+ ecosystems : Some ( vec ! [ "npm" . to_string( ) ] ) ,
754+ ..GlobalArgs :: default ( )
755+ } ;
756+ let purl = "pkg:npm/left-pad@1.3.0" . to_string ( ) ;
757+ let installed = crate :: ecosystem_dispatch:: find_manifest_package_copies_reusing (
758+ std:: slice:: from_ref ( & purl) ,
759+ & common,
760+ true ,
761+ None ,
762+ )
763+ . await ;
764+ assert ! ( installed. is_empty( ) , "{installed:?}" ) ;
765+ let ( mut paths, calls) = tracked_npm_hosted ( & common, & installed) . await ;
766+ assert_eq ! ( calls, vec![ vec![ alias. clone( ) ] ] ) ;
767+ let mut expected = peers;
768+ expected. push ( alias) ;
769+ paths. sort ( ) ;
770+ expected. sort ( ) ;
771+ assert_eq ! ( paths, expected) ;
772+ }
773+
774+ #[ cfg( unix) ]
775+ #[ tokio:: test]
776+ async fn hosted_expands_identity_fallback_with_empty_installed_entry ( ) {
777+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
778+ let peers = peer_copies (
779+ & tmp. path ( )
780+ . canonicalize ( )
781+ . unwrap ( )
782+ . join ( "external/node_modules/.pnpm" ) ,
783+ 2 ,
784+ ) ;
785+ let root = tmp. path ( ) . canonicalize ( ) . unwrap ( ) . join ( "project" ) ;
786+ let alias = root. join ( "node_modules/lp" ) ;
787+ std:: fs:: create_dir_all ( alias. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
788+ std:: os:: unix:: fs:: symlink ( & peers[ 0 ] , & alias) . unwrap ( ) ;
789+ let common = GlobalArgs {
790+ cwd : root,
791+ ecosystems : Some ( vec ! [ "npm" . to_string( ) ] ) ,
792+ ..GlobalArgs :: default ( )
793+ } ;
794+ let purl = "pkg:npm/left-pad@1.3.0" . to_string ( ) ;
795+ assert ! (
796+ npm_alias_copies( & common. crawler_options( ) , std:: slice:: from_ref( & purl) )
797+ . await
798+ . is_empty( )
799+ ) ;
800+ let installed = HashMap :: from ( [ ( purl, Vec :: new ( ) ) ] ) ;
801+ let ( mut paths, calls) = tracked_npm_hosted ( & common, & installed) . await ;
802+ assert_eq ! ( calls, vec![ vec![ alias. clone( ) ] ] ) ;
803+ let mut expected = vec ! [ alias, peers[ 1 ] . clone( ) ] ;
804+ paths. sort ( ) ;
805+ expected. sort ( ) ;
806+ assert_eq ! ( paths, expected) ;
807+ }
808+
579809 fn pkg ( dir : & Path , name : & str , version : & str ) {
580810 std:: fs:: create_dir_all ( dir) . unwrap ( ) ;
581811 std:: fs:: write (
0 commit comments