1use 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#[derive(Debug, Clone, Default)]
25pub struct QueryFilter {
26 pub pattern: Option<String>,
28 pub name: Option<String>,
30 pub purl: Option<String>,
32 pub version: Option<String>,
34 pub license: Option<String>,
36 pub ecosystem: Option<String>,
38 pub supplier: Option<String>,
40 pub affected_by: Option<String>,
42 pub crypto_type: Option<String>,
44 pub algorithm_family: Option<String>,
46 pub quantum_safe: Option<bool>,
48}
49
50impl QueryFilter {
51 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 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 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 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 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 return want_safe;
233 };
234 if want_safe {
235 algo.is_quantum_safe()
236 } else {
237 !algo.is_quantum_safe()
238 }
239 }
240
241 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 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#[derive(Debug, Clone, Serialize)]
310pub(crate) struct SbomSource {
311 pub name: String,
312 pub path: String,
313}
314
315#[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#[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#[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#[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 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 #[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 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 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 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 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
503fn 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#[cfg(feature = "enrichment")]
555fn enrich_if_needed(
556 mut sboms: Vec<NormalizedSbom>,
557 config: &crate::config::EnrichmentConfig,
558) -> Result<Vec<NormalizedSbom>> {
559 for sbom in &mut sboms {
562 crate::pipeline::enrich_sbom_full(sbom, config, false);
563 }
564 Ok(sboms)
565}
566
567fn 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 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 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 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
645fn 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 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
695fn 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
720fn 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
729fn 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#[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 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 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}