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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let result = compute_diff(&config, &old_parsed.sbom, &new_parsed.sbom)?;
165
166 let exit_code = determine_exit_code(&config, &result);
168
169 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 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 app.export_template = config.output.export_template.clone();
206
207 #[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
224fn determine_exit_code(config: &DiffConfig, result: &crate::diff::DiffResult) -> i32 {
231 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 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#[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#[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#[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}