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
16pub const QUERY_OUTPUT_FORMATS: &[ReportFormat] = &[
23 ReportFormat::Auto,
24 ReportFormat::Table,
25 ReportFormat::Json,
26 ReportFormat::Csv,
27 ReportFormat::Summary,
28];
29
30#[derive(Debug, Clone, Default)]
39pub struct QueryFilter {
40 pub pattern: Option<String>,
42 pub name: Option<String>,
44 pub purl: Option<String>,
46 pub version: Option<String>,
48 pub license: Option<String>,
50 pub ecosystem: Option<String>,
52 pub supplier: Option<String>,
54 pub affected_by: Option<String>,
56 pub crypto_type: Option<String>,
58 pub algorithm_family: Option<String>,
60 pub quantum_safe: Option<bool>,
62}
63
64impl QueryFilter {
65 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 let trimmed = version_filter.trim();
153 if version_filter_is_range(trimmed) {
154 let Ok(req) = semver::VersionReq::parse(trimmed) else {
155 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 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 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 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 return want_safe;
250 };
251 if want_safe {
252 algo.is_quantum_safe()
253 } else {
254 !algo.is_quantum_safe()
255 }
256 }
257
258 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 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
321fn 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
332fn 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#[derive(Debug, Clone, Serialize)]
351pub(crate) struct SbomSource {
352 pub name: String,
353 pub path: String,
354}
355
356#[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#[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#[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#[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 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 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 #[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 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 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 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
569fn 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
587fn 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#[cfg(feature = "enrichment")]
639fn enrich_if_needed(
640 mut sboms: Vec<NormalizedSbom>,
641 config: &crate::config::EnrichmentConfig,
642) -> Result<Vec<NormalizedSbom>> {
643 for sbom in &mut sboms {
646 crate::pipeline::enrich_sbom_full(sbom, config, false);
647 }
648 Ok(sboms)
649}
650
651fn 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 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 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 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
730fn 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 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
780fn 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
805fn 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
814fn 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#[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 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 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 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 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 let comp = make_component("foo", "1.5", None);
1096 let key = ComponentSortKey::from_component(&comp);
1097 assert!(!filter.matches(&comp, &key));
1098
1099 let comp2 = make_component("foo", "1.5.0", None);
1101 let key2 = ComponentSortKey::from_component(&comp2);
1102 assert!(filter.matches(&comp2, &key2));
1103
1104 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 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 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}