sbom-tools 0.1.19

Semantic SBOM diff and analysis tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
//! Multi-SBOM comparison data structures and engines.
//!
//! Supports:
//! - 1:N diff-multi (baseline vs multiple targets)
//! - Timeline analysis (incremental version evolution)
//! - N×N matrix comparison (all pairs)

use super::DiffResult;
use crate::model::{NormalizedSbom, VulnerabilityCounts};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

// ============================================================================
// SBOM Info (common metadata)
// ============================================================================

/// Basic information about an SBOM
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomInfo {
    /// Display name (user-provided label or filename)
    pub name: String,
    /// File path
    pub file_path: String,
    /// Format (`CycloneDX`, SPDX)
    pub format: String,
    /// Number of components
    pub component_count: usize,
    /// Number of dependencies
    pub dependency_count: usize,
    /// Vulnerability counts
    pub vulnerability_counts: VulnerabilityCounts,
    /// Timestamp if available
    pub timestamp: Option<String>,
}

impl SbomInfo {
    #[must_use]
    pub fn from_sbom(sbom: &NormalizedSbom, name: String, file_path: String) -> Self {
        Self {
            name,
            file_path,
            format: sbom.document.format.to_string(),
            component_count: sbom.component_count(),
            dependency_count: sbom.edges.len(),
            vulnerability_counts: sbom.vulnerability_counts(),
            timestamp: Some(sbom.document.created.to_rfc3339()),
        }
    }
}

// ============================================================================
// 1:N MULTI-DIFF RESULT
// ============================================================================

/// Result of 1:N baseline comparison
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiDiffResult {
    /// Baseline SBOM information
    pub baseline: SbomInfo,
    /// Individual comparison results for each target
    pub comparisons: Vec<ComparisonResult>,
    /// Aggregated summary across all comparisons
    pub summary: MultiDiffSummary,
}

/// Individual comparison result (baseline vs one target)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComparisonResult {
    /// Target SBOM information
    pub target: SbomInfo,
    /// Full diff result (same as 1:1 diff)
    pub diff: DiffResult,
    /// Components unique to this target (not in baseline or other targets)
    pub unique_components: Vec<String>,
    /// Components shared with baseline but different from other targets
    pub divergent_components: Vec<DivergentComponent>,
}

/// Component that differs across targets
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DivergentComponent {
    pub id: String,
    pub name: String,
    pub baseline_version: Option<String>,
    pub target_version: String,
    /// All versions across targets: `target_name` -> version
    pub versions_across_targets: HashMap<String, String>,
    pub divergence_type: DivergenceType,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DivergenceType {
    /// Version differs from baseline
    VersionMismatch,
    /// Component added (not in baseline)
    Added,
    /// Component removed (in baseline, not in target)
    Removed,
    /// Different license
    LicenseMismatch,
    /// Different supplier
    SupplierMismatch,
}

// ============================================================================
// MULTI-DIFF SUMMARY
// ============================================================================

/// Aggregated summary across all 1:N comparisons
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiDiffSummary {
    /// Total component count in baseline
    pub baseline_component_count: usize,
    /// Components present in ALL targets (including baseline)
    pub universal_components: Vec<String>,
    /// Components that have different versions across targets
    pub variable_components: Vec<VariableComponent>,
    /// Components missing from one or more targets
    pub inconsistent_components: Vec<InconsistentComponent>,
    /// Per-target deviation scores
    pub deviation_scores: HashMap<String, f64>,
    /// Maximum deviation from baseline
    pub max_deviation: f64,
    /// Aggregate vulnerability exposure across targets
    pub vulnerability_matrix: VulnerabilityMatrix,
}

/// Component with version variation across targets
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VariableComponent {
    pub id: String,
    pub name: String,
    pub ecosystem: Option<String>,
    pub version_spread: VersionSpread,
    pub targets_with_component: Vec<String>,
    pub security_impact: SecurityImpact,
}

/// Version distribution information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionSpread {
    /// Baseline version
    pub baseline: Option<String>,
    /// Lowest version seen (as string, parsed if semver)
    pub min_version: Option<String>,
    /// Highest version seen
    pub max_version: Option<String>,
    /// All unique versions
    pub unique_versions: Vec<String>,
    /// True if all targets have same version
    pub is_consistent: bool,
    /// Number of major version differences
    pub major_version_spread: u32,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SecurityImpact {
    /// Critical security component with version spread (e.g., openssl, curl)
    Critical,
    /// Security-relevant component
    High,
    /// Standard component
    Medium,
    /// Low-risk component
    Low,
}

impl SecurityImpact {
    #[must_use]
    pub const fn label(&self) -> &'static str {
        match self {
            Self::Critical => "CRITICAL",
            Self::High => "high",
            Self::Medium => "medium",
            Self::Low => "low",
        }
    }
}

/// Component missing from some targets
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InconsistentComponent {
    pub id: String,
    pub name: String,
    /// True if in baseline
    pub in_baseline: bool,
    /// Targets that have this component
    pub present_in: Vec<String>,
    /// Targets missing this component
    pub missing_from: Vec<String>,
}

/// Vulnerability counts across all SBOMs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VulnerabilityMatrix {
    /// Vulnerability counts per SBOM name
    pub per_sbom: HashMap<String, VulnerabilityCounts>,
    /// Vulnerabilities unique to specific targets
    pub unique_vulnerabilities: HashMap<String, Vec<String>>,
    /// Vulnerabilities common to all
    pub common_vulnerabilities: Vec<String>,
}

// ============================================================================
// TIMELINE RESULT
// ============================================================================

/// Timeline analysis result (incremental version evolution)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineResult {
    /// Ordered list of SBOMs in timeline
    pub sboms: Vec<SbomInfo>,
    /// Incremental diffs: [0→1, 1→2, 2→3, ...]
    pub incremental_diffs: Vec<DiffResult>,
    /// Cumulative diffs from first: [0→1, 0→2, 0→3, ...]
    pub cumulative_from_first: Vec<DiffResult>,
    /// High-level evolution summary
    pub evolution_summary: EvolutionSummary,
}

/// High-level evolution across the timeline
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionSummary {
    /// Components added over the timeline
    pub components_added: Vec<ComponentEvolution>,
    /// Components removed over the timeline
    pub components_removed: Vec<ComponentEvolution>,
    /// Version progression for each component: `component_id` -> versions at each point
    pub version_history: HashMap<String, Vec<VersionAtPoint>>,
    /// Vulnerability trend over time
    pub vulnerability_trend: Vec<VulnerabilitySnapshot>,
    /// License changes over time
    pub license_changes: Vec<LicenseChange>,
    /// Dependency count trend
    pub dependency_trend: Vec<DependencySnapshot>,
    /// Compliance score trend across SBOM versions
    pub compliance_trend: Vec<ComplianceSnapshot>,
}

/// Component lifecycle in the timeline
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentEvolution {
    pub id: String,
    pub name: String,
    /// Index in timeline when first seen
    pub first_seen_index: usize,
    pub first_seen_version: String,
    /// Index when last seen (None if still present at end)
    pub last_seen_index: Option<usize>,
    /// Current version (at end of timeline)
    pub current_version: Option<String>,
    /// Total version changes
    pub version_change_count: usize,
}

/// Version of a component at a point in the timeline
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionAtPoint {
    pub sbom_index: usize,
    pub sbom_name: String,
    pub version: Option<String>,
    pub change_type: VersionChangeType,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum VersionChangeType {
    Initial,
    MajorUpgrade,
    MinorUpgrade,
    PatchUpgrade,
    Downgrade,
    Unchanged,
    Removed,
    Absent,
}

impl VersionChangeType {
    #[must_use]
    pub const fn symbol(&self) -> &'static str {
        match self {
            Self::Initial => "",
            Self::MajorUpgrade => "",
            Self::MinorUpgrade => "",
            Self::PatchUpgrade => "",
            Self::Downgrade => "",
            Self::Unchanged => "",
            Self::Removed => "",
            Self::Absent => " ",
        }
    }
}

/// Compliance score at a point in timeline
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceSnapshot {
    pub sbom_index: usize,
    pub sbom_name: String,
    /// Compliance scores per standard: (`standard_name`, `error_count`, `warning_count`, `is_compliant`)
    pub scores: Vec<ComplianceScoreEntry>,
}

/// A single compliance score entry for one standard
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceScoreEntry {
    pub standard: String,
    pub error_count: usize,
    pub warning_count: usize,
    pub info_count: usize,
    pub is_compliant: bool,
}

/// Vulnerability counts at a point in timeline
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VulnerabilitySnapshot {
    pub sbom_index: usize,
    pub sbom_name: String,
    pub counts: VulnerabilityCounts,
    pub new_vulnerabilities: Vec<String>,
    pub resolved_vulnerabilities: Vec<String>,
}

/// License change record
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseChange {
    pub sbom_index: usize,
    pub component_id: String,
    pub component_name: String,
    pub old_license: Vec<String>,
    pub new_license: Vec<String>,
    pub change_type: LicenseChangeType,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum LicenseChangeType {
    MorePermissive,
    MoreRestrictive,
    Incompatible,
    Equivalent,
}

/// Dependency count at a point
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencySnapshot {
    pub sbom_index: usize,
    pub sbom_name: String,
    pub direct_dependencies: usize,
    pub transitive_dependencies: usize,
    pub total_edges: usize,
}

// ============================================================================
// MATRIX RESULT
// ============================================================================

/// N×N comparison matrix result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatrixResult {
    /// All SBOMs in comparison
    pub sboms: Vec<SbomInfo>,
    /// Upper-triangle matrix of diff results
    /// Access with matrix[i * `sboms.len()` + j] where i < j
    pub diffs: Vec<Option<DiffResult>>,
    /// Similarity scores (0.0 = completely different, 1.0 = identical)
    /// Same indexing as diffs
    pub similarity_scores: Vec<f64>,
    /// Optional clustering based on similarity
    pub clustering: Option<SbomClustering>,
}

impl MatrixResult {
    /// Get diff between sboms[i] and sboms[j]
    #[must_use]
    pub fn get_diff(&self, i: usize, j: usize) -> Option<&DiffResult> {
        if i == j {
            return None;
        }
        let (a, b) = if i < j { (i, j) } else { (j, i) };
        let idx = self.matrix_index(a, b);
        self.diffs.get(idx).and_then(|d| d.as_ref())
    }

    /// Get similarity between sboms[i] and sboms[j]
    #[must_use]
    pub fn get_similarity(&self, i: usize, j: usize) -> f64 {
        if i == j {
            return 1.0;
        }
        let (a, b) = if i < j { (i, j) } else { (j, i) };
        let idx = self.matrix_index(a, b);
        self.similarity_scores.get(idx).copied().unwrap_or(0.0)
    }

    /// Calculate index in flattened upper-triangle matrix
    fn matrix_index(&self, i: usize, j: usize) -> usize {
        let n = self.sboms.len();
        // Upper triangle index formula: i * (2n - i - 1) / 2 + (j - i - 1)
        i * (2 * n - i - 1) / 2 + (j - i - 1)
    }

    /// Number of pairs (n choose 2)
    #[must_use]
    pub fn num_pairs(&self) -> usize {
        let n = self.sboms.len();
        n * (n - 1) / 2
    }
}

/// Clustering of similar SBOMs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomClustering {
    /// Identified clusters of similar SBOMs
    pub clusters: Vec<SbomCluster>,
    /// Outliers that don't fit any cluster (indices into sboms)
    pub outliers: Vec<usize>,
    /// Clustering algorithm used
    pub algorithm: String,
    /// Threshold used for clustering
    pub threshold: f64,
}

/// A cluster of similar SBOMs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomCluster {
    /// Indices into sboms vec
    pub members: Vec<usize>,
    /// Most representative SBOM (centroid)
    pub centroid_index: usize,
    /// Average internal similarity
    pub internal_similarity: f64,
    /// Cluster label (auto-generated or user-provided)
    pub label: Option<String>,
}

// ============================================================================
// INCREMENTAL CHANGE SUMMARY (for timeline)
// ============================================================================

/// Summary of changes between two adjacent versions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncrementalChange {
    pub from_index: usize,
    pub to_index: usize,
    pub from_name: String,
    pub to_name: String,
    pub components_added: usize,
    pub components_removed: usize,
    pub components_modified: usize,
    pub vulnerabilities_introduced: usize,
    pub vulnerabilities_resolved: usize,
}

impl IncrementalChange {
    #[must_use]
    pub fn from_diff(
        from_idx: usize,
        to_idx: usize,
        from_name: &str,
        to_name: &str,
        diff: &DiffResult,
    ) -> Self {
        Self {
            from_index: from_idx,
            to_index: to_idx,
            from_name: from_name.to_string(),
            to_name: to_name.to_string(),
            components_added: diff.summary.components_added,
            components_removed: diff.summary.components_removed,
            components_modified: diff.summary.components_modified,
            vulnerabilities_introduced: diff.summary.vulnerabilities_introduced,
            vulnerabilities_resolved: diff.summary.vulnerabilities_resolved,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_security_impact_label() {
        assert_eq!(SecurityImpact::Critical.label(), "CRITICAL");
        assert_eq!(SecurityImpact::High.label(), "high");
        assert_eq!(SecurityImpact::Medium.label(), "medium");
        assert_eq!(SecurityImpact::Low.label(), "low");
    }

    #[test]
    fn test_version_change_type_symbol() {
        assert_eq!(VersionChangeType::Initial.symbol(), "");
        assert_eq!(VersionChangeType::MajorUpgrade.symbol(), "");
        assert_eq!(VersionChangeType::MinorUpgrade.symbol(), "");
        assert_eq!(VersionChangeType::PatchUpgrade.symbol(), "");
        assert_eq!(VersionChangeType::Downgrade.symbol(), "");
        assert_eq!(VersionChangeType::Unchanged.symbol(), "");
        assert_eq!(VersionChangeType::Removed.symbol(), "");
        assert_eq!(VersionChangeType::Absent.symbol(), " ");
    }

    fn make_matrix(n: usize) -> MatrixResult {
        let sboms = (0..n)
            .map(|i| SbomInfo {
                name: format!("sbom-{i}"),
                file_path: format!("sbom-{i}.json"),
                format: "CycloneDX".into(),
                component_count: 10,
                dependency_count: 5,
                vulnerability_counts: VulnerabilityCounts::default(),
                timestamp: None,
            })
            .collect::<Vec<_>>();
        let num_pairs = n * (n - 1) / 2;
        MatrixResult {
            sboms,
            diffs: vec![None; num_pairs],
            similarity_scores: vec![0.5; num_pairs],
            clustering: None,
        }
    }

    #[test]
    fn test_matrix_result_get_diff_self() {
        let matrix = make_matrix(3);
        assert!(matrix.get_diff(0, 0).is_none());
        assert!(matrix.get_diff(1, 1).is_none());
    }

    #[test]
    fn test_matrix_result_get_similarity_self() {
        let matrix = make_matrix(3);
        assert_eq!(matrix.get_similarity(0, 0), 1.0);
        assert_eq!(matrix.get_similarity(2, 2), 1.0);
    }

    #[test]
    fn test_matrix_result_get_similarity_symmetric() {
        let matrix = make_matrix(3);
        assert_eq!(matrix.get_similarity(0, 1), matrix.get_similarity(1, 0));
        assert_eq!(matrix.get_similarity(0, 2), matrix.get_similarity(2, 0));
    }

    #[test]
    fn test_matrix_result_num_pairs() {
        assert_eq!(make_matrix(3).num_pairs(), 3);
        assert_eq!(make_matrix(4).num_pairs(), 6);
        assert_eq!(make_matrix(5).num_pairs(), 10);
    }

    #[test]
    fn test_incremental_change_from_diff() {
        let mut diff = DiffResult::new();
        diff.summary.components_added = 5;
        diff.summary.components_removed = 2;
        diff.summary.components_modified = 3;
        diff.summary.vulnerabilities_introduced = 1;
        diff.summary.vulnerabilities_resolved = 4;

        let change = IncrementalChange::from_diff(0, 1, "v1.0", "v2.0", &diff);
        assert_eq!(change.from_index, 0);
        assert_eq!(change.to_index, 1);
        assert_eq!(change.from_name, "v1.0");
        assert_eq!(change.to_name, "v2.0");
        assert_eq!(change.components_added, 5);
        assert_eq!(change.components_removed, 2);
        assert_eq!(change.components_modified, 3);
        assert_eq!(change.vulnerabilities_introduced, 1);
        assert_eq!(change.vulnerabilities_resolved, 4);
    }

    #[test]
    fn test_divergence_type_variants() {
        // Ensure all variants are constructable and distinct
        let variants = [
            DivergenceType::VersionMismatch,
            DivergenceType::Added,
            DivergenceType::Removed,
            DivergenceType::LicenseMismatch,
            DivergenceType::SupplierMismatch,
        ];
        for (i, a) in variants.iter().enumerate() {
            for (j, b) in variants.iter().enumerate() {
                if i == j {
                    assert_eq!(a, b);
                } else {
                    assert_ne!(a, b);
                }
            }
        }
    }

    #[test]
    fn test_license_change_type_variants() {
        let variants = [
            LicenseChangeType::MorePermissive,
            LicenseChangeType::MoreRestrictive,
            LicenseChangeType::Incompatible,
            LicenseChangeType::Equivalent,
        ];
        for (i, a) in variants.iter().enumerate() {
            for (j, b) in variants.iter().enumerate() {
                if i == j {
                    assert_eq!(a, b);
                } else {
                    assert_ne!(a, b);
                }
            }
        }
    }
}