Skip to main content

sbom_tools/cli/
query.rs

1//! Multi-SBOM query command handler.
2//!
3//! Searches for components across multiple SBOMs by name, PURL, version,
4//! license, ecosystem, supplier, or vulnerability ID.
5
6use crate::config::QueryConfig;
7use crate::model::{
8    Component, ComponentType, CryptoAssetType, NormalizedSbom, NormalizedSbomIndex,
9};
10use crate::pipeline::{OutputTarget, auto_detect_format, write_output};
11use crate::reports::ReportFormat;
12use anyhow::{Result, bail};
13use serde::Serialize;
14use std::collections::HashMap;
15
16// ============================================================================
17// Query Filter
18// ============================================================================
19
20/// Filter criteria for querying components across SBOMs.
21///
22/// All active filters are AND-combined: a component must match every
23/// non-None filter to be included in results.
24#[derive(Debug, Clone, Default)]
25pub struct QueryFilter {
26    /// Free-text pattern matching across name, purl, version, and id
27    pub pattern: Option<String>,
28    /// Name substring filter
29    pub name: Option<String>,
30    /// PURL substring filter
31    pub purl: Option<String>,
32    /// Version filter: exact match or semver range (e.g., "<2.17.0")
33    pub version: Option<String>,
34    /// License substring filter
35    pub license: Option<String>,
36    /// Ecosystem filter (case-insensitive exact match)
37    pub ecosystem: Option<String>,
38    /// Supplier name substring filter
39    pub supplier: Option<String>,
40    /// Vulnerability ID filter (exact match on vuln IDs)
41    pub affected_by: Option<String>,
42    /// Crypto asset type filter (algorithm, certificate, key, protocol)
43    pub crypto_type: Option<String>,
44    /// Algorithm family filter (substring, e.g., "AES", "RSA", "ML-KEM")
45    pub algorithm_family: Option<String>,
46    /// Quantum safety filter: true = quantum-safe only, false = quantum-vulnerable only
47    pub quantum_safe: Option<bool>,
48}
49
50impl QueryFilter {
51    /// Check if a component matches all active filters.
52    pub fn matches(
53        &self,
54        component: &Component,
55        sort_key: &crate::model::ComponentSortKey,
56    ) -> bool {
57        if let Some(ref pattern) = self.pattern {
58            let pattern_lower = pattern.to_lowercase();
59            if !sort_key.contains(&pattern_lower) {
60                return false;
61            }
62        }
63
64        if let Some(ref name) = self.name {
65            let name_lower = name.to_lowercase();
66            if !sort_key.name_lower.contains(&name_lower) {
67                return false;
68            }
69        }
70
71        if let Some(ref purl) = self.purl {
72            let purl_lower = purl.to_lowercase();
73            if !sort_key.purl_lower.contains(&purl_lower) {
74                return false;
75            }
76        }
77
78        if let Some(ref version) = self.version
79            && !self.matches_version(component, version)
80        {
81            return false;
82        }
83
84        if let Some(ref license) = self.license
85            && !self.matches_license(component, license)
86        {
87            return false;
88        }
89
90        if let Some(ref ecosystem) = self.ecosystem
91            && !self.matches_ecosystem(component, ecosystem)
92        {
93            return false;
94        }
95
96        if let Some(ref supplier) = self.supplier
97            && !self.matches_supplier(component, supplier)
98        {
99            return false;
100        }
101
102        if let Some(ref vuln_id) = self.affected_by
103            && !self.matches_vuln(component, vuln_id)
104        {
105            return false;
106        }
107
108        if let Some(ref ct) = self.crypto_type
109            && !self.matches_crypto_type(component, ct)
110        {
111            return false;
112        }
113
114        if let Some(ref af) = self.algorithm_family
115            && !self.matches_algorithm_family(component, af)
116        {
117            return false;
118        }
119
120        if let Some(qs) = self.quantum_safe
121            && !self.matches_quantum_safe(component, qs)
122        {
123            return false;
124        }
125
126        true
127    }
128
129    fn matches_version(&self, component: &Component, version_filter: &str) -> bool {
130        let comp_version = match &component.version {
131            Some(v) => v,
132            None => return false,
133        };
134
135        // If the filter starts with an operator, parse as semver range
136        let trimmed = version_filter.trim();
137        let has_operator = trimmed.starts_with('<')
138            || trimmed.starts_with('>')
139            || trimmed.starts_with('=')
140            || trimmed.starts_with('~')
141            || trimmed.starts_with('^')
142            || trimmed.contains(',');
143
144        if has_operator
145            && let Ok(req) = semver::VersionReq::parse(trimmed)
146            && let Ok(ver) = semver::Version::parse(comp_version)
147        {
148            return req.matches(&ver);
149        }
150
151        // Exact string match (case-insensitive)
152        comp_version.to_lowercase() == version_filter.to_lowercase()
153    }
154
155    fn matches_license(&self, component: &Component, license_filter: &str) -> bool {
156        let filter_lower = license_filter.to_lowercase();
157        component
158            .licenses
159            .all_licenses()
160            .iter()
161            .any(|l| l.expression.to_lowercase().contains(&filter_lower))
162    }
163
164    fn matches_ecosystem(&self, component: &Component, ecosystem_filter: &str) -> bool {
165        match &component.ecosystem {
166            Some(eco) => eco.to_string().to_lowercase() == ecosystem_filter.to_lowercase(),
167            None => false,
168        }
169    }
170
171    fn matches_supplier(&self, component: &Component, supplier_filter: &str) -> bool {
172        let filter_lower = supplier_filter.to_lowercase();
173        match &component.supplier {
174            Some(org) => org.name.to_lowercase().contains(&filter_lower),
175            None => false,
176        }
177    }
178
179    fn matches_vuln(&self, component: &Component, vuln_id: &str) -> bool {
180        let id_upper = vuln_id.to_uppercase();
181        component
182            .vulnerabilities
183            .iter()
184            .any(|v| v.id.to_uppercase() == id_upper)
185    }
186
187    fn matches_crypto_type(&self, component: &Component, crypto_type: &str) -> bool {
188        if component.component_type != ComponentType::Cryptographic {
189            return false;
190        }
191        let Some(cp) = &component.crypto_properties else {
192            return false;
193        };
194        let ct_lower = crypto_type.to_lowercase();
195        match ct_lower.as_str() {
196            "algorithm" | "algo" => cp.asset_type == CryptoAssetType::Algorithm,
197            "certificate" | "cert" => cp.asset_type == CryptoAssetType::Certificate,
198            "key" | "material" => cp.asset_type == CryptoAssetType::RelatedCryptoMaterial,
199            "protocol" | "proto" => cp.asset_type == CryptoAssetType::Protocol,
200            _ => cp.asset_type.to_string().to_lowercase().contains(&ct_lower),
201        }
202    }
203
204    fn matches_algorithm_family(&self, component: &Component, family_filter: &str) -> bool {
205        if component.component_type != ComponentType::Cryptographic {
206            return false;
207        }
208        let Some(cp) = &component.crypto_properties else {
209            return false;
210        };
211        let filter_lower = family_filter.to_lowercase();
212        // Check algorithm_properties.algorithm_family
213        if let Some(algo) = &cp.algorithm_properties
214            && let Some(fam) = &algo.algorithm_family
215            && fam.to_lowercase().contains(&filter_lower)
216        {
217            return true;
218        }
219        // Fallback: check component name
220        component.name.to_lowercase().contains(&filter_lower)
221    }
222
223    fn matches_quantum_safe(&self, component: &Component, want_safe: bool) -> bool {
224        if component.component_type != ComponentType::Cryptographic {
225            return false;
226        }
227        let Some(cp) = &component.crypto_properties else {
228            return false;
229        };
230        let Some(algo) = &cp.algorithm_properties else {
231            // Non-algorithm crypto assets: include them if filtering for safe
232            return want_safe;
233        };
234        if want_safe {
235            algo.is_quantum_safe()
236        } else {
237            !algo.is_quantum_safe()
238        }
239    }
240
241    /// Returns true if no filters are set (would match everything).
242    pub fn is_empty(&self) -> bool {
243        self.pattern.is_none()
244            && self.name.is_none()
245            && self.purl.is_none()
246            && self.version.is_none()
247            && self.license.is_none()
248            && self.ecosystem.is_none()
249            && self.supplier.is_none()
250            && self.affected_by.is_none()
251            && self.crypto_type.is_none()
252            && self.algorithm_family.is_none()
253            && self.quantum_safe.is_none()
254    }
255
256    /// Build a human-readable description of the active filters.
257    fn description(&self) -> String {
258        let mut parts = Vec::new();
259        if let Some(ref p) = self.pattern {
260            parts.push(format!("\"{p}\""));
261        }
262        if let Some(ref n) = self.name {
263            parts.push(format!("name=\"{n}\""));
264        }
265        if let Some(ref p) = self.purl {
266            parts.push(format!("purl=\"{p}\""));
267        }
268        if let Some(ref v) = self.version {
269            parts.push(format!("version={v}"));
270        }
271        if let Some(ref l) = self.license {
272            parts.push(format!("license=\"{l}\""));
273        }
274        if let Some(ref e) = self.ecosystem {
275            parts.push(format!("ecosystem={e}"));
276        }
277        if let Some(ref s) = self.supplier {
278            parts.push(format!("supplier=\"{s}\""));
279        }
280        if let Some(ref v) = self.affected_by {
281            parts.push(format!("affected-by={v}"));
282        }
283        if let Some(ref ct) = self.crypto_type {
284            parts.push(format!("crypto-type={ct}"));
285        }
286        if let Some(ref af) = self.algorithm_family {
287            parts.push(format!("algorithm-family=\"{af}\""));
288        }
289        if let Some(qs) = self.quantum_safe {
290            parts.push(if qs {
291                "quantum-safe".to_string()
292            } else {
293                "quantum-vulnerable".to_string()
294            });
295        }
296        if parts.is_empty() {
297            "*".to_string()
298        } else {
299            parts.join(" AND ")
300        }
301    }
302}
303
304// ============================================================================
305// Query Results
306// ============================================================================
307
308/// Source SBOM where a component was found.
309#[derive(Debug, Clone, Serialize)]
310pub(crate) struct SbomSource {
311    pub name: String,
312    pub path: String,
313}
314
315/// A single matched component (possibly found in multiple SBOMs).
316#[derive(Debug, Clone, Serialize)]
317pub(crate) struct QueryMatch {
318    pub name: String,
319    pub version: String,
320    pub ecosystem: String,
321    pub license: String,
322    pub purl: String,
323    pub supplier: String,
324    pub vuln_count: usize,
325    pub vuln_ids: Vec<String>,
326    pub found_in: Vec<SbomSource>,
327    pub eol_status: String,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub crypto_asset_type: Option<String>,
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub crypto_quantum_level: Option<u8>,
332}
333
334/// Summary of an SBOM that was searched.
335#[derive(Debug, Clone, Serialize)]
336pub(crate) struct SbomSummary {
337    pub name: String,
338    pub path: String,
339    pub component_count: usize,
340    pub matches: usize,
341}
342
343/// Full query result.
344#[derive(Debug, Clone, Serialize)]
345pub(crate) struct QueryResult {
346    pub filter: String,
347    pub sboms_searched: usize,
348    pub total_components: usize,
349    pub matches: Vec<QueryMatch>,
350    pub sbom_summaries: Vec<SbomSummary>,
351}
352
353// ============================================================================
354// Core Implementation
355// ============================================================================
356
357/// Run the query command.
358#[allow(clippy::needless_pass_by_value)]
359pub fn run_query(config: QueryConfig, filter: QueryFilter) -> Result<()> {
360    if config.sbom_paths.is_empty() {
361        bail!("No SBOM files specified");
362    }
363
364    if filter.is_empty() {
365        bail!(
366            "No query filters specified. Provide a search pattern or use --name, --purl, --version, --license, --ecosystem, --supplier, --affected-by, --crypto-type, --algorithm-family, --quantum-safe, or --quantum-vulnerable"
367        );
368    }
369
370    let sboms = super::multi::parse_multiple_sboms(&config.sbom_paths)?;
371
372    // Optionally enrich with vulnerability data
373    #[cfg(feature = "enrichment")]
374    let sboms = enrich_if_needed(sboms, &config.enrichment)?;
375
376    let mut total_components = 0;
377    let mut sbom_summaries = Vec::with_capacity(sboms.len());
378
379    // Deduplicate matches by (name_lower, version)
380    let mut dedup_map: HashMap<(String, String), QueryMatch> = HashMap::new();
381
382    for (sbom, path) in sboms.iter().zip(config.sbom_paths.iter()) {
383        let sbom_name = super::multi::get_sbom_name(path);
384        let index = NormalizedSbomIndex::build(sbom);
385        let component_count = sbom.component_count();
386        total_components += component_count;
387
388        let mut match_count = 0;
389
390        for (_id, component) in &sbom.components {
391            let sort_key = index
392                .sort_key(&component.canonical_id)
393                .cloned()
394                .unwrap_or_default();
395
396            if !filter.matches(component, &sort_key) {
397                continue;
398            }
399
400            match_count += 1;
401            let dedup_key = (
402                component.name.to_lowercase(),
403                component.version.clone().unwrap_or_default(),
404            );
405
406            let source = SbomSource {
407                name: sbom_name.clone(),
408                path: path.to_string_lossy().to_string(),
409            };
410
411            dedup_map
412                .entry(dedup_key)
413                .and_modify(|existing| {
414                    // Merge: add source, union vuln IDs
415                    existing.found_in.push(source.clone());
416                    for vid in &component.vulnerabilities {
417                        let id_upper = vid.id.to_uppercase();
418                        if !existing
419                            .vuln_ids
420                            .iter()
421                            .any(|v| v.to_uppercase() == id_upper)
422                        {
423                            existing.vuln_ids.push(vid.id.clone());
424                        }
425                    }
426                    existing.vuln_count = existing.vuln_ids.len();
427                })
428                .or_insert_with(|| build_query_match(component, source));
429        }
430
431        sbom_summaries.push(SbomSummary {
432            name: sbom_name,
433            path: path.to_string_lossy().to_string(),
434            component_count,
435            matches: match_count,
436        });
437    }
438
439    let mut matches: Vec<QueryMatch> = dedup_map.into_values().collect();
440    matches.sort_by(|a, b| {
441        a.name
442            .to_lowercase()
443            .cmp(&b.name.to_lowercase())
444            .then_with(|| a.version.cmp(&b.version))
445    });
446
447    // Apply limit
448    if let Some(limit) = config.limit {
449        matches.truncate(limit);
450    }
451
452    let result = QueryResult {
453        filter: filter.description(),
454        sboms_searched: sbom_summaries.len(),
455        total_components,
456        matches,
457        sbom_summaries,
458    };
459
460    // Determine output format
461    let target = OutputTarget::from_option(config.output.file.clone());
462    let format = auto_detect_format(config.output.format, &target);
463
464    let output = match format {
465        ReportFormat::Json => serde_json::to_string_pretty(&result)?,
466        ReportFormat::Csv => format_csv_output(&result),
467        _ => {
468            if config.group_by_sbom {
469                format_table_grouped(&result)
470            } else {
471                format_table_output(&result)
472            }
473        }
474    };
475
476    write_output(&output, &target, false)?;
477
478    // Exit code: 1 if no matches
479    if result.matches.is_empty() {
480        std::process::exit(1);
481    }
482
483    Ok(())
484}
485
486/// Build a `QueryMatch` from a component and its source.
487fn build_query_match(component: &Component, source: SbomSource) -> QueryMatch {
488    let vuln_ids: Vec<String> = component
489        .vulnerabilities
490        .iter()
491        .map(|v| v.id.clone())
492        .collect();
493    let license = component
494        .licenses
495        .all_licenses()
496        .iter()
497        .map(|l| l.expression.as_str())
498        .collect::<Vec<_>>()
499        .join(", ");
500
501    QueryMatch {
502        name: component.name.clone(),
503        version: component.version.clone().unwrap_or_default(),
504        ecosystem: component
505            .ecosystem
506            .as_ref()
507            .map_or_else(String::new, ToString::to_string),
508        license,
509        purl: component.identifiers.purl.clone().unwrap_or_default(),
510        supplier: component
511            .supplier
512            .as_ref()
513            .map_or_else(String::new, |o| o.name.clone()),
514        vuln_count: vuln_ids.len(),
515        vuln_ids,
516        found_in: vec![source],
517        eol_status: component
518            .eol
519            .as_ref()
520            .map_or_else(String::new, |e| format!("{:?}", e.status)),
521        crypto_asset_type: component
522            .crypto_properties
523            .as_ref()
524            .map(|cp| cp.asset_type.to_string()),
525        crypto_quantum_level: component
526            .crypto_properties
527            .as_ref()
528            .and_then(|cp| cp.algorithm_properties.as_ref())
529            .and_then(|a| a.nist_quantum_security_level),
530    }
531}
532
533// ============================================================================
534// Enrichment (feature-gated)
535// ============================================================================
536
537#[cfg(feature = "enrichment")]
538fn enrich_if_needed(
539    mut sboms: Vec<NormalizedSbom>,
540    config: &crate::config::EnrichmentConfig,
541) -> Result<Vec<NormalizedSbom>> {
542    // VEX enrichment
543    if !config.vex_paths.is_empty() {
544        for sbom in &mut sboms {
545            crate::pipeline::enrich_vex(sbom, &config.vex_paths, false);
546        }
547    }
548    if config.enabled {
549        let osv_config = crate::pipeline::build_enrichment_config(config);
550        for sbom in &mut sboms {
551            crate::pipeline::enrich_sbom(sbom, &osv_config, false);
552        }
553    }
554    if config.enable_eol {
555        let eol_config = crate::enrichment::EolClientConfig {
556            cache_dir: config
557                .cache_dir
558                .clone()
559                .unwrap_or_else(crate::pipeline::dirs::eol_cache_dir),
560            cache_ttl: std::time::Duration::from_secs(config.cache_ttl_hours * 3600),
561            bypass_cache: config.bypass_cache,
562            timeout: std::time::Duration::from_secs(config.timeout_secs),
563            ..Default::default()
564        };
565        for sbom in &mut sboms {
566            crate::pipeline::enrich_eol(sbom, &eol_config, false);
567        }
568    }
569    Ok(sboms)
570}
571
572// ============================================================================
573// Output Formatting
574// ============================================================================
575
576/// Format results as a table for terminal output.
577fn format_table_output(result: &QueryResult) -> String {
578    let mut out = String::new();
579
580    out.push_str(&format!(
581        "Query: {} across {} SBOMs ({} total components)\n\n",
582        result.filter, result.sboms_searched, result.total_components
583    ));
584
585    if result.matches.is_empty() {
586        out.push_str("0 components found\n");
587        return out;
588    }
589
590    // Calculate column widths
591    let name_w = result
592        .matches
593        .iter()
594        .map(|m| m.name.len())
595        .max()
596        .unwrap_or(9)
597        .clamp(9, 40);
598    let ver_w = result
599        .matches
600        .iter()
601        .map(|m| m.version.len())
602        .max()
603        .unwrap_or(7)
604        .clamp(7, 20);
605    let eco_w = result
606        .matches
607        .iter()
608        .map(|m| m.ecosystem.len())
609        .max()
610        .unwrap_or(9)
611        .clamp(9, 15);
612    let lic_w = result
613        .matches
614        .iter()
615        .map(|m| m.license.len())
616        .max()
617        .unwrap_or(7)
618        .clamp(7, 20);
619
620    // Header
621    out.push_str(&format!(
622        "{:<name_w$}  {:<ver_w$}  {:<eco_w$}  {:<lic_w$}  {:>5}  FOUND IN\n",
623        "COMPONENT", "VERSION", "ECOSYSTEM", "LICENSE", "VULNS",
624    ));
625
626    // Rows
627    for m in &result.matches {
628        let name = truncate(&m.name, name_w);
629        let ver = truncate(&m.version, ver_w);
630        let eco = truncate(&m.ecosystem, eco_w);
631        let lic = truncate(&m.license, lic_w);
632        let found_in: Vec<&str> = m.found_in.iter().map(|s| s.name.as_str()).collect();
633
634        out.push_str(&format!(
635            "{name:<name_w$}  {ver:<ver_w$}  {eco:<eco_w$}  {lic:<lic_w$}  {:>5}  {}\n",
636            m.vuln_count,
637            found_in.join(", "),
638        ));
639    }
640
641    out.push_str(&format!(
642        "\n{} components found across {} SBOMs\n",
643        result.matches.len(),
644        result.sboms_searched
645    ));
646
647    out
648}
649
650/// Format results grouped by SBOM source.
651fn format_table_grouped(result: &QueryResult) -> String {
652    let mut out = String::new();
653
654    out.push_str(&format!(
655        "Query: {} across {} SBOMs ({} total components)\n\n",
656        result.filter, result.sboms_searched, result.total_components
657    ));
658
659    if result.matches.is_empty() {
660        out.push_str("0 components found\n");
661        return out;
662    }
663
664    // Group matches by SBOM
665    for summary in &result.sbom_summaries {
666        if summary.matches == 0 {
667            continue;
668        }
669
670        out.push_str(&format!(
671            "── {} ({} matches / {} components) ──\n",
672            summary.name, summary.matches, summary.component_count
673        ));
674
675        for m in &result.matches {
676            if m.found_in.iter().any(|s| s.name == summary.name) {
677                let vuln_str = if m.vuln_count > 0 {
678                    format!(" [{} vulns]", m.vuln_count)
679                } else {
680                    String::new()
681                };
682                out.push_str(&format!(
683                    "  {} {} ({}){}\n",
684                    m.name, m.version, m.ecosystem, vuln_str
685                ));
686            }
687        }
688        out.push('\n');
689    }
690
691    out.push_str(&format!(
692        "{} components found across {} SBOMs\n",
693        result.matches.len(),
694        result.sboms_searched
695    ));
696
697    out
698}
699
700/// Format results as CSV.
701fn format_csv_output(result: &QueryResult) -> String {
702    let mut out = String::from(
703        "Component,Version,Ecosystem,License,Vulns,Vulnerability IDs,Supplier,EOL Status,Found In\n",
704    );
705
706    for m in &result.matches {
707        let found_in: Vec<&str> = m.found_in.iter().map(|s| s.name.as_str()).collect();
708        out.push_str(&format!(
709            "{},{},{},{},{},{},{},{},{}\n",
710            csv_escape(&m.name),
711            csv_escape(&m.version),
712            csv_escape(&m.ecosystem),
713            csv_escape(&m.license),
714            m.vuln_count,
715            csv_escape(&m.vuln_ids.join("; ")),
716            csv_escape(&m.supplier),
717            csv_escape(&m.eol_status),
718            csv_escape(&found_in.join("; ")),
719        ));
720    }
721
722    out
723}
724
725/// Escape a CSV field value (quote if contains comma, quote, or newline).
726fn csv_escape(s: &str) -> String {
727    if s.contains(',') || s.contains('"') || s.contains('\n') {
728        format!("\"{}\"", s.replace('"', "\"\""))
729    } else {
730        s.to_string()
731    }
732}
733
734/// Truncate a string to the given width.
735fn truncate(s: &str, max: usize) -> String {
736    if s.len() <= max {
737        s.to_string()
738    } else if max > 3 {
739        format!("{}...", &s[..max - 3])
740    } else {
741        s[..max].to_string()
742    }
743}
744
745// ============================================================================
746// Tests
747// ============================================================================
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752    use crate::model::{Component, ComponentSortKey};
753
754    fn make_component(name: &str, version: &str, purl: Option<&str>) -> Component {
755        let mut c = Component::new(name.to_string(), format!("{name}@{version}"));
756        c.version = Some(version.to_string());
757        if let Some(p) = purl {
758            c.identifiers.purl = Some(p.to_string());
759        }
760        c
761    }
762
763    #[test]
764    fn test_filter_pattern_match() {
765        let filter = QueryFilter {
766            pattern: Some("log4j".to_string()),
767            ..Default::default()
768        };
769
770        let comp = make_component(
771            "log4j-core",
772            "2.14.1",
773            Some("pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"),
774        );
775        let key = ComponentSortKey::from_component(&comp);
776        assert!(filter.matches(&comp, &key));
777
778        let comp2 = make_component("openssl", "1.1.1", None);
779        let key2 = ComponentSortKey::from_component(&comp2);
780        assert!(!filter.matches(&comp2, &key2));
781    }
782
783    #[test]
784    fn test_filter_name_match() {
785        let filter = QueryFilter {
786            name: Some("openssl".to_string()),
787            ..Default::default()
788        };
789
790        let comp = make_component("openssl", "3.0.0", None);
791        let key = ComponentSortKey::from_component(&comp);
792        assert!(filter.matches(&comp, &key));
793
794        let comp2 = make_component("libssl", "1.0", None);
795        let key2 = ComponentSortKey::from_component(&comp2);
796        assert!(!filter.matches(&comp2, &key2));
797    }
798
799    #[test]
800    fn test_filter_version_exact() {
801        let filter = QueryFilter {
802            version: Some("2.14.1".to_string()),
803            ..Default::default()
804        };
805
806        let comp = make_component("log4j-core", "2.14.1", None);
807        let key = ComponentSortKey::from_component(&comp);
808        assert!(filter.matches(&comp, &key));
809
810        let comp2 = make_component("log4j-core", "2.17.0", None);
811        let key2 = ComponentSortKey::from_component(&comp2);
812        assert!(!filter.matches(&comp2, &key2));
813    }
814
815    #[test]
816    fn test_filter_version_semver_range() {
817        let filter = QueryFilter {
818            version: Some("<2.17.0".to_string()),
819            ..Default::default()
820        };
821
822        let comp = make_component("log4j-core", "2.14.1", None);
823        let key = ComponentSortKey::from_component(&comp);
824        assert!(filter.matches(&comp, &key));
825
826        let comp2 = make_component("log4j-core", "2.17.0", None);
827        let key2 = ComponentSortKey::from_component(&comp2);
828        assert!(!filter.matches(&comp2, &key2));
829
830        let comp3 = make_component("log4j-core", "2.18.0", None);
831        let key3 = ComponentSortKey::from_component(&comp3);
832        assert!(!filter.matches(&comp3, &key3));
833    }
834
835    #[test]
836    fn test_filter_license_match() {
837        let filter = QueryFilter {
838            license: Some("Apache".to_string()),
839            ..Default::default()
840        };
841
842        let mut comp = make_component("log4j-core", "2.14.1", None);
843        comp.licenses
844            .add_declared(crate::model::LicenseExpression::new(
845                "Apache-2.0".to_string(),
846            ));
847        let key = ComponentSortKey::from_component(&comp);
848        assert!(filter.matches(&comp, &key));
849
850        let comp2 = make_component("some-lib", "1.0.0", None);
851        let key2 = ComponentSortKey::from_component(&comp2);
852        assert!(!filter.matches(&comp2, &key2));
853    }
854
855    #[test]
856    fn test_filter_ecosystem_match() {
857        let filter = QueryFilter {
858            ecosystem: Some("npm".to_string()),
859            ..Default::default()
860        };
861
862        let mut comp = make_component("lodash", "4.17.21", None);
863        comp.ecosystem = Some(crate::model::Ecosystem::Npm);
864        let key = ComponentSortKey::from_component(&comp);
865        assert!(filter.matches(&comp, &key));
866
867        let mut comp2 = make_component("serde", "1.0", None);
868        comp2.ecosystem = Some(crate::model::Ecosystem::Cargo);
869        let key2 = ComponentSortKey::from_component(&comp2);
870        assert!(!filter.matches(&comp2, &key2));
871    }
872
873    #[test]
874    fn test_filter_affected_by() {
875        let filter = QueryFilter {
876            affected_by: Some("CVE-2021-44228".to_string()),
877            ..Default::default()
878        };
879
880        let mut comp = make_component("log4j-core", "2.14.1", None);
881        comp.vulnerabilities
882            .push(crate::model::VulnerabilityRef::new(
883                "CVE-2021-44228".to_string(),
884                crate::model::VulnerabilitySource::Osv,
885            ));
886        let key = ComponentSortKey::from_component(&comp);
887        assert!(filter.matches(&comp, &key));
888
889        let comp2 = make_component("log4j-core", "2.17.0", None);
890        let key2 = ComponentSortKey::from_component(&comp2);
891        assert!(!filter.matches(&comp2, &key2));
892    }
893
894    #[test]
895    fn test_filter_combined() {
896        let filter = QueryFilter {
897            name: Some("log4j".to_string()),
898            version: Some("<2.17.0".to_string()),
899            ..Default::default()
900        };
901
902        let comp = make_component("log4j-core", "2.14.1", None);
903        let key = ComponentSortKey::from_component(&comp);
904        assert!(filter.matches(&comp, &key));
905
906        // Name matches but version doesn't
907        let comp2 = make_component("log4j-core", "2.17.0", None);
908        let key2 = ComponentSortKey::from_component(&comp2);
909        assert!(!filter.matches(&comp2, &key2));
910
911        // Version matches but name doesn't
912        let comp3 = make_component("openssl", "2.14.1", None);
913        let key3 = ComponentSortKey::from_component(&comp3);
914        assert!(!filter.matches(&comp3, &key3));
915    }
916
917    #[test]
918    fn test_dedup_merges_sources() {
919        let source1 = SbomSource {
920            name: "sbom1".to_string(),
921            path: "sbom1.json".to_string(),
922        };
923        let source2 = SbomSource {
924            name: "sbom2".to_string(),
925            path: "sbom2.json".to_string(),
926        };
927
928        let comp = make_component("lodash", "4.17.21", None);
929
930        let mut dedup_map: HashMap<(String, String), QueryMatch> = HashMap::new();
931        let key = ("lodash".to_string(), "4.17.21".to_string());
932
933        dedup_map.insert(key.clone(), build_query_match(&comp, source1));
934        dedup_map.entry(key).and_modify(|existing| {
935            existing.found_in.push(source2);
936        });
937
938        let match_entry = dedup_map.values().next().expect("should have one entry");
939        assert_eq!(match_entry.found_in.len(), 2);
940        assert_eq!(match_entry.found_in[0].name, "sbom1");
941        assert_eq!(match_entry.found_in[1].name, "sbom2");
942    }
943
944    #[test]
945    fn test_filter_is_empty() {
946        let filter = QueryFilter::default();
947        assert!(filter.is_empty());
948
949        let filter = QueryFilter {
950            pattern: Some("test".to_string()),
951            ..Default::default()
952        };
953        assert!(!filter.is_empty());
954    }
955
956    #[test]
957    fn test_filter_description() {
958        let filter = QueryFilter {
959            pattern: Some("log4j".to_string()),
960            version: Some("<2.17.0".to_string()),
961            ..Default::default()
962        };
963        let desc = filter.description();
964        assert!(desc.contains("\"log4j\""));
965        assert!(desc.contains("version=<2.17.0"));
966        assert!(desc.contains("AND"));
967    }
968
969    #[test]
970    fn test_csv_escape() {
971        assert_eq!(csv_escape("hello"), "hello");
972        assert_eq!(csv_escape("hello,world"), "\"hello,world\"");
973        assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\"");
974    }
975
976    #[test]
977    fn test_truncate() {
978        assert_eq!(truncate("short", 10), "short");
979        assert_eq!(truncate("long string here", 10), "long st...");
980        assert_eq!(truncate("ab", 2), "ab");
981    }
982
983    #[test]
984    fn test_format_table_empty_results() {
985        let result = QueryResult {
986            filter: "\"nonexistent\"".to_string(),
987            sboms_searched: 1,
988            total_components: 100,
989            matches: vec![],
990            sbom_summaries: vec![],
991        };
992        let output = format_table_output(&result);
993        assert!(output.contains("0 components found"));
994    }
995
996    #[test]
997    fn test_format_csv_output() {
998        let result = QueryResult {
999            filter: "test".to_string(),
1000            sboms_searched: 1,
1001            total_components: 10,
1002            matches: vec![QueryMatch {
1003                name: "lodash".to_string(),
1004                version: "4.17.21".to_string(),
1005                ecosystem: "npm".to_string(),
1006                license: "MIT".to_string(),
1007                purl: "pkg:npm/lodash@4.17.21".to_string(),
1008                supplier: String::new(),
1009                vuln_count: 0,
1010                vuln_ids: vec![],
1011                found_in: vec![SbomSource {
1012                    name: "sbom1".to_string(),
1013                    path: "sbom1.json".to_string(),
1014                }],
1015                eol_status: String::new(),
1016                crypto_asset_type: None,
1017                crypto_quantum_level: None,
1018            }],
1019            sbom_summaries: vec![],
1020        };
1021        let csv = format_csv_output(&result);
1022        assert!(csv.starts_with("Component,Version"));
1023        assert!(csv.contains("lodash,4.17.21,npm,MIT"));
1024    }
1025
1026    fn make_crypto_component(
1027        name: &str,
1028        asset_type: crate::model::CryptoAssetType,
1029        ql: Option<u8>,
1030    ) -> Component {
1031        let mut c = Component::new(name.to_string(), format!("{name}@1.0"));
1032        c.component_type = ComponentType::Cryptographic;
1033        let mut props = crate::model::CryptoProperties::new(asset_type);
1034        if let Some(level) = ql {
1035            props = props.with_algorithm_properties(
1036                crate::model::AlgorithmProperties::new(crate::model::CryptoPrimitive::Ae)
1037                    .with_nist_quantum_security_level(level),
1038            );
1039        }
1040        c.crypto_properties = Some(props);
1041        c
1042    }
1043
1044    #[test]
1045    fn test_filter_crypto_type_algorithm() {
1046        let comp = make_crypto_component("AES-256", CryptoAssetType::Algorithm, Some(1));
1047        let key = ComponentSortKey::from_component(&comp);
1048        let filter = QueryFilter {
1049            crypto_type: Some("algorithm".to_string()),
1050            ..Default::default()
1051        };
1052        assert!(filter.matches(&comp, &key));
1053
1054        let filter2 = QueryFilter {
1055            crypto_type: Some("certificate".to_string()),
1056            ..Default::default()
1057        };
1058        assert!(!filter2.matches(&comp, &key));
1059    }
1060
1061    #[test]
1062    fn test_filter_quantum_safe() {
1063        let safe = make_crypto_component("ML-KEM-1024", CryptoAssetType::Algorithm, Some(5));
1064        let key_safe = ComponentSortKey::from_component(&safe);
1065        let vuln = make_crypto_component("RSA-2048", CryptoAssetType::Algorithm, Some(0));
1066        let key_vuln = ComponentSortKey::from_component(&vuln);
1067
1068        let filter = QueryFilter {
1069            quantum_safe: Some(true),
1070            ..Default::default()
1071        };
1072        assert!(filter.matches(&safe, &key_safe));
1073        assert!(!filter.matches(&vuln, &key_vuln));
1074    }
1075
1076    #[test]
1077    fn test_filter_quantum_vulnerable() {
1078        let vuln = make_crypto_component("RSA-2048", CryptoAssetType::Algorithm, Some(0));
1079        let key = ComponentSortKey::from_component(&vuln);
1080
1081        let filter = QueryFilter {
1082            quantum_safe: Some(false),
1083            ..Default::default()
1084        };
1085        assert!(filter.matches(&vuln, &key));
1086    }
1087
1088    #[test]
1089    fn test_filter_algorithm_family() {
1090        let mut comp = make_crypto_component("AES-256-GCM", CryptoAssetType::Algorithm, Some(1));
1091        if let Some(ref mut cp) = comp.crypto_properties {
1092            if let Some(ref mut algo) = cp.algorithm_properties {
1093                algo.algorithm_family = Some("AES".to_string());
1094            }
1095        }
1096        let key = ComponentSortKey::from_component(&comp);
1097
1098        let filter = QueryFilter {
1099            algorithm_family: Some("AES".to_string()),
1100            ..Default::default()
1101        };
1102        assert!(filter.matches(&comp, &key));
1103
1104        let filter2 = QueryFilter {
1105            algorithm_family: Some("RSA".to_string()),
1106            ..Default::default()
1107        };
1108        assert!(!filter2.matches(&comp, &key));
1109    }
1110}