1use 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#[allow(clippy::needless_pass_by_value)]
23pub fn run_diff(config: DiffConfig) -> Result<i32> {
24 let quiet = config.behavior.quiet;
25
26 if config.output.format == ReportFormat::OscalJson {
30 bail!(
31 "output format 'oscal-json' is not supported by `sbom-tools diff`; \
32 use `sbom-tools validate -o oscal-json` for OSCAL assessment results"
33 );
34 }
35 if config.output.format == ReportFormat::SbomqsJson {
39 bail!(
40 "output format 'sbomqs-json' is not supported by `sbom-tools diff`; \
41 use `sbom-tools quality -o sbomqs-json` for sbomqs-comparable scores"
42 );
43 }
44
45 if is_stdin_path(&config.paths.old) && is_stdin_path(&config.paths.new) {
47 bail!("Cannot read both SBOMs from stdin ('-'); only one '-' is allowed per diff");
48 }
49
50 crate::pipeline::validate_post_diff_filters(&config.filtering, &config.graph_diff)?;
54
55 let mut old_parsed = parse_sbom_with_context(&config.paths.old, quiet)?;
57 let mut new_parsed = parse_sbom_with_context(&config.paths.new, quiet)?;
58
59 if !quiet {
60 tracing::info!(
61 "Parsed {} components from old SBOM, {} from new SBOM",
62 old_parsed.sbom().component_count(),
63 new_parsed.sbom().component_count()
64 );
65 }
66
67 #[cfg(feature = "enrichment")]
69 let mut enrichment_warnings: Vec<&str> = Vec::new();
70
71 #[cfg(feature = "enrichment")]
72 let enrichment_stats = {
73 if config.enrichment.enabled {
74 let osv_config = crate::pipeline::build_enrichment_config(&config.enrichment);
75 let stats_old = crate::pipeline::enrich_sbom(old_parsed.sbom_mut(), &osv_config, quiet);
76 let stats_new = crate::pipeline::enrich_sbom(new_parsed.sbom_mut(), &osv_config, quiet);
77 if stats_old.is_none() || stats_new.is_none() {
78 enrichment_warnings.push("OSV vulnerability enrichment failed");
79 }
80 Some((stats_old, stats_new))
81 } else {
82 None
83 }
84 };
85
86 #[cfg(feature = "enrichment")]
88 {
89 if config.enrichment.enable_eol {
90 let eol_config = crate::enrichment::EolClientConfig {
91 cache_dir: config
92 .enrichment
93 .cache_dir
94 .clone()
95 .unwrap_or_else(crate::pipeline::dirs::eol_cache_dir),
96 cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
97 bypass_cache: config.enrichment.bypass_cache,
98 timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
99 ..Default::default()
100 };
101 let eol_old = crate::pipeline::enrich_eol(old_parsed.sbom_mut(), &eol_config, quiet);
102 let eol_new = crate::pipeline::enrich_eol(new_parsed.sbom_mut(), &eol_config, quiet);
103 if eol_old.is_none() || eol_new.is_none() {
104 enrichment_warnings.push("EOL enrichment failed");
105 }
106 }
107 }
108
109 #[cfg(feature = "enrichment")]
111 if config.enrichment.enable_kev {
112 let kev_config = kev_client_config(&config.enrichment);
113 let kev_old = crate::pipeline::enrich_kev(old_parsed.sbom_mut(), &kev_config, quiet);
114 let kev_new = crate::pipeline::enrich_kev(new_parsed.sbom_mut(), &kev_config, quiet);
115 if kev_old.is_none() || kev_new.is_none() {
116 if config.behavior.fail_on_kev {
122 bail!(
123 "--fail-on-kev requires KEV data: KEV catalog could not be loaded \
124 (see warning above for the cause; if running offline, pre-populate \
125 the KEV cache first)"
126 );
127 }
128 enrichment_warnings.push("KEV enrichment failed");
129 }
130 } else if config.behavior.fail_on_kev {
131 bail!("--fail-on-kev requires KEV data: KEV enrichment is not enabled");
135 }
136
137 #[cfg(feature = "enrichment")]
139 if config.enrichment.enable_epss {
140 let epss_config = epss_client_config(&config.enrichment);
141 let epss_old = crate::pipeline::enrich_epss(old_parsed.sbom_mut(), &epss_config, quiet);
142 let epss_new = crate::pipeline::enrich_epss(new_parsed.sbom_mut(), &epss_config, quiet);
143 if epss_old.is_none() || epss_new.is_none() {
144 enrichment_warnings.push("EPSS enrichment failed");
145 }
146 }
147
148 #[cfg(feature = "enrichment")]
150 if config.enrichment.enable_staleness {
151 let staleness_config = crate::enrichment::RegistryConfig {
152 cache_dir: config
153 .enrichment
154 .cache_dir
155 .clone()
156 .unwrap_or_else(crate::pipeline::dirs::staleness_cache_dir),
157 cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
158 bypass_cache: config.enrichment.bypass_cache,
159 timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
160 ..Default::default()
161 };
162 let stale_old =
163 crate::pipeline::enrich_staleness(old_parsed.sbom_mut(), &staleness_config, quiet);
164 let stale_new =
165 crate::pipeline::enrich_staleness(new_parsed.sbom_mut(), &staleness_config, quiet);
166 if stale_old.is_none() || stale_new.is_none() {
167 enrichment_warnings.push("Staleness enrichment failed");
168 }
169 }
170
171 #[cfg(feature = "enrichment")]
173 if config.enrichment.enable_huggingface {
174 let hf_config = huggingface_client_config(&config.enrichment);
175 let hf_old = crate::pipeline::enrich_huggingface(old_parsed.sbom_mut(), &hf_config, quiet);
176 let hf_new = crate::pipeline::enrich_huggingface(new_parsed.sbom_mut(), &hf_config, quiet);
177 if hf_old.is_none() || hf_new.is_none() {
178 enrichment_warnings.push("HuggingFace enrichment failed");
179 }
180 }
181
182 #[cfg(feature = "enrichment")]
184 if !config.enrichment.vex_paths.is_empty() {
185 let vex_old =
186 crate::pipeline::enrich_vex(old_parsed.sbom_mut(), &config.enrichment.vex_paths, quiet);
187 let vex_new =
188 crate::pipeline::enrich_vex(new_parsed.sbom_mut(), &config.enrichment.vex_paths, quiet);
189 if vex_old.is_none() || vex_new.is_none() {
190 enrichment_warnings.push("VEX enrichment failed");
191 }
192 }
193
194 #[cfg(not(feature = "enrichment"))]
195 {
196 if config.behavior.fail_on_kev {
199 bail!(
200 "--fail-on-kev requires KEV data: this binary was built without the \
201 'enrichment' feature. Rebuild with: cargo build --features enrichment"
202 );
203 }
204 if config.enrichment.enabled {
205 eprintln!(
206 "Warning: enrichment requested but the 'enrichment' feature is not enabled. \
207 Rebuild with: cargo build --features enrichment"
208 );
209 }
210 }
211
212 let mut result = compute_diff(&config, &old_parsed.sbom, &new_parsed.sbom)?;
214 result.ml_regressions = find_ml_regressions(&result);
215
216 let exit_code = determine_exit_code(&config, &result);
218
219 let output_target = OutputTarget::from_option(config.output.file.clone());
221 let effective_output = auto_detect_format(config.output.format, &output_target);
222
223 if effective_output == ReportFormat::Tui {
224 let old_sidecar = crate::pipeline::discover_cra_sidecar(&config.paths.old)?;
231 let new_sidecar = crate::pipeline::discover_cra_sidecar(&config.paths.new)?;
232
233 let (old_sbom, old_raw) = old_parsed.into_parts();
234 let (new_sbom, new_raw) = new_parsed.into_parts();
235
236 #[cfg(feature = "enrichment")]
237 let mut app = {
238 let app = App::new_diff(result, old_sbom, new_sbom, &old_raw, &new_raw);
239 if let Some((stats_old, stats_new)) = enrichment_stats {
240 app.with_enrichment_stats(stats_old, stats_new)
241 } else {
242 app
243 }
244 };
245
246 #[cfg(not(feature = "enrichment"))]
247 let mut app = App::new_diff(result, old_sbom, new_sbom, &old_raw, &new_raw);
248
249 app = app.with_cra_sidecars(old_sidecar, new_sidecar);
250
251 app.export_template = config.output.export_template.clone();
253
254 #[cfg(feature = "enrichment")]
256 if !enrichment_warnings.is_empty() {
257 app.set_status_message(format!("Warning: {}", enrichment_warnings.join(", ")));
258 app.status_sticky = true;
259 }
260
261 run_tui(&mut app, config.output.no_color)?;
262 } else {
263 old_parsed.drop_raw_content();
264 new_parsed.drop_raw_content();
265 output_report(&config, &result, &old_parsed.sbom, &new_parsed.sbom)?;
266 }
267
268 Ok(exit_code)
269}
270
271fn determine_exit_code(config: &DiffConfig, result: &crate::diff::DiffResult) -> i32 {
279 if config.filtering.fail_on_ml_regression && !result.ml_regressions.is_empty() {
280 for regression in &result.ml_regressions {
281 eprintln!(
282 "ML regression: component={} metric={} previous={} new={}",
283 regression.component,
284 regression.metric,
285 regression.previous_value,
286 regression.new_value
287 );
288 }
289 return exit_codes::ML_REGRESSION;
290 }
291 if config.filtering.fail_on_vex_gap {
293 let vex_summary = result.vulnerabilities.vex_summary();
294 let total_gaps = vex_summary.introduced_without_vex + vex_summary.persistent_without_vex;
295 if total_gaps > 0 {
296 eprintln!(
297 "VEX gap: {} vulnerability(ies) lack VEX statements ({} introduced, {} persistent)",
298 total_gaps, vex_summary.introduced_without_vex, vex_summary.persistent_without_vex,
299 );
300 return exit_codes::VEX_GAPS_FOUND;
301 }
302 }
303 if config.behavior.fail_on_kev {
307 let kev_count = result
308 .vulnerabilities
309 .introduced
310 .iter()
311 .filter(|v| v.is_kev)
312 .count();
313 if kev_count > 0 {
314 eprintln!(
315 "KEV gate: {kev_count} introduced vulnerability(ies) are in CISA's Known Exploited Vulnerabilities catalog",
316 );
317 return exit_codes::KEV_INTRODUCED;
318 }
319 }
320 if config.behavior.fail_on_vuln && result.summary.vulnerabilities_introduced > 0 {
321 return exit_codes::VULNS_INTRODUCED;
322 }
323 if config.behavior.fail_on_change && result.summary.total_changes > 0 {
324 return exit_codes::CHANGES_DETECTED;
325 }
326 exit_codes::SUCCESS
327}
328
329fn find_ml_regressions(result: &crate::diff::DiffResult) -> Vec<crate::diff::MlRegression> {
330 result
331 .components
332 .modified
333 .iter()
334 .flat_map(|component| {
335 component.field_changes.iter().filter_map(move |change| {
336 let metric = change.field.strip_prefix("ml_metric:")?;
337 let higher_is_better = crate::diff::ml_metric_higher_is_better(metric)?;
338 let previous_value = change.old_value.as_deref()?.parse::<f64>().ok()?;
339 let new_value = change.new_value.as_deref()?.parse::<f64>().ok()?;
340 let regressed = if higher_is_better {
341 new_value < previous_value
342 } else {
343 new_value > previous_value
344 };
345 regressed.then(|| crate::diff::MlRegression {
346 component: component.name.clone(),
347 metric: metric.to_string(),
348 previous_value,
349 new_value,
350 })
351 })
352 })
353 .collect()
354}
355
356#[cfg(feature = "enrichment")]
359fn kev_client_config(
360 enrichment: &crate::config::EnrichmentConfig,
361) -> crate::enrichment::KevClientConfig {
362 let mut cfg = crate::enrichment::KevClientConfig {
363 cache_dir: enrichment
364 .cache_dir
365 .clone()
366 .unwrap_or_else(crate::pipeline::dirs::kev_cache_dir),
367 cache_ttl: std::time::Duration::from_secs(enrichment.cache_ttl_hours * 3600),
368 bypass_cache: enrichment.bypass_cache,
369 timeout: std::time::Duration::from_secs(enrichment.timeout_secs),
370 ..Default::default()
371 };
372 if let Some(ref url) = enrichment.kev_url {
373 cfg.kev_url = url.clone();
374 }
375 cfg
376}
377
378#[cfg(feature = "enrichment")]
381fn epss_client_config(
382 enrichment: &crate::config::EnrichmentConfig,
383) -> crate::enrichment::EpssClientConfig {
384 let mut cfg = crate::enrichment::EpssClientConfig {
385 cache_dir: enrichment
386 .cache_dir
387 .clone()
388 .unwrap_or_else(crate::pipeline::dirs::epss_cache_dir),
389 cache_ttl: std::time::Duration::from_secs(enrichment.cache_ttl_hours * 3600),
390 bypass_cache: enrichment.bypass_cache,
391 timeout: std::time::Duration::from_secs(enrichment.timeout_secs),
392 ..Default::default()
393 };
394 if let Some(ref url) = enrichment.epss_url {
395 cfg.epss_url = url.clone();
396 }
397 cfg
398}
399
400#[cfg(feature = "enrichment")]
403fn huggingface_client_config(
404 enrichment: &crate::config::EnrichmentConfig,
405) -> crate::enrichment::HuggingFaceConfig {
406 let mut cfg = crate::enrichment::HuggingFaceConfig {
407 cache_dir: enrichment
408 .cache_dir
409 .clone()
410 .unwrap_or_else(crate::pipeline::dirs::huggingface_cache_dir),
411 cache_ttl: std::time::Duration::from_secs(enrichment.cache_ttl_hours * 3600),
412 bypass_cache: enrichment.bypass_cache,
413 timeout: std::time::Duration::from_secs(enrichment.timeout_secs),
414 ..Default::default()
415 };
416 if let Some(ref url) = enrichment.huggingface_url {
417 cfg.api_url = url.clone();
418 }
419 cfg
420}
421
422#[cfg(test)]
423mod tests {
424 use super::find_ml_regressions;
425 use crate::diff::{ChangeType, ComponentChange, DiffResult, FieldChange};
426 use crate::pipeline::OutputTarget;
427 use std::path::PathBuf;
428
429 #[test]
430 fn test_output_target_conversion() {
431 let none_target = OutputTarget::from_option(None);
432 assert!(matches!(none_target, OutputTarget::Stdout));
433
434 let some_target = OutputTarget::from_option(Some(PathBuf::from("/tmp/test.json")));
435 assert!(matches!(some_target, OutputTarget::File(_)));
436 }
437
438 fn result_with_metric(metric: &str, old: &str, new: &str) -> DiffResult {
439 let mut result = DiffResult::new();
440 result.components.modified.push(ComponentChange {
441 id: "model".to_string(),
442 canonical_id: None,
443 component_ref: None,
444 old_canonical_id: None,
445 name: "classifier".to_string(),
446 old_version: None,
447 new_version: None,
448 ecosystem: None,
449 component_type: None,
450 change_type: ChangeType::Modified,
451 field_changes: vec![FieldChange {
452 field: format!("ml_metric:{metric}"),
453 old_value: Some(old.to_string()),
454 new_value: Some(new.to_string()),
455 }],
456 cost: 1,
457 match_info: None,
458 });
459 result
460 }
461
462 #[test]
463 fn ml_regression_respects_metric_direction() {
464 assert_eq!(
465 find_ml_regressions(&result_with_metric("accuracy", "0.9", "0.8")).len(),
466 1
467 );
468 assert_eq!(
469 find_ml_regressions(&result_with_metric("loss", "0.2", "0.3")).len(),
470 1
471 );
472 assert!(find_ml_regressions(&result_with_metric("accuracy", "0.8", "0.9")).is_empty());
473 assert!(find_ml_regressions(&result_with_metric("loss", "0.3", "0.2")).is_empty());
474 }
475
476 #[test]
477 fn ml_regression_ignores_unknown_or_non_numeric_metrics() {
478 assert!(find_ml_regressions(&result_with_metric("custom", "1", "0")).is_empty());
479 assert!(find_ml_regressions(&result_with_metric("accuracy", "high", "low")).is_empty());
480 }
481}