Skip to main content

sbom_tools/cli/
view.rs

1//! View command handler.
2//!
3//! Implements the `view` subcommand for viewing a single SBOM.
4
5use crate::config::ViewConfig;
6use crate::model::{NormalizedSbom, Severity};
7use crate::pipeline::{
8    auto_detect_format, parse_sbom_with_context, should_use_color, write_output, OutputTarget,
9};
10use crate::reports::{create_reporter_with_options, ReportConfig, ReportFormat};
11use crate::tui::{run_view_tui, ViewApp};
12use anyhow::Result;
13
14/// Run the view command
15pub fn run_view(config: ViewConfig) -> Result<()> {
16    let mut parsed = parse_sbom_with_context(&config.sbom_path, false)?;
17
18    // Apply filters to SBOM
19    let filtered_count = apply_view_filters(parsed.sbom_mut(), &config);
20    if filtered_count > 0 {
21        tracing::info!(
22            "Filtered to {} components (removed {})",
23            parsed.sbom().component_count(),
24            filtered_count
25        );
26    }
27
28    // Run NTIA validation if requested
29    if config.validate_ntia {
30        super::validate::validate_ntia_elements(parsed.sbom())?;
31    }
32
33    // Output the result
34    let output_target = OutputTarget::from_option(config.output.file.clone());
35    let effective_output = auto_detect_format(config.output.format, &output_target);
36
37    if effective_output == ReportFormat::Tui {
38        let (sbom, raw_content) = parsed.into_parts();
39        let mut app = ViewApp::new(sbom, raw_content);
40        run_view_tui(&mut app)?;
41    } else {
42        parsed.drop_raw_content();
43        output_view_report(&config, parsed.sbom(), &output_target)?;
44    }
45
46    Ok(())
47}
48
49/// Apply view filters to the SBOM, returns number of components removed
50pub fn apply_view_filters(sbom: &mut NormalizedSbom, config: &ViewConfig) -> usize {
51    let original_count = sbom.component_count();
52
53    // Parse minimum severity if provided
54    let min_severity = config
55        .min_severity
56        .as_ref()
57        .map(|s| parse_severity(s));
58
59    // Parse ecosystem filter if provided
60    let ecosystem_filter = config.ecosystem_filter.as_ref().map(|e| e.to_lowercase());
61
62    // Collect keys to remove
63    let keys_to_remove: Vec<_> = sbom
64        .components
65        .iter()
66        .filter_map(|(key, comp)| {
67            // Check vulnerable_only filter
68            if config.vulnerable_only && comp.vulnerabilities.is_empty() {
69                return Some(key.clone());
70            }
71
72            // Check severity filter
73            if let Some(min_sev) = &min_severity {
74                let has_matching_vuln = comp.vulnerabilities.iter().any(|v| {
75                    v.severity
76                        .as_ref()
77                        .is_some_and(|s| severity_meets_minimum(s, min_sev))
78                });
79                if !has_matching_vuln && !comp.vulnerabilities.is_empty() {
80                    return Some(key.clone());
81                }
82                // If vulnerable_only is set and min_severity is set, only keep vulns meeting threshold
83                if config.vulnerable_only && !has_matching_vuln {
84                    return Some(key.clone());
85                }
86            }
87
88            // Check ecosystem filter
89            if let Some(eco_filter) = &ecosystem_filter {
90                let comp_eco = comp
91                    .ecosystem
92                    .as_ref()
93                    .map(|e| format!("{:?}", e).to_lowercase())
94                    .unwrap_or_default();
95                if !comp_eco.contains(eco_filter) {
96                    return Some(key.clone());
97                }
98            }
99
100            None
101        })
102        .collect();
103
104    // Remove filtered components
105    for key in &keys_to_remove {
106        sbom.components.shift_remove(key);
107    }
108
109    original_count - sbom.component_count()
110}
111
112/// Parse severity string into Severity enum
113fn parse_severity(s: &str) -> Severity {
114    match s.to_lowercase().as_str() {
115        "critical" => Severity::Critical,
116        "high" => Severity::High,
117        "medium" => Severity::Medium,
118        "low" => Severity::Low,
119        _ => Severity::Unknown,
120    }
121}
122
123/// Check if a severity meets the minimum threshold
124pub fn severity_meets_minimum(severity: &Severity, minimum: &Severity) -> bool {
125    let severity_order = |s: &Severity| match s {
126        Severity::Critical => 4,
127        Severity::High => 3,
128        Severity::Medium => 2,
129        Severity::Low => 1,
130        Severity::Info => 0,
131        Severity::None => 0,
132        Severity::Unknown => 0,
133    };
134
135    severity_order(severity) >= severity_order(minimum)
136}
137
138/// Output view report to file or stdout
139fn output_view_report(
140    config: &ViewConfig,
141    sbom: &NormalizedSbom,
142    output_target: &OutputTarget,
143) -> Result<()> {
144    let effective_output = auto_detect_format(config.output.format, output_target);
145
146    // Pre-compute CRA compliance once for reporters
147    let cra_result = crate::quality::ComplianceChecker::new(crate::quality::ComplianceLevel::CraPhase2)
148        .check(sbom);
149
150    let report_config = ReportConfig {
151        metadata: crate::reports::ReportMetadata {
152            old_sbom_path: Some(config.sbom_path.to_string_lossy().to_string()),
153            ..Default::default()
154        },
155        view_cra_compliance: Some(cra_result),
156        ..Default::default()
157    };
158
159    let use_color = should_use_color(config.output.no_color);
160    let reporter = create_reporter_with_options(effective_output, use_color);
161    let report = reporter.generate_view_report(sbom, &report_config)?;
162
163    write_output(&report, output_target, false)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_parse_severity() {
172        assert!(matches!(parse_severity("critical"), Severity::Critical));
173        assert!(matches!(parse_severity("HIGH"), Severity::High));
174        assert!(matches!(parse_severity("Medium"), Severity::Medium));
175        assert!(matches!(parse_severity("low"), Severity::Low));
176        assert!(matches!(parse_severity("unknown"), Severity::Unknown));
177        assert!(matches!(parse_severity("invalid"), Severity::Unknown));
178    }
179
180    #[test]
181    fn test_severity_meets_minimum() {
182        assert!(severity_meets_minimum(&Severity::Critical, &Severity::High));
183        assert!(severity_meets_minimum(&Severity::High, &Severity::High));
184        assert!(!severity_meets_minimum(&Severity::Medium, &Severity::High));
185        assert!(!severity_meets_minimum(&Severity::Low, &Severity::High));
186    }
187
188    #[test]
189    fn test_severity_order() {
190        assert!(severity_meets_minimum(&Severity::Critical, &Severity::Low));
191        assert!(severity_meets_minimum(&Severity::Critical, &Severity::Medium));
192        assert!(severity_meets_minimum(&Severity::Critical, &Severity::High));
193        assert!(severity_meets_minimum(&Severity::Critical, &Severity::Critical));
194    }
195
196    #[test]
197    fn test_apply_view_filters_no_filters() {
198        let mut sbom = NormalizedSbom::default();
199        let config = ViewConfig {
200            sbom_path: std::path::PathBuf::from("test.json"),
201            output: crate::config::OutputConfig {
202                format: ReportFormat::Summary,
203                file: None,
204                report_types: crate::reports::ReportType::All,
205                no_color: false,
206                streaming: crate::config::StreamingConfig::default(),
207            },
208            validate_ntia: false,
209            min_severity: None,
210            vulnerable_only: false,
211            ecosystem_filter: None,
212        };
213
214        let removed = apply_view_filters(&mut sbom, &config);
215        assert_eq!(removed, 0);
216    }
217}