1use crate::config::{
7 FilterConfig, GraphAwareDiffConfig, MatchingRulesPathConfig, MatrixConfig, MultiDiffConfig,
8 TimelineConfig,
9};
10use crate::diff::{DiffResult, MultiDiffEngine};
11use crate::matching::{FuzzyMatchConfig, MatchingRulesConfig};
12use crate::model::NormalizedSbom;
13use crate::pipeline::{
14 OutputTarget, apply_post_diff_filters, auto_detect_format, enrich_sbom_full, enrich_sboms,
15 exit_codes, graph_diff_config_from, parse_sbom_with_context, validate_post_diff_filters,
16 write_output,
17};
18use crate::reports::ReportFormat;
19use crate::tui::{App, run_tui};
20use anyhow::{Result, bail};
21use std::path::{Path, PathBuf};
22
23enum MultiOutput {
25 Tui,
27 Json(OutputTarget),
29}
30
31fn resolve_multi_output(output: &crate::config::OutputConfig) -> Result<MultiOutput> {
39 let target = OutputTarget::from_option(output.file.clone());
40 match output.format {
41 ReportFormat::Tui => Ok(MultiOutput::Tui),
42 ReportFormat::Json => Ok(MultiOutput::Json(target)),
43 ReportFormat::Auto => match auto_detect_format(ReportFormat::Auto, &target) {
44 ReportFormat::Tui => Ok(MultiOutput::Tui),
45 _ => Ok(MultiOutput::Json(target)),
48 },
49 other => bail!(
50 "output format '{other}' is not supported for multi-SBOM commands \
51 (diff-multi/timeline/matrix); supported formats: tui, json"
52 ),
53 }
54}
55
56fn load_multi_rules(rules: &MatchingRulesPathConfig) -> Option<MatchingRulesConfig> {
60 let path = rules.rules_file.as_ref()?;
61 match MatchingRulesConfig::from_file(path) {
62 Ok(loaded) => {
63 if rules.dry_run {
64 tracing::info!("Dry-run mode: matching rules parsed but not applied");
65 None
66 } else {
67 Some(loaded)
68 }
69 }
70 Err(e) => {
71 tracing::warn!("Failed to load matching rules: {e}");
72 None
73 }
74 }
75}
76
77fn build_multi_engine(
80 fuzzy_config: FuzzyMatchConfig,
81 include_unchanged: bool,
82 graph: &GraphAwareDiffConfig,
83 rules: &MatchingRulesPathConfig,
84) -> MultiDiffEngine {
85 let mut engine = MultiDiffEngine::new()
86 .with_fuzzy_config(fuzzy_config)
87 .include_unchanged(include_unchanged);
88 if graph.enabled {
89 engine = engine.with_graph_diff(graph_diff_config_from(graph));
90 }
91 if let Some(loaded) = load_multi_rules(rules) {
92 engine = engine.with_matching_rules(loaded);
93 }
94 engine
95}
96
97#[allow(clippy::needless_pass_by_value)]
99pub fn run_diff_multi(config: MultiDiffConfig) -> Result<i32> {
100 let quiet = config.behavior.quiet;
101
102 let output_mode = resolve_multi_output(&config.output)?;
106 validate_post_diff_filters(&config.filtering, &config.graph_diff)?;
107
108 let mut baseline_parsed = parse_sbom_with_context(&config.baseline, quiet)?;
110 let (target_sboms, target_stats) =
112 parse_and_enrich_sboms(&config.targets, &config.enrichment, quiet)?;
113
114 let baseline_stats = enrich_sbom_full(baseline_parsed.sbom_mut(), &config.enrichment, quiet);
116
117 tracing::info!(
118 "Comparing baseline ({} components) against {} targets",
119 baseline_parsed.sbom().component_count(),
120 target_sboms.len()
121 );
122
123 let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
124
125 let mut all_paths = vec![config.baseline.clone()];
128 all_paths.extend(config.targets.iter().cloned());
129 let mut all_names = unique_sbom_names(&all_paths);
130 let baseline_name = all_names.remove(0);
131
132 let targets: Vec<(&NormalizedSbom, String, String)> = target_sboms
133 .iter()
134 .zip(all_names)
135 .zip(config.targets.iter())
136 .map(|((sbom, name), path)| (sbom, name, path.to_string_lossy().to_string()))
137 .collect();
138 let target_refs: Vec<_> = targets
139 .iter()
140 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
141 .collect();
142
143 let mut engine = build_multi_engine(
145 fuzzy_config,
146 config.matching.include_unchanged,
147 &config.graph_diff,
148 &config.rules,
149 );
150
151 let mut result = engine.diff_multi(
152 baseline_parsed.sbom(),
153 &baseline_name,
154 &config.baseline.to_string_lossy(),
155 &target_refs,
156 )?;
157
158 for comparison in &mut result.comparisons {
160 apply_post_diff_filters(&mut comparison.diff, &config.filtering, &config.graph_diff);
161 }
162
163 tracing::info!(
164 "Multi-diff complete: {} comparisons, max deviation: {:.1}%",
165 result.comparisons.len(),
166 result.summary.max_deviation * 100.0
167 );
168
169 let exit_code = determine_multi_exit_code(
171 &config.behavior,
172 &config.filtering,
173 result.comparisons.iter().map(|c| &c.diff),
174 PairDirection::Ordered,
175 );
176
177 if let MultiOutput::Json(ref output_target) = output_mode {
178 let json = serde_json::to_string_pretty(&result)?;
179 write_output(&json, output_target, quiet)?;
180 } else {
181 let mut app = App::new_multi_diff(result);
182 app.export_template = config.output.export_template.clone();
183
184 let all_warnings: Vec<_> = std::iter::once(&baseline_stats)
186 .chain(target_stats.iter())
187 .flat_map(|s| s.warnings.iter())
188 .collect();
189 if !all_warnings.is_empty() {
190 app.set_status_message(format!(
191 "Warning: {}",
192 all_warnings
193 .iter()
194 .map(|s| s.as_str())
195 .collect::<Vec<_>>()
196 .join(", ")
197 ));
198 app.status_sticky = true;
199 }
200
201 run_tui(&mut app, config.output.no_color)?;
202 }
203
204 Ok(exit_code)
205}
206
207#[allow(clippy::needless_pass_by_value)]
209pub fn run_timeline(config: TimelineConfig) -> Result<i32> {
210 let quiet = config.behavior.quiet;
211
212 if config.sbom_paths.len() < 2 {
213 bail!("Timeline analysis requires at least 2 SBOMs");
214 }
215
216 let output_mode = resolve_multi_output(&config.output)?;
219 validate_post_diff_filters(&config.filtering, &config.graph_diff)?;
220
221 let (sboms, _enrich_stats) =
222 parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
223
224 tracing::info!("Analyzing timeline of {} SBOMs", sboms.len());
225
226 if !quiet {
230 let out_of_order: Vec<usize> = sboms
231 .windows(2)
232 .enumerate()
233 .filter(|(_, w)| w[1].document.created < w[0].document.created)
234 .map(|(i, _)| i + 1)
235 .collect();
236 if !out_of_order.is_empty() {
237 eprintln!(
238 "Warning: SBOM document timestamps are not in chronological order \
239 (position{} {}); timeline analysis assumes oldest-first argument order",
240 if out_of_order.len() == 1 { "" } else { "s" },
241 out_of_order
242 .iter()
243 .map(std::string::ToString::to_string)
244 .collect::<Vec<_>>()
245 .join(", ")
246 );
247 }
248 }
249
250 let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
251
252 let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
254 let sbom_refs: Vec<_> = sbom_data
255 .iter()
256 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
257 .collect();
258
259 let mut engine = build_multi_engine(
261 fuzzy_config,
262 config.matching.include_unchanged,
263 &config.graph_diff,
264 &config.rules,
265 );
266 let mut result = engine.timeline(&sbom_refs)?;
267
268 for diff in result
270 .incremental_diffs
271 .iter_mut()
272 .chain(result.cumulative_from_first.iter_mut())
273 {
274 apply_post_diff_filters(diff, &config.filtering, &config.graph_diff);
275 }
276
277 tracing::info!(
278 "Timeline analysis complete: {} incremental diffs",
279 result.incremental_diffs.len()
280 );
281
282 let exit_code = determine_multi_exit_code(
284 &config.behavior,
285 &config.filtering,
286 result.incremental_diffs.iter(),
287 PairDirection::Ordered,
288 );
289
290 if let MultiOutput::Json(ref output_target) = output_mode {
291 let json = serde_json::to_string_pretty(&result)?;
292 write_output(&json, output_target, quiet)?;
293 } else {
294 let mut app = App::new_timeline(result);
295 run_tui(&mut app, config.output.no_color)?;
296 }
297
298 Ok(exit_code)
299}
300
301#[allow(clippy::needless_pass_by_value)]
303pub fn run_matrix(config: MatrixConfig) -> Result<i32> {
304 let quiet = config.behavior.quiet;
305
306 if config.sbom_paths.len() < 2 {
307 bail!("Matrix comparison requires at least 2 SBOMs");
308 }
309
310 let output_mode = resolve_multi_output(&config.output)?;
313 validate_post_diff_filters(&config.filtering, &config.graph_diff)?;
314
315 let (sboms, _enrich_stats) =
316 parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
317
318 tracing::info!(
319 "Computing {}x{} comparison matrix",
320 sboms.len(),
321 sboms.len()
322 );
323
324 let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
325
326 let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
328 let sbom_refs: Vec<_> = sbom_data
329 .iter()
330 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
331 .collect();
332
333 let mut engine = build_multi_engine(
335 fuzzy_config,
336 config.matching.include_unchanged,
337 &config.graph_diff,
338 &config.rules,
339 );
340 let mut result = engine.matrix(&sbom_refs, Some(config.cluster_threshold))?;
341
342 for diff in result.diffs.iter_mut().flatten() {
344 apply_post_diff_filters(diff, &config.filtering, &config.graph_diff);
345 }
346
347 tracing::info!(
348 "Matrix comparison complete: {} pairs computed",
349 result.num_pairs()
350 );
351
352 if let Some(ref clustering) = result.clustering {
353 tracing::info!(
354 "Found {} clusters, {} outliers",
355 clustering.clusters.len(),
356 clustering.outliers.len()
357 );
358 }
359
360 let exit_code = determine_multi_exit_code(
362 &config.behavior,
363 &config.filtering,
364 result.diffs.iter().flatten(),
365 PairDirection::Unordered,
366 );
367
368 if let MultiOutput::Json(ref output_target) = output_mode {
369 let json = serde_json::to_string_pretty(&result)?;
370 write_output(&json, output_target, quiet)?;
371 } else {
372 let mut app = App::new_matrix(result);
373 run_tui(&mut app, config.output.no_color)?;
374 }
375
376 Ok(exit_code)
377}
378
379fn parse_and_enrich_sboms(
381 paths: &[PathBuf],
382 enrichment: &crate::config::EnrichmentConfig,
383 quiet: bool,
384) -> Result<(
385 Vec<NormalizedSbom>,
386 Vec<crate::pipeline::AggregatedEnrichmentStats>,
387)> {
388 let mut sboms = Vec::with_capacity(paths.len());
389 for path in paths {
390 let parsed = parse_sbom_with_context(path, quiet)?;
391 sboms.push(parsed.into_sbom());
392 }
393 let stats = enrich_sboms(&mut sboms, enrichment, quiet);
394 Ok((sboms, stats))
395}
396
397pub(crate) fn parse_multiple_sboms(paths: &[PathBuf]) -> Result<Vec<NormalizedSbom>> {
401 let mut sboms = Vec::with_capacity(paths.len());
402 for path in paths {
403 let parsed = parse_sbom_with_context(path, false)?;
404 sboms.push(parsed.into_sbom());
405 }
406 Ok(sboms)
407}
408
409#[derive(Clone, Copy, PartialEq, Eq)]
411enum PairDirection {
412 Ordered,
416 Unordered,
423}
424
425fn determine_multi_exit_code<'a, I>(
433 behavior: &crate::config::BehaviorConfig,
434 filtering: &FilterConfig,
435 diffs: I,
436 direction: PairDirection,
437) -> i32
438where
439 I: IntoIterator<Item = &'a DiffResult>,
440{
441 let mut total_introduced = 0usize;
442 let mut total_changes = 0usize;
443 let mut total_gaps = 0usize;
444 let mut introduced_gaps = 0usize;
445 let mut persistent_gaps = 0usize;
446
447 for diff in diffs {
448 total_introduced += diff.summary.vulnerabilities_introduced;
449 if direction == PairDirection::Unordered {
450 total_introduced += diff.summary.vulnerabilities_resolved;
453 }
454 total_changes += diff.summary.total_changes;
455 if filtering.fail_on_vex_gap {
456 let vex = diff.vulnerabilities.vex_summary();
457 introduced_gaps += vex.introduced_without_vex;
458 persistent_gaps += vex.persistent_without_vex;
459 total_gaps += vex.introduced_without_vex + vex.persistent_without_vex;
460 }
461 }
462
463 if filtering.fail_on_vex_gap && total_gaps > 0 {
464 eprintln!(
465 "VEX gap: {total_gaps} vulnerability(ies) lack VEX statements \
466 ({introduced_gaps} introduced, {persistent_gaps} persistent)",
467 );
468 return exit_codes::VEX_GAPS_FOUND;
469 }
470 if behavior.fail_on_vuln && total_introduced > 0 {
471 return exit_codes::VULNS_INTRODUCED;
472 }
473 if behavior.fail_on_change && total_changes > 0 {
474 return exit_codes::CHANGES_DETECTED;
475 }
476 exit_codes::SUCCESS
477}
478
479fn get_fuzzy_config(preset: &crate::config::FuzzyPreset) -> FuzzyMatchConfig {
481 FuzzyMatchConfig::from_preset(preset.as_str()).unwrap_or_else(|| {
482 FuzzyMatchConfig::balanced()
484 })
485}
486
487pub(crate) fn get_sbom_name(path: &Path) -> String {
489 path.file_stem().map_or_else(
490 || "unknown".to_string(),
491 |s| s.to_string_lossy().to_string(),
492 )
493}
494
495fn prepare_sbom_refs<'a>(
497 sboms: &'a [NormalizedSbom],
498 paths: &[PathBuf],
499) -> Vec<(&'a NormalizedSbom, String, String)> {
500 let names = unique_sbom_names(paths);
501 sboms
502 .iter()
503 .zip(names)
504 .zip(paths.iter())
505 .map(|((sbom, name), path)| {
506 let path_str = path.to_string_lossy().to_string();
507 (sbom, name, path_str)
508 })
509 .collect()
510}
511
512pub(crate) fn unique_sbom_names(paths: &[PathBuf]) -> Vec<String> {
520 use std::collections::HashMap;
521
522 let stems: Vec<String> = paths.iter().map(|p| get_sbom_name(p)).collect();
523 let mut counts: HashMap<&str, usize> = HashMap::new();
524 for stem in &stems {
525 *counts.entry(stem.as_str()).or_default() += 1;
526 }
527
528 let mut names: Vec<String> = stems
529 .iter()
530 .zip(paths.iter())
531 .map(|(stem, path)| {
532 if counts[stem.as_str()] > 1 {
533 match path.parent().and_then(|p| p.file_name()) {
534 Some(parent) => format!("{}/{}", parent.to_string_lossy(), stem),
535 None => stem.clone(),
536 }
537 } else {
538 stem.clone()
539 }
540 })
541 .collect();
542
543 let mut totals: HashMap<String, usize> = HashMap::new();
549 for name in &names {
550 *totals.entry(name.clone()).or_default() += 1;
551 }
552 let mut taken: std::collections::HashSet<String> = names
553 .iter()
554 .filter(|n| totals[n.as_str()] == 1)
555 .cloned()
556 .collect();
557 for name in &mut names {
558 if totals[name.as_str()] > 1 {
559 let mut ordinal = 1;
560 while taken.contains(&format!("{name} ({ordinal})")) {
561 ordinal += 1;
562 }
563 *name = format!("{name} ({ordinal})");
564 taken.insert(name.clone());
565 }
566 }
567 names
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 #[test]
575 fn test_get_fuzzy_config_valid_presets() {
576 let config = get_fuzzy_config(&crate::config::FuzzyPreset::Strict);
577 assert!(config.threshold > 0.8);
578
579 let config = get_fuzzy_config(&crate::config::FuzzyPreset::Balanced);
580 assert!(config.threshold >= 0.7 && config.threshold <= 0.85);
581
582 let config = get_fuzzy_config(&crate::config::FuzzyPreset::Permissive);
583 assert!(config.threshold <= 0.70);
584 }
585
586 #[test]
587 fn test_get_sbom_name() {
588 let path = PathBuf::from("/path/to/my-sbom.cdx.json");
589 assert_eq!(get_sbom_name(&path), "my-sbom.cdx");
590
591 let path = PathBuf::from("simple.json");
592 assert_eq!(get_sbom_name(&path), "simple");
593 }
594
595 #[test]
600 fn test_unique_sbom_names_disambiguates_duplicates() {
601 let paths = vec![
602 PathBuf::from("v1/app.json"),
603 PathBuf::from("v2/app.json"),
604 PathBuf::from("v3/app.json"),
605 ];
606 assert_eq!(
607 unique_sbom_names(&paths),
608 vec!["v1/app", "v2/app", "v3/app"]
609 );
610
611 let paths = vec![PathBuf::from("a/x.json"), PathBuf::from("a/x.json")];
613 assert_eq!(unique_sbom_names(&paths), vec!["a/x (1)", "a/x (2)"]);
614
615 let paths = vec![PathBuf::from("old.json"), PathBuf::from("new.json")];
617 assert_eq!(unique_sbom_names(&paths), vec!["old", "new"]);
618
619 let paths = vec![
622 PathBuf::from("app.json"),
623 PathBuf::from("app.xml"),
624 PathBuf::from("app (1).json"),
625 ];
626 let names = unique_sbom_names(&paths);
627 let unique: std::collections::HashSet<_> = names.iter().collect();
628 assert_eq!(
629 unique.len(),
630 names.len(),
631 "names must be unique even against literal ordinal-style stems: {names:?}"
632 );
633 }
634
635 #[test]
636 fn test_prepare_sbom_refs() {
637 let sbom1 = NormalizedSbom::default();
638 let sbom2 = NormalizedSbom::default();
639 let sboms = vec![sbom1, sbom2];
640 let paths = vec![PathBuf::from("first.json"), PathBuf::from("second.json")];
641
642 let refs = prepare_sbom_refs(&sboms, &paths);
643 assert_eq!(refs.len(), 2);
644 assert_eq!(refs[0].1, "first");
645 assert_eq!(refs[1].1, "second");
646 }
647
648 fn output_config(format: ReportFormat, file: Option<PathBuf>) -> crate::config::OutputConfig {
649 crate::config::OutputConfig {
650 format,
651 file,
652 ..Default::default()
653 }
654 }
655
656 #[test]
657 fn resolve_multi_output_accepts_tui_and_json() {
658 assert!(matches!(
659 resolve_multi_output(&output_config(ReportFormat::Tui, None)).unwrap(),
660 MultiOutput::Tui
661 ));
662 assert!(matches!(
663 resolve_multi_output(&output_config(ReportFormat::Json, None)).unwrap(),
664 MultiOutput::Json(_)
665 ));
666 }
667
668 #[test]
669 fn resolve_multi_output_auto_to_file_is_json() {
670 let cfg = output_config(ReportFormat::Auto, Some(PathBuf::from("/tmp/out.json")));
672 assert!(matches!(
673 resolve_multi_output(&cfg).unwrap(),
674 MultiOutput::Json(_)
675 ));
676 }
677
678 #[test]
679 fn resolve_multi_output_rejects_unsupported_formats() {
680 for fmt in [
681 ReportFormat::Table,
682 ReportFormat::Markdown,
683 ReportFormat::Summary,
684 ReportFormat::Sarif,
685 ReportFormat::Html,
686 ReportFormat::Csv,
687 ReportFormat::SideBySide,
688 ] {
689 let result = resolve_multi_output(&output_config(fmt, None));
690 let msg = match result {
691 Ok(_) => panic!("format {fmt} must be rejected"),
692 Err(e) => e.to_string(),
693 };
694 assert!(
695 msg.contains("not supported for multi-SBOM commands"),
696 "{msg}"
697 );
698 assert!(msg.contains("tui, json"), "{msg}");
699 }
700 }
701
702 #[test]
703 fn determine_multi_exit_code_change_gate() {
704 let mut diff = DiffResult::new();
705 diff.summary.total_changes = 3;
706 let behavior = crate::config::BehaviorConfig {
707 fail_on_change: true,
708 ..Default::default()
709 };
710 let filtering = FilterConfig::default();
711 assert_eq!(
712 determine_multi_exit_code(
713 &behavior,
714 &filtering,
715 std::iter::once(&diff),
716 PairDirection::Ordered
717 ),
718 exit_codes::CHANGES_DETECTED
719 );
720
721 let behavior = crate::config::BehaviorConfig::default();
723 assert_eq!(
724 determine_multi_exit_code(
725 &behavior,
726 &filtering,
727 std::iter::once(&diff),
728 PairDirection::Ordered
729 ),
730 exit_codes::SUCCESS
731 );
732 }
733
734 #[test]
739 fn determine_multi_exit_code_vuln_gate_symmetric_for_unordered_pairs() {
740 let behavior = crate::config::BehaviorConfig {
741 fail_on_vuln: true,
742 ..Default::default()
743 };
744 let filtering = FilterConfig::default();
745
746 let mut forward = DiffResult::new();
748 forward.summary.vulnerabilities_introduced = 1;
749 let mut reverse = DiffResult::new();
751 reverse.summary.vulnerabilities_resolved = 1;
752
753 for diff in [&forward, &reverse] {
754 assert_eq!(
755 determine_multi_exit_code(
756 &behavior,
757 &filtering,
758 std::iter::once(diff),
759 PairDirection::Unordered
760 ),
761 exit_codes::VULNS_INTRODUCED,
762 "matrix vuln gate must be argument-order independent"
763 );
764 }
765
766 assert_eq!(
769 determine_multi_exit_code(
770 &behavior,
771 &filtering,
772 std::iter::once(&reverse),
773 PairDirection::Ordered
774 ),
775 exit_codes::SUCCESS
776 );
777 }
778}