Skip to main content

sbom_tools/diff/
multi_engine.rs

1//! Multi-SBOM comparison engines.
2//!
3//! Uses [`IncrementalDiffEngine`] internally to cache diff results across
4//! repeated comparisons (timeline, matrix, diff-multi), avoiding redundant
5//! recomputation when the same SBOM pair is compared multiple times.
6
7use super::incremental::IncrementalDiffEngine;
8use super::multi::*;
9use super::{DiffEngine, DiffResult};
10use crate::matching::FuzzyMatchConfig;
11use crate::model::{NormalizedSbom, VulnerabilityCounts};
12use std::collections::{HashMap, HashSet};
13
14/// Engine for multi-SBOM comparisons.
15///
16/// Internally wraps an [`IncrementalDiffEngine`] so that repeated comparisons
17/// of the same SBOM pairs (common in timeline and matrix modes) benefit from
18/// result caching.
19pub struct MultiDiffEngine {
20    /// Fuzzy matching configuration (applied when building the engine).
21    fuzzy_config: Option<FuzzyMatchConfig>,
22    /// Whether to include unchanged components in diff results.
23    include_unchanged: bool,
24    /// Caching wrapper built lazily on first diff operation.
25    incremental: Option<IncrementalDiffEngine>,
26}
27
28impl MultiDiffEngine {
29    pub fn new() -> Self {
30        Self {
31            fuzzy_config: None,
32            include_unchanged: false,
33            incremental: None,
34        }
35    }
36
37    /// Configure fuzzy matching
38    pub fn with_fuzzy_config(mut self, config: FuzzyMatchConfig) -> Self {
39        self.fuzzy_config = Some(config);
40        self.incremental = None;
41        self
42    }
43
44    /// Include unchanged components
45    pub fn include_unchanged(mut self, include: bool) -> Self {
46        self.include_unchanged = include;
47        self.incremental = None;
48        self
49    }
50
51    /// Build the configured DiffEngine and wrap it in an IncrementalDiffEngine.
52    fn ensure_engine(&mut self) {
53        if self.incremental.is_none() {
54            let mut engine = DiffEngine::new();
55            if let Some(config) = self.fuzzy_config.clone() {
56                engine = engine.with_fuzzy_config(config);
57            }
58            engine = engine.include_unchanged(self.include_unchanged);
59            self.incremental = Some(IncrementalDiffEngine::new(engine));
60        }
61    }
62
63    /// Perform a single diff using the cached incremental engine.
64    fn cached_diff(&mut self, old: &NormalizedSbom, new: &NormalizedSbom) -> DiffResult {
65        self.ensure_engine();
66        self.incremental
67            .as_ref()
68            .expect("engine initialized by ensure_engine")
69            .diff(old, new)
70            .into_result()
71    }
72
73    /// Perform 1:N diff-multi comparison (baseline vs multiple targets)
74    pub fn diff_multi(
75        &mut self,
76        baseline: &NormalizedSbom,
77        baseline_name: &str,
78        baseline_path: &str,
79        targets: &[(&NormalizedSbom, &str, &str)], // (sbom, name, path)
80    ) -> MultiDiffResult {
81        let baseline_info = SbomInfo::from_sbom(
82            baseline,
83            baseline_name.to_string(),
84            baseline_path.to_string(),
85        );
86
87        // Compute individual diffs
88        let mut comparisons: Vec<ComparisonResult> = Vec::new();
89        let mut all_versions: HashMap<String, HashMap<String, String>> = HashMap::new(); // component_id -> (target_name -> version)
90
91        // Collect baseline versions
92        for (id, comp) in &baseline.components {
93            let version = comp.version.clone().unwrap_or_default();
94            all_versions
95                .entry(id.value().to_string())
96                .or_default()
97                .insert(baseline_name.to_string(), version);
98        }
99
100        for (target_sbom, target_name, target_path) in targets {
101            let diff = self.cached_diff(baseline, target_sbom);
102            let target_info = SbomInfo::from_sbom(
103                target_sbom,
104                target_name.to_string(),
105                target_path.to_string(),
106            );
107
108            // Collect target versions
109            for (id, comp) in &target_sbom.components {
110                let version = comp.version.clone().unwrap_or_default();
111                all_versions
112                    .entry(id.value().to_string())
113                    .or_default()
114                    .insert(target_name.to_string(), version);
115            }
116
117            comparisons.push(ComparisonResult {
118                target: target_info,
119                diff,
120                unique_components: vec![],    // Computed in summary phase
121                divergent_components: vec![], // Computed in summary phase
122            });
123        }
124
125        // Compute summary
126        let summary = self.compute_multi_diff_summary(
127            &baseline_info,
128            baseline,
129            &comparisons,
130            targets,
131            &all_versions,
132        );
133
134        // Update comparisons with divergent component info
135        for (i, comp) in comparisons.iter_mut().enumerate() {
136            let (target_sbom, target_name, _) = &targets[i];
137            comp.divergent_components =
138                self.find_divergent_components(baseline, target_sbom, target_name, &all_versions);
139        }
140
141        MultiDiffResult {
142            baseline: baseline_info,
143            comparisons,
144            summary,
145        }
146    }
147
148    fn compute_multi_diff_summary(
149        &self,
150        baseline_info: &SbomInfo,
151        baseline: &NormalizedSbom,
152        comparisons: &[ComparisonResult],
153        targets: &[(&NormalizedSbom, &str, &str)],
154        all_versions: &HashMap<String, HashMap<String, String>>,
155    ) -> MultiDiffSummary {
156        let baseline_components: HashSet<_> = baseline
157            .components
158            .keys()
159            .map(|k| k.value().to_string())
160            .collect();
161        let _target_names: Vec<_> = targets
162            .iter()
163            .map(|(_, name, _)| name.to_string())
164            .collect();
165
166        // Find universal components (in baseline and ALL targets)
167        let mut universal: HashSet<String> = baseline_components.clone();
168        for (target_sbom, _, _) in targets {
169            let target_components: HashSet<_> = target_sbom
170                .components
171                .keys()
172                .map(|k| k.value().to_string())
173                .collect();
174            universal = universal
175                .intersection(&target_components)
176                .cloned()
177                .collect();
178        }
179
180        // Find variable components (different versions across targets)
181        let mut variable_components: Vec<VariableComponent> = vec![];
182        for (comp_id, versions) in all_versions {
183            let unique_versions: HashSet<_> = versions.values().collect();
184            if unique_versions.len() > 1 {
185                let name = baseline
186                    .components
187                    .iter()
188                    .find(|(id, _)| id.value() == comp_id)
189                    .map(|(_, c)| c.name.clone())
190                    .or_else(|| {
191                        targets.iter().find_map(|(sbom, _, _)| {
192                            sbom.components
193                                .iter()
194                                .find(|(id, _)| id.value() == comp_id)
195                                .map(|(_, c)| c.name.clone())
196                        })
197                    })
198                    .unwrap_or_else(|| comp_id.clone());
199
200                let baseline_version = versions.get(&baseline_info.name.to_string()).cloned();
201                let all_versions_vec: Vec<_> = unique_versions.into_iter().cloned().collect();
202
203                // Calculate major version spread
204                let major_spread = calculate_major_version_spread(&all_versions_vec);
205
206                variable_components.push(VariableComponent {
207                    id: comp_id.clone(),
208                    name: name.clone(),
209                    ecosystem: None,
210                    version_spread: VersionSpread {
211                        baseline: baseline_version,
212                        min_version: all_versions_vec.iter().min().cloned(),
213                        max_version: all_versions_vec.iter().max().cloned(),
214                        unique_versions: all_versions_vec,
215                        is_consistent: false,
216                        major_version_spread: major_spread,
217                    },
218                    targets_with_component: versions.keys().cloned().collect(),
219                    security_impact: classify_security_impact(&name),
220                });
221            }
222        }
223
224        // Find inconsistent components (missing from some targets)
225        let mut inconsistent_components: Vec<InconsistentComponent> = vec![];
226        let all_component_ids: HashSet<_> = all_versions.keys().cloned().collect();
227
228        for comp_id in &all_component_ids {
229            if universal.contains(comp_id) {
230                continue; // Present everywhere, not inconsistent
231            }
232
233            let in_baseline = baseline_components.contains(comp_id);
234            let mut present_in: Vec<String> = vec![];
235            let mut missing_from: Vec<String> = vec![];
236
237            if in_baseline {
238                present_in.push(baseline_info.name.clone());
239            } else {
240                missing_from.push(baseline_info.name.clone());
241            }
242
243            for (target_sbom, target_name, _) in targets {
244                let has_component = target_sbom
245                    .components
246                    .iter()
247                    .any(|(id, _)| id.value() == comp_id);
248                if has_component {
249                    present_in.push(target_name.to_string());
250                } else {
251                    missing_from.push(target_name.to_string());
252                }
253            }
254
255            if !missing_from.is_empty() {
256                let name = all_versions
257                    .get(comp_id)
258                    .and_then(|_| {
259                        baseline
260                            .components
261                            .iter()
262                            .find(|(id, _)| id.value() == comp_id)
263                            .map(|(_, c)| c.name.clone())
264                    })
265                    .unwrap_or_else(|| comp_id.clone());
266
267                inconsistent_components.push(InconsistentComponent {
268                    id: comp_id.clone(),
269                    name,
270                    in_baseline,
271                    present_in,
272                    missing_from,
273                });
274            }
275        }
276
277        // Compute deviation scores
278        let mut deviation_scores: HashMap<String, f64> = HashMap::new();
279        let mut max_deviation = 0.0f64;
280
281        for comp in comparisons {
282            let score = 100.0 - comp.diff.semantic_score;
283            deviation_scores.insert(comp.target.name.clone(), score);
284            max_deviation = max_deviation.max(score);
285        }
286
287        // Build vulnerability matrix with unique and common vulnerabilities
288        let vulnerability_matrix =
289            compute_vulnerability_matrix(baseline, &baseline_info.name, targets);
290
291        MultiDiffSummary {
292            baseline_component_count: baseline_info.component_count,
293            universal_components: universal.into_iter().collect(),
294            variable_components,
295            inconsistent_components,
296            deviation_scores,
297            max_deviation,
298            vulnerability_matrix,
299        }
300    }
301
302    fn find_divergent_components(
303        &self,
304        baseline: &NormalizedSbom,
305        target: &NormalizedSbom,
306        _target_name: &str,
307        all_versions: &HashMap<String, HashMap<String, String>>,
308    ) -> Vec<DivergentComponent> {
309        let mut divergent = vec![];
310
311        for (id, comp) in &target.components {
312            let comp_id = id.value().to_string();
313            let target_version = comp.version.clone().unwrap_or_default();
314
315            // Check if baseline has different version
316            let baseline_version = baseline
317                .components
318                .iter()
319                .find(|(bid, _)| bid.value() == comp_id)
320                .and_then(|(_, bc)| bc.version.clone());
321
322            let divergence_type = if baseline_version.is_none() {
323                DivergenceType::Added
324            } else if baseline_version.as_ref() != Some(&target_version) {
325                DivergenceType::VersionMismatch
326            } else {
327                continue; // Same version, not divergent
328            };
329
330            divergent.push(DivergentComponent {
331                id: comp_id.clone(),
332                name: comp.name.clone(),
333                baseline_version,
334                target_version,
335                versions_across_targets: all_versions.get(&comp_id).cloned().unwrap_or_default(),
336                divergence_type,
337            });
338        }
339
340        // Check for removed components
341        for (id, comp) in &baseline.components {
342            let comp_id = id.value().to_string();
343            let in_target = target
344                .components
345                .iter()
346                .any(|(tid, _)| tid.value() == comp_id);
347
348            if !in_target {
349                divergent.push(DivergentComponent {
350                    id: comp_id.clone(),
351                    name: comp.name.clone(),
352                    baseline_version: comp.version.clone(),
353                    target_version: String::new(),
354                    versions_across_targets: all_versions
355                        .get(&comp_id)
356                        .cloned()
357                        .unwrap_or_default(),
358                    divergence_type: DivergenceType::Removed,
359                });
360            }
361        }
362
363        divergent
364    }
365
366    /// Perform timeline analysis across ordered SBOM versions
367    pub fn timeline(
368        &mut self,
369        sboms: &[(&NormalizedSbom, &str, &str)], // (sbom, name, path)
370    ) -> TimelineResult {
371        let sbom_infos: Vec<SbomInfo> = sboms
372            .iter()
373            .map(|(sbom, name, path)| SbomInfo::from_sbom(sbom, name.to_string(), path.to_string()))
374            .collect();
375
376        // Compute incremental diffs (adjacent pairs)
377        let mut incremental_diffs: Vec<DiffResult> = vec![];
378        for i in 0..sboms.len().saturating_sub(1) {
379            let diff = self.cached_diff(sboms[i].0, sboms[i + 1].0);
380            incremental_diffs.push(diff);
381        }
382
383        // Compute cumulative diffs from first
384        let mut cumulative_from_first: Vec<DiffResult> = vec![];
385        if !sboms.is_empty() {
386            for i in 1..sboms.len() {
387                let diff = self.cached_diff(sboms[0].0, sboms[i].0);
388                cumulative_from_first.push(diff);
389            }
390        }
391
392        // Build evolution summary
393        let evolution_summary =
394            self.build_evolution_summary(sboms, &sbom_infos, &incremental_diffs);
395
396        TimelineResult {
397            sboms: sbom_infos,
398            incremental_diffs,
399            cumulative_from_first,
400            evolution_summary,
401        }
402    }
403
404    fn build_evolution_summary(
405        &self,
406        sboms: &[(&NormalizedSbom, &str, &str)],
407        sbom_infos: &[SbomInfo],
408        _incremental_diffs: &[DiffResult],
409    ) -> EvolutionSummary {
410        // Track component versions across timeline
411        let mut version_history: HashMap<String, Vec<VersionAtPoint>> = HashMap::new();
412        let mut components_added: Vec<ComponentEvolution> = vec![];
413        let mut components_removed: Vec<ComponentEvolution> = vec![];
414        let mut all_components: HashSet<String> = HashSet::new();
415
416        // Collect all component IDs
417        for (sbom, _, _) in sboms {
418            for (id, _) in &sbom.components {
419                all_components.insert(id.value().to_string());
420            }
421        }
422
423        // Build version history for each component
424        for comp_id in &all_components {
425            let mut history: Vec<VersionAtPoint> = vec![];
426            let mut first_seen: Option<(usize, String)> = None;
427            let mut last_seen: Option<usize> = None;
428            let mut prev_version: Option<String> = None;
429            let mut version_change_count: usize = 0;
430
431            for (i, (sbom, name, _)) in sboms.iter().enumerate() {
432                let comp = sbom.components.iter().find(|(id, _)| id.value() == comp_id);
433
434                let (version, change_type) = if let Some((_, c)) = comp {
435                    let ver = c.version.clone();
436                    let change = if first_seen.is_none() {
437                        first_seen = Some((i, ver.clone().unwrap_or_default()));
438                        VersionChangeType::Initial
439                    } else {
440                        let ct = classify_version_change(&prev_version, &ver);
441                        // Count actual version changes (not unchanged or absent)
442                        if !matches!(ct, VersionChangeType::Unchanged | VersionChangeType::Absent) {
443                            version_change_count += 1;
444                        }
445                        ct
446                    };
447                    last_seen = Some(i);
448                    prev_version = ver.clone();
449                    (ver, change)
450                } else if first_seen.is_some() {
451                    (None, VersionChangeType::Removed)
452                } else {
453                    (None, VersionChangeType::Absent)
454                };
455
456                history.push(VersionAtPoint {
457                    sbom_index: i,
458                    sbom_name: name.to_string(),
459                    version,
460                    change_type,
461                });
462            }
463
464            version_history.insert(comp_id.clone(), history);
465
466            // Track added/removed
467            if let Some((first_idx, first_ver)) = first_seen {
468                let still_present = last_seen == Some(sboms.len() - 1);
469                let current_version = if still_present {
470                    sboms.last().and_then(|(sbom, _, _)| {
471                        sbom.components
472                            .iter()
473                            .find(|(id, _)| id.value() == comp_id)
474                            .and_then(|(_, c)| c.version.clone())
475                    })
476                } else {
477                    None
478                };
479
480                let name = sboms
481                    .iter()
482                    .find_map(|(sbom, _, _)| {
483                        sbom.components
484                            .iter()
485                            .find(|(id, _)| id.value() == comp_id)
486                            .map(|(_, c)| c.name.clone())
487                    })
488                    .unwrap_or_else(|| comp_id.clone());
489
490                let evolution = ComponentEvolution {
491                    id: comp_id.clone(),
492                    name,
493                    first_seen_index: first_idx,
494                    first_seen_version: first_ver,
495                    last_seen_index: if still_present { None } else { last_seen },
496                    current_version,
497                    version_change_count,
498                };
499
500                if first_idx > 0 {
501                    components_added.push(evolution.clone());
502                }
503                if !still_present {
504                    components_removed.push(evolution);
505                }
506            }
507        }
508
509        // Build vulnerability trend
510        let vulnerability_trend: Vec<VulnerabilitySnapshot> = sbom_infos
511            .iter()
512            .enumerate()
513            .map(|(i, info)| VulnerabilitySnapshot {
514                sbom_index: i,
515                sbom_name: info.name.clone(),
516                counts: info.vulnerability_counts.clone(),
517                new_vulnerabilities: vec![],
518                resolved_vulnerabilities: vec![],
519            })
520            .collect();
521
522        // Build dependency trend
523        let dependency_trend: Vec<DependencySnapshot> = sbom_infos
524            .iter()
525            .enumerate()
526            .map(|(i, info)| DependencySnapshot {
527                sbom_index: i,
528                sbom_name: info.name.clone(),
529                direct_dependencies: info.dependency_count,
530                transitive_dependencies: 0,
531                total_edges: info.dependency_count,
532            })
533            .collect();
534
535        // Build compliance trend
536        let compliance_trend: Vec<ComplianceSnapshot> = sboms
537            .iter()
538            .enumerate()
539            .map(|(i, (sbom, name, _))| {
540                use crate::quality::{ComplianceChecker, ComplianceLevel};
541                let scores = ComplianceLevel::all()
542                    .iter()
543                    .map(|level| {
544                        let result = ComplianceChecker::new(*level).check(sbom);
545                        ComplianceScoreEntry {
546                            standard: level.name().to_string(),
547                            error_count: result.error_count,
548                            warning_count: result.warning_count,
549                            info_count: result.info_count,
550                            is_compliant: result.is_compliant,
551                        }
552                    })
553                    .collect();
554                ComplianceSnapshot {
555                    sbom_index: i,
556                    sbom_name: name.to_string(),
557                    scores,
558                }
559            })
560            .collect();
561
562        EvolutionSummary {
563            components_added,
564            components_removed,
565            version_history,
566            vulnerability_trend,
567            license_changes: vec![],
568            dependency_trend,
569            compliance_trend,
570        }
571    }
572
573    /// Perform N×N matrix comparison
574    pub fn matrix(
575        &mut self,
576        sboms: &[(&NormalizedSbom, &str, &str)], // (sbom, name, path)
577        similarity_threshold: Option<f64>,
578    ) -> MatrixResult {
579        let sbom_infos: Vec<SbomInfo> = sboms
580            .iter()
581            .map(|(sbom, name, path)| SbomInfo::from_sbom(sbom, name.to_string(), path.to_string()))
582            .collect();
583
584        let n = sboms.len();
585        let num_pairs = n * (n - 1) / 2;
586
587        let mut diffs: Vec<Option<DiffResult>> = vec![None; num_pairs];
588        let mut similarity_scores: Vec<f64> = vec![0.0; num_pairs];
589
590        // Compute upper triangle
591        let mut idx = 0;
592        for i in 0..n {
593            for j in (i + 1)..n {
594                let diff = self.cached_diff(sboms[i].0, sboms[j].0);
595                let similarity = diff.semantic_score / 100.0;
596                similarity_scores[idx] = similarity;
597                diffs[idx] = Some(diff);
598                idx += 1;
599            }
600        }
601
602        // Optional clustering
603        let clustering = similarity_threshold
604            .map(|threshold| self.cluster_sboms(&sbom_infos, &similarity_scores, threshold));
605
606        MatrixResult {
607            sboms: sbom_infos,
608            diffs,
609            similarity_scores,
610            clustering,
611        }
612    }
613
614    fn cluster_sboms(
615        &self,
616        sboms: &[SbomInfo],
617        similarity_scores: &[f64],
618        threshold: f64,
619    ) -> SbomClustering {
620        let n = sboms.len();
621        let mut clusters: Vec<SbomCluster> = vec![];
622        let mut assigned: HashSet<usize> = HashSet::new();
623
624        // Simple greedy clustering
625        for i in 0..n {
626            if assigned.contains(&i) {
627                continue;
628            }
629
630            let mut cluster_members = vec![i];
631            assigned.insert(i);
632
633            for j in (i + 1)..n {
634                if assigned.contains(&j) {
635                    continue;
636                }
637
638                // Get similarity between i and j
639                let idx = i * (2 * n - i - 1) / 2 + (j - i - 1);
640                let similarity = similarity_scores.get(idx).copied().unwrap_or(0.0);
641
642                if similarity >= threshold {
643                    cluster_members.push(j);
644                    assigned.insert(j);
645                }
646            }
647
648            if cluster_members.len() > 1 {
649                // Calculate average internal similarity
650                let mut total_sim = 0.0;
651                let mut count = 0;
652                for (mi, &a) in cluster_members.iter().enumerate() {
653                    for &b in cluster_members.iter().skip(mi + 1) {
654                        let (x, y) = if a < b { (a, b) } else { (b, a) };
655                        let idx = x * (2 * n - x - 1) / 2 + (y - x - 1);
656                        total_sim += similarity_scores.get(idx).copied().unwrap_or(0.0);
657                        count += 1;
658                    }
659                }
660
661                clusters.push(SbomCluster {
662                    members: cluster_members.clone(),
663                    centroid_index: cluster_members[0],
664                    internal_similarity: if count > 0 {
665                        total_sim / count as f64
666                    } else {
667                        1.0
668                    },
669                    label: None,
670                });
671            }
672        }
673
674        // Find outliers
675        let outliers: Vec<usize> = (0..n).filter(|i| !assigned.contains(i)).collect();
676
677        SbomClustering {
678            clusters,
679            outliers,
680            algorithm: "greedy".to_string(),
681            threshold,
682        }
683    }
684}
685
686impl Default for MultiDiffEngine {
687    fn default() -> Self {
688        Self::new()
689    }
690}
691
692/// Classify security impact based on component name
693fn classify_security_impact(name: &str) -> SecurityImpact {
694    let name_lower = name.to_lowercase();
695    let critical_components = [
696        "openssl",
697        "curl",
698        "libcurl",
699        "gnutls",
700        "mbedtls",
701        "wolfssl",
702        "boringssl",
703    ];
704    let high_components = [
705        "zlib", "libssh", "openssh", "gnupg", "gpg", "sqlite", "kernel", "glibc",
706    ];
707
708    if critical_components.iter().any(|c| name_lower.contains(c)) {
709        SecurityImpact::Critical
710    } else if high_components.iter().any(|c| name_lower.contains(c)) {
711        SecurityImpact::High
712    } else {
713        SecurityImpact::Low
714    }
715}
716
717/// Calculate major version spread from a list of version strings
718fn calculate_major_version_spread(versions: &[String]) -> u32 {
719    let mut major_versions: HashSet<u64> = HashSet::new();
720
721    for version in versions {
722        // Try to parse as semver first
723        if let Ok(v) = semver::Version::parse(version) {
724            major_versions.insert(v.major);
725        } else {
726            // Fallback: try to extract leading number
727            if let Some(major_str) = version.split(['.', '-', '_']).next() {
728                if let Ok(major) = major_str.parse::<u64>() {
729                    major_versions.insert(major);
730                }
731            }
732        }
733    }
734
735    match (major_versions.iter().min(), major_versions.iter().max()) {
736        (Some(&min), Some(&max)) => (max - min) as u32,
737        _ => 0,
738    }
739}
740
741/// Compute vulnerability matrix with unique and common vulnerabilities
742fn compute_vulnerability_matrix(
743    baseline: &NormalizedSbom,
744    baseline_name: &str,
745    targets: &[(&NormalizedSbom, &str, &str)],
746) -> VulnerabilityMatrix {
747    // Collect all vulnerabilities per SBOM
748    let mut vuln_sets: HashMap<String, HashSet<String>> = HashMap::new();
749    let mut per_sbom: HashMap<String, VulnerabilityCounts> = HashMap::new();
750
751    // Baseline vulnerabilities
752    let baseline_vulns: HashSet<String> = baseline
753        .all_vulnerabilities()
754        .iter()
755        .map(|(_, v)| v.id.clone())
756        .collect();
757    vuln_sets.insert(baseline_name.to_string(), baseline_vulns);
758    per_sbom.insert(baseline_name.to_string(), baseline.vulnerability_counts());
759
760    // Target vulnerabilities
761    for (sbom, name, _) in targets {
762        let target_vulns: HashSet<String> = sbom
763            .all_vulnerabilities()
764            .iter()
765            .map(|(_, v)| v.id.clone())
766            .collect();
767        vuln_sets.insert(name.to_string(), target_vulns);
768        per_sbom.insert(name.to_string(), sbom.vulnerability_counts());
769    }
770
771    // Find common vulnerabilities (in ALL SBOMs)
772    let mut common_vulnerabilities: HashSet<String> =
773        vuln_sets.values().next().cloned().unwrap_or_default();
774
775    for vulns in vuln_sets.values() {
776        common_vulnerabilities = common_vulnerabilities
777            .intersection(vulns)
778            .cloned()
779            .collect();
780    }
781
782    // Find unique vulnerabilities per SBOM
783    let mut unique_vulnerabilities: HashMap<String, Vec<String>> = HashMap::new();
784
785    for (sbom_name, vulns) in &vuln_sets {
786        let mut unique: HashSet<String> = vulns.clone();
787
788        // Remove vulnerabilities that exist in any other SBOM
789        for (other_name, other_vulns) in &vuln_sets {
790            if other_name != sbom_name {
791                unique = unique.difference(other_vulns).cloned().collect();
792            }
793        }
794
795        if !unique.is_empty() {
796            unique_vulnerabilities.insert(sbom_name.clone(), unique.into_iter().collect());
797        }
798    }
799
800    VulnerabilityMatrix {
801        per_sbom,
802        unique_vulnerabilities,
803        common_vulnerabilities: common_vulnerabilities.into_iter().collect(),
804    }
805}
806
807/// Classify version change type
808fn classify_version_change(old: &Option<String>, new: &Option<String>) -> VersionChangeType {
809    match (old, new) {
810        (None, Some(_)) => VersionChangeType::Initial,
811        (Some(_), None) => VersionChangeType::Removed,
812        (Some(o), Some(n)) if o == n => VersionChangeType::Unchanged,
813        (Some(o), Some(n)) => {
814            // Try to parse as semver
815            if let (Ok(old_v), Ok(new_v)) = (semver::Version::parse(o), semver::Version::parse(n)) {
816                if new_v.major > old_v.major {
817                    VersionChangeType::MajorUpgrade
818                } else if new_v.major < old_v.major {
819                    VersionChangeType::Downgrade
820                } else if new_v.minor > old_v.minor {
821                    VersionChangeType::MinorUpgrade
822                } else if new_v.minor < old_v.minor {
823                    VersionChangeType::Downgrade
824                } else if new_v.patch > old_v.patch {
825                    VersionChangeType::PatchUpgrade
826                } else {
827                    VersionChangeType::Downgrade
828                }
829            } else {
830                // String comparison fallback
831                if n > o {
832                    VersionChangeType::PatchUpgrade
833                } else {
834                    VersionChangeType::Downgrade
835                }
836            }
837        }
838        (None, None) => VersionChangeType::Absent,
839    }
840}