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};
11use std::collections::HashMap;
12
13// ============================================================================
14// SBOM Info (common metadata)
15// ============================================================================
16
17/// Basic information about an SBOM
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SbomInfo {
20    /// Display name (user-provided label or filename)
21    pub name: String,
22    /// File path
23    pub file_path: String,
24    /// Format (`CycloneDX`, SPDX)
25    pub format: String,
26    /// Number of components
27    pub component_count: usize,
28    /// Number of dependencies
29    pub dependency_count: usize,
30    /// Vulnerability counts
31    pub vulnerability_counts: VulnerabilityCounts,
32    /// Timestamp if available
33    pub timestamp: Option<String>,
34}
35
36impl SbomInfo {
37    #[must_use]
38    pub fn from_sbom(sbom: &NormalizedSbom, name: String, file_path: String) -> Self {
39        Self {
40            name,
41            file_path,
42            format: sbom.document.format.to_string(),
43            component_count: sbom.component_count(),
44            dependency_count: sbom.edges.len(),
45            vulnerability_counts: sbom.vulnerability_counts(),
46            timestamp: Some(sbom.document.created.to_rfc3339()),
47        }
48    }
49}
50
51// ============================================================================
52// 1:N MULTI-DIFF RESULT
53// ============================================================================
54
55/// Result of 1:N baseline comparison
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct MultiDiffResult {
58    /// Baseline SBOM information
59    pub baseline: SbomInfo,
60    /// Individual comparison results for each target
61    pub comparisons: Vec<ComparisonResult>,
62    /// Aggregated summary across all comparisons
63    pub summary: MultiDiffSummary,
64}
65
66/// Individual comparison result (baseline vs one target)
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ComparisonResult {
69    /// Target SBOM information
70    pub target: SbomInfo,
71    /// Full diff result (same as 1:1 diff)
72    pub diff: DiffResult,
73    /// Components unique to this target (not in baseline or other targets)
74    pub unique_components: Vec<String>,
75    /// Components shared with baseline but different from other targets
76    pub divergent_components: Vec<DivergentComponent>,
77}
78
79/// Component that differs across targets
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct DivergentComponent {
82    pub id: String,
83    pub name: String,
84    pub baseline_version: Option<String>,
85    pub target_version: String,
86    /// All versions across targets: `target_name` -> version
87    pub versions_across_targets: HashMap<String, String>,
88    pub divergence_type: DivergenceType,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub enum DivergenceType {
93    /// Version differs from baseline
94    VersionMismatch,
95    /// Component added (not in baseline)
96    Added,
97    /// Component removed (in baseline, not in target)
98    Removed,
99    /// Different license
100    LicenseMismatch,
101    /// Different supplier
102    SupplierMismatch,
103}
104
105// ============================================================================
106// MULTI-DIFF SUMMARY
107// ============================================================================
108
109/// Aggregated summary across all 1:N comparisons
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct MultiDiffSummary {
112    /// Total component count in baseline
113    pub baseline_component_count: usize,
114    /// Components present in ALL targets (including baseline)
115    pub universal_components: Vec<String>,
116    /// Components that have different versions across targets
117    pub variable_components: Vec<VariableComponent>,
118    /// Components missing from one or more targets
119    pub inconsistent_components: Vec<InconsistentComponent>,
120    /// Per-target deviation scores
121    pub deviation_scores: HashMap<String, f64>,
122    /// Maximum deviation from baseline
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: HashMap<String, VulnerabilityCounts>,
198    /// Vulnerabilities unique to specific targets
199    pub unique_vulnerabilities: HashMap<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    /// Cumulative diffs from first: [0→1, 0→2, 0→3, ...]
216    pub cumulative_from_first: Vec<DiffResult>,
217    /// High-level evolution summary
218    pub evolution_summary: EvolutionSummary,
219}
220
221/// High-level evolution across the timeline
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct EvolutionSummary {
224    /// Components added over the timeline
225    pub components_added: Vec<ComponentEvolution>,
226    /// Components removed over the timeline
227    pub components_removed: Vec<ComponentEvolution>,
228    /// Version progression for each component: `component_id` -> versions at each point
229    pub version_history: HashMap<String, Vec<VersionAtPoint>>,
230    /// Vulnerability trend over time
231    pub vulnerability_trend: Vec<VulnerabilitySnapshot>,
232    /// License changes over time
233    pub license_changes: Vec<LicenseChange>,
234    /// Dependency count trend
235    pub dependency_trend: Vec<DependencySnapshot>,
236    /// Compliance score trend across SBOM versions
237    pub compliance_trend: Vec<ComplianceSnapshot>,
238}
239
240/// Component lifecycle in the timeline
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ComponentEvolution {
243    pub id: String,
244    pub name: String,
245    /// Index in timeline when first seen
246    pub first_seen_index: usize,
247    pub first_seen_version: String,
248    /// Index when last seen (None if still present at end)
249    pub last_seen_index: Option<usize>,
250    /// Current version (at end of timeline)
251    pub current_version: Option<String>,
252    /// Total version changes
253    pub version_change_count: usize,
254}
255
256/// Version of a component at a point in the timeline
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct VersionAtPoint {
259    pub sbom_index: usize,
260    pub sbom_name: String,
261    pub version: Option<String>,
262    pub change_type: VersionChangeType,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub enum VersionChangeType {
267    Initial,
268    MajorUpgrade,
269    MinorUpgrade,
270    PatchUpgrade,
271    Downgrade,
272    Unchanged,
273    Removed,
274    Absent,
275}
276
277impl VersionChangeType {
278    #[must_use]
279    pub const fn symbol(&self) -> &'static str {
280        match self {
281            Self::Initial => "●",
282            Self::MajorUpgrade => "⬆",
283            Self::MinorUpgrade => "↑",
284            Self::PatchUpgrade => "↗",
285            Self::Downgrade => "⬇",
286            Self::Unchanged => "─",
287            Self::Removed => "✗",
288            Self::Absent => " ",
289        }
290    }
291}
292
293/// Compliance score at a point in timeline
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct ComplianceSnapshot {
296    pub sbom_index: usize,
297    pub sbom_name: String,
298    /// Compliance scores per standard: (`standard_name`, `error_count`, `warning_count`, `is_compliant`)
299    pub scores: Vec<ComplianceScoreEntry>,
300}
301
302/// A single compliance score entry for one standard
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct ComplianceScoreEntry {
305    pub standard: String,
306    pub error_count: usize,
307    pub warning_count: usize,
308    pub info_count: usize,
309    pub is_compliant: bool,
310}
311
312/// Vulnerability counts at a point in timeline
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct VulnerabilitySnapshot {
315    pub sbom_index: usize,
316    pub sbom_name: String,
317    pub counts: VulnerabilityCounts,
318    pub new_vulnerabilities: Vec<String>,
319    pub resolved_vulnerabilities: Vec<String>,
320}
321
322/// License change record
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct LicenseChange {
325    pub sbom_index: usize,
326    pub component_id: String,
327    pub component_name: String,
328    pub old_license: Vec<String>,
329    pub new_license: Vec<String>,
330    pub change_type: LicenseChangeType,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub enum LicenseChangeType {
335    MorePermissive,
336    MoreRestrictive,
337    Incompatible,
338    Equivalent,
339}
340
341/// Dependency count at a point
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct DependencySnapshot {
344    pub sbom_index: usize,
345    pub sbom_name: String,
346    pub direct_dependencies: usize,
347    pub transitive_dependencies: usize,
348    pub total_edges: usize,
349}
350
351// ============================================================================
352// MATRIX RESULT
353// ============================================================================
354
355/// N×N comparison matrix result
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct MatrixResult {
358    /// All SBOMs in comparison
359    pub sboms: Vec<SbomInfo>,
360    /// Upper-triangle matrix of diff results
361    /// Access with matrix[i * `sboms.len()` + j] where i < j
362    pub diffs: Vec<Option<DiffResult>>,
363    /// Similarity scores (0.0 = completely different, 1.0 = identical)
364    /// Same indexing as diffs
365    pub similarity_scores: Vec<f64>,
366    /// Optional clustering based on similarity
367    pub clustering: Option<SbomClustering>,
368}
369
370impl MatrixResult {
371    /// Get diff between sboms[i] and sboms[j]
372    #[must_use]
373    pub fn get_diff(&self, i: usize, j: usize) -> Option<&DiffResult> {
374        if i == j {
375            return None;
376        }
377        let (a, b) = if i < j { (i, j) } else { (j, i) };
378        let idx = self.matrix_index(a, b);
379        self.diffs.get(idx).and_then(|d| d.as_ref())
380    }
381
382    /// Get similarity between sboms[i] and sboms[j]
383    #[must_use]
384    pub fn get_similarity(&self, i: usize, j: usize) -> f64 {
385        if i == j {
386            return 1.0;
387        }
388        let (a, b) = if i < j { (i, j) } else { (j, i) };
389        let idx = self.matrix_index(a, b);
390        self.similarity_scores.get(idx).copied().unwrap_or(0.0)
391    }
392
393    /// Calculate index in flattened upper-triangle matrix
394    fn matrix_index(&self, i: usize, j: usize) -> usize {
395        let n = self.sboms.len();
396        // Upper triangle index formula: i * (2n - i - 1) / 2 + (j - i - 1)
397        i * (2 * n - i - 1) / 2 + (j - i - 1)
398    }
399
400    /// Number of pairs (n choose 2)
401    #[must_use]
402    pub fn num_pairs(&self) -> usize {
403        let n = self.sboms.len();
404        n * (n - 1) / 2
405    }
406}
407
408/// Clustering of similar SBOMs
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct SbomClustering {
411    /// Identified clusters of similar SBOMs
412    pub clusters: Vec<SbomCluster>,
413    /// Outliers that don't fit any cluster (indices into sboms)
414    pub outliers: Vec<usize>,
415    /// Clustering algorithm used
416    pub algorithm: String,
417    /// Threshold used for clustering
418    pub threshold: f64,
419}
420
421/// A cluster of similar SBOMs
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct SbomCluster {
424    /// Indices into sboms vec
425    pub members: Vec<usize>,
426    /// Most representative SBOM (centroid)
427    pub centroid_index: usize,
428    /// Average internal similarity
429    pub internal_similarity: f64,
430    /// Cluster label (auto-generated or user-provided)
431    pub label: Option<String>,
432}
433
434// ============================================================================
435// INCREMENTAL CHANGE SUMMARY (for timeline)
436// ============================================================================
437
438/// Summary of changes between two adjacent versions
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct IncrementalChange {
441    pub from_index: usize,
442    pub to_index: usize,
443    pub from_name: String,
444    pub to_name: String,
445    pub components_added: usize,
446    pub components_removed: usize,
447    pub components_modified: usize,
448    pub vulnerabilities_introduced: usize,
449    pub vulnerabilities_resolved: usize,
450}
451
452impl IncrementalChange {
453    #[must_use]
454    pub fn from_diff(
455        from_idx: usize,
456        to_idx: usize,
457        from_name: &str,
458        to_name: &str,
459        diff: &DiffResult,
460    ) -> Self {
461        Self {
462            from_index: from_idx,
463            to_index: to_idx,
464            from_name: from_name.to_string(),
465            to_name: to_name.to_string(),
466            components_added: diff.summary.components_added,
467            components_removed: diff.summary.components_removed,
468            components_modified: diff.summary.components_modified,
469            vulnerabilities_introduced: diff.summary.vulnerabilities_introduced,
470            vulnerabilities_resolved: diff.summary.vulnerabilities_resolved,
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_security_impact_label() {
481        assert_eq!(SecurityImpact::Critical.label(), "CRITICAL");
482        assert_eq!(SecurityImpact::High.label(), "high");
483        assert_eq!(SecurityImpact::Medium.label(), "medium");
484        assert_eq!(SecurityImpact::Low.label(), "low");
485    }
486
487    #[test]
488    fn test_version_change_type_symbol() {
489        assert_eq!(VersionChangeType::Initial.symbol(), "●");
490        assert_eq!(VersionChangeType::MajorUpgrade.symbol(), "⬆");
491        assert_eq!(VersionChangeType::MinorUpgrade.symbol(), "↑");
492        assert_eq!(VersionChangeType::PatchUpgrade.symbol(), "↗");
493        assert_eq!(VersionChangeType::Downgrade.symbol(), "⬇");
494        assert_eq!(VersionChangeType::Unchanged.symbol(), "─");
495        assert_eq!(VersionChangeType::Removed.symbol(), "✗");
496        assert_eq!(VersionChangeType::Absent.symbol(), " ");
497    }
498
499    fn make_matrix(n: usize) -> MatrixResult {
500        let sboms = (0..n)
501            .map(|i| SbomInfo {
502                name: format!("sbom-{i}"),
503                file_path: format!("sbom-{i}.json"),
504                format: "CycloneDX".into(),
505                component_count: 10,
506                dependency_count: 5,
507                vulnerability_counts: VulnerabilityCounts::default(),
508                timestamp: None,
509            })
510            .collect::<Vec<_>>();
511        let num_pairs = n * (n - 1) / 2;
512        MatrixResult {
513            sboms,
514            diffs: vec![None; num_pairs],
515            similarity_scores: vec![0.5; num_pairs],
516            clustering: None,
517        }
518    }
519
520    #[test]
521    fn test_matrix_result_get_diff_self() {
522        let matrix = make_matrix(3);
523        assert!(matrix.get_diff(0, 0).is_none());
524        assert!(matrix.get_diff(1, 1).is_none());
525    }
526
527    #[test]
528    fn test_matrix_result_get_similarity_self() {
529        let matrix = make_matrix(3);
530        assert_eq!(matrix.get_similarity(0, 0), 1.0);
531        assert_eq!(matrix.get_similarity(2, 2), 1.0);
532    }
533
534    #[test]
535    fn test_matrix_result_get_similarity_symmetric() {
536        let matrix = make_matrix(3);
537        assert_eq!(matrix.get_similarity(0, 1), matrix.get_similarity(1, 0));
538        assert_eq!(matrix.get_similarity(0, 2), matrix.get_similarity(2, 0));
539    }
540
541    #[test]
542    fn test_matrix_result_num_pairs() {
543        assert_eq!(make_matrix(3).num_pairs(), 3);
544        assert_eq!(make_matrix(4).num_pairs(), 6);
545        assert_eq!(make_matrix(5).num_pairs(), 10);
546    }
547
548    #[test]
549    fn test_incremental_change_from_diff() {
550        let mut diff = DiffResult::new();
551        diff.summary.components_added = 5;
552        diff.summary.components_removed = 2;
553        diff.summary.components_modified = 3;
554        diff.summary.vulnerabilities_introduced = 1;
555        diff.summary.vulnerabilities_resolved = 4;
556
557        let change = IncrementalChange::from_diff(0, 1, "v1.0", "v2.0", &diff);
558        assert_eq!(change.from_index, 0);
559        assert_eq!(change.to_index, 1);
560        assert_eq!(change.from_name, "v1.0");
561        assert_eq!(change.to_name, "v2.0");
562        assert_eq!(change.components_added, 5);
563        assert_eq!(change.components_removed, 2);
564        assert_eq!(change.components_modified, 3);
565        assert_eq!(change.vulnerabilities_introduced, 1);
566        assert_eq!(change.vulnerabilities_resolved, 4);
567    }
568
569    #[test]
570    fn test_divergence_type_variants() {
571        // Ensure all variants are constructable and distinct
572        let variants = [
573            DivergenceType::VersionMismatch,
574            DivergenceType::Added,
575            DivergenceType::Removed,
576            DivergenceType::LicenseMismatch,
577            DivergenceType::SupplierMismatch,
578        ];
579        for (i, a) in variants.iter().enumerate() {
580            for (j, b) in variants.iter().enumerate() {
581                if i == j {
582                    assert_eq!(a, b);
583                } else {
584                    assert_ne!(a, b);
585                }
586            }
587        }
588    }
589
590    #[test]
591    fn test_license_change_type_variants() {
592        let variants = [
593            LicenseChangeType::MorePermissive,
594            LicenseChangeType::MoreRestrictive,
595            LicenseChangeType::Incompatible,
596            LicenseChangeType::Equivalent,
597        ];
598        for (i, a) in variants.iter().enumerate() {
599            for (j, b) in variants.iter().enumerate() {
600                if i == j {
601                    assert_eq!(a, b);
602                } else {
603                    assert_ne!(a, b);
604                }
605            }
606        }
607    }
608}