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