Skip to main content

sbom_tools/diff/
multi.rs

1//! Multi-SBOM comparison data structures and engines.
2//!
3//! Supports:
4//! - 1:N diff-multi (baseline vs multiple targets)
5//! - Timeline analysis (incremental version evolution)
6//! - N×N matrix comparison (all pairs)
7
8use super::DiffResult;
9use crate::model::{NormalizedSbom, VulnerabilityCounts};
10use serde::{Deserialize, Serialize};
11
12// ============================================================================
13// SBOM Info (common metadata)
14// ============================================================================
15
16/// Basic information about an SBOM
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SbomInfo {
19    /// Display name (user-provided label or filename)
20    pub name: String,
21    /// File path
22    pub file_path: String,
23    /// Format (`CycloneDX`, SPDX)
24    pub format: String,
25    /// Number of components
26    pub component_count: usize,
27    /// Number of dependencies
28    pub dependency_count: usize,
29    /// Vulnerability counts
30    pub vulnerability_counts: VulnerabilityCounts,
31    /// Timestamp if available
32    pub timestamp: Option<String>,
33}
34
35impl SbomInfo {
36    #[must_use]
37    pub fn from_sbom(sbom: &NormalizedSbom, name: String, file_path: String) -> Self {
38        Self {
39            name,
40            file_path,
41            format: sbom.document.format.to_string(),
42            component_count: sbom.component_count(),
43            dependency_count: sbom.edges.len(),
44            vulnerability_counts: sbom.vulnerability_counts(),
45            timestamp: Some(sbom.document.created.to_rfc3339()),
46        }
47    }
48}
49
50// ============================================================================
51// 1:N MULTI-DIFF RESULT
52// ============================================================================
53
54/// Result of 1:N baseline comparison
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct MultiDiffResult {
57    /// Baseline SBOM information
58    pub baseline: SbomInfo,
59    /// Individual comparison results for each target
60    pub comparisons: Vec<ComparisonResult>,
61    /// Aggregated summary across all comparisons
62    pub summary: MultiDiffSummary,
63}
64
65/// Individual comparison result (baseline vs one target)
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ComparisonResult {
68    /// Target SBOM information
69    pub target: SbomInfo,
70    /// Full diff result (same as 1:1 diff)
71    pub diff: DiffResult,
72    /// Components unique to this target (not in baseline or other targets)
73    pub unique_components: Vec<String>,
74    /// Components shared with baseline but different from other targets
75    pub divergent_components: Vec<DivergentComponent>,
76}
77
78/// Component that differs across targets
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct DivergentComponent {
81    pub id: String,
82    pub name: String,
83    pub baseline_version: Option<String>,
84    pub target_version: String,
85    /// All versions across targets: `target_name` -> version
86    pub versions_across_targets: std::collections::BTreeMap<String, String>,
87    pub divergence_type: DivergenceType,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub enum DivergenceType {
92    /// Version differs from baseline
93    VersionMismatch,
94    /// Component added (not in baseline)
95    Added,
96    /// Component removed (in baseline, not in target)
97    Removed,
98    /// Different license
99    LicenseMismatch,
100    /// Different supplier
101    SupplierMismatch,
102}
103
104// ============================================================================
105// MULTI-DIFF SUMMARY
106// ============================================================================
107
108/// Aggregated summary across all 1:N comparisons
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct MultiDiffSummary {
111    /// Total component count in baseline
112    pub baseline_component_count: usize,
113    /// Components present in ALL targets (including baseline)
114    pub universal_components: Vec<String>,
115    /// Components that have different versions across targets
116    pub variable_components: Vec<VariableComponent>,
117    /// Components missing from one or more targets
118    pub inconsistent_components: Vec<InconsistentComponent>,
119    /// Per-target deviation from the baseline as a **0.0-1.0 fraction**
120    /// (`1 - similarity`; 0.0 = identical to baseline).
121    pub deviation_scores: std::collections::BTreeMap<String, f64>,
122    /// Largest value in `deviation_scores`, same **0.0-1.0** scale.
123    pub max_deviation: f64,
124    /// Aggregate vulnerability exposure across targets
125    pub vulnerability_matrix: VulnerabilityMatrix,
126}
127
128/// Component with version variation across targets
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct VariableComponent {
131    pub id: String,
132    pub name: String,
133    pub ecosystem: Option<String>,
134    pub version_spread: VersionSpread,
135    pub targets_with_component: Vec<String>,
136    pub security_impact: SecurityImpact,
137}
138
139/// Version distribution information
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct VersionSpread {
142    /// Baseline version
143    pub baseline: Option<String>,
144    /// Lowest version seen (as string, parsed if semver)
145    pub min_version: Option<String>,
146    /// Highest version seen
147    pub max_version: Option<String>,
148    /// All unique versions
149    pub unique_versions: Vec<String>,
150    /// True if all targets have same version
151    pub is_consistent: bool,
152    /// Number of major version differences
153    pub major_version_spread: u32,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub enum SecurityImpact {
158    /// Critical security component with version spread (e.g., openssl, curl)
159    Critical,
160    /// Security-relevant component
161    High,
162    /// Standard component
163    Medium,
164    /// Low-risk component
165    Low,
166}
167
168impl SecurityImpact {
169    #[must_use]
170    pub const fn label(&self) -> &'static str {
171        match self {
172            Self::Critical => "CRITICAL",
173            Self::High => "high",
174            Self::Medium => "medium",
175            Self::Low => "low",
176        }
177    }
178}
179
180/// Component missing from some targets
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct InconsistentComponent {
183    pub id: String,
184    pub name: String,
185    /// True if in baseline
186    pub in_baseline: bool,
187    /// Targets that have this component
188    pub present_in: Vec<String>,
189    /// Targets missing this component
190    pub missing_from: Vec<String>,
191}
192
193/// Vulnerability counts across all SBOMs
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct VulnerabilityMatrix {
196    /// Vulnerability counts per SBOM name
197    pub per_sbom: std::collections::BTreeMap<String, VulnerabilityCounts>,
198    /// Vulnerabilities unique to specific targets
199    pub unique_vulnerabilities: std::collections::BTreeMap<String, Vec<String>>,
200    /// Vulnerabilities common to all
201    pub common_vulnerabilities: Vec<String>,
202}
203
204// ============================================================================
205// TIMELINE RESULT
206// ============================================================================
207
208/// Timeline analysis result (incremental version evolution)
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct TimelineResult {
211    /// Ordered list of SBOMs in timeline
212    pub sboms: Vec<SbomInfo>,
213    /// Incremental diffs: [0→1, 1→2, 2→3, ...]
214    pub incremental_diffs: Vec<DiffResult>,
215    /// Which pair each entry of `incremental_diffs` compares, same index.
216    ///
217    /// A [`DiffResult`] carries no identity of its own, so consumers could
218    /// previously only infer the pair from array position and the documented
219    /// ordering. These labels make it explicit.
220    #[serde(default)]
221    pub incremental_pairs: Vec<TimelinePair>,
222    /// Cumulative diffs from first: [0→1, 0→2, 0→3, ...]
223    pub cumulative_from_first: Vec<DiffResult>,
224    /// Which pair each entry of `cumulative_from_first` compares, same index.
225    #[serde(default)]
226    pub cumulative_pairs: Vec<TimelinePair>,
227    /// High-level evolution summary
228    pub evolution_summary: EvolutionSummary,
229}
230
231/// Identifies the two SBOMs a timeline diff compares.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct TimelinePair {
234    /// Index of the older SBOM in `TimelineResult::sboms`
235    pub from_index: usize,
236    /// Index of the newer SBOM in `TimelineResult::sboms`
237    pub to_index: usize,
238    /// Display name of the older SBOM
239    pub from_name: String,
240    /// Display name of the newer SBOM
241    pub to_name: String,
242}
243
244/// High-level evolution across the timeline
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct EvolutionSummary {
247    /// Components added over the timeline
248    pub components_added: Vec<ComponentEvolution>,
249    /// Components removed over the timeline
250    pub components_removed: Vec<ComponentEvolution>,
251    /// Version progression for each component: `component_id` -> versions at each point
252    pub version_history: std::collections::BTreeMap<String, Vec<VersionAtPoint>>,
253    /// Vulnerability trend over time
254    pub vulnerability_trend: Vec<VulnerabilitySnapshot>,
255    /// License changes over time
256    pub license_changes: Vec<LicenseChange>,
257    /// Dependency count trend
258    pub dependency_trend: Vec<DependencySnapshot>,
259    /// Compliance score trend across SBOM versions
260    pub compliance_trend: Vec<ComplianceSnapshot>,
261}
262
263/// Component lifecycle in the timeline
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ComponentEvolution {
266    pub id: String,
267    pub name: String,
268    /// Index in timeline when first seen
269    pub first_seen_index: usize,
270    pub first_seen_version: String,
271    /// Index when last seen (None if still present at end)
272    pub last_seen_index: Option<usize>,
273    /// Current version (at end of timeline)
274    pub current_version: Option<String>,
275    /// Total version changes
276    pub version_change_count: usize,
277}
278
279/// Version of a component at a point in the timeline
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct VersionAtPoint {
282    pub sbom_index: usize,
283    pub sbom_name: String,
284    pub version: Option<String>,
285    pub change_type: VersionChangeType,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub enum VersionChangeType {
290    Initial,
291    MajorUpgrade,
292    MinorUpgrade,
293    PatchUpgrade,
294    Downgrade,
295    /// Version string changed but the direction is not classifiable
296    /// (incomparable, non-numeric schemes). Reported honestly instead of
297    /// fabricating an upgrade/downgrade direction.
298    Changed,
299    Unchanged,
300    Removed,
301    Absent,
302}
303
304impl VersionChangeType {
305    #[must_use]
306    pub const fn symbol(&self) -> &'static str {
307        match self {
308            Self::Initial => "●",
309            Self::MajorUpgrade => "⬆",
310            Self::MinorUpgrade => "↑",
311            Self::PatchUpgrade => "↗",
312            Self::Downgrade => "⬇",
313            Self::Changed => "~",
314            Self::Unchanged => "─",
315            Self::Removed => "✗",
316            Self::Absent => " ",
317        }
318    }
319}
320
321/// Compliance score at a point in timeline
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct ComplianceSnapshot {
324    pub sbom_index: usize,
325    pub sbom_name: String,
326    /// Compliance scores per standard: (`standard_name`, `error_count`, `warning_count`, `is_compliant`)
327    pub scores: Vec<ComplianceScoreEntry>,
328}
329
330/// A single compliance score entry for one standard
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct ComplianceScoreEntry {
333    pub standard: String,
334    pub error_count: usize,
335    pub warning_count: usize,
336    pub info_count: usize,
337    pub is_compliant: bool,
338}
339
340/// Vulnerability counts at a point in timeline
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct VulnerabilitySnapshot {
343    pub sbom_index: usize,
344    pub sbom_name: String,
345    pub counts: VulnerabilityCounts,
346    pub new_vulnerabilities: Vec<String>,
347    pub resolved_vulnerabilities: Vec<String>,
348}
349
350/// License change record
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct LicenseChange {
353    pub sbom_index: usize,
354    pub component_id: String,
355    pub component_name: String,
356    pub old_license: Vec<String>,
357    pub new_license: Vec<String>,
358    pub change_type: LicenseChangeType,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub enum LicenseChangeType {
363    MorePermissive,
364    MoreRestrictive,
365    Incompatible,
366    Equivalent,
367}
368
369/// Dependency count at a point
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct DependencySnapshot {
372    pub sbom_index: usize,
373    pub sbom_name: String,
374    pub direct_dependencies: usize,
375    pub transitive_dependencies: usize,
376    pub total_edges: usize,
377}
378
379// ============================================================================
380// MATRIX RESULT
381// ============================================================================
382
383/// N×N comparison matrix result
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct MatrixResult {
386    /// All SBOMs in comparison
387    pub sboms: Vec<SbomInfo>,
388    /// Upper-triangle matrix of diff results
389    /// Access with matrix[i * `sboms.len()` + j] where i < j
390    pub diffs: Vec<Option<DiffResult>>,
391    /// Similarity per pair as a **0.0-1.0 fraction** (1.0 = identical),
392    /// indexed like `diffs`. This is `DiffResult::semantic_score / 100`:
393    /// the multi-SBOM commands express similarity and deviation as
394    /// fractions, while the embedded per-pair `DiffResult` keeps the
395    /// 0-100 single-diff scale.
396    pub similarity_scores: Vec<f64>,
397    /// Optional clustering based on similarity
398    pub clustering: Option<SbomClustering>,
399}
400
401impl MatrixResult {
402    /// Get diff between sboms[i] and sboms[j]
403    #[must_use]
404    pub fn get_diff(&self, i: usize, j: usize) -> Option<&DiffResult> {
405        if i == j {
406            return None;
407        }
408        let (a, b) = if i < j { (i, j) } else { (j, i) };
409        let idx = self.matrix_index(a, b);
410        self.diffs.get(idx).and_then(|d| d.as_ref())
411    }
412
413    /// Get similarity between sboms[i] and sboms[j]
414    #[must_use]
415    pub fn get_similarity(&self, i: usize, j: usize) -> f64 {
416        if i == j {
417            return 1.0;
418        }
419        let (a, b) = if i < j { (i, j) } else { (j, i) };
420        let idx = self.matrix_index(a, b);
421        self.similarity_scores.get(idx).copied().unwrap_or(0.0)
422    }
423
424    /// Calculate index in flattened upper-triangle matrix
425    fn matrix_index(&self, i: usize, j: usize) -> usize {
426        let n = self.sboms.len();
427        // Upper triangle index formula: i * (2n - i - 1) / 2 + (j - i - 1)
428        i * (2 * n - i - 1) / 2 + (j - i - 1)
429    }
430
431    /// Number of pairs (n choose 2)
432    #[must_use]
433    pub fn num_pairs(&self) -> usize {
434        let n = self.sboms.len();
435        n * (n - 1) / 2
436    }
437}
438
439/// Clustering of similar SBOMs
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct SbomClustering {
442    /// Identified clusters of similar SBOMs
443    pub clusters: Vec<SbomCluster>,
444    /// Outliers that don't fit any cluster (indices into sboms)
445    pub outliers: Vec<usize>,
446    /// Clustering algorithm used
447    pub algorithm: String,
448    /// Threshold used for clustering
449    pub threshold: f64,
450}
451
452/// A cluster of similar SBOMs
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct SbomCluster {
455    /// Indices into sboms vec
456    pub members: Vec<usize>,
457    /// Most representative SBOM (centroid)
458    pub centroid_index: usize,
459    /// Average internal similarity
460    pub internal_similarity: f64,
461    /// Cluster label (auto-generated or user-provided)
462    pub label: Option<String>,
463}
464
465// ============================================================================
466// INCREMENTAL CHANGE SUMMARY (for timeline)
467// ============================================================================
468
469/// Summary of changes between two adjacent versions
470#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct IncrementalChange {
472    pub from_index: usize,
473    pub to_index: usize,
474    pub from_name: String,
475    pub to_name: String,
476    pub components_added: usize,
477    pub components_removed: usize,
478    pub components_modified: usize,
479    pub vulnerabilities_introduced: usize,
480    pub vulnerabilities_resolved: usize,
481}
482
483impl IncrementalChange {
484    #[must_use]
485    pub fn from_diff(
486        from_idx: usize,
487        to_idx: usize,
488        from_name: &str,
489        to_name: &str,
490        diff: &DiffResult,
491    ) -> Self {
492        Self {
493            from_index: from_idx,
494            to_index: to_idx,
495            from_name: from_name.to_string(),
496            to_name: to_name.to_string(),
497            components_added: diff.summary.components_added,
498            components_removed: diff.summary.components_removed,
499            components_modified: diff.summary.components_modified,
500            vulnerabilities_introduced: diff.summary.vulnerabilities_introduced,
501            vulnerabilities_resolved: diff.summary.vulnerabilities_resolved,
502        }
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn test_security_impact_label() {
512        assert_eq!(SecurityImpact::Critical.label(), "CRITICAL");
513        assert_eq!(SecurityImpact::High.label(), "high");
514        assert_eq!(SecurityImpact::Medium.label(), "medium");
515        assert_eq!(SecurityImpact::Low.label(), "low");
516    }
517
518    #[test]
519    fn test_version_change_type_symbol() {
520        assert_eq!(VersionChangeType::Initial.symbol(), "●");
521        assert_eq!(VersionChangeType::MajorUpgrade.symbol(), "⬆");
522        assert_eq!(VersionChangeType::MinorUpgrade.symbol(), "↑");
523        assert_eq!(VersionChangeType::PatchUpgrade.symbol(), "↗");
524        assert_eq!(VersionChangeType::Downgrade.symbol(), "⬇");
525        assert_eq!(VersionChangeType::Unchanged.symbol(), "─");
526        assert_eq!(VersionChangeType::Removed.symbol(), "✗");
527        assert_eq!(VersionChangeType::Absent.symbol(), " ");
528    }
529
530    fn make_matrix(n: usize) -> MatrixResult {
531        let sboms = (0..n)
532            .map(|i| SbomInfo {
533                name: format!("sbom-{i}"),
534                file_path: format!("sbom-{i}.json"),
535                format: "CycloneDX".into(),
536                component_count: 10,
537                dependency_count: 5,
538                vulnerability_counts: VulnerabilityCounts::default(),
539                timestamp: None,
540            })
541            .collect::<Vec<_>>();
542        let num_pairs = n * (n - 1) / 2;
543        MatrixResult {
544            sboms,
545            diffs: vec![None; num_pairs],
546            similarity_scores: vec![0.5; num_pairs],
547            clustering: None,
548        }
549    }
550
551    #[test]
552    fn test_matrix_result_get_diff_self() {
553        let matrix = make_matrix(3);
554        assert!(matrix.get_diff(0, 0).is_none());
555        assert!(matrix.get_diff(1, 1).is_none());
556    }
557
558    #[test]
559    fn test_matrix_result_get_similarity_self() {
560        let matrix = make_matrix(3);
561        assert_eq!(matrix.get_similarity(0, 0), 1.0);
562        assert_eq!(matrix.get_similarity(2, 2), 1.0);
563    }
564
565    #[test]
566    fn test_matrix_result_get_similarity_symmetric() {
567        let matrix = make_matrix(3);
568        assert_eq!(matrix.get_similarity(0, 1), matrix.get_similarity(1, 0));
569        assert_eq!(matrix.get_similarity(0, 2), matrix.get_similarity(2, 0));
570    }
571
572    #[test]
573    fn test_matrix_result_num_pairs() {
574        assert_eq!(make_matrix(3).num_pairs(), 3);
575        assert_eq!(make_matrix(4).num_pairs(), 6);
576        assert_eq!(make_matrix(5).num_pairs(), 10);
577    }
578
579    #[test]
580    fn test_incremental_change_from_diff() {
581        let mut diff = DiffResult::new();
582        diff.summary.components_added = 5;
583        diff.summary.components_removed = 2;
584        diff.summary.components_modified = 3;
585        diff.summary.vulnerabilities_introduced = 1;
586        diff.summary.vulnerabilities_resolved = 4;
587
588        let change = IncrementalChange::from_diff(0, 1, "v1.0", "v2.0", &diff);
589        assert_eq!(change.from_index, 0);
590        assert_eq!(change.to_index, 1);
591        assert_eq!(change.from_name, "v1.0");
592        assert_eq!(change.to_name, "v2.0");
593        assert_eq!(change.components_added, 5);
594        assert_eq!(change.components_removed, 2);
595        assert_eq!(change.components_modified, 3);
596        assert_eq!(change.vulnerabilities_introduced, 1);
597        assert_eq!(change.vulnerabilities_resolved, 4);
598    }
599
600    #[test]
601    fn test_divergence_type_variants() {
602        // Ensure all variants are constructable and distinct
603        let variants = [
604            DivergenceType::VersionMismatch,
605            DivergenceType::Added,
606            DivergenceType::Removed,
607            DivergenceType::LicenseMismatch,
608            DivergenceType::SupplierMismatch,
609        ];
610        for (i, a) in variants.iter().enumerate() {
611            for (j, b) in variants.iter().enumerate() {
612                if i == j {
613                    assert_eq!(a, b);
614                } else {
615                    assert_ne!(a, b);
616                }
617            }
618        }
619    }
620
621    #[test]
622    fn test_license_change_type_variants() {
623        let variants = [
624            LicenseChangeType::MorePermissive,
625            LicenseChangeType::MoreRestrictive,
626            LicenseChangeType::Incompatible,
627            LicenseChangeType::Equivalent,
628        ];
629        for (i, a) in variants.iter().enumerate() {
630            for (j, b) in variants.iter().enumerate() {
631                if i == j {
632                    assert_eq!(a, b);
633                } else {
634                    assert_ne!(a, b);
635                }
636            }
637        }
638    }
639}