1use crate::config::ViewConfig;
6use crate::model::{BomProfile, NormalizedSbom, Severity};
7use crate::pipeline::{
8 OutputTarget, auto_detect_format, parse_sbom_with_context, should_use_color, write_output,
9};
10use crate::reports::{ReportConfig, ReportFormat, create_reporter_with_options};
11use crate::tui::{ViewApp, run_view_tui};
12use anyhow::Result;
13
14#[allow(clippy::needless_pass_by_value)]
16pub fn run_view(config: ViewConfig) -> Result<i32> {
17 if config.output.format == ReportFormat::OscalJson {
21 anyhow::bail!(
22 "output format 'oscal-json' is not supported by `sbom-tools view`; \
23 use `sbom-tools validate -o oscal-json` for OSCAL assessment results"
24 );
25 }
26 if config.output.format == ReportFormat::SbomqsJson {
30 anyhow::bail!(
31 "output format 'sbomqs-json' is not supported by `sbom-tools view`; \
32 use `sbom-tools quality -o sbomqs-json` for sbomqs-comparable scores"
33 );
34 }
35
36 if let Some(s) = config.min_severity.as_deref() {
39 parse_severity(s)?;
40 }
41
42 let cra_sidecar =
46 super::load_cra_sidecar(config.cra_sidecar_path.as_deref(), &config.sbom_path)?;
47
48 let cli_class = super::parse_cra_product_class(config.cra_product_class.as_deref())?;
53 let sidecar_class = cra_sidecar.as_ref().and_then(|s| s.product_class);
54 if let (Some(cli), Some(side)) = (cli_class, sidecar_class)
55 && cli != side
56 {
57 tracing::warn!(
58 "CRA product class mismatch: --cra-product-class={} but sidecar says {}; using sidecar.",
59 cli.label(),
60 side.label()
61 );
62 }
63 let effective_class = sidecar_class.or(cli_class);
64
65 let mut parsed = parse_sbom_with_context(&config.sbom_path, false)?;
66
67 #[cfg(feature = "enrichment")]
69 let mut enrichment_warnings: Vec<&str> = Vec::new();
70
71 #[cfg(feature = "enrichment")]
72 if config.enrichment.enabled {
73 let osv_config = crate::pipeline::build_enrichment_config(&config.enrichment);
74 if crate::pipeline::enrich_sbom(parsed.sbom_mut(), &osv_config, false).is_none() {
75 enrichment_warnings.push("OSV vulnerability enrichment failed");
76 }
77 }
78
79 #[cfg(feature = "enrichment")]
81 if config.enrichment.enable_eol {
82 let eol_config = crate::enrichment::EolClientConfig {
83 cache_dir: config
84 .enrichment
85 .cache_dir
86 .clone()
87 .unwrap_or_else(crate::pipeline::dirs::eol_cache_dir),
88 cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
89 bypass_cache: config.enrichment.bypass_cache,
90 timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
91 ..Default::default()
92 };
93 if crate::pipeline::enrich_eol(parsed.sbom_mut(), &eol_config, false).is_none() {
94 enrichment_warnings.push("EOL enrichment failed");
95 }
96 }
97
98 #[cfg(feature = "enrichment")]
100 if config.enrichment.enable_kev {
101 let mut kev_config = crate::enrichment::KevClientConfig {
102 cache_dir: config
103 .enrichment
104 .cache_dir
105 .clone()
106 .unwrap_or_else(crate::pipeline::dirs::kev_cache_dir),
107 cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
108 bypass_cache: config.enrichment.bypass_cache,
109 timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
110 ..Default::default()
111 };
112 if let Some(ref url) = config.enrichment.kev_url {
113 kev_config.kev_url = url.clone();
114 }
115 if crate::pipeline::enrich_kev(parsed.sbom_mut(), &kev_config, false).is_none() {
116 enrichment_warnings.push("KEV enrichment failed");
117 }
118 }
119
120 #[cfg(feature = "enrichment")]
122 if config.enrichment.enable_epss {
123 let mut epss_config = crate::enrichment::EpssClientConfig {
124 cache_dir: config
125 .enrichment
126 .cache_dir
127 .clone()
128 .unwrap_or_else(crate::pipeline::dirs::epss_cache_dir),
129 cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
130 bypass_cache: config.enrichment.bypass_cache,
131 timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
132 ..Default::default()
133 };
134 if let Some(ref url) = config.enrichment.epss_url {
135 epss_config.epss_url = url.clone();
136 }
137 if crate::pipeline::enrich_epss(parsed.sbom_mut(), &epss_config, false).is_none() {
138 enrichment_warnings.push("EPSS enrichment failed");
139 }
140 }
141
142 #[cfg(feature = "enrichment")]
144 if config.enrichment.enable_staleness {
145 let staleness_config = crate::enrichment::RegistryConfig {
146 cache_dir: config
147 .enrichment
148 .cache_dir
149 .clone()
150 .unwrap_or_else(crate::pipeline::dirs::staleness_cache_dir),
151 cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
152 bypass_cache: config.enrichment.bypass_cache,
153 timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
154 ..Default::default()
155 };
156 if crate::pipeline::enrich_staleness(parsed.sbom_mut(), &staleness_config, false).is_none()
157 {
158 enrichment_warnings.push("Staleness enrichment failed");
159 }
160 }
161
162 #[cfg(feature = "enrichment")]
164 if config.enrichment.enable_huggingface {
165 let mut hf_config = crate::enrichment::HuggingFaceConfig {
166 cache_dir: config
167 .enrichment
168 .cache_dir
169 .clone()
170 .unwrap_or_else(crate::pipeline::dirs::huggingface_cache_dir),
171 cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
172 bypass_cache: config.enrichment.bypass_cache,
173 timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
174 ..Default::default()
175 };
176 if let Some(ref url) = config.enrichment.huggingface_url {
177 hf_config.api_url = url.clone();
178 }
179 if crate::pipeline::enrich_huggingface(parsed.sbom_mut(), &hf_config, false).is_none() {
180 enrichment_warnings.push("HuggingFace enrichment failed");
181 }
182 }
183
184 #[cfg(feature = "enrichment")]
186 if !config.enrichment.vex_paths.is_empty()
187 && crate::pipeline::enrich_vex(parsed.sbom_mut(), &config.enrichment.vex_paths, false)
188 .is_none()
189 {
190 enrichment_warnings.push("VEX enrichment failed");
191 }
192
193 #[cfg(not(feature = "enrichment"))]
195 if config.enrichment.enabled
196 || config.enrichment.enable_eol
197 || config.enrichment.enable_kev
198 || config.enrichment.enable_epss
199 || config.enrichment.enable_staleness
200 {
201 eprintln!(
202 "Warning: enrichment requested but the 'enrichment' feature is not enabled. \
203 Rebuild with: cargo build --features enrichment"
204 );
205 }
206
207 let vuln_count: usize = parsed
212 .sbom()
213 .components
214 .values()
215 .map(|c| c.vulnerabilities.len())
216 .sum();
217
218 let filtered_count = apply_view_filters(parsed.sbom_mut(), &config)?;
220 if filtered_count > 0 {
221 tracing::info!(
222 "Filtered to {} components (removed {})",
223 parsed.sbom().component_count(),
224 filtered_count
225 );
226 }
227
228 if config.validate_ntia {
230 super::validate::print_ntia_validation(parsed.sbom());
231 }
232
233 let output_target = OutputTarget::from_option(config.output.file.clone());
235 let effective_output = auto_detect_format(config.output.format, &output_target);
236
237 let bom_profile = config
239 .bom_profile
240 .unwrap_or_else(|| BomProfile::detect(parsed.sbom()));
241 tracing::info!("BOM profile: {bom_profile}");
242
243 if effective_output == ReportFormat::Tui {
244 let (sbom, raw_content) = parsed.into_parts();
249 let mut app = ViewApp::new(sbom, &raw_content, bom_profile);
250 if let Some(sc) = cra_sidecar.clone() {
251 app = app.with_cra_sidecar(sc);
252 }
253 if let Some(c) = effective_class {
254 app = app.with_cra_product_class(c);
255 }
256 app.export_template = config.output.export_template.clone();
257
258 #[cfg(feature = "enrichment")]
260 if !enrichment_warnings.is_empty() {
261 app.set_status_message(format!("Warning: {}", enrichment_warnings.join(", ")));
262 app.status_sticky = true;
263 }
264
265 run_view_tui(&mut app, config.output.no_color)?;
266 } else {
267 parsed.drop_raw_content();
268 output_view_report(
269 &config,
270 cra_sidecar,
271 effective_class,
272 parsed.sbom(),
273 &output_target,
274 )?;
275 }
276
277 if config.fail_on_vuln && vuln_count > 0 {
278 return Ok(crate::pipeline::exit_codes::VULNS_INTRODUCED);
279 }
280
281 Ok(crate::pipeline::exit_codes::SUCCESS)
282}
283
284pub fn apply_view_filters(sbom: &mut NormalizedSbom, config: &ViewConfig) -> Result<usize> {
289 let original_count = sbom.component_count();
290
291 let min_severity = config
293 .min_severity
294 .as_deref()
295 .map(parse_severity)
296 .transpose()?;
297
298 let ecosystem_filter = config.ecosystem_filter.as_ref().map(|e| e.to_lowercase());
300
301 let keys_to_remove: Vec<_> = sbom
303 .components
304 .iter()
305 .filter_map(|(key, comp)| {
306 if config.vulnerable_only && comp.vulnerabilities.is_empty() {
308 return Some(key.clone());
309 }
310
311 if let Some(min_sev) = &min_severity {
313 let has_matching_vuln = comp.vulnerabilities.iter().any(|v| {
314 v.severity
315 .as_ref()
316 .is_some_and(|s| severity_meets_minimum(s, min_sev))
317 });
318 if !has_matching_vuln && !comp.vulnerabilities.is_empty() {
319 return Some(key.clone());
320 }
321 if config.vulnerable_only && !has_matching_vuln {
323 return Some(key.clone());
324 }
325 }
326
327 if let Some(eco_filter) = &ecosystem_filter {
329 let comp_eco = comp
330 .ecosystem
331 .as_ref()
332 .map(|e| format!("{e:?}").to_lowercase())
333 .unwrap_or_default();
334 if !comp_eco.contains(eco_filter) {
335 return Some(key.clone());
336 }
337 }
338
339 None
340 })
341 .collect();
342
343 for key in &keys_to_remove {
345 sbom.components.shift_remove(key);
346 }
347
348 Ok(original_count - sbom.component_count())
349}
350
351fn parse_severity(s: &str) -> Result<Severity> {
358 match s.to_lowercase().as_str() {
359 "critical" => Ok(Severity::Critical),
360 "high" => Ok(Severity::High),
361 "medium" => Ok(Severity::Medium),
362 "low" => Ok(Severity::Low),
363 _ => anyhow::bail!("invalid --severity '{s}'; valid values: critical, high, medium, low"),
364 }
365}
366
367pub fn severity_meets_minimum(severity: &Severity, minimum: &Severity) -> bool {
369 let severity_order = |s: &Severity| match s {
370 Severity::Critical => 4,
371 Severity::High => 3,
372 Severity::Medium => 2,
373 Severity::Low => 1,
374 Severity::Info | Severity::None | Severity::Unknown => 0,
375 };
376
377 severity_order(severity) >= severity_order(minimum)
378}
379
380fn output_view_report(
382 config: &ViewConfig,
383 sidecar: Option<crate::model::CraSidecarMetadata>,
384 effective_class: Option<crate::model::CraProductClass>,
385 sbom: &NormalizedSbom,
386 output_target: &OutputTarget,
387) -> Result<()> {
388 let effective_output = auto_detect_format(config.output.format, output_target);
389
390 let mut checker =
394 crate::quality::ComplianceChecker::new(crate::quality::ComplianceLevel::CraPhase2);
395 if let Some(sc) = sidecar {
396 checker = checker.with_sidecar(sc);
397 }
398 if let Some(c) = effective_class {
399 checker = checker.with_product_class(c);
400 }
401 let cra_result = checker.check(sbom);
402
403 let report_config = ReportConfig {
404 report_types: vec![config.output.report_types],
405 metadata: crate::reports::ReportMetadata {
406 old_sbom_path: Some(config.sbom_path.to_string_lossy().to_string()),
407 ..Default::default()
408 },
409 view_cra_compliance: Some(cra_result),
410 ..Default::default()
411 };
412
413 let use_color = should_use_color(config.output.no_color);
414 let reporter = create_reporter_with_options(effective_output, use_color);
415 let report = reporter.generate_view_report(sbom, &report_config)?;
416
417 write_output(&report, output_target, false)
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn test_parse_severity() {
426 assert!(matches!(parse_severity("critical"), Ok(Severity::Critical)));
427 assert!(matches!(parse_severity("HIGH"), Ok(Severity::High)));
428 assert!(matches!(parse_severity("Medium"), Ok(Severity::Medium)));
429 assert!(matches!(parse_severity("low"), Ok(Severity::Low)));
430 }
431
432 #[test]
433 fn test_parse_severity_rejects_unknown() {
434 for bad in ["banana", "unknown", "info", "none", ""] {
437 let err = parse_severity(bad).expect_err("should reject");
438 let msg = err.to_string();
439 assert!(msg.contains("invalid --severity"), "message: {msg}");
440 assert!(
441 msg.contains("critical, high, medium, low"),
442 "message should list valid values: {msg}"
443 );
444 }
445 }
446
447 #[test]
448 fn test_severity_meets_minimum() {
449 assert!(severity_meets_minimum(&Severity::Critical, &Severity::High));
450 assert!(severity_meets_minimum(&Severity::High, &Severity::High));
451 assert!(!severity_meets_minimum(&Severity::Medium, &Severity::High));
452 assert!(!severity_meets_minimum(&Severity::Low, &Severity::High));
453 }
454
455 #[test]
456 fn test_severity_order() {
457 assert!(severity_meets_minimum(&Severity::Critical, &Severity::Low));
458 assert!(severity_meets_minimum(
459 &Severity::Critical,
460 &Severity::Medium
461 ));
462 assert!(severity_meets_minimum(&Severity::Critical, &Severity::High));
463 assert!(severity_meets_minimum(
464 &Severity::Critical,
465 &Severity::Critical
466 ));
467 }
468
469 fn test_view_config(min_severity: Option<&str>) -> ViewConfig {
470 ViewConfig {
471 sbom_path: std::path::PathBuf::from("test.json"),
472 output: crate::config::OutputConfig {
473 format: ReportFormat::Summary,
474 file: None,
475 report_types: crate::reports::ReportType::All,
476 no_color: false,
477 streaming: crate::config::StreamingConfig::default(),
478 export_template: None,
479 },
480 validate_ntia: false,
481 min_severity: min_severity.map(str::to_string),
482 vulnerable_only: false,
483 ecosystem_filter: None,
484 fail_on_vuln: false,
485 bom_profile: None,
486 enrichment: crate::config::EnrichmentConfig::default(),
487 cra_sidecar_path: None,
488 cra_product_class: None,
489 }
490 }
491
492 #[test]
493 fn test_apply_view_filters_no_filters() {
494 let mut sbom = NormalizedSbom::default();
495 let config = test_view_config(None);
496
497 let removed = apply_view_filters(&mut sbom, &config).expect("no filters should succeed");
498 assert_eq!(removed, 0);
499 }
500
501 #[test]
502 fn test_apply_view_filters_invalid_severity_errors() {
503 let mut sbom = NormalizedSbom::default();
504 let config = test_view_config(Some("banana"));
505
506 let err = apply_view_filters(&mut sbom, &config).expect_err("should reject");
507 assert!(err.to_string().contains("invalid --severity"));
508 }
509}