@@ -132,48 +132,89 @@ fn crawled_from_purl(
132132 } )
133133}
134134
135+ /// What [`vendored_ledger_supplement`] adds to discovery, and what it
136+ /// deliberately left out.
137+ #[ derive( Debug , Default ) ]
138+ pub ( crate ) struct LedgerSupplement {
139+ /// Ledger packages to discover (decoded purls, sorted).
140+ pub ( crate ) packages : Vec < socket_patch_core:: crawlers:: types:: CrawledPackage > ,
141+ /// Ledger keys whose lock provably no longer resolves through their
142+ /// committed artifact (the dependency was upgraded or removed), so they
143+ /// are not discoverable packages. `scan --prune` reverts them. Sorted.
144+ pub ( crate ) unwired : Vec < String > ,
145+ }
146+
135147/// Vendored-ledger packages with no crawled counterpart: on a fresh clone
136148/// the committed artifact IS the dependency, so these stay discoverable
137149/// (updates[] detection, the table, and `scan --vendor` re-vendor/in-sync
138150/// runs all keep working before any install). They are NOT "lockfile-only"
139151/// — nothing needs installing; the artifact satisfies the lock. `state` is
140152/// the ledger `run` already loaded (`vendor::load_state`).
153+ ///
154+ /// That holds only while the lock still wires the artifact. An entry the
155+ /// lockfile in-use probe (the one the prune GC reverts by) answers
156+ /// `Some(false)` for is the dependency having left the lock — bumped or
157+ /// uninstalled — and is reported in [`LedgerSupplement::unwired`] instead:
158+ /// re-vendoring it would fail against a lock that no longer has it. `None`
159+ /// (no probe for the ecosystem, or no readable lock) keeps the entry.
141160pub ( crate ) async fn vendored_ledger_supplement (
142161 common : & GlobalArgs ,
143162 crawled : & [ socket_patch_core:: crawlers:: types:: CrawledPackage ] ,
144163 state : & std:: io:: Result < VendorState > ,
145- ) -> Vec < socket_patch_core:: crawlers:: types:: CrawledPackage > {
164+ ) -> LedgerSupplement {
165+ let mut out = LedgerSupplement :: default ( ) ;
146166 if common. is_global ( ) {
147- return Vec :: new ( ) ;
167+ return out ;
148168 }
149- let base_purls: Vec < String > = match state {
150- Ok ( state) => state
151- . entries
152- . values ( )
153- . map ( |entry| strip_purl_qualifiers ( & entry. base_purl ) . to_string ( ) )
154- . collect ( ) ,
155- // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
156- // recover the vendored set from the committed artifacts, or
157- // `scan --prune` (whose ledger exemption also degrades to empty)
158- // would delete still-vendored packages' manifest entries and blobs.
159- Err ( _) => vendored_purls_from_artifacts ( common) . await ,
160- } ;
169+ // `(ledger key, base purl, entry)`; the artifact fallback has no
170+ // entries to probe, so it never reports unwired keys.
171+ let candidates: Vec < ( String , String , Option < & socket_patch_core:: vendor:: VendorEntry > ) > =
172+ match state {
173+ Ok ( state) => state
174+ . entries
175+ . iter ( )
176+ . map ( |( key, entry) | {
177+ (
178+ key. clone ( ) ,
179+ strip_purl_qualifiers ( & entry. base_purl ) . to_string ( ) ,
180+ Some ( entry) ,
181+ )
182+ } )
183+ . collect ( ) ,
184+ // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
185+ // recover the vendored set from the committed artifacts, or
186+ // `scan --prune` (whose ledger exemption also degrades to empty)
187+ // would delete still-vendored packages' manifest entries and blobs.
188+ Err ( _) => vendored_purls_from_artifacts ( common)
189+ . await
190+ . into_iter ( )
191+ . map ( |base| ( base. clone ( ) , base, None ) )
192+ . collect ( ) ,
193+ } ;
161194 // Composer by release identity: a ledger `@3.0.2.0` is the crawled
162195 // `@3.0.2`, not a second package to supplement.
163196 let key = |p : & str | composer_purl_identity ( p) . unwrap_or_else ( || normalize_purl ( p) . into_owned ( ) ) ;
164197 let crawled_norm: HashSet < String > = crawled. iter ( ) . map ( |p| key ( & p. purl ) ) . collect ( ) ;
165198 let mut seen: HashSet < String > = HashSet :: new ( ) ;
166- let mut out = Vec :: new ( ) ;
167- for base in & base_purls {
199+ for ( ledger_key, base, entry) in & candidates {
168200 let norm = key ( base) ;
169- if crawled_norm. contains ( & norm) || ! seen. insert ( norm) {
201+ if crawled_norm. contains ( & norm) || seen. contains ( & norm) {
170202 continue ;
171203 }
204+ if let Some ( entry) = entry {
205+ if crate :: commands:: vendor:: dispatch_in_use_one ( entry, & common. cwd ) . await == Some ( false )
206+ {
207+ out. unwired . push ( ledger_key. clone ( ) ) ;
208+ continue ;
209+ }
210+ }
211+ seen. insert ( norm) ;
172212 if let Some ( pkg) = crawled_from_purl ( base, & common. cwd ) {
173- out. push ( pkg) ;
213+ out. packages . push ( pkg) ;
174214 }
175215 }
176- out. sort_by ( |a, b| a. purl . cmp ( & b. purl ) ) ;
216+ out. packages . sort_by ( |a, b| a. purl . cmp ( & b. purl ) ) ;
217+ out. unwired . sort ( ) ;
177218 out
178219}
179220
@@ -997,7 +1038,7 @@ mod tests {
9971038 ..GlobalArgs :: default ( )
9981039 } ;
9991040 let state = socket_patch_core:: vendor:: load_state ( root) . await ;
1000- vendored_ledger_supplement ( & args, crawled, & state) . await
1041+ vendored_ledger_supplement ( & args, crawled, & state) . await . packages
10011042 }
10021043
10031044 /// A ledger entry vendored as `@3.0.2.0` is the crawled composer
@@ -1023,20 +1064,126 @@ mod tests {
10231064 cwd : tmp. path ( ) . to_path_buf ( ) ,
10241065 ..GlobalArgs :: default ( )
10251066 } ;
1026- let out = vendored_ledger_supplement ( & args, & [ crawled] , & Ok ( state. clone ( ) ) ) . await ;
1067+ let out = vendored_ledger_supplement ( & args, & [ crawled] , & Ok ( state. clone ( ) ) )
1068+ . await
1069+ . packages ;
10271070 assert ! (
10281071 out. is_empty( ) ,
10291072 "{:?}" ,
10301073 out. iter( ) . map( |p| & p. purl) . collect:: <Vec <_>>( )
10311074 ) ;
10321075
1033- let out = vendored_ledger_supplement ( & args, & [ ] , & Ok ( state) ) . await ;
1076+ let out = vendored_ledger_supplement ( & args, & [ ] , & Ok ( state) ) . await . packages ;
10341077 assert_eq ! (
10351078 out. iter( ) . map( |p| p. purl. as_str( ) ) . collect:: <Vec <_>>( ) ,
10361079 vec![ "pkg:composer/psr/log@3.0.2.0" ]
10371080 ) ;
10381081 }
10391082
1083+ /// An npm (package-lock flavor) ledger entry for `left-pad@1.3.0`
1084+ /// vendored under [`VENDORED_UUID`], with `lock` as the project's
1085+ /// package-lock.json (`None`: no lock at all).
1086+ async fn npm_ledger_with_lock (
1087+ root : & std:: path:: Path ,
1088+ lock : Option < & str > ,
1089+ ) -> std:: io:: Result < VendorState > {
1090+ let mut state = VendorState :: new ( ) ;
1091+ let entry: socket_patch_core:: vendor:: VendorEntry =
1092+ serde_json:: from_value ( serde_json:: json!( {
1093+ "ecosystem" : "npm" ,
1094+ "basePurl" : "pkg:npm/left-pad@1.3.0" ,
1095+ "uuid" : VENDORED_UUID ,
1096+ "artifact" : { "path" : format!( ".socket/vendor/npm/{VENDORED_UUID}/left-pad-1.3.0/node_modules/left-pad" ) , "sha256" : "" } ,
1097+ "wiring" : [ ] ,
1098+ } ) )
1099+ . unwrap ( ) ;
1100+ state
1101+ . entries
1102+ . insert ( "pkg:npm/left-pad@1.3.0" . to_string ( ) , entry) ;
1103+ if let Some ( lock) = lock {
1104+ std:: fs:: write ( root. join ( "package-lock.json" ) , lock) . unwrap ( ) ;
1105+ }
1106+ Ok ( state)
1107+ }
1108+
1109+ fn npm_lock_resolving ( left_pad : & str ) -> String {
1110+ serde_json:: json!( {
1111+ "name" : "app" ,
1112+ "lockfileVersion" : 3 ,
1113+ "packages" : {
1114+ "" : { "name" : "app" , "dependencies" : { "left-pad" : "*" } } ,
1115+ "node_modules/left-pad" : { "version" : "1.3.0" , "resolved" : left_pad} ,
1116+ }
1117+ } )
1118+ . to_string ( )
1119+ }
1120+
1121+ /// #541: once the dependency left the lock (bumped to another release,
1122+ /// or uninstalled), the ledger entry is no longer a discoverable
1123+ /// package: supplementing it made the vendor step re-vendor a package
1124+ /// the lock no longer has and fail the whole scan.
1125+ #[ tokio:: test]
1126+ async fn ledger_supplement_skips_entries_the_lock_no_longer_wires ( ) {
1127+ let args = |root : & std:: path:: Path | GlobalArgs {
1128+ cwd : root. to_path_buf ( ) ,
1129+ ..GlobalArgs :: default ( )
1130+ } ;
1131+ // Bumped: the lock resolves left-pad from the registry again.
1132+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
1133+ let bumped = serde_json:: json!( {
1134+ "name" : "app" ,
1135+ "lockfileVersion" : 3 ,
1136+ "packages" : {
1137+ "" : { "name" : "app" , "dependencies" : { "left-pad" : "1.2.0" } } ,
1138+ "node_modules/left-pad" : {
1139+ "version" : "1.2.0" ,
1140+ "resolved" : "https://registry.npmjs.org/left-pad/-/left-pad-1.2.0.tgz" ,
1141+ } ,
1142+ }
1143+ } )
1144+ . to_string ( ) ;
1145+ let state = npm_ledger_with_lock ( tmp. path ( ) , Some ( & bumped) ) . await ;
1146+ let out = vendored_ledger_supplement ( & args ( tmp. path ( ) ) , & [ ] , & state) . await ;
1147+ assert ! ( out. packages. is_empty( ) , "{:?}" , out. packages) ;
1148+ assert_eq ! ( out. unwired, vec![ "pkg:npm/left-pad@1.3.0" . to_string( ) ] ) ;
1149+
1150+ // Uninstalled: the lock has no left-pad at all.
1151+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
1152+ let removed = r#"{"name":"app","lockfileVersion":3,"packages":{"":{"name":"app"}}}"# ;
1153+ let state = npm_ledger_with_lock ( tmp. path ( ) , Some ( removed) ) . await ;
1154+ let out = vendored_ledger_supplement ( & args ( tmp. path ( ) ) , & [ ] , & state) . await ;
1155+ assert ! ( out. packages. is_empty( ) , "{:?}" , out. packages) ;
1156+ assert_eq ! ( out. unwired, vec![ "pkg:npm/left-pad@1.3.0" . to_string( ) ] ) ;
1157+ }
1158+
1159+ /// The fresh-clone case the supplement exists for: the lock still
1160+ /// resolves through the committed artifact, so the entry stays
1161+ /// discoverable. With no lock at all, nothing proves the entry unused,
1162+ /// so it is kept (fail-safe, like the prune GC).
1163+ #[ tokio:: test]
1164+ async fn ledger_supplement_keeps_wired_and_undecidable_entries ( ) {
1165+ for lock in [
1166+ Some ( npm_lock_resolving ( & format ! (
1167+ "file:.socket/vendor/npm/{VENDORED_UUID}/left-pad-1.3.0/node_modules/left-pad"
1168+ ) ) ) ,
1169+ None ,
1170+ ] {
1171+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
1172+ let args = GlobalArgs {
1173+ cwd : tmp. path ( ) . to_path_buf ( ) ,
1174+ ..GlobalArgs :: default ( )
1175+ } ;
1176+ let state = npm_ledger_with_lock ( tmp. path ( ) , lock. as_deref ( ) ) . await ;
1177+ let out = vendored_ledger_supplement ( & args, & [ ] , & state) . await ;
1178+ assert_eq ! (
1179+ out. packages. iter( ) . map( |p| p. purl. as_str( ) ) . collect:: <Vec <_>>( ) ,
1180+ vec![ "pkg:npm/left-pad@1.3.0" ] ,
1181+ "lock={lock:?}"
1182+ ) ;
1183+ assert ! ( out. unwired. is_empty( ) , "lock={lock:?}: {:?}" , out. unwired) ;
1184+ }
1185+ }
1186+
10401187 #[ tokio:: test]
10411188 async fn corrupt_ledger_recovers_vendored_purls_from_committed_artifacts ( ) {
10421189 let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
0 commit comments