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, exit_codes, 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, returning the desired exit code.
358///
359/// # Exit codes
360/// - [`exit_codes::SUCCESS`] (0): at least one component matched the filter
361/// - [`exit_codes::NO_MATCHES`] (1): no components matched the filter
362///
363/// The caller is responsible for calling `std::process::exit()` with the
364/// returned code when it is non-zero.
365#[allow(clippy::needless_pass_by_value)]
366pub fn run_query(config: QueryConfig, filter: QueryFilter) -> Result<i32> {
367    if config.sbom_paths.is_empty() {
368        bail!("No SBOM files specified");
369    }
370
371    if filter.is_empty() {
372        bail!(
373            "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"
374        );
375    }
376
377    // Stdin can only be consumed once, so at most one input may be "-".
378    if config
379        .sbom_paths
380        .iter()
381        .filter(|p| crate::pipeline::is_stdin_path(p))
382        .count()
383        > 1
384    {
385        bail!("Cannot read more than one SBOM from stdin ('-')");
386    }
387
388    let sboms = super::multi::parse_multiple_sboms(&config.sbom_paths)?;
389
390    // Optionally enrich with vulnerability data
391    #[cfg(feature = "enrichment")]
392    let sboms = enrich_if_needed(sboms, &config.enrichment)?;
393
394    let mut total_components = 0;
395    let mut sbom_summaries = Vec::with_capacity(sboms.len());
396
397    // Deduplicate matches by (name_lower, version)
398    let mut dedup_map: HashMap<(String, String), QueryMatch> = HashMap::new();
399
400    for (sbom, path) in sboms.iter().zip(config.sbom_paths.iter()) {
401        let sbom_name = super::multi::get_sbom_name(path);
402        let index = NormalizedSbomIndex::build(sbom);
403        let component_count = sbom.component_count();
404        total_components += component_count;
405
406        let mut match_count = 0;
407
408        for (_id, component) in &sbom.components {
409            let sort_key = index
410                .sort_key(&component.canonical_id)
411                .cloned()
412                .unwrap_or_default();
413
414            if !filter.matches(component, &sort_key) {
415                continue;
416            }
417
418            match_count += 1;
419            let dedup_key = (
420                component.name.to_lowercase(),
421                component.version.clone().unwrap_or_default(),
422            );
423
424            let source = SbomSource {
425                name: sbom_name.clone(),
426                path: path.to_string_lossy().to_string(),
427            };
428
429            dedup_map
430                .entry(dedup_key)
431                .and_modify(|existing| {
432                    // Merge: add source, union vuln IDs
433                    existing.found_in.push(source.clone());
434                    for vid in &component.vulnerabilities {
435                        let id_upper = vid.id.to_uppercase();
436                        if !existing
437                            .vuln_ids
438                            .iter()
439                            .any(|v| v.to_uppercase() == id_upper)
440                        {
441                            existing.vuln_ids.push(vid.id.clone());
442                        }
443                    }
444                    existing.vuln_count = existing.vuln_ids.len();
445                })
446                .or_insert_with(|| build_query_match(component, source));
447        }
448
449        sbom_summaries.push(SbomSummary {
450            name: sbom_name,
451            path: path.to_string_lossy().to_string(),
452            component_count,
453            matches: match_count,
454        });
455    }
456
457    let mut matches: Vec<QueryMatch> = dedup_map.into_values().collect();
458    matches.sort_by(|a, b| {
459        a.name
460            .to_lowercase()
461            .cmp(&b.name.to_lowercase())
462            .then_with(|| a.version.cmp(&b.version))
463    });
464
465    // Apply limit
466    if let Some(limit) = config.limit {
467        matches.truncate(limit);
468    }
469
470    let result = QueryResult {
471        filter: filter.description(),
472        sboms_searched: sbom_summaries.len(),
473        total_components,
474        matches,
475        sbom_summaries,
476    };
477
478    // Determine output format
479    let target = OutputTarget::from_option(config.output.file.clone());
480    let format = auto_detect_format(config.output.format, &target);
481
482    let output = match format {
483        ReportFormat::Json => serde_json::to_string_pretty(&result)?,
484        ReportFormat::Csv => format_csv_output(&result),
485        _ => {
486            if config.group_by_sbom {
487                format_table_grouped(&result)
488            } else {
489                format_table_output(&result)
490            }
491        }
492    };
493
494    write_output(&output, &target, false)?;
495
496    if result.matches.is_empty() {
497        return Ok(exit_codes::NO_MATCHES);
498    }
499
500    Ok(exit_codes::SUCCESS)
501}
502
503/// Build a `QueryMatch` from a component and its source.
504fn build_query_match(component: &Component, source: SbomSource) -> QueryMatch {
505    let vuln_ids: Vec<String> = component
506        .vulnerabilities
507        .iter()
508        .map(|v| v.id.clone())
509        .collect();
510    let license = component
511        .licenses
512        .all_licenses()
513        .iter()
514        .map(|l| l.expression.as_str())
515        .collect::<Vec<_>>()
516        .join(", ");
517
518    QueryMatch {
519        name: component.name.clone(),
520        version: component.version.clone().unwrap_or_default(),
521        ecosystem: component
522            .ecosystem
523            .as_ref()
524            .map_or_else(String::new, ToString::to_string),
525        license,
526        purl: component.identifiers.purl.clone().unwrap_or_default(),
527        supplier: component
528            .supplier
529            .as_ref()
530            .map_or_else(String::new, |o| o.name.clone()),
531        vuln_count: vuln_ids.len(),
532        vuln_ids,
533        found_in: vec![source],
534        eol_status: component
535            .eol
536            .as_ref()
537            .map_or_else(String::new, |e| format!("{:?}", e.status)),
538        crypto_asset_type: component
539            .crypto_properties
540            .as_ref()
541            .map(|cp| cp.asset_type.to_string()),
542        crypto_quantum_level: component
543            .crypto_properties
544            .as_ref()
545            .and_then(|cp| cp.algorithm_properties.as_ref())
546            .and_then(|a| a.nist_quantum_security_level),
547    }
548}
549
550// ============================================================================
551// Enrichment (feature-gated)
552// ============================================================================
553
554#[cfg(feature = "enrichment")]
555fn enrich_if_needed(
556    mut sboms: Vec<NormalizedSbom>,
557    config: &crate::config::EnrichmentConfig,
558) -> Result<Vec<NormalizedSbom>> {
559    // Delegate to the central pipeline so OSV / KEV / EOL / staleness / VEX are
560    // all sequenced consistently with the other commands.
561    for sbom in &mut sboms {
562        crate::pipeline::enrich_sbom_full(sbom, config, false);
563    }
564    Ok(sboms)
565}
566
567// ============================================================================
568// Output Formatting
569// ============================================================================
570
571/// Format results as a table for terminal output.
572fn format_table_output(result: &QueryResult) -> String {
573    let mut out = String::new();
574
575    out.push_str(&format!(
576        "Query: {} across {} SBOMs ({} total components)\n\n",
577        result.filter, result.sboms_searched, result.total_components
578    ));
579
580    if result.matches.is_empty() {
581        out.push_str("0 components found\n");
582        return out;
583    }
584
585    // Calculate column widths
586    let name_w = result
587        .matches
588        .iter()
589        .map(|m| m.name.len())
590        .max()
591        .unwrap_or(9)
592        .clamp(9, 40);
593    let ver_w = result
594        .matches
595        .iter()
596        .map(|m| m.version.len())
597        .max()
598        .unwrap_or(7)
599        .clamp(7, 20);
600    let eco_w = result
601        .matches
602        .iter()
603        .map(|m| m.ecosystem.len())
604        .max()
605        .unwrap_or(9)
606        .clamp(9, 15);
607    let lic_w = result
608        .matches
609        .iter()
610        .map(|m| m.license.len())
611        .max()
612        .unwrap_or(7)
613        .clamp(7, 20);
614
615    // Header
616    out.push_str(&format!(
617        "{:<name_w$}  {:<ver_w$}  {:<eco_w$}  {:<lic_w$}  {:>5}  FOUND IN\n",
618        "COMPONENT", "VERSION", "ECOSYSTEM", "LICENSE", "VULNS",
619    ));
620
621    // Rows
622    for m in &result.matches {
623        let name = truncate(&m.name, name_w);
624        let ver = truncate(&m.version, ver_w);
625        let eco = truncate(&m.ecosystem, eco_w);
626        let lic = truncate(&m.license, lic_w);
627        let found_in: Vec<&str> = m.found_in.iter().map(|s| s.name.as_str()).collect();
628
629        out.push_str(&format!(
630            "{name:<name_w$}  {ver:<ver_w$}  {eco:<eco_w$}  {lic:<lic_w$}  {:>5}  {}\n",
631            m.vuln_count,
632            found_in.join(", "),
633        ));
634    }
635
636    out.push_str(&format!(
637        "\n{} components found across {} SBOMs\n",
638        result.matches.len(),
639        result.sboms_searched
640    ));
641
642    out
643}
644
645/// Format results grouped by SBOM source.
646fn format_table_grouped(result: &QueryResult) -> String {
647    let mut out = String::new();
648
649    out.push_str(&format!(
650        "Query: {} across {} SBOMs ({} total components)\n\n",
651        result.filter, result.sboms_searched, result.total_components
652    ));
653
654    if result.matches.is_empty() {
655        out.push_str("0 components found\n");
656        return out;
657    }
658
659    // Group matches by SBOM
660    for summary in &result.sbom_summaries {
661        if summary.matches == 0 {
662            continue;
663        }
664
665        out.push_str(&format!(
666            "── {} ({} matches / {} components) ──\n",
667            summary.name, summary.matches, summary.component_count
668        ));
669
670        for m in &result.matches {
671            if m.found_in.iter().any(|s| s.name == summary.name) {
672                let vuln_str = if m.vuln_count > 0 {
673                    format!(" [{} vulns]", m.vuln_count)
674                } else {
675                    String::new()
676                };
677                out.push_str(&format!(
678                    "  {} {} ({}){}\n",
679                    m.name, m.version, m.ecosystem, vuln_str
680                ));
681            }
682        }
683        out.push('\n');
684    }
685
686    out.push_str(&format!(
687        "{} components found across {} SBOMs\n",
688        result.matches.len(),
689        result.sboms_searched
690    ));
691
692    out
693}
694
695/// Format results as CSV.
696fn format_csv_output(result: &QueryResult) -> String {
697    let mut out = String::from(
698        "Component,Version,Ecosystem,License,Vulns,Vulnerability IDs,Supplier,EOL Status,Found In\n",
699    );
700
701    for m in &result.matches {
702        let found_in: Vec<&str> = m.found_in.iter().map(|s| s.name.as_str()).collect();
703        out.push_str(&format!(
704            "{},{},{},{},{},{},{},{},{}\n",
705            csv_escape(&m.name),
706            csv_escape(&m.version),
707            csv_escape(&m.ecosystem),
708            csv_escape(&m.license),
709            m.vuln_count,
710            csv_escape(&m.vuln_ids.join("; ")),
711            csv_escape(&m.supplier),
712            csv_escape(&m.eol_status),
713            csv_escape(&found_in.join("; ")),
714        ));
715    }
716
717    out
718}
719
720/// Escape a CSV field value (quote if contains comma, quote, or newline).
721fn csv_escape(s: &str) -> String {
722    if s.contains(',') || s.contains('"') || s.contains('\n') {
723        format!("\"{}\"", s.replace('"', "\"\""))
724    } else {
725        s.to_string()
726    }
727}
728
729/// Truncate a string to the given width.
730fn truncate(s: &str, max: usize) -> String {
731    if s.len() <= max {
732        s.to_string()
733    } else if max > 3 {
734        format!("{}...", &s[..max - 3])
735    } else {
736        s[..max].to_string()
737    }
738}
739
740// ============================================================================
741// Tests
742// ============================================================================
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use crate::model::{Component, ComponentSortKey};
748
749    fn make_component(name: &str, version: &str, purl: Option<&str>) -> Component {
750        let mut c = Component::new(name.to_string(), format!("{name}@{version}"));
751        c.version = Some(version.to_string());
752        if let Some(p) = purl {
753            c.identifiers.purl = Some(p.to_string());
754        }
755        c
756    }
757
758    #[test]
759    fn test_filter_pattern_match() {
760        let filter = QueryFilter {
761            pattern: Some("log4j".to_string()),
762            ..Default::default()
763        };
764
765        let comp = make_component(
766            "log4j-core",
767            "2.14.1",
768            Some("pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"),
769        );
770        let key = ComponentSortKey::from_component(&comp);
771        assert!(filter.matches(&comp, &key));
772
773        let comp2 = make_component("openssl", "1.1.1", None);
774        let key2 = ComponentSortKey::from_component(&comp2);
775        assert!(!filter.matches(&comp2, &key2));
776    }
777
778    #[test]
779    fn test_filter_name_match() {
780        let filter = QueryFilter {
781            name: Some("openssl".to_string()),
782            ..Default::default()
783        };
784
785        let comp = make_component("openssl", "3.0.0", None);
786        let key = ComponentSortKey::from_component(&comp);
787        assert!(filter.matches(&comp, &key));
788
789        let comp2 = make_component("libssl", "1.0", None);
790        let key2 = ComponentSortKey::from_component(&comp2);
791        assert!(!filter.matches(&comp2, &key2));
792    }
793
794    #[test]
795    fn test_filter_version_exact() {
796        let filter = QueryFilter {
797            version: Some("2.14.1".to_string()),
798            ..Default::default()
799        };
800
801        let comp = make_component("log4j-core", "2.14.1", None);
802        let key = ComponentSortKey::from_component(&comp);
803        assert!(filter.matches(&comp, &key));
804
805        let comp2 = make_component("log4j-core", "2.17.0", None);
806        let key2 = ComponentSortKey::from_component(&comp2);
807        assert!(!filter.matches(&comp2, &key2));
808    }
809
810    #[test]
811    fn test_filter_version_semver_range() {
812        let filter = QueryFilter {
813            version: Some("<2.17.0".to_string()),
814            ..Default::default()
815        };
816
817        let comp = make_component("log4j-core", "2.14.1", None);
818        let key = ComponentSortKey::from_component(&comp);
819        assert!(filter.matches(&comp, &key));
820
821        let comp2 = make_component("log4j-core", "2.17.0", None);
822        let key2 = ComponentSortKey::from_component(&comp2);
823        assert!(!filter.matches(&comp2, &key2));
824
825        let comp3 = make_component("log4j-core", "2.18.0", None);
826        let key3 = ComponentSortKey::from_component(&comp3);
827        assert!(!filter.matches(&comp3, &key3));
828    }
829
830    #[test]
831    fn test_filter_license_match() {
832        let filter = QueryFilter {
833            license: Some("Apache".to_string()),
834            ..Default::default()
835        };
836
837        let mut comp = make_component("log4j-core", "2.14.1", None);
838        comp.licenses
839            .add_declared(crate::model::LicenseExpression::new(
840                "Apache-2.0".to_string(),
841            ));
842        let key = ComponentSortKey::from_component(&comp);
843        assert!(filter.matches(&comp, &key));
844
845        let comp2 = make_component("some-lib", "1.0.0", None);
846        let key2 = ComponentSortKey::from_component(&comp2);
847        assert!(!filter.matches(&comp2, &key2));
848    }
849
850    #[test]
851    fn test_filter_ecosystem_match() {
852        let filter = QueryFilter {
853            ecosystem: Some("npm".to_string()),
854            ..Default::default()
855        };
856
857        let mut comp = make_component("lodash", "4.17.21", None);
858        comp.ecosystem = Some(crate::model::Ecosystem::Npm);
859        let key = ComponentSortKey::from_component(&comp);
860        assert!(filter.matches(&comp, &key));
861
862        let mut comp2 = make_component("serde", "1.0", None);
863        comp2.ecosystem = Some(crate::model::Ecosystem::Cargo);
864        let key2 = ComponentSortKey::from_component(&comp2);
865        assert!(!filter.matches(&comp2, &key2));
866    }
867
868    #[test]
869    fn test_filter_affected_by() {
870        let filter = QueryFilter {
871            affected_by: Some("CVE-2021-44228".to_string()),
872            ..Default::default()
873        };
874
875        let mut comp = make_component("log4j-core", "2.14.1", None);
876        comp.vulnerabilities
877            .push(crate::model::VulnerabilityRef::new(
878                "CVE-2021-44228".to_string(),
879                crate::model::VulnerabilitySource::Osv,
880            ));
881        let key = ComponentSortKey::from_component(&comp);
882        assert!(filter.matches(&comp, &key));
883
884        let comp2 = make_component("log4j-core", "2.17.0", None);
885        let key2 = ComponentSortKey::from_component(&comp2);
886        assert!(!filter.matches(&comp2, &key2));
887    }
888
889    #[test]
890    fn test_filter_combined() {
891        let filter = QueryFilter {
892            name: Some("log4j".to_string()),
893            version: Some("<2.17.0".to_string()),
894            ..Default::default()
895        };
896
897        let comp = make_component("log4j-core", "2.14.1", None);
898        let key = ComponentSortKey::from_component(&comp);
899        assert!(filter.matches(&comp, &key));
900
901        // Name matches but version doesn't
902        let comp2 = make_component("log4j-core", "2.17.0", None);
903        let key2 = ComponentSortKey::from_component(&comp2);
904        assert!(!filter.matches(&comp2, &key2));
905
906        // Version matches but name doesn't
907        let comp3 = make_component("openssl", "2.14.1", None);
908        let key3 = ComponentSortKey::from_component(&comp3);
909        assert!(!filter.matches(&comp3, &key3));
910    }
911
912    #[test]
913    fn test_dedup_merges_sources() {
914        let source1 = SbomSource {
915            name: "sbom1".to_string(),
916            path: "sbom1.json".to_string(),
917        };
918        let source2 = SbomSource {
919            name: "sbom2".to_string(),
920            path: "sbom2.json".to_string(),
921        };
922
923        let comp = make_component("lodash", "4.17.21", None);
924
925        let mut dedup_map: HashMap<(String, String), QueryMatch> = HashMap::new();
926        let key = ("lodash".to_string(), "4.17.21".to_string());
927
928        dedup_map.insert(key.clone(), build_query_match(&comp, source1));
929        dedup_map.entry(key).and_modify(|existing| {
930            existing.found_in.push(source2);
931        });
932
933        let match_entry = dedup_map.values().next().expect("should have one entry");
934        assert_eq!(match_entry.found_in.len(), 2);
935        assert_eq!(match_entry.found_in[0].name, "sbom1");
936        assert_eq!(match_entry.found_in[1].name, "sbom2");
937    }
938
939    #[test]
940    fn test_filter_is_empty() {
941        let filter = QueryFilter::default();
942        assert!(filter.is_empty());
943
944        let filter = QueryFilter {
945            pattern: Some("test".to_string()),
946            ..Default::default()
947        };
948        assert!(!filter.is_empty());
949    }
950
951    #[test]
952    fn test_filter_description() {
953        let filter = QueryFilter {
954            pattern: Some("log4j".to_string()),
955            version: Some("<2.17.0".to_string()),
956            ..Default::default()
957        };
958        let desc = filter.description();
959        assert!(desc.contains("\"log4j\""));
960        assert!(desc.contains("version=<2.17.0"));
961        assert!(desc.contains("AND"));
962    }
963
964    #[test]
965    fn test_csv_escape() {
966        assert_eq!(csv_escape("hello"), "hello");
967        assert_eq!(csv_escape("hello,world"), "\"hello,world\"");
968        assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\"");
969    }
970
971    #[test]
972    fn test_truncate() {
973        assert_eq!(truncate("short", 10), "short");
974        assert_eq!(truncate("long string here", 10), "long st...");
975        assert_eq!(truncate("ab", 2), "ab");
976    }
977
978    #[test]
979    fn test_format_table_empty_results() {
980        let result = QueryResult {
981            filter: "\"nonexistent\"".to_string(),
982            sboms_searched: 1,
983            total_components: 100,
984            matches: vec![],
985            sbom_summaries: vec![],
986        };
987        let output = format_table_output(&result);
988        assert!(output.contains("0 components found"));
989    }
990
991    #[test]
992    fn test_format_csv_output() {
993        let result = QueryResult {
994            filter: "test".to_string(),
995            sboms_searched: 1,
996            total_components: 10,
997            matches: vec![QueryMatch {
998                name: "lodash".to_string(),
999                version: "4.17.21".to_string(),
1000                ecosystem: "npm".to_string(),
1001                license: "MIT".to_string(),
1002                purl: "pkg:npm/lodash@4.17.21".to_string(),
1003                supplier: String::new(),
1004                vuln_count: 0,
1005                vuln_ids: vec![],
1006                found_in: vec![SbomSource {
1007                    name: "sbom1".to_string(),
1008                    path: "sbom1.json".to_string(),
1009                }],
1010                eol_status: String::new(),
1011                crypto_asset_type: None,
1012                crypto_quantum_level: None,
1013            }],
1014            sbom_summaries: vec![],
1015        };
1016        let csv = format_csv_output(&result);
1017        assert!(csv.starts_with("Component,Version"));
1018        assert!(csv.contains("lodash,4.17.21,npm,MIT"));
1019    }
1020
1021    fn make_crypto_component(
1022        name: &str,
1023        asset_type: crate::model::CryptoAssetType,
1024        ql: Option<u8>,
1025    ) -> Component {
1026        let mut c = Component::new(name.to_string(), format!("{name}@1.0"));
1027        c.component_type = ComponentType::Cryptographic;
1028        let mut props = crate::model::CryptoProperties::new(asset_type);
1029        if let Some(level) = ql {
1030            props = props.with_algorithm_properties(
1031                crate::model::AlgorithmProperties::new(crate::model::CryptoPrimitive::Ae)
1032                    .with_nist_quantum_security_level(level),
1033            );
1034        }
1035        c.crypto_properties = Some(props);
1036        c
1037    }
1038
1039    #[test]
1040    fn test_filter_crypto_type_algorithm() {
1041        let comp = make_crypto_component("AES-256", CryptoAssetType::Algorithm, Some(1));
1042        let key = ComponentSortKey::from_component(&comp);
1043        let filter = QueryFilter {
1044            crypto_type: Some("algorithm".to_string()),
1045            ..Default::default()
1046        };
1047        assert!(filter.matches(&comp, &key));
1048
1049        let filter2 = QueryFilter {
1050            crypto_type: Some("certificate".to_string()),
1051            ..Default::default()
1052        };
1053        assert!(!filter2.matches(&comp, &key));
1054    }
1055
1056    #[test]
1057    fn test_filter_quantum_safe() {
1058        let safe = make_crypto_component("ML-KEM-1024", CryptoAssetType::Algorithm, Some(5));
1059        let key_safe = ComponentSortKey::from_component(&safe);
1060        let vuln = make_crypto_component("RSA-2048", CryptoAssetType::Algorithm, Some(0));
1061        let key_vuln = ComponentSortKey::from_component(&vuln);
1062
1063        let filter = QueryFilter {
1064            quantum_safe: Some(true),
1065            ..Default::default()
1066        };
1067        assert!(filter.matches(&safe, &key_safe));
1068        assert!(!filter.matches(&vuln, &key_vuln));
1069    }
1070
1071    #[test]
1072    fn test_filter_quantum_vulnerable() {
1073        let vuln = make_crypto_component("RSA-2048", CryptoAssetType::Algorithm, Some(0));
1074        let key = ComponentSortKey::from_component(&vuln);
1075
1076        let filter = QueryFilter {
1077            quantum_safe: Some(false),
1078            ..Default::default()
1079        };
1080        assert!(filter.matches(&vuln, &key));
1081    }
1082
1083    #[test]
1084    fn test_filter_algorithm_family() {
1085        let mut comp = make_crypto_component("AES-256-GCM", CryptoAssetType::Algorithm, Some(1));
1086        if let Some(ref mut cp) = comp.crypto_properties {
1087            if let Some(ref mut algo) = cp.algorithm_properties {
1088                algo.algorithm_family = Some("AES".to_string());
1089            }
1090        }
1091        let key = ComponentSortKey::from_component(&comp);
1092
1093        let filter = QueryFilter {
1094            algorithm_family: Some("AES".to_string()),
1095            ..Default::default()
1096        };
1097        assert!(filter.matches(&comp, &key));
1098
1099        let filter2 = QueryFilter {
1100            algorithm_family: Some("RSA".to_string()),
1101            ..Default::default()
1102        };
1103        assert!(!filter2.matches(&comp, &key));
1104    }
1105}