Skip to main content

sbom_tools/cli/
diff.rs

1//! Diff command handler.
2//!
3//! Implements the `diff` subcommand for comparing two SBOMs.
4
5use crate::config::DiffConfig;
6use crate::pipeline::{
7    OutputTarget, auto_detect_format, compute_diff, exit_codes, output_report,
8    parse_sbom_with_context,
9};
10use crate::reports::ReportFormat;
11use crate::tui::{App, run_tui};
12use anyhow::Result;
13
14/// Run the diff command, returning the desired exit code.
15///
16/// Enrichment is handled based on the `enrichment` feature flag and the
17/// `config.enrichment.enabled` setting. When the feature is disabled,
18/// enrichment settings are silently ignored.
19///
20/// The caller is responsible for calling `std::process::exit()` with the
21/// returned code when it is non-zero.
22#[allow(clippy::needless_pass_by_value)]
23pub fn run_diff(config: DiffConfig) -> Result<i32> {
24    let quiet = config.behavior.quiet;
25
26    // Parse SBOMs
27    let mut old_parsed = parse_sbom_with_context(&config.paths.old, quiet)?;
28    let mut new_parsed = parse_sbom_with_context(&config.paths.new, quiet)?;
29
30    if !quiet {
31        tracing::info!(
32            "Parsed {} components from old SBOM, {} from new SBOM",
33            old_parsed.sbom().component_count(),
34            new_parsed.sbom().component_count()
35        );
36    }
37
38    // Enrich with OSV vulnerability data if enabled (runtime feature check)
39    #[cfg(feature = "enrichment")]
40    let mut enrichment_warnings: Vec<&str> = Vec::new();
41
42    #[cfg(feature = "enrichment")]
43    let enrichment_stats = {
44        if config.enrichment.enabled {
45            let osv_config = crate::pipeline::build_enrichment_config(&config.enrichment);
46            let stats_old = crate::pipeline::enrich_sbom(old_parsed.sbom_mut(), &osv_config, quiet);
47            let stats_new = crate::pipeline::enrich_sbom(new_parsed.sbom_mut(), &osv_config, quiet);
48            if stats_old.is_none() || stats_new.is_none() {
49                enrichment_warnings.push("OSV vulnerability enrichment failed");
50            }
51            Some((stats_old, stats_new))
52        } else {
53            None
54        }
55    };
56
57    // Enrich with end-of-life data if enabled
58    #[cfg(feature = "enrichment")]
59    {
60        if config.enrichment.enable_eol {
61            let eol_config = crate::enrichment::EolClientConfig {
62                cache_dir: config
63                    .enrichment
64                    .cache_dir
65                    .clone()
66                    .unwrap_or_else(crate::pipeline::dirs::eol_cache_dir),
67                cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
68                bypass_cache: config.enrichment.bypass_cache,
69                timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
70                ..Default::default()
71            };
72            let eol_old = crate::pipeline::enrich_eol(old_parsed.sbom_mut(), &eol_config, quiet);
73            let eol_new = crate::pipeline::enrich_eol(new_parsed.sbom_mut(), &eol_config, quiet);
74            if eol_old.is_none() || eol_new.is_none() {
75                enrichment_warnings.push("EOL enrichment failed");
76            }
77        }
78    }
79
80    // Enrich with VEX data if VEX documents provided
81    #[cfg(feature = "enrichment")]
82    if !config.enrichment.vex_paths.is_empty() {
83        let vex_old =
84            crate::pipeline::enrich_vex(old_parsed.sbom_mut(), &config.enrichment.vex_paths, quiet);
85        let vex_new =
86            crate::pipeline::enrich_vex(new_parsed.sbom_mut(), &config.enrichment.vex_paths, quiet);
87        if vex_old.is_none() || vex_new.is_none() {
88            enrichment_warnings.push("VEX enrichment failed");
89        }
90    }
91
92    #[cfg(not(feature = "enrichment"))]
93    {
94        if config.enrichment.enabled {
95            eprintln!(
96                "Warning: enrichment requested but the 'enrichment' feature is not enabled. \
97                 Rebuild with: cargo build --features enrichment"
98            );
99        }
100    }
101
102    // Compute the diff
103    let result = compute_diff(&config, &old_parsed.sbom, &new_parsed.sbom)?;
104
105    // Determine exit code before potentially moving result into TUI
106    let exit_code = determine_exit_code(&config, &result);
107
108    // Route output
109    let output_target = OutputTarget::from_option(config.output.file.clone());
110    let effective_output = auto_detect_format(config.output.format, &output_target);
111
112    if effective_output == ReportFormat::Tui {
113        let (old_sbom, old_raw) = old_parsed.into_parts();
114        let (new_sbom, new_raw) = new_parsed.into_parts();
115
116        #[cfg(feature = "enrichment")]
117        let mut app = {
118            let app = App::new_diff(result, old_sbom, new_sbom, &old_raw, &new_raw);
119            if let Some((stats_old, stats_new)) = enrichment_stats {
120                app.with_enrichment_stats(stats_old, stats_new)
121            } else {
122                app
123            }
124        };
125
126        #[cfg(not(feature = "enrichment"))]
127        let mut app = App::new_diff(result, old_sbom, new_sbom, &old_raw, &new_raw);
128
129        // Set export template if configured
130        app.export_template = config.output.export_template.clone();
131
132        // Show enrichment warnings in TUI footer
133        #[cfg(feature = "enrichment")]
134        if !enrichment_warnings.is_empty() {
135            app.set_status_message(format!("Warning: {}", enrichment_warnings.join(", ")));
136            app.status_sticky = true;
137        }
138
139        run_tui(&mut app)?;
140    } else {
141        old_parsed.drop_raw_content();
142        new_parsed.drop_raw_content();
143        output_report(&config, &result, &old_parsed.sbom, &new_parsed.sbom)?;
144    }
145
146    Ok(exit_code)
147}
148
149/// Determine the appropriate exit code based on diff results and config flags.
150///
151/// Priority (highest exit code wins): VEX gaps (4) > vulns introduced (2) > changes (1).
152/// VEX gaps are checked first because they are more specific — a user who sets
153/// `--fail-on-vex-gap` wants to know about missing VEX statements, not just
154/// that vulns were introduced.
155fn determine_exit_code(config: &DiffConfig, result: &crate::diff::DiffResult) -> i32 {
156    // Check for VEX gaps first (most specific gate)
157    if config.filtering.fail_on_vex_gap {
158        let vex_summary = result.vulnerabilities.vex_summary();
159        let total_gaps = vex_summary.introduced_without_vex + vex_summary.persistent_without_vex;
160        if total_gaps > 0 {
161            eprintln!(
162                "VEX gap: {} vulnerability(ies) lack VEX statements ({} introduced, {} persistent)",
163                total_gaps, vex_summary.introduced_without_vex, vex_summary.persistent_without_vex,
164            );
165            return exit_codes::VEX_GAPS_FOUND;
166        }
167    }
168    if config.behavior.fail_on_vuln && result.summary.vulnerabilities_introduced > 0 {
169        return exit_codes::VULNS_INTRODUCED;
170    }
171    if config.behavior.fail_on_change && result.summary.total_changes > 0 {
172        return exit_codes::CHANGES_DETECTED;
173    }
174    exit_codes::SUCCESS
175}
176
177#[cfg(test)]
178mod tests {
179    use crate::pipeline::OutputTarget;
180    use std::path::PathBuf;
181
182    #[test]
183    fn test_output_target_conversion() {
184        let none_target = OutputTarget::from_option(None);
185        assert!(matches!(none_target, OutputTarget::Stdout));
186
187        let some_target = OutputTarget::from_option(Some(PathBuf::from("/tmp/test.json")));
188        assert!(matches!(some_target, OutputTarget::File(_)));
189    }
190}