Skip to main content

sbom_tools/cli/
multi.rs

1//! Multi-SBOM command handlers.
2//!
3//! Implements the `diff-multi`, `timeline`, and `matrix` subcommands.
4//! Uses the pipeline module for parsing and enrichment (shared with `diff`).
5
6use 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, write_output,
16};
17use crate::reports::ReportFormat;
18use crate::tui::{App, run_tui};
19use anyhow::{Result, bail};
20use std::path::{Path, PathBuf};
21
22/// How a multi-SBOM command should emit its result.
23enum MultiOutput {
24    /// Launch the interactive TUI.
25    Tui,
26    /// Serialize to pretty JSON and write to the resolved target.
27    Json(OutputTarget),
28}
29
30/// Resolve and validate the output mode for a multi-SBOM command.
31///
32/// Multi-SBOM results only have two real renderers: the interactive TUI and
33/// pretty JSON. `auto` resolves to TUI on a terminal and JSON when piped/redirected.
34/// Any other explicitly requested format (summary, table, markdown, sarif, …) has
35/// no multi-SBOM renderer, so it is rejected with a clear error rather than
36/// silently emitting JSON.
37fn resolve_multi_output(output: &crate::config::OutputConfig) -> Result<MultiOutput> {
38    let target = OutputTarget::from_option(output.file.clone());
39    match output.format {
40        ReportFormat::Tui => Ok(MultiOutput::Tui),
41        ReportFormat::Json => Ok(MultiOutput::Json(target)),
42        ReportFormat::Auto => match auto_detect_format(ReportFormat::Auto, &target) {
43            ReportFormat::Tui => Ok(MultiOutput::Tui),
44            // Off-TTY `auto` resolves to summary in single-diff; multi has no
45            // summary renderer, so default the piped case to JSON.
46            _ => Ok(MultiOutput::Json(target)),
47        },
48        other => bail!(
49            "output format '{other}' is not supported for multi-SBOM commands \
50             (diff-multi/timeline/matrix); supported formats: tui, json"
51        ),
52    }
53}
54
55/// Load custom matching rules from a path config, mirroring the single-diff
56/// pipeline: a missing/invalid file is logged and skipped rather than aborting,
57/// and dry-run mode parses-but-skips application.
58fn load_multi_rules(rules: &MatchingRulesPathConfig) -> Option<MatchingRulesConfig> {
59    let path = rules.rules_file.as_ref()?;
60    match MatchingRulesConfig::from_file(path) {
61        Ok(loaded) => {
62            if rules.dry_run {
63                tracing::info!("Dry-run mode: matching rules parsed but not applied");
64                None
65            } else {
66                Some(loaded)
67            }
68        }
69        Err(e) => {
70            tracing::warn!("Failed to load matching rules: {e}");
71            None
72        }
73    }
74}
75
76/// Build a `MultiDiffEngine` with graph diffing and matching rules wired in
77/// from CLI configuration.
78fn build_multi_engine(
79    fuzzy_config: FuzzyMatchConfig,
80    include_unchanged: bool,
81    graph: &GraphAwareDiffConfig,
82    rules: &MatchingRulesPathConfig,
83) -> MultiDiffEngine {
84    let mut engine = MultiDiffEngine::new()
85        .with_fuzzy_config(fuzzy_config)
86        .include_unchanged(include_unchanged);
87    if graph.enabled {
88        engine = engine.with_graph_diff(graph_diff_config_from(graph));
89    }
90    if let Some(loaded) = load_multi_rules(rules) {
91        engine = engine.with_matching_rules(loaded);
92    }
93    engine
94}
95
96/// Run the diff-multi command (1:N comparison), returning the desired exit code.
97#[allow(clippy::needless_pass_by_value)]
98pub fn run_diff_multi(config: MultiDiffConfig) -> Result<i32> {
99    let quiet = config.behavior.quiet;
100
101    // Parse baseline
102    let mut baseline_parsed = parse_sbom_with_context(&config.baseline, quiet)?;
103    // Parse and optionally enrich targets
104    let (target_sboms, target_stats) =
105        parse_and_enrich_sboms(&config.targets, &config.enrichment, quiet)?;
106
107    // Enrich baseline
108    let baseline_stats = enrich_sbom_full(baseline_parsed.sbom_mut(), &config.enrichment, quiet);
109
110    tracing::info!(
111        "Comparing baseline ({} components) against {} targets",
112        baseline_parsed.sbom().component_count(),
113        target_sboms.len()
114    );
115
116    // Validate output mode before doing work so unsupported formats fail fast.
117    let output_mode = resolve_multi_output(&config.output)?;
118
119    let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
120
121    // Prepare target references with names
122    let targets = prepare_sbom_refs(&target_sboms, &config.targets);
123    let target_refs: Vec<_> = targets
124        .iter()
125        .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
126        .collect();
127
128    // Run multi-diff
129    let mut engine = build_multi_engine(
130        fuzzy_config,
131        config.matching.include_unchanged,
132        &config.graph_diff,
133        &config.rules,
134    );
135
136    let baseline_name = get_sbom_name(&config.baseline);
137
138    let mut result = engine.diff_multi(
139        baseline_parsed.sbom(),
140        &baseline_name,
141        &config.baseline.to_string_lossy(),
142        &target_refs,
143    )?;
144
145    // Apply severity/VEX/graph-impact post-processing to each pairwise diff.
146    for comparison in &mut result.comparisons {
147        apply_post_diff_filters(&mut comparison.diff, &config.filtering, &config.graph_diff);
148    }
149
150    tracing::info!(
151        "Multi-diff complete: {} comparisons, max deviation: {:.1}%",
152        result.comparisons.len(),
153        result.summary.max_deviation * 100.0
154    );
155
156    // Determine exit code
157    let exit_code = determine_multi_exit_code(
158        &config.behavior,
159        &config.filtering,
160        result.comparisons.iter().map(|c| &c.diff),
161    );
162
163    if let MultiOutput::Json(ref output_target) = output_mode {
164        let json = serde_json::to_string_pretty(&result)?;
165        write_output(&json, output_target, quiet)?;
166    } else {
167        let mut app = App::new_multi_diff(result);
168        app.export_template = config.output.export_template.clone();
169
170        // Show enrichment warnings if any
171        let all_warnings: Vec<_> = std::iter::once(&baseline_stats)
172            .chain(target_stats.iter())
173            .flat_map(|s| s.warnings.iter())
174            .collect();
175        if !all_warnings.is_empty() {
176            app.set_status_message(format!(
177                "Warning: {}",
178                all_warnings
179                    .iter()
180                    .map(|s| s.as_str())
181                    .collect::<Vec<_>>()
182                    .join(", ")
183            ));
184            app.status_sticky = true;
185        }
186
187        run_tui(&mut app)?;
188    }
189
190    Ok(exit_code)
191}
192
193/// Run the timeline command, returning the desired exit code.
194#[allow(clippy::needless_pass_by_value)]
195pub fn run_timeline(config: TimelineConfig) -> Result<i32> {
196    let quiet = config.behavior.quiet;
197
198    if config.sbom_paths.len() < 2 {
199        bail!("Timeline analysis requires at least 2 SBOMs");
200    }
201
202    // Validate output mode before doing work so unsupported formats fail fast.
203    let output_mode = resolve_multi_output(&config.output)?;
204
205    let (sboms, _enrich_stats) =
206        parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
207
208    tracing::info!("Analyzing timeline of {} SBOMs", sboms.len());
209
210    let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
211
212    // Prepare SBOM references with names
213    let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
214    let sbom_refs: Vec<_> = sbom_data
215        .iter()
216        .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
217        .collect();
218
219    // Run timeline analysis
220    let mut engine = build_multi_engine(
221        fuzzy_config,
222        config.matching.include_unchanged,
223        &config.graph_diff,
224        &config.rules,
225    );
226    let mut result = engine.timeline(&sbom_refs)?;
227
228    // Apply severity/VEX/graph-impact post-processing to each incremental diff.
229    for diff in result
230        .incremental_diffs
231        .iter_mut()
232        .chain(result.cumulative_from_first.iter_mut())
233    {
234        apply_post_diff_filters(diff, &config.filtering, &config.graph_diff);
235    }
236
237    tracing::info!(
238        "Timeline analysis complete: {} incremental diffs",
239        result.incremental_diffs.len()
240    );
241
242    // Determine exit code
243    let exit_code = determine_multi_exit_code(
244        &config.behavior,
245        &config.filtering,
246        result.incremental_diffs.iter(),
247    );
248
249    if let MultiOutput::Json(ref output_target) = output_mode {
250        let json = serde_json::to_string_pretty(&result)?;
251        write_output(&json, output_target, quiet)?;
252    } else {
253        let mut app = App::new_timeline(result);
254        run_tui(&mut app)?;
255    }
256
257    Ok(exit_code)
258}
259
260/// Run the matrix command (N×N comparison), returning the desired exit code.
261#[allow(clippy::needless_pass_by_value)]
262pub fn run_matrix(config: MatrixConfig) -> Result<i32> {
263    let quiet = config.behavior.quiet;
264
265    if config.sbom_paths.len() < 2 {
266        bail!("Matrix comparison requires at least 2 SBOMs");
267    }
268
269    // Validate output mode before doing work so unsupported formats fail fast.
270    let output_mode = resolve_multi_output(&config.output)?;
271
272    let (sboms, _enrich_stats) =
273        parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
274
275    tracing::info!(
276        "Computing {}x{} comparison matrix",
277        sboms.len(),
278        sboms.len()
279    );
280
281    let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
282
283    // Prepare SBOM references with names
284    let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
285    let sbom_refs: Vec<_> = sbom_data
286        .iter()
287        .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
288        .collect();
289
290    // Run matrix comparison
291    let mut engine = build_multi_engine(
292        fuzzy_config,
293        config.matching.include_unchanged,
294        &config.graph_diff,
295        &config.rules,
296    );
297    let mut result = engine.matrix(&sbom_refs, Some(config.cluster_threshold))?;
298
299    // Apply severity/VEX/graph-impact post-processing to each pairwise diff.
300    for diff in result.diffs.iter_mut().flatten() {
301        apply_post_diff_filters(diff, &config.filtering, &config.graph_diff);
302    }
303
304    tracing::info!(
305        "Matrix comparison complete: {} pairs computed",
306        result.num_pairs()
307    );
308
309    if let Some(ref clustering) = result.clustering {
310        tracing::info!(
311            "Found {} clusters, {} outliers",
312            clustering.clusters.len(),
313            clustering.outliers.len()
314        );
315    }
316
317    // Determine exit code
318    let exit_code = determine_multi_exit_code(
319        &config.behavior,
320        &config.filtering,
321        result.diffs.iter().flatten(),
322    );
323
324    if let MultiOutput::Json(ref output_target) = output_mode {
325        let json = serde_json::to_string_pretty(&result)?;
326        write_output(&json, output_target, quiet)?;
327    } else {
328        let mut app = App::new_matrix(result);
329        run_tui(&mut app)?;
330    }
331
332    Ok(exit_code)
333}
334
335/// Parse and optionally enrich multiple SBOMs.
336fn parse_and_enrich_sboms(
337    paths: &[PathBuf],
338    enrichment: &crate::config::EnrichmentConfig,
339    quiet: bool,
340) -> Result<(
341    Vec<NormalizedSbom>,
342    Vec<crate::pipeline::AggregatedEnrichmentStats>,
343)> {
344    let mut sboms = Vec::with_capacity(paths.len());
345    for path in paths {
346        let parsed = parse_sbom_with_context(path, quiet)?;
347        sboms.push(parsed.into_sbom());
348    }
349    let stats = enrich_sboms(&mut sboms, enrichment, quiet);
350    Ok((sboms, stats))
351}
352
353/// Parse multiple SBOMs without enrichment.
354///
355/// Used by the query command where enrichment is handled separately.
356pub(crate) fn parse_multiple_sboms(paths: &[PathBuf]) -> Result<Vec<NormalizedSbom>> {
357    let mut sboms = Vec::with_capacity(paths.len());
358    for path in paths {
359        let parsed = parse_sbom_with_context(path, false)?;
360        sboms.push(parsed.into_sbom());
361    }
362    Ok(sboms)
363}
364
365/// Determine the exit code for a multi-SBOM command from its pairwise diffs.
366///
367/// Aggregates VEX gaps, introduced vulnerabilities, and total changes across
368/// every pairwise [`DiffResult`] the command produced. Priority mirrors the
369/// single-SBOM `diff` gate (highest code wins): VEX gaps (4) > vulns (2) >
370/// changes (1). `--fail-on-vex-gap` is checked first because it is the most
371/// specific signal a user can ask for.
372fn determine_multi_exit_code<'a, I>(
373    behavior: &crate::config::BehaviorConfig,
374    filtering: &FilterConfig,
375    diffs: I,
376) -> i32
377where
378    I: IntoIterator<Item = &'a DiffResult>,
379{
380    let mut total_introduced = 0usize;
381    let mut total_changes = 0usize;
382    let mut total_gaps = 0usize;
383    let mut introduced_gaps = 0usize;
384    let mut persistent_gaps = 0usize;
385
386    for diff in diffs {
387        total_introduced += diff.summary.vulnerabilities_introduced;
388        total_changes += diff.summary.total_changes;
389        if filtering.fail_on_vex_gap {
390            let vex = diff.vulnerabilities.vex_summary();
391            introduced_gaps += vex.introduced_without_vex;
392            persistent_gaps += vex.persistent_without_vex;
393            total_gaps += vex.introduced_without_vex + vex.persistent_without_vex;
394        }
395    }
396
397    if filtering.fail_on_vex_gap && total_gaps > 0 {
398        eprintln!(
399            "VEX gap: {total_gaps} vulnerability(ies) lack VEX statements \
400             ({introduced_gaps} introduced, {persistent_gaps} persistent)",
401        );
402        return exit_codes::VEX_GAPS_FOUND;
403    }
404    if behavior.fail_on_vuln && total_introduced > 0 {
405        return exit_codes::VULNS_INTRODUCED;
406    }
407    if behavior.fail_on_change && total_changes > 0 {
408        return exit_codes::CHANGES_DETECTED;
409    }
410    exit_codes::SUCCESS
411}
412
413/// Get fuzzy matching config from preset name
414fn get_fuzzy_config(preset: &crate::config::FuzzyPreset) -> FuzzyMatchConfig {
415    FuzzyMatchConfig::from_preset(preset.as_str()).unwrap_or_else(|| {
416        // Enum guarantees valid preset, but from_preset may not know all variants
417        FuzzyMatchConfig::balanced()
418    })
419}
420
421/// Get SBOM name from path
422pub(crate) fn get_sbom_name(path: &Path) -> String {
423    path.file_stem().map_or_else(
424        || "unknown".to_string(),
425        |s| s.to_string_lossy().to_string(),
426    )
427}
428
429/// Prepare SBOM references with names and paths
430fn prepare_sbom_refs<'a>(
431    sboms: &'a [NormalizedSbom],
432    paths: &[PathBuf],
433) -> Vec<(&'a NormalizedSbom, String, String)> {
434    sboms
435        .iter()
436        .zip(paths.iter())
437        .map(|(sbom, path)| {
438            let name = get_sbom_name(path);
439            let path_str = path.to_string_lossy().to_string();
440            (sbom, name, path_str)
441        })
442        .collect()
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn test_get_fuzzy_config_valid_presets() {
451        let config = get_fuzzy_config(&crate::config::FuzzyPreset::Strict);
452        assert!(config.threshold > 0.8);
453
454        let config = get_fuzzy_config(&crate::config::FuzzyPreset::Balanced);
455        assert!(config.threshold >= 0.7 && config.threshold <= 0.85);
456
457        let config = get_fuzzy_config(&crate::config::FuzzyPreset::Permissive);
458        assert!(config.threshold <= 0.70);
459    }
460
461    #[test]
462    fn test_get_sbom_name() {
463        let path = PathBuf::from("/path/to/my-sbom.cdx.json");
464        assert_eq!(get_sbom_name(&path), "my-sbom.cdx");
465
466        let path = PathBuf::from("simple.json");
467        assert_eq!(get_sbom_name(&path), "simple");
468    }
469
470    #[test]
471    fn test_prepare_sbom_refs() {
472        let sbom1 = NormalizedSbom::default();
473        let sbom2 = NormalizedSbom::default();
474        let sboms = vec![sbom1, sbom2];
475        let paths = vec![PathBuf::from("first.json"), PathBuf::from("second.json")];
476
477        let refs = prepare_sbom_refs(&sboms, &paths);
478        assert_eq!(refs.len(), 2);
479        assert_eq!(refs[0].1, "first");
480        assert_eq!(refs[1].1, "second");
481    }
482
483    fn output_config(format: ReportFormat, file: Option<PathBuf>) -> crate::config::OutputConfig {
484        crate::config::OutputConfig {
485            format,
486            file,
487            ..Default::default()
488        }
489    }
490
491    #[test]
492    fn resolve_multi_output_accepts_tui_and_json() {
493        assert!(matches!(
494            resolve_multi_output(&output_config(ReportFormat::Tui, None)).unwrap(),
495            MultiOutput::Tui
496        ));
497        assert!(matches!(
498            resolve_multi_output(&output_config(ReportFormat::Json, None)).unwrap(),
499            MultiOutput::Json(_)
500        ));
501    }
502
503    #[test]
504    fn resolve_multi_output_auto_to_file_is_json() {
505        // Writing to a file is never a TTY, so `auto` must resolve to JSON.
506        let cfg = output_config(ReportFormat::Auto, Some(PathBuf::from("/tmp/out.json")));
507        assert!(matches!(
508            resolve_multi_output(&cfg).unwrap(),
509            MultiOutput::Json(_)
510        ));
511    }
512
513    #[test]
514    fn resolve_multi_output_rejects_unsupported_formats() {
515        for fmt in [
516            ReportFormat::Table,
517            ReportFormat::Markdown,
518            ReportFormat::Summary,
519            ReportFormat::Sarif,
520            ReportFormat::Html,
521            ReportFormat::Csv,
522            ReportFormat::SideBySide,
523        ] {
524            let result = resolve_multi_output(&output_config(fmt, None));
525            let msg = match result {
526                Ok(_) => panic!("format {fmt} must be rejected"),
527                Err(e) => e.to_string(),
528            };
529            assert!(
530                msg.contains("not supported for multi-SBOM commands"),
531                "{msg}"
532            );
533            assert!(msg.contains("tui, json"), "{msg}");
534        }
535    }
536
537    #[test]
538    fn determine_multi_exit_code_change_gate() {
539        let mut diff = DiffResult::new();
540        diff.summary.total_changes = 3;
541        let behavior = crate::config::BehaviorConfig {
542            fail_on_change: true,
543            ..Default::default()
544        };
545        let filtering = FilterConfig::default();
546        assert_eq!(
547            determine_multi_exit_code(&behavior, &filtering, std::iter::once(&diff)),
548            exit_codes::CHANGES_DETECTED
549        );
550
551        // Without the gate flag, the same diff is success.
552        let behavior = crate::config::BehaviorConfig::default();
553        assert_eq!(
554            determine_multi_exit_code(&behavior, &filtering, std::iter::once(&diff)),
555            exit_codes::SUCCESS
556        );
557    }
558}