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