1use 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
14pub struct MultiDiffEngine {
20 fuzzy_config: Option<FuzzyMatchConfig>,
22 include_unchanged: bool,
24 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 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 pub fn include_unchanged(mut self, include: bool) -> Self {
46 self.include_unchanged = include;
47 self.incremental = None;
48 self
49 }
50
51 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 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 pub fn diff_multi(
75 &mut self,
76 baseline: &NormalizedSbom,
77 baseline_name: &str,
78 baseline_path: &str,
79 targets: &[(&NormalizedSbom, &str, &str)], ) -> MultiDiffResult {
81 let baseline_info = SbomInfo::from_sbom(
82 baseline,
83 baseline_name.to_string(),
84 baseline_path.to_string(),
85 );
86
87 let mut comparisons: Vec<ComparisonResult> = Vec::new();
89 let mut all_versions: HashMap<String, HashMap<String, String>> = HashMap::new(); 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 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![], divergent_components: vec![], });
123 }
124
125 let summary = self.compute_multi_diff_summary(
127 &baseline_info,
128 baseline,
129 &comparisons,
130 targets,
131 &all_versions,
132 );
133
134 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 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 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 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 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; }
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 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 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 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; };
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 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 pub fn timeline(
368 &mut self,
369 sboms: &[(&NormalizedSbom, &str, &str)], ) -> 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 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 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 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 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 for (sbom, _, _) in sboms {
418 for (id, _) in &sbom.components {
419 all_components.insert(id.value().to_string());
420 }
421 }
422
423 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 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 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 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 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 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 pub fn matrix(
575 &mut self,
576 sboms: &[(&NormalizedSbom, &str, &str)], 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 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 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 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 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 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 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
692fn 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
717fn calculate_major_version_spread(versions: &[String]) -> u32 {
719 let mut major_versions: HashSet<u64> = HashSet::new();
720
721 for version in versions {
722 if let Ok(v) = semver::Version::parse(version) {
724 major_versions.insert(v.major);
725 } else {
726 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
741fn compute_vulnerability_matrix(
743 baseline: &NormalizedSbom,
744 baseline_name: &str,
745 targets: &[(&NormalizedSbom, &str, &str)],
746) -> VulnerabilityMatrix {
747 let mut vuln_sets: HashMap<String, HashSet<String>> = HashMap::new();
749 let mut per_sbom: HashMap<String, VulnerabilityCounts> = HashMap::new();
750
751 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 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 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 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 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
807fn 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 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 if n > o {
832 VersionChangeType::PatchUpgrade
833 } else {
834 VersionChangeType::Downgrade
835 }
836 }
837 }
838 (None, None) => VersionChangeType::Absent,
839 }
840}