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