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    env_truthy("SOCKET_EXPERIMENTAL_MAVEN")
33}
34
35#[cfg(feature = "maven")]
36fn warn_maven_disabled(skipped: usize) {
37    eprintln!(
38        "Warning: {} Maven patch(es) skipped — Maven support is experimental.",
39        skipped
40    );
41    eprintln!("  Maven patches corrupt jar sidecar checksums (sha1/md5).");
42    eprintln!("  Set SOCKET_EXPERIMENTAL_MAVEN=1 to enable at your own risk.");
43}
44
45/// Runtime opt-in gate for experimental NuGet support. Same shape as
46/// the Maven gate. Even with the sidecar fixup deleting
47/// `.nupkg.metadata`, signed packages still carry a `.nupkg.sha512`
48/// marker that NuGet treats as tamper-evidence at restore time. The
49/// fixup cannot honestly rewrite this without the original `.nupkg`
50/// (which we don't have post-extraction). Refuse to dispatch unless
51/// the operator has explicitly opted in to the experimental tier.
52#[cfg(feature = "nuget")]
53fn nuget_runtime_enabled() -> bool {
54    env_truthy("SOCKET_EXPERIMENTAL_NUGET")
55}
56
57#[cfg(feature = "nuget")]
58fn warn_nuget_disabled(skipped: usize) {
59    eprintln!(
60        "Warning: {} NuGet patch(es) skipped — NuGet support is experimental.",
61        skipped
62    );
63    eprintln!("  NuGet patches corrupt the .nupkg.sha512 signature sidecar that");
64    eprintln!("  `dotnet restore` reads as tamper-evidence.");
65    eprintln!("  Set SOCKET_EXPERIMENTAL_NUGET=1 to enable at your own risk.");
66}
67
68#[cfg(any(feature = "maven", feature = "nuget"))]
69fn env_truthy(name: &str) -> bool {
70    std::env::var(name)
71        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
72        .unwrap_or(false)
73}
74
75/// Partition PURLs by ecosystem, filtering by the `--ecosystems` flag if set.
76pub fn partition_purls(
77    purls: &[String],
78    allowed_ecosystems: Option<&[String]>,
79) -> HashMap<Ecosystem, Vec<String>> {
80    let mut map: HashMap<Ecosystem, Vec<String>> = HashMap::new();
81    for purl in purls {
82        if let Some(eco) = Ecosystem::from_purl(purl) {
83            if let Some(allowed) = allowed_ecosystems {
84                if !allowed.iter().any(|a| a == eco.cli_name()) {
85                    continue;
86                }
87            }
88            map.entry(eco).or_default().push(purl.clone());
89        }
90    }
91    map
92}
93
94/// Standard scan-one-ecosystem pattern: discover source paths, run
95/// `find_by_purls` on each, and merge results into `$out` keyed by PURL
96/// (first wins). Used by every ecosystem except pypi (which dedups
97/// PURLs and, on rollback, remaps base PURLs back to qualified ones).
98///
99/// `$using_label` is the noun in "Using <X> at: <path>" for global
100/// scans; pass `""` to suppress that line.
101macro_rules! scan_ecosystem {
102    (
103        out = $out:ident,
104        partitioned = $partitioned:expr,
105        eco = $eco:expr,
106        options = $options:expr,
107        silent = $silent:expr,
108        crawler = $crawler:expr,
109        get_paths = $get_paths:ident,
110        using_label = $using_label:expr,
111        err_label = $err_label:expr,
112        purls_override = $purls_override:expr,
113        on_match = $on_match:expr $(,)?
114    ) => {{
115        if let Some(purls) = $partitioned.get(&$eco) {
116            if !purls.is_empty() {
117                let crawler = $crawler;
118                let purls_to_use: Vec<String> = $purls_override(purls);
119                match crawler.$get_paths($options).await {
120                    Ok(paths) => {
121                        let using: &str = $using_label;
122                        if !using.is_empty()
123                            && ($options.global || $options.global_prefix.is_some())
124                            && !$silent
125                        {
126                            if let Some(first) = paths.first() {
127                                println!("Using {} at: {}", using, first.display());
128                            }
129                        }
130                        for path in &paths {
131                            match crawler.find_by_purls(path, &purls_to_use).await {
132                                Ok(packages) => {
133                                    $on_match(&mut $out, purls, packages);
134                                }
135                                Err(e) => {
136                                    if !$silent {
137                                        eprintln!(
138                                            "Warning: Failed to scan {}: {}",
139                                            path.display(),
140                                            e
141                                        );
142                                    }
143                                }
144                            }
145                        }
146                    }
147                    Err(e) => {
148                        if !$silent {
149                            eprintln!("Failed to find {}: {}", $err_label, e);
150                        }
151                    }
152                }
153            }
154        }
155    }};
156}
157
158/// Signature shared by `merge_first_wins` and `merge_qualified`.
159/// `dispatch_find` swaps between them so the rollback path can fan one
160/// crawler result back out to every caller-supplied qualified PURL.
161type MergeFn =
162    fn(&mut HashMap<String, PathBuf>, &[String], HashMap<String, CrawledPackage>);
163
164/// Default merge: insert the crawler-returned PURL → first wins.
165fn merge_first_wins(
166    out: &mut HashMap<String, PathBuf>,
167    _purls: &[String],
168    packages: HashMap<String, socket_patch_core::crawlers::CrawledPackage>,
169) {
170    for (purl, pkg) in packages {
171        out.entry(purl).or_insert(pkg.path);
172    }
173}
174
175/// Release-variant merge: the crawler is queried with base PURLs (no
176/// `?qualifiers`); fan the resulting paths back out to every qualified
177/// caller-supplied PURL that strips to the same base. Used for the
178/// release-variant ecosystems (PyPI / RubyGems / Maven) so a single
179/// installed package directory is mapped to every manifest variant for
180/// later hash-based selection.
181fn merge_qualified(
182    out: &mut HashMap<String, PathBuf>,
183    purls: &[String],
184    packages: HashMap<String, socket_patch_core::crawlers::CrawledPackage>,
185) {
186    for (base_purl, pkg) in packages {
187        for qualified in purls {
188            if strip_purl_qualifiers(qualified) == base_purl
189                && !out.contains_key(qualified)
190            {
191                out.insert(qualified.clone(), pkg.path.clone());
192            }
193        }
194    }
195}
196
197/// Strip qualifiers and dedupe — the crawler only needs the base PURL of
198/// a release-variant ecosystem; the variant is resolved later by hashing
199/// the installed files.
200fn dedup_qualified_purls(purls: &[String]) -> Vec<String> {
201    purls
202        .iter()
203        .map(|p| strip_purl_qualifiers(p).to_string())
204        .collect::<HashSet<_>>()
205        .into_iter()
206        .collect()
207}
208
209fn passthrough_purls(purls: &[String]) -> Vec<String> {
210    purls.to_vec()
211}
212
213/// Drive every enabled ecosystem's find-by-purls path, accumulating
214/// into one `purl -> path` map.
215///
216/// `variant_merge` lets the rollback variant fan a single crawler result
217/// out to every caller-supplied qualified PURL; everything else just
218/// inserts the crawler-returned PURL with first-wins semantics. It is
219/// applied to the release-variant ecosystems (PyPI / RubyGems / Maven),
220/// which are also queried with deduped base PURLs.
221async fn dispatch_find(
222    partitioned: &HashMap<Ecosystem, Vec<String>>,
223    options: &CrawlerOptions,
224    silent: bool,
225    variant_merge: MergeFn,
226) -> HashMap<String, PathBuf> {
227    let mut out: HashMap<String, PathBuf> = HashMap::new();
228
229    scan_ecosystem!(
230        out = out,
231        partitioned = partitioned,
232        eco = Ecosystem::Npm,
233        options = options,
234        silent = silent,
235        crawler = NpmCrawler,
236        get_paths = get_node_modules_paths,
237        using_label = "global npm packages",
238        err_label = "npm packages",
239        purls_override = passthrough_purls,
240        on_match = merge_first_wins,
241    );
242
243    scan_ecosystem!(
244        out = out,
245        partitioned = partitioned,
246        eco = Ecosystem::Pypi,
247        options = options,
248        silent = silent,
249        crawler = PythonCrawler,
250        get_paths = get_site_packages_paths,
251        using_label = "",
252        err_label = "Python packages",
253        purls_override = dedup_qualified_purls,
254        on_match = variant_merge,
255    );
256
257    #[cfg(feature = "cargo")]
258    scan_ecosystem!(
259        out = out,
260        partitioned = partitioned,
261        eco = Ecosystem::Cargo,
262        options = options,
263        silent = silent,
264        crawler = CargoCrawler,
265        get_paths = get_crate_source_paths,
266        using_label = "cargo crate sources",
267        err_label = "Cargo crates",
268        purls_override = passthrough_purls,
269        on_match = merge_first_wins,
270    );
271
272    scan_ecosystem!(
273        out = out,
274        partitioned = partitioned,
275        eco = Ecosystem::Gem,
276        options = options,
277        silent = silent,
278        crawler = RubyCrawler,
279        get_paths = get_gem_paths,
280        using_label = "ruby gem paths",
281        err_label = "Ruby gems",
282        // RubyGems has per-platform release variants (`?platform=`); the
283        // crawler emits the base PURL and the platform is resolved by
284        // hashing the installed files, same as PyPI.
285        purls_override = dedup_qualified_purls,
286        on_match = variant_merge,
287    );
288
289    #[cfg(feature = "golang")]
290    scan_ecosystem!(
291        out = out,
292        partitioned = partitioned,
293        eco = Ecosystem::Golang,
294        options = options,
295        silent = silent,
296        crawler = GoCrawler,
297        get_paths = get_module_cache_paths,
298        using_label = "Go module cache",
299        err_label = "Go modules",
300        purls_override = passthrough_purls,
301        on_match = merge_first_wins,
302    );
303
304    #[cfg(feature = "maven")]
305    if let Some(maven_purls) = partitioned.get(&Ecosystem::Maven) {
306        if !maven_purls.is_empty() && !maven_runtime_enabled() {
307            if !silent {
308                warn_maven_disabled(maven_purls.len());
309            }
310        } else {
311            scan_ecosystem!(
312                out = out,
313                partitioned = partitioned,
314                eco = Ecosystem::Maven,
315                options = options,
316                silent = silent,
317                crawler = MavenCrawler,
318                get_paths = get_maven_repo_paths,
319                using_label = "Maven repository",
320                err_label = "Maven packages",
321                // Maven has per-classifier release variants
322                // (`?classifier=&ext=`) that coexist as distinct jars in
323                // one version dir; the crawler emits the base PURL and
324                // each variant is resolved by hashing its jar file.
325                purls_override = dedup_qualified_purls,
326                on_match = variant_merge,
327            );
328        }
329    }
330
331    #[cfg(feature = "composer")]
332    scan_ecosystem!(
333        out = out,
334        partitioned = partitioned,
335        eco = Ecosystem::Composer,
336        options = options,
337        silent = silent,
338        crawler = ComposerCrawler,
339        get_paths = get_vendor_paths,
340        using_label = "PHP vendor packages",
341        err_label = "PHP packages",
342        purls_override = passthrough_purls,
343        on_match = merge_first_wins,
344    );
345
346    #[cfg(feature = "nuget")]
347    if let Some(nuget_purls) = partitioned.get(&Ecosystem::Nuget) {
348        if !nuget_purls.is_empty() && !nuget_runtime_enabled() {
349            if !silent {
350                warn_nuget_disabled(nuget_purls.len());
351            }
352        } else {
353            scan_ecosystem!(
354                out = out,
355                partitioned = partitioned,
356                eco = Ecosystem::Nuget,
357                options = options,
358                silent = silent,
359                crawler = NuGetCrawler,
360                get_paths = get_nuget_package_paths,
361                using_label = "NuGet packages",
362                err_label = "NuGet packages",
363                purls_override = passthrough_purls,
364                on_match = merge_first_wins,
365            );
366        }
367    }
368
369    #[cfg(feature = "deno")]
370    scan_ecosystem!(
371        out = out,
372        partitioned = partitioned,
373        eco = Ecosystem::Deno,
374        options = options,
375        silent = silent,
376        crawler = DenoCrawler,
377        get_paths = get_jsr_cache_paths,
378        using_label = "Deno JSR cache",
379        err_label = "Deno JSR packages",
380        purls_override = passthrough_purls,
381        on_match = merge_first_wins,
382    );
383
384    out
385}
386
387/// For each ecosystem in the partitioned map, create the crawler, discover
388/// source paths, and look up the given PURLs. Returns a unified
389/// `purl -> path` map.
390pub async fn find_packages_for_purls(
391    partitioned: &HashMap<Ecosystem, Vec<String>>,
392    options: &CrawlerOptions,
393    silent: bool,
394) -> HashMap<String, PathBuf> {
395    dispatch_find(partitioned, options, silent, merge_first_wins).await
396}
397
398/// Variant of `find_packages_for_purls` for rollback and narrow-release
399/// resolution, which needs to remap qualified PURLs (PyPI
400/// `?artifact_id=`, RubyGems `?platform=`, Maven `?classifier=&ext=`) to
401/// the base PURL found by the crawler.
402pub async fn find_packages_for_rollback(
403    partitioned: &HashMap<Ecosystem, Vec<String>>,
404    options: &CrawlerOptions,
405    silent: bool,
406) -> HashMap<String, PathBuf> {
407    dispatch_find(partitioned, options, silent, merge_qualified).await
408}
409
410/// Crawl all enabled ecosystems and return all packages plus per-ecosystem counts.
411pub async fn crawl_all_ecosystems(
412    options: &CrawlerOptions,
413) -> (Vec<CrawledPackage>, HashMap<Ecosystem, usize>) {
414    let mut all_packages = Vec::new();
415    let mut counts: HashMap<Ecosystem, usize> = HashMap::new();
416
417    macro_rules! crawl {
418        ($eco:expr, $crawler:expr) => {{
419            let pkgs = $crawler.crawl_all(options).await;
420            counts.insert($eco, pkgs.len());
421            all_packages.extend(pkgs);
422        }};
423    }
424
425    crawl!(Ecosystem::Npm, NpmCrawler);
426    crawl!(Ecosystem::Pypi, PythonCrawler);
427    #[cfg(feature = "cargo")]
428    crawl!(Ecosystem::Cargo, CargoCrawler);
429    crawl!(Ecosystem::Gem, RubyCrawler);
430    #[cfg(feature = "golang")]
431    crawl!(Ecosystem::Golang, GoCrawler);
432    #[cfg(feature = "maven")]
433    if maven_runtime_enabled() {
434        // Same runtime gate as `find_packages_for_purls` — `scan`
435        // walks the Maven repo only when the operator has explicitly
436        // opted into experimental support.
437        crawl!(Ecosystem::Maven, MavenCrawler);
438    }
439    #[cfg(feature = "composer")]
440    crawl!(Ecosystem::Composer, ComposerCrawler);
441    #[cfg(feature = "nuget")]
442    if nuget_runtime_enabled() {
443        crawl!(Ecosystem::Nuget, NuGetCrawler);
444    }
445    #[cfg(feature = "deno")]
446    crawl!(Ecosystem::Deno, DenoCrawler);
447
448    (all_packages, counts)
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    /// Build a `CrawledPackage` keyed by `purl` whose `path` encodes the
456    /// supplied directory, for exercising the merge helpers in isolation.
457    fn pkg(purl: &str, path: &str) -> CrawledPackage {
458        CrawledPackage {
459            name: "n".to_string(),
460            version: "v".to_string(),
461            namespace: None,
462            purl: purl.to_string(),
463            path: PathBuf::from(path),
464        }
465    }
466
467    fn packages(entries: &[(&str, &str)]) -> HashMap<String, CrawledPackage> {
468        entries
469            .iter()
470            .map(|(purl, path)| (purl.to_string(), pkg(purl, path)))
471            .collect()
472    }
473
474    // ---- merge_first_wins -------------------------------------------------
475
476    #[test]
477    fn merge_first_wins_inserts_crawler_keyed_purls() {
478        let mut out: HashMap<String, PathBuf> = HashMap::new();
479        merge_first_wins(
480            &mut out,
481            &[],
482            packages(&[("pkg:npm/foo@1.0", "/a"), ("pkg:npm/bar@2.0", "/b")]),
483        );
484        assert_eq!(out.len(), 2);
485        assert_eq!(out.get("pkg:npm/foo@1.0"), Some(&PathBuf::from("/a")));
486        assert_eq!(out.get("pkg:npm/bar@2.0"), Some(&PathBuf::from("/b")));
487    }
488
489    #[test]
490    fn merge_first_wins_keeps_first_path_across_calls() {
491        // Simulates the macro calling on_match once per discovered path:
492        // the first path that yields a given PURL wins.
493        let mut out: HashMap<String, PathBuf> = HashMap::new();
494        merge_first_wins(&mut out, &[], packages(&[("pkg:npm/foo@1.0", "/first")]));
495        merge_first_wins(&mut out, &[], packages(&[("pkg:npm/foo@1.0", "/second")]));
496        assert_eq!(out.get("pkg:npm/foo@1.0"), Some(&PathBuf::from("/first")));
497    }
498
499    #[test]
500    fn merge_first_wins_ignores_purls_arg() {
501        // The `purls` slice must not influence first-wins merging — only
502        // the crawler-returned keys matter.
503        let mut out: HashMap<String, PathBuf> = HashMap::new();
504        let unrelated = vec!["pkg:npm/unrelated@9.9".to_string()];
505        merge_first_wins(&mut out, &unrelated, packages(&[("pkg:npm/foo@1.0", "/a")]));
506        assert_eq!(out.len(), 1);
507        assert!(out.contains_key("pkg:npm/foo@1.0"));
508    }
509
510    // ---- merge_qualified --------------------------------------------------
511
512    #[test]
513    fn merge_qualified_fans_base_out_to_every_variant() {
514        // Crawler is queried with the base PURL and returns it keyed to a
515        // single install dir; every caller-supplied qualified variant that
516        // strips to that base must map to the same path.
517        let mut out: HashMap<String, PathBuf> = HashMap::new();
518        let qualified = vec![
519            "pkg:pypi/requests@2.28.0?artifact_id=wheel".to_string(),
520            "pkg:pypi/requests@2.28.0?artifact_id=sdist".to_string(),
521        ];
522        merge_qualified(
523            &mut out,
524            &qualified,
525            packages(&[("pkg:pypi/requests@2.28.0", "/site-packages")]),
526        );
527        assert_eq!(out.len(), 2);
528        assert_eq!(
529            out.get("pkg:pypi/requests@2.28.0?artifact_id=wheel"),
530            Some(&PathBuf::from("/site-packages"))
531        );
532        assert_eq!(
533            out.get("pkg:pypi/requests@2.28.0?artifact_id=sdist"),
534            Some(&PathBuf::from("/site-packages"))
535        );
536    }
537
538    #[test]
539    fn merge_qualified_matches_bare_base_identifier() {
540        // A caller may supply the bare base PURL (no `?`); it strips to
541        // itself and must still map to the crawler result.
542        let mut out: HashMap<String, PathBuf> = HashMap::new();
543        let purls = vec!["pkg:pypi/requests@2.28.0".to_string()];
544        merge_qualified(
545            &mut out,
546            &purls,
547            packages(&[("pkg:pypi/requests@2.28.0", "/sp")]),
548        );
549        assert_eq!(out.get("pkg:pypi/requests@2.28.0"), Some(&PathBuf::from("/sp")));
550    }
551
552    #[test]
553    fn merge_qualified_does_not_cross_versions() {
554        // A variant of a *different* version must not be mapped to the
555        // crawler result for 2.28.0.
556        let mut out: HashMap<String, PathBuf> = HashMap::new();
557        let purls = vec!["pkg:pypi/requests@2.29.0?artifact_id=wheel".to_string()];
558        merge_qualified(
559            &mut out,
560            &purls,
561            packages(&[("pkg:pypi/requests@2.28.0", "/sp")]),
562        );
563        assert!(out.is_empty());
564    }
565
566    #[test]
567    fn merge_qualified_keeps_first_path_per_qualified_key() {
568        // First discovered path wins for a given qualified key, mirroring
569        // the per-path iteration in the scan macro.
570        let mut out: HashMap<String, PathBuf> = HashMap::new();
571        let purls = vec!["pkg:gem/nokogiri@1.16.5?platform=arm64-darwin".to_string()];
572        merge_qualified(&mut out, &purls, packages(&[("pkg:gem/nokogiri@1.16.5", "/first")]));
573        merge_qualified(&mut out, &purls, packages(&[("pkg:gem/nokogiri@1.16.5", "/second")]));
574        assert_eq!(
575            out.get("pkg:gem/nokogiri@1.16.5?platform=arm64-darwin"),
576            Some(&PathBuf::from("/first"))
577        );
578    }
579
580    // ---- purls_override helpers ------------------------------------------
581
582    #[test]
583    fn dedup_qualified_purls_strips_and_dedupes() {
584        let purls = vec![
585            "pkg:pypi/requests@2.28.0?artifact_id=wheel".to_string(),
586            "pkg:pypi/requests@2.28.0?artifact_id=sdist".to_string(),
587            "pkg:pypi/requests@2.28.0".to_string(),
588        ];
589        let mut out = dedup_qualified_purls(&purls);
590        out.sort();
591        assert_eq!(out, vec!["pkg:pypi/requests@2.28.0".to_string()]);
592    }
593
594    #[test]
595    fn dedup_qualified_purls_keeps_distinct_bases() {
596        let purls = vec![
597            "pkg:pypi/requests@2.28.0?artifact_id=wheel".to_string(),
598            "pkg:pypi/flask@3.0.0?artifact_id=wheel".to_string(),
599        ];
600        let mut out = dedup_qualified_purls(&purls);
601        out.sort();
602        assert_eq!(
603            out,
604            vec![
605                "pkg:pypi/flask@3.0.0".to_string(),
606                "pkg:pypi/requests@2.28.0".to_string(),
607            ]
608        );
609    }
610
611    #[test]
612    fn passthrough_purls_is_identity() {
613        let purls = vec![
614            "pkg:npm/foo@1.0".to_string(),
615            "pkg:npm/bar@2.0".to_string(),
616        ];
617        assert_eq!(passthrough_purls(&purls), purls);
618    }
619
620    /// The dedup/merge release-variant treatment must stay aligned with
621    /// `Ecosystem::supports_release_variants()`. If a new ecosystem flips
622    /// that predicate, this test flags that `dispatch_find` needs the
623    /// matching `dedup_qualified_purls` + `variant_merge` wiring.
624    #[test]
625    fn release_variant_predicate_matches_dispatch_expectations() {
626        assert!(Ecosystem::Pypi.supports_release_variants());
627        assert!(Ecosystem::Gem.supports_release_variants());
628        #[cfg(feature = "maven")]
629        assert!(Ecosystem::Maven.supports_release_variants());
630        assert!(!Ecosystem::Npm.supports_release_variants());
631        #[cfg(feature = "cargo")]
632        assert!(!Ecosystem::Cargo.supports_release_variants());
633        #[cfg(feature = "golang")]
634        assert!(!Ecosystem::Golang.supports_release_variants());
635        #[cfg(feature = "composer")]
636        assert!(!Ecosystem::Composer.supports_release_variants());
637        #[cfg(feature = "nuget")]
638        assert!(!Ecosystem::Nuget.supports_release_variants());
639        #[cfg(feature = "deno")]
640        assert!(!Ecosystem::Deno.supports_release_variants());
641    }
642
643    #[cfg(any(feature = "maven", feature = "nuget"))]
644    #[test]
645    fn env_truthy_accepts_one_and_true_case_insensitive() {
646        let key = "SOCKET_TEST_ENV_TRUTHY";
647        std::env::set_var(key, "1");
648        assert!(env_truthy(key));
649        std::env::set_var(key, "TrUe");
650        assert!(env_truthy(key));
651        std::env::set_var(key, "0");
652        assert!(!env_truthy(key));
653        std::env::set_var(key, "yes");
654        assert!(!env_truthy(key));
655        std::env::remove_var(key);
656        assert!(!env_truthy(key));
657    }
658
659    #[test]
660    fn partition_purls_no_filter_single_npm() {
661        let purls = vec!["pkg:npm/foo@1.0".to_string()];
662        let map = partition_purls(&purls, None);
663        assert_eq!(map.len(), 1);
664        assert_eq!(
665            map.get(&Ecosystem::Npm),
666            Some(&vec!["pkg:npm/foo@1.0".to_string()])
667        );
668    }
669
670    #[test]
671    fn partition_purls_no_filter_mixed_ecosystems() {
672        let purls = vec![
673            "pkg:npm/foo@1.0".to_string(),
674            "pkg:pypi/bar@2.0".to_string(),
675            "pkg:cargo/baz@3.0".to_string(),
676        ];
677        let map = partition_purls(&purls, None);
678        // `pkg:cargo/...` is only recognized when the `cargo` feature is
679        // compiled in; otherwise `Ecosystem::from_purl` drops it. Keep the
680        // expected length in step with the active feature set so this test
681        // is correct in both configurations.
682        #[cfg(feature = "cargo")]
683        let expected_len = 3;
684        #[cfg(not(feature = "cargo"))]
685        let expected_len = 2;
686        assert_eq!(map.len(), expected_len);
687        assert_eq!(
688            map.get(&Ecosystem::Npm),
689            Some(&vec!["pkg:npm/foo@1.0".to_string()])
690        );
691        assert_eq!(
692            map.get(&Ecosystem::Pypi),
693            Some(&vec!["pkg:pypi/bar@2.0".to_string()])
694        );
695        #[cfg(feature = "cargo")]
696        assert_eq!(
697            map.get(&Ecosystem::Cargo),
698            Some(&vec!["pkg:cargo/baz@3.0".to_string()])
699        );
700    }
701
702    #[test]
703    fn partition_purls_no_filter_empty_input() {
704        let purls: Vec<String> = Vec::new();
705        let map = partition_purls(&purls, None);
706        assert!(map.is_empty());
707    }
708
709    #[test]
710    fn partition_purls_no_filter_duplicate_purls_preserved() {
711        let purls = vec![
712            "pkg:npm/foo@1.0".to_string(),
713            "pkg:npm/foo@1.0".to_string(),
714        ];
715        let map = partition_purls(&purls, None);
716        assert_eq!(map.len(), 1);
717        assert_eq!(
718            map.get(&Ecosystem::Npm),
719            Some(&vec![
720                "pkg:npm/foo@1.0".to_string(),
721                "pkg:npm/foo@1.0".to_string(),
722            ])
723        );
724    }
725
726    #[test]
727    fn partition_purls_no_filter_unknown_ecosystem_dropped() {
728        let purls = vec!["pkg:weirdo/x@1".to_string()];
729        let map = partition_purls(&purls, None);
730        assert!(map.is_empty());
731    }
732
733    #[test]
734    fn partition_purls_allow_list_excludes_one() {
735        let purls = vec![
736            "pkg:npm/foo@1.0".to_string(),
737            "pkg:pypi/bar@2.0".to_string(),
738        ];
739        let allowed = vec!["npm".to_string()];
740        let map = partition_purls(&purls, Some(allowed.as_slice()));
741        assert_eq!(map.len(), 1);
742        assert_eq!(
743            map.get(&Ecosystem::Npm),
744            Some(&vec!["pkg:npm/foo@1.0".to_string()])
745        );
746        assert!(!map.contains_key(&Ecosystem::Pypi));
747    }
748
749    #[test]
750    fn partition_purls_allow_list_matches_none() {
751        let purls = vec!["pkg:npm/foo@1.0".to_string()];
752        let allowed = vec!["pypi".to_string()];
753        let map = partition_purls(&purls, Some(allowed.as_slice()));
754        assert!(map.is_empty());
755    }
756
757    #[test]
758    fn partition_purls_allow_list_matches_all() {
759        let purls = vec![
760            "pkg:npm/foo@1.0".to_string(),
761            "pkg:pypi/bar@2.0".to_string(),
762        ];
763        let allowed = vec!["npm".to_string(), "pypi".to_string()];
764        let map = partition_purls(&purls, Some(allowed.as_slice()));
765        assert_eq!(map.len(), 2);
766        assert_eq!(
767            map.get(&Ecosystem::Npm),
768            Some(&vec!["pkg:npm/foo@1.0".to_string()])
769        );
770        assert_eq!(
771            map.get(&Ecosystem::Pypi),
772            Some(&vec!["pkg:pypi/bar@2.0".to_string()])
773        );
774    }
775
776    #[test]
777    fn partition_purls_empty_allow_list_matches_nothing() {
778        let purls = vec![
779            "pkg:npm/foo@1.0".to_string(),
780            "pkg:pypi/bar@2.0".to_string(),
781        ];
782        let allowed: Vec<String> = Vec::new();
783        let map = partition_purls(&purls, Some(allowed.as_slice()));
784        assert!(map.is_empty());
785    }
786}