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    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/// Run the view command
15#[allow(clippy::needless_pass_by_value)]
16pub fn run_view(config: ViewConfig) -> Result<i32> {
17    let mut parsed = parse_sbom_with_context(&config.sbom_path, false)?;
18
19    // Enrich with OSV vulnerability data if enabled
20    #[cfg(feature = "enrichment")]
21    let mut enrichment_warnings: Vec<&str> = Vec::new();
22
23    #[cfg(feature = "enrichment")]
24    if config.enrichment.enabled {
25        let osv_config = crate::pipeline::build_enrichment_config(&config.enrichment);
26        if crate::pipeline::enrich_sbom(parsed.sbom_mut(), &osv_config, false).is_none() {
27            enrichment_warnings.push("OSV vulnerability enrichment failed");
28        }
29    }
30
31    // Enrich with end-of-life data if enabled
32    #[cfg(feature = "enrichment")]
33    if config.enrichment.enable_eol {
34        let eol_config = crate::enrichment::EolClientConfig {
35            cache_dir: config
36                .enrichment
37                .cache_dir
38                .clone()
39                .unwrap_or_else(crate::pipeline::dirs::eol_cache_dir),
40            cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
41            bypass_cache: config.enrichment.bypass_cache,
42            timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
43            ..Default::default()
44        };
45        if crate::pipeline::enrich_eol(parsed.sbom_mut(), &eol_config, false).is_none() {
46            enrichment_warnings.push("EOL enrichment failed");
47        }
48    }
49
50    // Enrich with VEX data if VEX documents provided
51    #[cfg(feature = "enrichment")]
52    if !config.enrichment.vex_paths.is_empty()
53        && crate::pipeline::enrich_vex(parsed.sbom_mut(), &config.enrichment.vex_paths, false)
54            .is_none()
55    {
56        enrichment_warnings.push("VEX enrichment failed");
57    }
58
59    // Warn if enrichment requested but feature not enabled
60    #[cfg(not(feature = "enrichment"))]
61    if config.enrichment.enabled || config.enrichment.enable_eol {
62        eprintln!(
63            "Warning: enrichment requested but the 'enrichment' feature is not enabled. \
64             Rebuild with: cargo build --features enrichment"
65        );
66    }
67
68    // Apply filters to SBOM
69    let filtered_count = apply_view_filters(parsed.sbom_mut(), &config);
70    if filtered_count > 0 {
71        tracing::info!(
72            "Filtered to {} components (removed {})",
73            parsed.sbom().component_count(),
74            filtered_count
75        );
76    }
77
78    // Run NTIA validation if requested
79    if config.validate_ntia {
80        super::validate::validate_ntia_elements(parsed.sbom())?;
81    }
82
83    // Output the result
84    let output_target = OutputTarget::from_option(config.output.file.clone());
85    let effective_output = auto_detect_format(config.output.format, &output_target);
86
87    // Check for vulnerabilities before rendering (for --fail-on-vuln exit code)
88    let vuln_count: usize = parsed
89        .sbom()
90        .components
91        .values()
92        .map(|c| c.vulnerabilities.len())
93        .sum();
94
95    if effective_output == ReportFormat::Tui {
96        let (sbom, raw_content) = parsed.into_parts();
97        let mut app = ViewApp::new(sbom, &raw_content);
98        app.export_template = config.output.export_template.clone();
99
100        // Show enrichment warnings in TUI footer
101        #[cfg(feature = "enrichment")]
102        if !enrichment_warnings.is_empty() {
103            app.set_status_message(format!("Warning: {}", enrichment_warnings.join(", ")));
104            app.status_sticky = true;
105        }
106
107        run_view_tui(&mut app)?;
108    } else {
109        parsed.drop_raw_content();
110        output_view_report(&config, parsed.sbom(), &output_target)?;
111    }
112
113    if config.fail_on_vuln && vuln_count > 0 {
114        return Ok(crate::pipeline::exit_codes::VULNS_INTRODUCED);
115    }
116
117    Ok(crate::pipeline::exit_codes::SUCCESS)
118}
119
120/// Apply view filters to the SBOM, returns number of components removed
121pub fn apply_view_filters(sbom: &mut NormalizedSbom, config: &ViewConfig) -> usize {
122    let original_count = sbom.component_count();
123
124    // Parse minimum severity if provided
125    let min_severity = config.min_severity.as_ref().map(|s| parse_severity(s));
126
127    // Parse ecosystem filter if provided
128    let ecosystem_filter = config.ecosystem_filter.as_ref().map(|e| e.to_lowercase());
129
130    // Collect keys to remove
131    let keys_to_remove: Vec<_> = sbom
132        .components
133        .iter()
134        .filter_map(|(key, comp)| {
135            // Check vulnerable_only filter
136            if config.vulnerable_only && comp.vulnerabilities.is_empty() {
137                return Some(key.clone());
138            }
139
140            // Check severity filter
141            if let Some(min_sev) = &min_severity {
142                let has_matching_vuln = comp.vulnerabilities.iter().any(|v| {
143                    v.severity
144                        .as_ref()
145                        .is_some_and(|s| severity_meets_minimum(s, min_sev))
146                });
147                if !has_matching_vuln && !comp.vulnerabilities.is_empty() {
148                    return Some(key.clone());
149                }
150                // If vulnerable_only is set and min_severity is set, only keep vulns meeting threshold
151                if config.vulnerable_only && !has_matching_vuln {
152                    return Some(key.clone());
153                }
154            }
155
156            // Check ecosystem filter
157            if let Some(eco_filter) = &ecosystem_filter {
158                let comp_eco = comp
159                    .ecosystem
160                    .as_ref()
161                    .map(|e| format!("{e:?}").to_lowercase())
162                    .unwrap_or_default();
163                if !comp_eco.contains(eco_filter) {
164                    return Some(key.clone());
165                }
166            }
167
168            None
169        })
170        .collect();
171
172    // Remove filtered components
173    for key in &keys_to_remove {
174        sbom.components.shift_remove(key);
175    }
176
177    original_count - sbom.component_count()
178}
179
180/// Parse severity string into Severity enum
181fn parse_severity(s: &str) -> Severity {
182    match s.to_lowercase().as_str() {
183        "critical" => Severity::Critical,
184        "high" => Severity::High,
185        "medium" => Severity::Medium,
186        "low" => Severity::Low,
187        _ => Severity::Unknown,
188    }
189}
190
191/// Check if a severity meets the minimum threshold
192pub fn severity_meets_minimum(severity: &Severity, minimum: &Severity) -> bool {
193    let severity_order = |s: &Severity| match s {
194        Severity::Critical => 4,
195        Severity::High => 3,
196        Severity::Medium => 2,
197        Severity::Low => 1,
198        Severity::Info | Severity::None | Severity::Unknown => 0,
199    };
200
201    severity_order(severity) >= severity_order(minimum)
202}
203
204/// Output view report to file or stdout
205fn output_view_report(
206    config: &ViewConfig,
207    sbom: &NormalizedSbom,
208    output_target: &OutputTarget,
209) -> Result<()> {
210    let effective_output = auto_detect_format(config.output.format, output_target);
211
212    // Pre-compute CRA compliance once for reporters
213    let cra_result =
214        crate::quality::ComplianceChecker::new(crate::quality::ComplianceLevel::CraPhase2)
215            .check(sbom);
216
217    let report_config = ReportConfig {
218        report_types: vec![config.output.report_types],
219        metadata: crate::reports::ReportMetadata {
220            old_sbom_path: Some(config.sbom_path.to_string_lossy().to_string()),
221            ..Default::default()
222        },
223        view_cra_compliance: Some(cra_result),
224        ..Default::default()
225    };
226
227    let use_color = should_use_color(config.output.no_color);
228    let reporter = create_reporter_with_options(effective_output, use_color);
229    let report = reporter.generate_view_report(sbom, &report_config)?;
230
231    write_output(&report, output_target, false)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_parse_severity() {
240        assert!(matches!(parse_severity("critical"), Severity::Critical));
241        assert!(matches!(parse_severity("HIGH"), Severity::High));
242        assert!(matches!(parse_severity("Medium"), Severity::Medium));
243        assert!(matches!(parse_severity("low"), Severity::Low));
244        assert!(matches!(parse_severity("unknown"), Severity::Unknown));
245        assert!(matches!(parse_severity("invalid"), Severity::Unknown));
246    }
247
248    #[test]
249    fn test_severity_meets_minimum() {
250        assert!(severity_meets_minimum(&Severity::Critical, &Severity::High));
251        assert!(severity_meets_minimum(&Severity::High, &Severity::High));
252        assert!(!severity_meets_minimum(&Severity::Medium, &Severity::High));
253        assert!(!severity_meets_minimum(&Severity::Low, &Severity::High));
254    }
255
256    #[test]
257    fn test_severity_order() {
258        assert!(severity_meets_minimum(&Severity::Critical, &Severity::Low));
259        assert!(severity_meets_minimum(
260            &Severity::Critical,
261            &Severity::Medium
262        ));
263        assert!(severity_meets_minimum(&Severity::Critical, &Severity::High));
264        assert!(severity_meets_minimum(
265            &Severity::Critical,
266            &Severity::Critical
267        ));
268    }
269
270    #[test]
271    fn test_apply_view_filters_no_filters() {
272        let mut sbom = NormalizedSbom::default();
273        let config = ViewConfig {
274            sbom_path: std::path::PathBuf::from("test.json"),
275            output: crate::config::OutputConfig {
276                format: ReportFormat::Summary,
277                file: None,
278                report_types: crate::reports::ReportType::All,
279                no_color: false,
280                streaming: crate::config::StreamingConfig::default(),
281                export_template: None,
282            },
283            validate_ntia: false,
284            min_severity: None,
285            vulnerable_only: false,
286            ecosystem_filter: None,
287            fail_on_vuln: false,
288            enrichment: crate::config::EnrichmentConfig::default(),
289        };
290
291        let removed = apply_view_filters(&mut sbom, &config);
292        assert_eq!(removed, 0);
293    }
294}