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, is_stdin_path, output_report,
8    parse_sbom_with_context,
9};
10use crate::reports::ReportFormat;
11use crate::tui::{App, run_tui};
12use anyhow::{Result, bail};
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    // Stdin can only be consumed once, so a diff can read at most one side from "-".
27    if is_stdin_path(&config.paths.old) && is_stdin_path(&config.paths.new) {
28        bail!("Cannot read both SBOMs from stdin ('-'); only one '-' is allowed per diff");
29    }
30
31    // Parse SBOMs
32    let mut old_parsed = parse_sbom_with_context(&config.paths.old, quiet)?;
33    let mut new_parsed = parse_sbom_with_context(&config.paths.new, quiet)?;
34
35    if !quiet {
36        tracing::info!(
37            "Parsed {} components from old SBOM, {} from new SBOM",
38            old_parsed.sbom().component_count(),
39            new_parsed.sbom().component_count()
40        );
41    }
42
43    // Enrich with OSV vulnerability data if enabled (runtime feature check)
44    #[cfg(feature = "enrichment")]
45    let mut enrichment_warnings: Vec<&str> = Vec::new();
46
47    #[cfg(feature = "enrichment")]
48    let enrichment_stats = {
49        if config.enrichment.enabled {
50            let osv_config = crate::pipeline::build_enrichment_config(&config.enrichment);
51            let stats_old = crate::pipeline::enrich_sbom(old_parsed.sbom_mut(), &osv_config, quiet);
52            let stats_new = crate::pipeline::enrich_sbom(new_parsed.sbom_mut(), &osv_config, quiet);
53            if stats_old.is_none() || stats_new.is_none() {
54                enrichment_warnings.push("OSV vulnerability enrichment failed");
55            }
56            Some((stats_old, stats_new))
57        } else {
58            None
59        }
60    };
61
62    // Enrich with end-of-life data if enabled
63    #[cfg(feature = "enrichment")]
64    {
65        if config.enrichment.enable_eol {
66            let eol_config = crate::enrichment::EolClientConfig {
67                cache_dir: config
68                    .enrichment
69                    .cache_dir
70                    .clone()
71                    .unwrap_or_else(crate::pipeline::dirs::eol_cache_dir),
72                cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
73                bypass_cache: config.enrichment.bypass_cache,
74                timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
75                ..Default::default()
76            };
77            let eol_old = crate::pipeline::enrich_eol(old_parsed.sbom_mut(), &eol_config, quiet);
78            let eol_new = crate::pipeline::enrich_eol(new_parsed.sbom_mut(), &eol_config, quiet);
79            if eol_old.is_none() || eol_new.is_none() {
80                enrichment_warnings.push("EOL enrichment failed");
81            }
82        }
83    }
84
85    // Enrich with CISA KEV catalog (flags actively exploited vulnerabilities)
86    #[cfg(feature = "enrichment")]
87    if config.enrichment.enable_kev {
88        let kev_config = kev_client_config(&config.enrichment);
89        let kev_old = crate::pipeline::enrich_kev(old_parsed.sbom_mut(), &kev_config, quiet);
90        let kev_new = crate::pipeline::enrich_kev(new_parsed.sbom_mut(), &kev_config, quiet);
91        if kev_old.is_none() || kev_new.is_none() {
92            enrichment_warnings.push("KEV enrichment failed");
93        }
94    }
95
96    // Enrich with FIRST EPSS exploit-probability scores
97    #[cfg(feature = "enrichment")]
98    if config.enrichment.enable_epss {
99        let epss_config = epss_client_config(&config.enrichment);
100        let epss_old = crate::pipeline::enrich_epss(old_parsed.sbom_mut(), &epss_config, quiet);
101        let epss_new = crate::pipeline::enrich_epss(new_parsed.sbom_mut(), &epss_config, quiet);
102        if epss_old.is_none() || epss_new.is_none() {
103            enrichment_warnings.push("EPSS enrichment failed");
104        }
105    }
106
107    // Enrich with dependency staleness data
108    #[cfg(feature = "enrichment")]
109    if config.enrichment.enable_staleness {
110        let staleness_config = crate::enrichment::RegistryConfig {
111            cache_dir: config
112                .enrichment
113                .cache_dir
114                .clone()
115                .unwrap_or_else(crate::pipeline::dirs::staleness_cache_dir),
116            cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
117            bypass_cache: config.enrichment.bypass_cache,
118            timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
119            ..Default::default()
120        };
121        let stale_old =
122            crate::pipeline::enrich_staleness(old_parsed.sbom_mut(), &staleness_config, quiet);
123        let stale_new =
124            crate::pipeline::enrich_staleness(new_parsed.sbom_mut(), &staleness_config, quiet);
125        if stale_old.is_none() || stale_new.is_none() {
126            enrichment_warnings.push("Staleness enrichment failed");
127        }
128    }
129
130    // Enrich ML-model components with HuggingFace Hub data (weight hashes, task)
131    #[cfg(feature = "enrichment")]
132    if config.enrichment.enable_huggingface {
133        let hf_config = huggingface_client_config(&config.enrichment);
134        let hf_old = crate::pipeline::enrich_huggingface(old_parsed.sbom_mut(), &hf_config, quiet);
135        let hf_new = crate::pipeline::enrich_huggingface(new_parsed.sbom_mut(), &hf_config, quiet);
136        if hf_old.is_none() || hf_new.is_none() {
137            enrichment_warnings.push("HuggingFace enrichment failed");
138        }
139    }
140
141    // Enrich with VEX data if VEX documents provided
142    #[cfg(feature = "enrichment")]
143    if !config.enrichment.vex_paths.is_empty() {
144        let vex_old =
145            crate::pipeline::enrich_vex(old_parsed.sbom_mut(), &config.enrichment.vex_paths, quiet);
146        let vex_new =
147            crate::pipeline::enrich_vex(new_parsed.sbom_mut(), &config.enrichment.vex_paths, quiet);
148        if vex_old.is_none() || vex_new.is_none() {
149            enrichment_warnings.push("VEX enrichment failed");
150        }
151    }
152
153    #[cfg(not(feature = "enrichment"))]
154    {
155        if config.enrichment.enabled {
156            eprintln!(
157                "Warning: enrichment requested but the 'enrichment' feature is not enabled. \
158                 Rebuild with: cargo build --features enrichment"
159            );
160        }
161    }
162
163    // Compute the diff
164    let result = compute_diff(&config, &old_parsed.sbom, &new_parsed.sbom)?;
165
166    // Determine exit code before potentially moving result into TUI
167    let exit_code = determine_exit_code(&config, &result);
168
169    // Route output
170    let output_target = OutputTarget::from_option(config.output.file.clone());
171    let effective_output = auto_detect_format(config.output.format, &output_target);
172
173    if effective_output == ReportFormat::Tui {
174        // Resolve the CRA sidecar (auto-discovered next to the new/"current"
175        // SBOM unless it reads from stdin) so the compliance tab applies the
176        // same sidecar-driven verdicts as the CLI — most importantly EU AI Act
177        // high-risk escalation, which otherwise renders COMPLIANT in the TUI.
178        let tui_sidecar = if is_stdin_path(&config.paths.new) {
179            None
180        } else {
181            crate::model::CraSidecarMetadata::find_for_sbom(&config.paths.new)
182        };
183
184        let (old_sbom, old_raw) = old_parsed.into_parts();
185        let (new_sbom, new_raw) = new_parsed.into_parts();
186
187        #[cfg(feature = "enrichment")]
188        let mut app = {
189            let app = App::new_diff(result, old_sbom, new_sbom, &old_raw, &new_raw);
190            if let Some((stats_old, stats_new)) = enrichment_stats {
191                app.with_enrichment_stats(stats_old, stats_new)
192            } else {
193                app
194            }
195        };
196
197        #[cfg(not(feature = "enrichment"))]
198        let mut app = App::new_diff(result, old_sbom, new_sbom, &old_raw, &new_raw);
199
200        if let Some(sc) = tui_sidecar {
201            app = app.with_cra_sidecar(sc);
202        }
203
204        // Set export template if configured
205        app.export_template = config.output.export_template.clone();
206
207        // Show enrichment warnings in TUI footer
208        #[cfg(feature = "enrichment")]
209        if !enrichment_warnings.is_empty() {
210            app.set_status_message(format!("Warning: {}", enrichment_warnings.join(", ")));
211            app.status_sticky = true;
212        }
213
214        run_tui(&mut app)?;
215    } else {
216        old_parsed.drop_raw_content();
217        new_parsed.drop_raw_content();
218        output_report(&config, &result, &old_parsed.sbom, &new_parsed.sbom)?;
219    }
220
221    Ok(exit_code)
222}
223
224/// Determine the appropriate exit code based on diff results and config flags.
225///
226/// Priority (highest exit code wins): VEX gaps (4) > vulns introduced (2) > changes (1).
227/// VEX gaps are checked first because they are more specific — a user who sets
228/// `--fail-on-vex-gap` wants to know about missing VEX statements, not just
229/// that vulns were introduced.
230fn determine_exit_code(config: &DiffConfig, result: &crate::diff::DiffResult) -> i32 {
231    // Check for VEX gaps first (most specific gate)
232    if config.filtering.fail_on_vex_gap {
233        let vex_summary = result.vulnerabilities.vex_summary();
234        let total_gaps = vex_summary.introduced_without_vex + vex_summary.persistent_without_vex;
235        if total_gaps > 0 {
236            eprintln!(
237                "VEX gap: {} vulnerability(ies) lack VEX statements ({} introduced, {} persistent)",
238                total_gaps, vex_summary.introduced_without_vex, vex_summary.persistent_without_vex,
239            );
240            return exit_codes::VEX_GAPS_FOUND;
241        }
242    }
243    // KEV gate is more specific than the generic vuln gate: an actively
244    // exploited (KEV) finding is the highest-priority signal for CRA Art.14
245    // reporting, so it outranks --fail-on-vuln.
246    if config.behavior.fail_on_kev {
247        let kev_count = result
248            .vulnerabilities
249            .introduced
250            .iter()
251            .filter(|v| v.is_kev)
252            .count();
253        if kev_count > 0 {
254            eprintln!(
255                "KEV gate: {kev_count} introduced vulnerability(ies) are in CISA's Known Exploited Vulnerabilities catalog",
256            );
257            return exit_codes::KEV_INTRODUCED;
258        }
259    }
260    if config.behavior.fail_on_vuln && result.summary.vulnerabilities_introduced > 0 {
261        return exit_codes::VULNS_INTRODUCED;
262    }
263    if config.behavior.fail_on_change && result.summary.total_changes > 0 {
264        return exit_codes::CHANGES_DETECTED;
265    }
266    exit_codes::SUCCESS
267}
268
269/// Build a `KevClientConfig` from the user-facing `EnrichmentConfig`, honoring
270/// the cache directory, TTL, timeout, and optional URL override.
271#[cfg(feature = "enrichment")]
272fn kev_client_config(
273    enrichment: &crate::config::EnrichmentConfig,
274) -> crate::enrichment::KevClientConfig {
275    let mut cfg = crate::enrichment::KevClientConfig {
276        cache_dir: enrichment
277            .cache_dir
278            .clone()
279            .unwrap_or_else(crate::pipeline::dirs::kev_cache_dir),
280        cache_ttl: std::time::Duration::from_secs(enrichment.cache_ttl_hours * 3600),
281        bypass_cache: enrichment.bypass_cache,
282        timeout: std::time::Duration::from_secs(enrichment.timeout_secs),
283        ..Default::default()
284    };
285    if let Some(ref url) = enrichment.kev_url {
286        cfg.kev_url = url.clone();
287    }
288    cfg
289}
290
291/// Build an `EpssClientConfig` from the user-facing `EnrichmentConfig`, honoring
292/// the cache directory, TTL, timeout, and optional URL override.
293#[cfg(feature = "enrichment")]
294fn epss_client_config(
295    enrichment: &crate::config::EnrichmentConfig,
296) -> crate::enrichment::EpssClientConfig {
297    let mut cfg = crate::enrichment::EpssClientConfig {
298        cache_dir: enrichment
299            .cache_dir
300            .clone()
301            .unwrap_or_else(crate::pipeline::dirs::epss_cache_dir),
302        cache_ttl: std::time::Duration::from_secs(enrichment.cache_ttl_hours * 3600),
303        bypass_cache: enrichment.bypass_cache,
304        timeout: std::time::Duration::from_secs(enrichment.timeout_secs),
305        ..Default::default()
306    };
307    if let Some(ref url) = enrichment.epss_url {
308        cfg.epss_url = url.clone();
309    }
310    cfg
311}
312
313/// Build a `HuggingFaceConfig` from the user-facing `EnrichmentConfig`, honoring
314/// the cache directory, TTL, timeout, and optional URL override.
315#[cfg(feature = "enrichment")]
316fn huggingface_client_config(
317    enrichment: &crate::config::EnrichmentConfig,
318) -> crate::enrichment::HuggingFaceConfig {
319    let mut cfg = crate::enrichment::HuggingFaceConfig {
320        cache_dir: enrichment
321            .cache_dir
322            .clone()
323            .unwrap_or_else(crate::pipeline::dirs::huggingface_cache_dir),
324        cache_ttl: std::time::Duration::from_secs(enrichment.cache_ttl_hours * 3600),
325        bypass_cache: enrichment.bypass_cache,
326        timeout: std::time::Duration::from_secs(enrichment.timeout_secs),
327        ..Default::default()
328    };
329    if let Some(ref url) = enrichment.huggingface_url {
330        cfg.api_url = url.clone();
331    }
332    cfg
333}
334
335#[cfg(test)]
336mod tests {
337    use crate::pipeline::OutputTarget;
338    use std::path::PathBuf;
339
340    #[test]
341    fn test_output_target_conversion() {
342        let none_target = OutputTarget::from_option(None);
343        assert!(matches!(none_target, OutputTarget::Stdout));
344
345        let some_target = OutputTarget::from_option(Some(PathBuf::from("/tmp/test.json")));
346        assert!(matches!(some_target, OutputTarget::File(_)));
347    }
348}