Skip to main content

socket_patch_cli/
ecosystem_dispatch.rs

1use socket_patch_core::crawlers::{
2    CrawledPackage, CrawlerOptions, Ecosystem, NpmCrawler, PythonCrawler,
3};
4use socket_patch_core::utils::purl::strip_purl_qualifiers;
5use std::collections::{HashMap, HashSet};
6use std::path::PathBuf;
7
8#[cfg(feature = "cargo")]
9use socket_patch_core::crawlers::CargoCrawler;
10use socket_patch_core::crawlers::RubyCrawler;
11#[cfg(feature = "golang")]
12use socket_patch_core::crawlers::GoCrawler;
13#[cfg(feature = "maven")]
14use socket_patch_core::crawlers::MavenCrawler;
15#[cfg(feature = "composer")]
16use socket_patch_core::crawlers::ComposerCrawler;
17#[cfg(feature = "nuget")]
18use socket_patch_core::crawlers::NuGetCrawler;
19#[cfg(feature = "deno")]
20use socket_patch_core::crawlers::DenoCrawler;
21
22/// Runtime opt-in gate for experimental Maven support.
23///
24/// Even when the binary is compiled with `--features maven`, the
25/// crawler does NOT run unless `SOCKET_EXPERIMENTAL_MAVEN=1` (or
26/// `=true`). Applying a Maven patch corrupts the jar sidecar
27/// checksums (`<jar>.jar.sha1`, `<jar>.jar.md5`) that the local
28/// Maven repository keeps next to each artifact, and there is no
29/// recovery — the user has to re-download the jar.
30#[cfg(feature = "maven")]
31fn maven_runtime_enabled() -> bool {
32    std::env::var("SOCKET_EXPERIMENTAL_MAVEN")
33        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
34        .unwrap_or(false)
35}
36
37/// One-line stderr warning for the "Maven patches present, but
38/// experimental gate is off" path.
39#[cfg(feature = "maven")]
40fn warn_maven_disabled(skipped: usize) {
41    eprintln!(
42        "Warning: {} Maven patch(es) skipped — Maven support is experimental.",
43        skipped
44    );
45    eprintln!("  Maven patches corrupt jar sidecar checksums (sha1/md5).");
46    eprintln!("  Set SOCKET_EXPERIMENTAL_MAVEN=1 to enable at your own risk.");
47}
48
49/// Runtime opt-in gate for experimental NuGet support.
50///
51/// Same shape as the Maven gate. Even with the sidecar fixup
52/// deleting `.nupkg.metadata`, signed packages still carry a
53/// `.nupkg.sha512` marker that NuGet treats as tamper-evidence
54/// at restore time. The fixup cannot honestly rewrite this
55/// without the original `.nupkg` (which we don't have post-
56/// extraction). Refuse to dispatch unless the operator has
57/// explicitly opted in to the experimental tier.
58#[cfg(feature = "nuget")]
59fn nuget_runtime_enabled() -> bool {
60    std::env::var("SOCKET_EXPERIMENTAL_NUGET")
61        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
62        .unwrap_or(false)
63}
64
65/// One-line stderr warning for the "NuGet patches present, but
66/// experimental gate is off" path.
67#[cfg(feature = "nuget")]
68fn warn_nuget_disabled(skipped: usize) {
69    eprintln!(
70        "Warning: {} NuGet patch(es) skipped — NuGet support is experimental.",
71        skipped
72    );
73    eprintln!("  NuGet patches corrupt the .nupkg.sha512 signature sidecar that");
74    eprintln!("  `dotnet restore` reads as tamper-evidence.");
75    eprintln!("  Set SOCKET_EXPERIMENTAL_NUGET=1 to enable at your own risk.");
76}
77
78/// Partition PURLs by ecosystem, filtering by the `--ecosystems` flag if set.
79pub fn partition_purls(
80    purls: &[String],
81    allowed_ecosystems: Option<&[String]>,
82) -> HashMap<Ecosystem, Vec<String>> {
83    let mut map: HashMap<Ecosystem, Vec<String>> = HashMap::new();
84
85    for purl in purls {
86        if let Some(eco) = Ecosystem::from_purl(purl) {
87            if let Some(allowed) = allowed_ecosystems {
88                if !allowed.iter().any(|a| a == eco.cli_name()) {
89                    continue;
90                }
91            }
92            map.entry(eco).or_default().push(purl.clone());
93        }
94    }
95
96    map
97}
98
99/// For each ecosystem in the partitioned map, create the crawler, discover
100/// source paths, and look up the given PURLs. Returns a unified
101/// `purl -> path` map.
102pub async fn find_packages_for_purls(
103    partitioned: &HashMap<Ecosystem, Vec<String>>,
104    options: &CrawlerOptions,
105    silent: bool,
106) -> HashMap<String, PathBuf> {
107    let mut all_packages: HashMap<String, PathBuf> = HashMap::new();
108
109    // npm
110    if let Some(npm_purls) = partitioned.get(&Ecosystem::Npm) {
111        if !npm_purls.is_empty() {
112            let npm_crawler = NpmCrawler;
113            match npm_crawler.get_node_modules_paths(options).await {
114                Ok(nm_paths) => {
115                    if (options.global || options.global_prefix.is_some()) && !silent {
116                        if let Some(first) = nm_paths.first() {
117                            println!("Using global npm packages at: {}", first.display());
118                        }
119                    }
120                    for nm_path in &nm_paths {
121                        match npm_crawler.find_by_purls(nm_path, npm_purls).await {
122                            Ok(packages) => {
123                                for (purl, pkg) in packages {
124                                    all_packages.entry(purl).or_insert(pkg.path);
125                                }
126                            }
127                            Err(e) => {
128                                if !silent {
129                                    eprintln!("Warning: Failed to scan {}: {}", nm_path.display(), e);
130                                }
131                            }
132                        }
133                    }
134                }
135                Err(e) => {
136                    if !silent {
137                        eprintln!("Failed to find npm packages: {e}");
138                    }
139                }
140            }
141        }
142    }
143
144    // pypi — deduplicate by base PURL (stripping qualifiers)
145    if let Some(pypi_purls) = partitioned.get(&Ecosystem::Pypi) {
146        if !pypi_purls.is_empty() {
147            let python_crawler = PythonCrawler;
148            let base_pypi_purls: Vec<String> = pypi_purls
149                .iter()
150                .map(|p| strip_purl_qualifiers(p).to_string())
151                .collect::<HashSet<_>>()
152                .into_iter()
153                .collect();
154
155            match python_crawler.get_site_packages_paths(options).await {
156                Ok(sp_paths) => {
157                    for sp_path in &sp_paths {
158                        match python_crawler.find_by_purls(sp_path, &base_pypi_purls).await {
159                            Ok(packages) => {
160                                for (purl, pkg) in packages {
161                                    all_packages.entry(purl).or_insert(pkg.path);
162                                }
163                            }
164                            Err(e) => {
165                                if !silent {
166                                    eprintln!("Warning: Failed to scan {}: {}", sp_path.display(), e);
167                                }
168                            }
169                        }
170                    }
171                }
172                Err(e) => {
173                    if !silent {
174                        eprintln!("Failed to find Python packages: {e}");
175                    }
176                }
177            }
178        }
179    }
180
181    // cargo
182    #[cfg(feature = "cargo")]
183    if let Some(cargo_purls) = partitioned.get(&Ecosystem::Cargo) {
184        if !cargo_purls.is_empty() {
185            let cargo_crawler = CargoCrawler;
186            match cargo_crawler.get_crate_source_paths(options).await {
187                Ok(src_paths) => {
188                    if (options.global || options.global_prefix.is_some()) && !silent {
189                        if let Some(first) = src_paths.first() {
190                            println!("Using cargo crate sources at: {}", first.display());
191                        }
192                    }
193                    for src_path in &src_paths {
194                        match cargo_crawler.find_by_purls(src_path, cargo_purls).await {
195                            Ok(packages) => {
196                                for (purl, pkg) in packages {
197                                    all_packages.entry(purl).or_insert(pkg.path);
198                                }
199                            }
200                            Err(e) => {
201                                if !silent {
202                                    eprintln!("Warning: Failed to scan {}: {}", src_path.display(), e);
203                                }
204                            }
205                        }
206                    }
207                }
208                Err(e) => {
209                    if !silent {
210                        eprintln!("Failed to find Cargo crates: {e}");
211                    }
212                }
213            }
214        }
215    }
216
217    // gem
218    if let Some(gem_purls) = partitioned.get(&Ecosystem::Gem) {
219        if !gem_purls.is_empty() {
220            let ruby_crawler = RubyCrawler;
221            match ruby_crawler.get_gem_paths(options).await {
222                Ok(gem_paths) => {
223                    if (options.global || options.global_prefix.is_some()) && !silent {
224                        if let Some(first) = gem_paths.first() {
225                            println!("Using ruby gem paths at: {}", first.display());
226                        }
227                    }
228                    for gem_path in &gem_paths {
229                        match ruby_crawler.find_by_purls(gem_path, gem_purls).await {
230                            Ok(packages) => {
231                                for (purl, pkg) in packages {
232                                    all_packages.entry(purl).or_insert(pkg.path);
233                                }
234                            }
235                            Err(e) => {
236                                if !silent {
237                                    eprintln!("Warning: Failed to scan {}: {}", gem_path.display(), e);
238                                }
239                            }
240                        }
241                    }
242                }
243                Err(e) => {
244                    if !silent {
245                        eprintln!("Failed to find Ruby gems: {e}");
246                    }
247                }
248            }
249        }
250    }
251
252    // golang
253    #[cfg(feature = "golang")]
254    if let Some(golang_purls) = partitioned.get(&Ecosystem::Golang) {
255        if !golang_purls.is_empty() {
256            let go_crawler = GoCrawler;
257            match go_crawler.get_module_cache_paths(options).await {
258                Ok(cache_paths) => {
259                    if (options.global || options.global_prefix.is_some()) && !silent {
260                        if let Some(first) = cache_paths.first() {
261                            println!("Using Go module cache at: {}", first.display());
262                        }
263                    }
264                    for cache_path in &cache_paths {
265                        match go_crawler.find_by_purls(cache_path, golang_purls).await {
266                            Ok(packages) => {
267                                for (purl, pkg) in packages {
268                                    all_packages.entry(purl).or_insert(pkg.path);
269                                }
270                            }
271                            Err(e) => {
272                                if !silent {
273                                    eprintln!("Warning: Failed to scan {}: {}", cache_path.display(), e);
274                                }
275                            }
276                        }
277                    }
278                }
279                Err(e) => {
280                    if !silent {
281                        eprintln!("Failed to find Go modules: {e}");
282                    }
283                }
284            }
285        }
286    }
287
288    // maven — experimental, double-gated. See `maven_runtime_enabled`.
289    #[cfg(feature = "maven")]
290    if let Some(maven_purls) = partitioned.get(&Ecosystem::Maven) {
291        if !maven_purls.is_empty() && !maven_runtime_enabled() {
292            if !silent {
293                warn_maven_disabled(maven_purls.len());
294            }
295        } else if !maven_purls.is_empty() {
296            let maven_crawler = MavenCrawler;
297            match maven_crawler.get_maven_repo_paths(options).await {
298                Ok(repo_paths) => {
299                    if (options.global || options.global_prefix.is_some()) && !silent {
300                        if let Some(first) = repo_paths.first() {
301                            println!("Using Maven repository at: {}", first.display());
302                        }
303                    }
304                    for repo_path in &repo_paths {
305                        match maven_crawler.find_by_purls(repo_path, maven_purls).await {
306                            Ok(packages) => {
307                                for (purl, pkg) in packages {
308                                    all_packages.entry(purl).or_insert(pkg.path);
309                                }
310                            }
311                            Err(e) => {
312                                if !silent {
313                                    eprintln!("Warning: Failed to scan {}: {}", repo_path.display(), e);
314                                }
315                            }
316                        }
317                    }
318                }
319                Err(e) => {
320                    if !silent {
321                        eprintln!("Failed to find Maven packages: {e}");
322                    }
323                }
324            }
325        }
326    }
327
328    // composer
329    #[cfg(feature = "composer")]
330    if let Some(composer_purls) = partitioned.get(&Ecosystem::Composer) {
331        if !composer_purls.is_empty() {
332            let composer_crawler = ComposerCrawler;
333            match composer_crawler.get_vendor_paths(options).await {
334                Ok(vendor_paths) => {
335                    if (options.global || options.global_prefix.is_some()) && !silent {
336                        if let Some(first) = vendor_paths.first() {
337                            println!("Using PHP vendor packages at: {}", first.display());
338                        }
339                    }
340                    for vendor_path in &vendor_paths {
341                        match composer_crawler.find_by_purls(vendor_path, composer_purls).await {
342                            Ok(packages) => {
343                                for (purl, pkg) in packages {
344                                    all_packages.entry(purl).or_insert(pkg.path);
345                                }
346                            }
347                            Err(e) => {
348                                if !silent {
349                                    eprintln!("Warning: Failed to scan {}: {}", vendor_path.display(), e);
350                                }
351                            }
352                        }
353                    }
354                }
355                Err(e) => {
356                    if !silent {
357                        eprintln!("Failed to find PHP packages: {e}");
358                    }
359                }
360            }
361        }
362    }
363
364    // nuget — experimental, double-gated. See `nuget_runtime_enabled`.
365    #[cfg(feature = "nuget")]
366    if let Some(nuget_purls) = partitioned.get(&Ecosystem::Nuget) {
367        if !nuget_purls.is_empty() && !nuget_runtime_enabled() {
368            if !silent {
369                warn_nuget_disabled(nuget_purls.len());
370            }
371        } else if !nuget_purls.is_empty() {
372            let nuget_crawler = NuGetCrawler;
373            match nuget_crawler.get_nuget_package_paths(options).await {
374                Ok(pkg_paths) => {
375                    if (options.global || options.global_prefix.is_some()) && !silent {
376                        if let Some(first) = pkg_paths.first() {
377                            println!("Using NuGet packages at: {}", first.display());
378                        }
379                    }
380                    for pkg_path in &pkg_paths {
381                        match nuget_crawler.find_by_purls(pkg_path, nuget_purls).await {
382                            Ok(packages) => {
383                                for (purl, pkg) in packages {
384                                    all_packages.entry(purl).or_insert(pkg.path);
385                                }
386                            }
387                            Err(e) => {
388                                if !silent {
389                                    eprintln!("Warning: Failed to scan {}: {}", pkg_path.display(), e);
390                                }
391                            }
392                        }
393                    }
394                }
395                Err(e) => {
396                    if !silent {
397                        eprintln!("Failed to find NuGet packages: {e}");
398                    }
399                }
400            }
401        }
402    }
403
404    // deno — JSR registry packages cached under DENO_DIR/npm/jsr.io/.
405    #[cfg(feature = "deno")]
406    if let Some(deno_purls) = partitioned.get(&Ecosystem::Deno) {
407        if !deno_purls.is_empty() {
408            let deno_crawler = DenoCrawler;
409            match deno_crawler.get_jsr_cache_paths(options).await {
410                Ok(cache_paths) => {
411                    if (options.global || options.global_prefix.is_some()) && !silent {
412                        if let Some(first) = cache_paths.first() {
413                            println!("Using Deno JSR cache at: {}", first.display());
414                        }
415                    }
416                    for cache_path in &cache_paths {
417                        match deno_crawler.find_by_purls(cache_path, deno_purls).await {
418                            Ok(packages) => {
419                                for (purl, pkg) in packages {
420                                    all_packages.entry(purl).or_insert(pkg.path);
421                                }
422                            }
423                            Err(e) => {
424                                if !silent {
425                                    eprintln!("Warning: Failed to scan {}: {}", cache_path.display(), e);
426                                }
427                            }
428                        }
429                    }
430                }
431                Err(e) => {
432                    if !silent {
433                        eprintln!("Failed to find Deno JSR packages: {e}");
434                    }
435                }
436            }
437        }
438    }
439
440    all_packages
441}
442
443/// Crawl all enabled ecosystems and return all packages plus per-ecosystem counts.
444pub async fn crawl_all_ecosystems(
445    options: &CrawlerOptions,
446) -> (Vec<CrawledPackage>, HashMap<Ecosystem, usize>) {
447    let mut all_packages = Vec::new();
448    let mut counts: HashMap<Ecosystem, usize> = HashMap::new();
449
450    let npm_crawler = NpmCrawler;
451    let npm_packages = npm_crawler.crawl_all(options).await;
452    counts.insert(Ecosystem::Npm, npm_packages.len());
453    all_packages.extend(npm_packages);
454
455    let python_crawler = PythonCrawler;
456    let python_packages = python_crawler.crawl_all(options).await;
457    counts.insert(Ecosystem::Pypi, python_packages.len());
458    all_packages.extend(python_packages);
459
460    #[cfg(feature = "cargo")]
461    {
462        let cargo_crawler = CargoCrawler;
463        let cargo_packages = cargo_crawler.crawl_all(options).await;
464        counts.insert(Ecosystem::Cargo, cargo_packages.len());
465        all_packages.extend(cargo_packages);
466    }
467
468    {
469        let ruby_crawler = RubyCrawler;
470        let gem_packages = ruby_crawler.crawl_all(options).await;
471        counts.insert(Ecosystem::Gem, gem_packages.len());
472        all_packages.extend(gem_packages);
473    }
474
475    #[cfg(feature = "golang")]
476    {
477        let go_crawler = GoCrawler;
478        let go_packages = go_crawler.crawl_all(options).await;
479        counts.insert(Ecosystem::Golang, go_packages.len());
480        all_packages.extend(go_packages);
481    }
482
483    #[cfg(feature = "maven")]
484    if maven_runtime_enabled() {
485        // Same runtime gate as `find_packages_for_purls` — `scan`
486        // walks the Maven repo only when the operator has explicitly
487        // opted into experimental support.
488        let maven_crawler = MavenCrawler;
489        let maven_packages = maven_crawler.crawl_all(options).await;
490        counts.insert(Ecosystem::Maven, maven_packages.len());
491        all_packages.extend(maven_packages);
492    }
493
494    #[cfg(feature = "composer")]
495    {
496        let composer_crawler = ComposerCrawler;
497        let composer_packages = composer_crawler.crawl_all(options).await;
498        counts.insert(Ecosystem::Composer, composer_packages.len());
499        all_packages.extend(composer_packages);
500    }
501
502    #[cfg(feature = "nuget")]
503    if nuget_runtime_enabled() {
504        // Same runtime gate as `find_packages_for_purls`.
505        let nuget_crawler = NuGetCrawler;
506        let nuget_packages = nuget_crawler.crawl_all(options).await;
507        counts.insert(Ecosystem::Nuget, nuget_packages.len());
508        all_packages.extend(nuget_packages);
509    }
510
511    #[cfg(feature = "deno")]
512    {
513        let deno_crawler = DenoCrawler;
514        let deno_packages = deno_crawler.crawl_all(options).await;
515        counts.insert(Ecosystem::Deno, deno_packages.len());
516        all_packages.extend(deno_packages);
517    }
518
519    (all_packages, counts)
520}
521
522/// Variant of `find_packages_for_purls` for rollback, which needs to remap
523/// pypi qualified PURLs (with `?artifact_id=...`) to the base PURL found
524/// by the crawler.
525pub async fn find_packages_for_rollback(
526    partitioned: &HashMap<Ecosystem, Vec<String>>,
527    options: &CrawlerOptions,
528    silent: bool,
529) -> HashMap<String, PathBuf> {
530    let mut all_packages: HashMap<String, PathBuf> = HashMap::new();
531
532    // npm
533    if let Some(npm_purls) = partitioned.get(&Ecosystem::Npm) {
534        if !npm_purls.is_empty() {
535            let npm_crawler = NpmCrawler;
536            match npm_crawler.get_node_modules_paths(options).await {
537                Ok(nm_paths) => {
538                    if (options.global || options.global_prefix.is_some()) && !silent {
539                        if let Some(first) = nm_paths.first() {
540                            println!("Using global npm packages at: {}", first.display());
541                        }
542                    }
543                    for nm_path in &nm_paths {
544                        match npm_crawler.find_by_purls(nm_path, npm_purls).await {
545                            Ok(packages) => {
546                                for (purl, pkg) in packages {
547                                    all_packages.entry(purl).or_insert(pkg.path);
548                                }
549                            }
550                            Err(e) => {
551                                if !silent {
552                                    eprintln!("Warning: Failed to scan {}: {}", nm_path.display(), e);
553                                }
554                            }
555                        }
556                    }
557                }
558                Err(e) => {
559                    if !silent {
560                        eprintln!("Failed to find npm packages: {e}");
561                    }
562                }
563            }
564        }
565    }
566
567    // pypi — remap qualified PURLs to found base PURLs
568    if let Some(pypi_purls) = partitioned.get(&Ecosystem::Pypi) {
569        if !pypi_purls.is_empty() {
570            let python_crawler = PythonCrawler;
571            let base_pypi_purls: Vec<String> = pypi_purls
572                .iter()
573                .map(|p| strip_purl_qualifiers(p).to_string())
574                .collect::<HashSet<_>>()
575                .into_iter()
576                .collect();
577
578            if let Ok(sp_paths) = python_crawler.get_site_packages_paths(options).await {
579                for sp_path in &sp_paths {
580                    match python_crawler.find_by_purls(sp_path, &base_pypi_purls).await {
581                        Ok(packages) => {
582                            for (base_purl, pkg) in packages {
583                                for qualified_purl in pypi_purls {
584                                    if strip_purl_qualifiers(qualified_purl) == base_purl
585                                        && !all_packages.contains_key(qualified_purl)
586                                    {
587                                        all_packages
588                                            .insert(qualified_purl.clone(), pkg.path.clone());
589                                    }
590                                }
591                            }
592                        }
593                        Err(e) => {
594                            if !silent {
595                                eprintln!("Warning: Failed to scan {}: {}", sp_path.display(), e);
596                            }
597                        }
598                    }
599                }
600            }
601        }
602    }
603
604    // cargo
605    #[cfg(feature = "cargo")]
606    if let Some(cargo_purls) = partitioned.get(&Ecosystem::Cargo) {
607        if !cargo_purls.is_empty() {
608            let cargo_crawler = CargoCrawler;
609            match cargo_crawler.get_crate_source_paths(options).await {
610                Ok(src_paths) => {
611                    if (options.global || options.global_prefix.is_some()) && !silent {
612                        if let Some(first) = src_paths.first() {
613                            println!("Using cargo crate sources at: {}", first.display());
614                        }
615                    }
616                    for src_path in &src_paths {
617                        match cargo_crawler.find_by_purls(src_path, cargo_purls).await {
618                            Ok(packages) => {
619                                for (purl, pkg) in packages {
620                                    all_packages.entry(purl).or_insert(pkg.path);
621                                }
622                            }
623                            Err(e) => {
624                                if !silent {
625                                    eprintln!("Warning: Failed to scan {}: {}", src_path.display(), e);
626                                }
627                            }
628                        }
629                    }
630                }
631                Err(e) => {
632                    if !silent {
633                        eprintln!("Failed to find Cargo crates: {e}");
634                    }
635                }
636            }
637        }
638    }
639
640    // gem
641    if let Some(gem_purls) = partitioned.get(&Ecosystem::Gem) {
642        if !gem_purls.is_empty() {
643            let ruby_crawler = RubyCrawler;
644            match ruby_crawler.get_gem_paths(options).await {
645                Ok(gem_paths) => {
646                    if (options.global || options.global_prefix.is_some()) && !silent {
647                        if let Some(first) = gem_paths.first() {
648                            println!("Using ruby gem paths at: {}", first.display());
649                        }
650                    }
651                    for gem_path in &gem_paths {
652                        match ruby_crawler.find_by_purls(gem_path, gem_purls).await {
653                            Ok(packages) => {
654                                for (purl, pkg) in packages {
655                                    all_packages.entry(purl).or_insert(pkg.path);
656                                }
657                            }
658                            Err(e) => {
659                                if !silent {
660                                    eprintln!("Warning: Failed to scan {}: {}", gem_path.display(), e);
661                                }
662                            }
663                        }
664                    }
665                }
666                Err(e) => {
667                    if !silent {
668                        eprintln!("Failed to find Ruby gems: {e}");
669                    }
670                }
671            }
672        }
673    }
674
675    // golang
676    #[cfg(feature = "golang")]
677    if let Some(golang_purls) = partitioned.get(&Ecosystem::Golang) {
678        if !golang_purls.is_empty() {
679            let go_crawler = GoCrawler;
680            match go_crawler.get_module_cache_paths(options).await {
681                Ok(cache_paths) => {
682                    if (options.global || options.global_prefix.is_some()) && !silent {
683                        if let Some(first) = cache_paths.first() {
684                            println!("Using Go module cache at: {}", first.display());
685                        }
686                    }
687                    for cache_path in &cache_paths {
688                        match go_crawler.find_by_purls(cache_path, golang_purls).await {
689                            Ok(packages) => {
690                                for (purl, pkg) in packages {
691                                    all_packages.entry(purl).or_insert(pkg.path);
692                                }
693                            }
694                            Err(e) => {
695                                if !silent {
696                                    eprintln!("Warning: Failed to scan {}: {}", cache_path.display(), e);
697                                }
698                            }
699                        }
700                    }
701                }
702                Err(e) => {
703                    if !silent {
704                        eprintln!("Failed to find Go modules: {e}");
705                    }
706                }
707            }
708        }
709    }
710
711    // maven — experimental, double-gated. See `maven_runtime_enabled`.
712    #[cfg(feature = "maven")]
713    if let Some(maven_purls) = partitioned.get(&Ecosystem::Maven) {
714        if !maven_purls.is_empty() && !maven_runtime_enabled() {
715            if !silent {
716                warn_maven_disabled(maven_purls.len());
717            }
718        } else if !maven_purls.is_empty() {
719            let maven_crawler = MavenCrawler;
720            match maven_crawler.get_maven_repo_paths(options).await {
721                Ok(repo_paths) => {
722                    if (options.global || options.global_prefix.is_some()) && !silent {
723                        if let Some(first) = repo_paths.first() {
724                            println!("Using Maven repository at: {}", first.display());
725                        }
726                    }
727                    for repo_path in &repo_paths {
728                        match maven_crawler.find_by_purls(repo_path, maven_purls).await {
729                            Ok(packages) => {
730                                for (purl, pkg) in packages {
731                                    all_packages.entry(purl).or_insert(pkg.path);
732                                }
733                            }
734                            Err(e) => {
735                                if !silent {
736                                    eprintln!("Warning: Failed to scan {}: {}", repo_path.display(), e);
737                                }
738                            }
739                        }
740                    }
741                }
742                Err(e) => {
743                    if !silent {
744                        eprintln!("Failed to find Maven packages: {e}");
745                    }
746                }
747            }
748        }
749    }
750
751    // composer
752    #[cfg(feature = "composer")]
753    if let Some(composer_purls) = partitioned.get(&Ecosystem::Composer) {
754        if !composer_purls.is_empty() {
755            let composer_crawler = ComposerCrawler;
756            match composer_crawler.get_vendor_paths(options).await {
757                Ok(vendor_paths) => {
758                    if (options.global || options.global_prefix.is_some()) && !silent {
759                        if let Some(first) = vendor_paths.first() {
760                            println!("Using PHP vendor packages at: {}", first.display());
761                        }
762                    }
763                    for vendor_path in &vendor_paths {
764                        match composer_crawler.find_by_purls(vendor_path, composer_purls).await {
765                            Ok(packages) => {
766                                for (purl, pkg) in packages {
767                                    all_packages.entry(purl).or_insert(pkg.path);
768                                }
769                            }
770                            Err(e) => {
771                                if !silent {
772                                    eprintln!("Warning: Failed to scan {}: {}", vendor_path.display(), e);
773                                }
774                            }
775                        }
776                    }
777                }
778                Err(e) => {
779                    if !silent {
780                        eprintln!("Failed to find PHP packages: {e}");
781                    }
782                }
783            }
784        }
785    }
786
787    // nuget — experimental, double-gated. See `nuget_runtime_enabled`.
788    #[cfg(feature = "nuget")]
789    if let Some(nuget_purls) = partitioned.get(&Ecosystem::Nuget) {
790        if !nuget_purls.is_empty() && !nuget_runtime_enabled() {
791            if !silent {
792                warn_nuget_disabled(nuget_purls.len());
793            }
794        } else if !nuget_purls.is_empty() {
795            let nuget_crawler = NuGetCrawler;
796            match nuget_crawler.get_nuget_package_paths(options).await {
797                Ok(pkg_paths) => {
798                    if (options.global || options.global_prefix.is_some()) && !silent {
799                        if let Some(first) = pkg_paths.first() {
800                            println!("Using NuGet packages at: {}", first.display());
801                        }
802                    }
803                    for pkg_path in &pkg_paths {
804                        match nuget_crawler.find_by_purls(pkg_path, nuget_purls).await {
805                            Ok(packages) => {
806                                for (purl, pkg) in packages {
807                                    all_packages.entry(purl).or_insert(pkg.path);
808                                }
809                            }
810                            Err(e) => {
811                                if !silent {
812                                    eprintln!("Warning: Failed to scan {}: {}", pkg_path.display(), e);
813                                }
814                            }
815                        }
816                    }
817                }
818                Err(e) => {
819                    if !silent {
820                        eprintln!("Failed to find NuGet packages: {e}");
821                    }
822                }
823            }
824        }
825    }
826
827    all_packages
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    #[test]
835    fn partition_purls_no_filter_single_npm() {
836        let purls = vec!["pkg:npm/foo@1.0".to_string()];
837        let map = partition_purls(&purls, None);
838        assert_eq!(map.len(), 1);
839        assert_eq!(
840            map.get(&Ecosystem::Npm),
841            Some(&vec!["pkg:npm/foo@1.0".to_string()])
842        );
843    }
844
845    #[test]
846    fn partition_purls_no_filter_mixed_ecosystems() {
847        let purls = vec![
848            "pkg:npm/foo@1.0".to_string(),
849            "pkg:pypi/bar@2.0".to_string(),
850            "pkg:cargo/baz@3.0".to_string(),
851        ];
852        let map = partition_purls(&purls, None);
853        assert_eq!(map.len(), 3);
854        assert_eq!(
855            map.get(&Ecosystem::Npm),
856            Some(&vec!["pkg:npm/foo@1.0".to_string()])
857        );
858        assert_eq!(
859            map.get(&Ecosystem::Pypi),
860            Some(&vec!["pkg:pypi/bar@2.0".to_string()])
861        );
862        #[cfg(feature = "cargo")]
863        assert_eq!(
864            map.get(&Ecosystem::Cargo),
865            Some(&vec!["pkg:cargo/baz@3.0".to_string()])
866        );
867    }
868
869    #[test]
870    fn partition_purls_no_filter_empty_input() {
871        let purls: Vec<String> = Vec::new();
872        let map = partition_purls(&purls, None);
873        assert!(map.is_empty());
874    }
875
876    #[test]
877    fn partition_purls_no_filter_duplicate_purls_preserved() {
878        let purls = vec![
879            "pkg:npm/foo@1.0".to_string(),
880            "pkg:npm/foo@1.0".to_string(),
881        ];
882        let map = partition_purls(&purls, None);
883        assert_eq!(map.len(), 1);
884        assert_eq!(
885            map.get(&Ecosystem::Npm),
886            Some(&vec![
887                "pkg:npm/foo@1.0".to_string(),
888                "pkg:npm/foo@1.0".to_string(),
889            ])
890        );
891    }
892
893    #[test]
894    fn partition_purls_no_filter_unknown_ecosystem_dropped() {
895        let purls = vec!["pkg:weirdo/x@1".to_string()];
896        let map = partition_purls(&purls, None);
897        assert!(map.is_empty());
898    }
899
900    #[test]
901    fn partition_purls_allow_list_excludes_one() {
902        let purls = vec![
903            "pkg:npm/foo@1.0".to_string(),
904            "pkg:pypi/bar@2.0".to_string(),
905        ];
906        let allowed = vec!["npm".to_string()];
907        let map = partition_purls(&purls, Some(allowed.as_slice()));
908        assert_eq!(map.len(), 1);
909        assert_eq!(
910            map.get(&Ecosystem::Npm),
911            Some(&vec!["pkg:npm/foo@1.0".to_string()])
912        );
913        assert!(!map.contains_key(&Ecosystem::Pypi));
914    }
915
916    #[test]
917    fn partition_purls_allow_list_matches_none() {
918        let purls = vec!["pkg:npm/foo@1.0".to_string()];
919        let allowed = vec!["pypi".to_string()];
920        let map = partition_purls(&purls, Some(allowed.as_slice()));
921        assert!(map.is_empty());
922    }
923
924    #[test]
925    fn partition_purls_allow_list_matches_all() {
926        let purls = vec![
927            "pkg:npm/foo@1.0".to_string(),
928            "pkg:pypi/bar@2.0".to_string(),
929        ];
930        let allowed = vec!["npm".to_string(), "pypi".to_string()];
931        let map = partition_purls(&purls, Some(allowed.as_slice()));
932        assert_eq!(map.len(), 2);
933        assert_eq!(
934            map.get(&Ecosystem::Npm),
935            Some(&vec!["pkg:npm/foo@1.0".to_string()])
936        );
937        assert_eq!(
938            map.get(&Ecosystem::Pypi),
939            Some(&vec!["pkg:pypi/bar@2.0".to_string()])
940        );
941    }
942
943    #[test]
944    fn partition_purls_empty_allow_list_matches_nothing() {
945        let purls = vec![
946            "pkg:npm/foo@1.0".to_string(),
947            "pkg:pypi/bar@2.0".to_string(),
948        ];
949        let allowed: Vec<String> = Vec::new();
950        let map = partition_purls(&purls, Some(allowed.as_slice()));
951        assert!(map.is_empty());
952    }
953}