1use super::incremental::IncrementalDiffEngine;
8use super::multi::{
9 ComparisonResult, ComplianceScoreEntry, ComplianceSnapshot, ComponentEvolution,
10 DependencySnapshot, DivergenceType, DivergentComponent, EvolutionSummary,
11 InconsistentComponent, MatrixResult, MultiDiffResult, MultiDiffSummary, SbomCluster,
12 SbomClustering, SbomInfo, SecurityImpact, TimelineResult, VariableComponent, VersionAtPoint,
13 VersionChangeType, VersionSpread, VulnerabilityMatrix, VulnerabilitySnapshot,
14};
15use super::{DiffEngine, DiffResult};
16use crate::error::SbomDiffError;
17use crate::matching::{FuzzyMatchConfig, MatchingRulesConfig};
18use crate::model::{NormalizedSbom, VulnerabilityCounts};
19use std::collections::{HashMap, HashSet};
20
21pub struct MultiDiffEngine {
27 fuzzy_config: Option<FuzzyMatchConfig>,
29 include_unchanged: bool,
31 graph_diff_config: Option<super::GraphDiffConfig>,
33 matching_rules: Option<MatchingRulesConfig>,
35 incremental: Option<IncrementalDiffEngine>,
37}
38
39impl MultiDiffEngine {
40 #[must_use]
41 pub const fn new() -> Self {
42 Self {
43 fuzzy_config: None,
44 include_unchanged: false,
45 graph_diff_config: None,
46 matching_rules: None,
47 incremental: None,
48 }
49 }
50
51 #[must_use]
53 pub fn with_fuzzy_config(mut self, config: FuzzyMatchConfig) -> Self {
54 self.fuzzy_config = Some(config);
55 self.incremental = None;
56 self
57 }
58
59 #[must_use]
61 pub fn include_unchanged(mut self, include: bool) -> Self {
62 self.include_unchanged = include;
63 self.incremental = None;
64 self
65 }
66
67 #[must_use]
69 pub fn with_graph_diff(mut self, config: super::GraphDiffConfig) -> Self {
70 self.graph_diff_config = Some(config);
71 self.incremental = None;
72 self
73 }
74
75 #[must_use]
77 pub fn with_matching_rules(mut self, rules: MatchingRulesConfig) -> Self {
78 self.matching_rules = Some(rules);
79 self.incremental = None;
80 self
81 }
82
83 fn ensure_engine(&mut self) {
85 if self.incremental.is_none() {
86 let mut engine = DiffEngine::new();
87 if let Some(config) = self.fuzzy_config.clone() {
88 engine = engine.with_fuzzy_config(config);
89 }
90 engine = engine.include_unchanged(self.include_unchanged);
91 if let Some(config) = self.graph_diff_config.clone() {
92 engine = engine.with_graph_diff(config);
93 }
94 if let Some(rules) = self.matching_rules.clone() {
95 match crate::matching::RuleEngine::new(rules) {
96 Ok(rule_engine) => engine = engine.with_rule_engine(rule_engine),
97 Err(err) => {
98 tracing::warn!("Failed to initialize matching rule engine: {err}");
99 }
100 }
101 }
102 self.incremental = Some(IncrementalDiffEngine::new(engine));
103 }
104 }
105
106 fn cached_diff(
108 &mut self,
109 old: &NormalizedSbom,
110 new: &NormalizedSbom,
111 ) -> Result<DiffResult, SbomDiffError> {
112 self.ensure_engine();
113 Ok(self
114 .incremental
115 .as_ref()
116 .expect("engine initialized by ensure_engine")
117 .diff(old, new)?
118 .into_result())
119 }
120
121 pub fn diff_multi(
127 &mut self,
128 baseline: &NormalizedSbom,
129 baseline_name: &str,
130 baseline_path: &str,
131 targets: &[(&NormalizedSbom, &str, &str)], ) -> Result<MultiDiffResult, SbomDiffError> {
133 let baseline_info = SbomInfo::from_sbom(
134 baseline,
135 baseline_name.to_string(),
136 baseline_path.to_string(),
137 );
138
139 let mut comparisons: Vec<ComparisonResult> = Vec::new();
141 let mut all_versions: HashMap<String, HashMap<String, String>> = HashMap::new();
145
146 for (id, comp) in &baseline.components {
148 let version = comp.version.clone().unwrap_or_default();
149 all_versions
150 .entry(strip_purl_version(id.value()).to_string())
151 .or_default()
152 .insert(baseline_name.to_string(), version);
153 }
154
155 for (target_sbom, target_name, target_path) in targets {
156 let diff = self.cached_diff(baseline, target_sbom)?;
157 let target_info = SbomInfo::from_sbom(
158 target_sbom,
159 target_name.to_string(),
160 target_path.to_string(),
161 );
162
163 for (id, comp) in &target_sbom.components {
165 let version = comp.version.clone().unwrap_or_default();
166 all_versions
167 .entry(strip_purl_version(id.value()).to_string())
168 .or_default()
169 .insert(target_name.to_string(), version);
170 }
171
172 comparisons.push(ComparisonResult {
173 target: target_info,
174 diff,
175 unique_components: vec![], divergent_components: vec![], });
178 }
179
180 let summary = self.compute_multi_diff_summary(
182 &baseline_info,
183 baseline,
184 &comparisons,
185 targets,
186 &all_versions,
187 );
188
189 for (i, comp) in comparisons.iter_mut().enumerate() {
191 let (target_sbom, target_name, _) = &targets[i];
192 comp.divergent_components =
193 self.find_divergent_components(baseline, target_sbom, target_name, &all_versions);
194 }
195
196 Ok(MultiDiffResult {
197 baseline: baseline_info,
198 comparisons,
199 summary,
200 })
201 }
202
203 fn compute_multi_diff_summary(
204 &self,
205 baseline_info: &SbomInfo,
206 baseline: &NormalizedSbom,
207 comparisons: &[ComparisonResult],
208 targets: &[(&NormalizedSbom, &str, &str)],
209 all_versions: &HashMap<String, HashMap<String, String>>,
210 ) -> MultiDiffSummary {
211 let baseline_components: HashSet<_> = baseline
215 .components
216 .keys()
217 .map(|k| strip_purl_version(k.value()).to_string())
218 .collect();
219
220 let target_component_sets: Vec<HashSet<&str>> = targets
224 .iter()
225 .map(|(target_sbom, _, _)| {
226 target_sbom
227 .components
228 .keys()
229 .map(|k| strip_purl_version(k.value()))
230 .collect()
231 })
232 .collect();
233 let baseline_names: HashMap<&str, &str> = baseline
234 .components
235 .iter()
236 .map(|(id, c)| (strip_purl_version(id.value()), c.name.as_str()))
237 .collect();
238 let target_names: Vec<HashMap<&str, &str>> = targets
239 .iter()
240 .map(|(target_sbom, _, _)| {
241 target_sbom
242 .components
243 .iter()
244 .map(|(id, c)| (strip_purl_version(id.value()), c.name.as_str()))
245 .collect()
246 })
247 .collect();
248
249 let mut universal: HashSet<String> = baseline_components.clone();
251 universal.retain(|comp_id| {
252 target_component_sets
253 .iter()
254 .all(|set| set.contains(comp_id.as_str()))
255 });
256
257 let mut variable_components: Vec<VariableComponent> = vec![];
259 for (comp_id, versions) in all_versions {
260 let unique_versions: HashSet<_> = versions.values().collect();
261 if unique_versions.len() > 1 {
262 let name = baseline_names
263 .get(comp_id.as_str())
264 .copied()
265 .or_else(|| {
266 target_names
267 .iter()
268 .find_map(|names| names.get(comp_id.as_str()).copied())
269 })
270 .map_or_else(|| comp_id.clone(), str::to_string);
271
272 let baseline_version = versions.get(&baseline_info.name.clone()).cloned();
273 let all_versions_vec: Vec<_> = unique_versions.into_iter().cloned().collect();
274
275 let major_spread = calculate_major_version_spread(&all_versions_vec);
277
278 variable_components.push(VariableComponent {
279 id: comp_id.clone(),
280 name: name.clone(),
281 ecosystem: None,
282 version_spread: VersionSpread {
283 baseline: baseline_version,
284 min_version: all_versions_vec.iter().min().cloned(),
285 max_version: all_versions_vec.iter().max().cloned(),
286 unique_versions: all_versions_vec,
287 is_consistent: false,
288 major_version_spread: major_spread,
289 },
290 targets_with_component: versions.keys().cloned().collect(),
291 security_impact: classify_security_impact(&name),
292 });
293 }
294 }
295
296 let mut inconsistent_components: Vec<InconsistentComponent> = vec![];
298 let all_component_ids: HashSet<_> = all_versions.keys().cloned().collect();
299
300 for comp_id in &all_component_ids {
301 if universal.contains(comp_id) {
302 continue; }
304
305 let in_baseline = baseline_components.contains(comp_id);
306 let mut present_in: Vec<String> = vec![];
307 let mut missing_from: Vec<String> = vec![];
308
309 if in_baseline {
310 present_in.push(baseline_info.name.clone());
311 } else {
312 missing_from.push(baseline_info.name.clone());
313 }
314
315 for ((_, target_name, _), component_set) in targets.iter().zip(&target_component_sets) {
316 if component_set.contains(comp_id.as_str()) {
317 present_in.push(target_name.to_string());
318 } else {
319 missing_from.push(target_name.to_string());
320 }
321 }
322
323 if !missing_from.is_empty() {
324 let name = baseline_names
325 .get(comp_id.as_str())
326 .map_or_else(|| comp_id.clone(), |n| (*n).to_string());
327
328 inconsistent_components.push(InconsistentComponent {
329 id: comp_id.clone(),
330 name,
331 in_baseline,
332 present_in,
333 missing_from,
334 });
335 }
336 }
337
338 let mut deviation_scores: HashMap<String, f64> = HashMap::new();
344 let mut max_deviation = 0.0f64;
345
346 for comp in comparisons {
347 let score = ((100.0 - comp.diff.semantic_score) / 100.0).clamp(0.0, 1.0);
348 deviation_scores.insert(comp.target.name.clone(), score);
349 max_deviation = max_deviation.max(score);
350 }
351
352 let vulnerability_matrix =
354 compute_vulnerability_matrix(baseline, &baseline_info.name, targets);
355
356 let mut universal_components: Vec<String> = universal.into_iter().collect();
360 universal_components.sort_unstable();
361 variable_components.sort_by(|a, b| a.id.cmp(&b.id));
362 inconsistent_components.sort_by(|a, b| a.id.cmp(&b.id));
363
364 MultiDiffSummary {
365 baseline_component_count: baseline_info.component_count,
366 universal_components,
367 variable_components,
368 inconsistent_components,
369 deviation_scores: deviation_scores.into_iter().collect(),
370 max_deviation,
371 vulnerability_matrix,
372 }
373 }
374
375 fn find_divergent_components(
376 &self,
377 baseline: &NormalizedSbom,
378 target: &NormalizedSbom,
379 _target_name: &str,
380 all_versions: &HashMap<String, HashMap<String, String>>,
381 ) -> Vec<DivergentComponent> {
382 let mut divergent = vec![];
383
384 let baseline_by_value: HashMap<&str, &crate::model::Component> = baseline
390 .components
391 .iter()
392 .map(|(id, c)| (strip_purl_version(id.value()), c))
393 .collect();
394 let target_ids: HashSet<&str> = target
395 .components
396 .keys()
397 .map(|k| strip_purl_version(k.value()))
398 .collect();
399
400 for (id, comp) in &target.components {
401 let comp_id = strip_purl_version(id.value()).to_string();
402 let target_version = comp.version.clone().unwrap_or_default();
403
404 let baseline_comp = baseline_by_value.get(comp_id.as_str()).copied();
408
409 let divergence_type = match baseline_comp {
410 None => DivergenceType::Added,
411 Some(bc) if bc.version != comp.version => DivergenceType::VersionMismatch,
412 Some(_) => continue, };
414 let baseline_version = baseline_comp.and_then(|bc| bc.version.clone());
415
416 divergent.push(DivergentComponent {
417 id: comp_id.clone(),
418 name: comp.name.clone(),
419 baseline_version,
420 target_version,
421 versions_across_targets: all_versions
422 .get(&comp_id)
423 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
424 .unwrap_or_default(),
425 divergence_type,
426 });
427 }
428
429 for (id, comp) in &baseline.components {
432 let comp_id = strip_purl_version(id.value()).to_string();
433 if !target_ids.contains(comp_id.as_str()) {
434 divergent.push(DivergentComponent {
435 id: comp_id.clone(),
436 name: comp.name.clone(),
437 baseline_version: comp.version.clone(),
438 target_version: String::new(),
439 versions_across_targets: all_versions
440 .get(&comp_id)
441 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
442 .unwrap_or_default(),
443 divergence_type: DivergenceType::Removed,
444 });
445 }
446 }
447
448 divergent
449 }
450
451 pub fn timeline(
457 &mut self,
458 sboms: &[(&NormalizedSbom, &str, &str)], ) -> Result<TimelineResult, SbomDiffError> {
460 let sbom_infos: Vec<SbomInfo> = sboms
461 .iter()
462 .map(|(sbom, name, path)| SbomInfo::from_sbom(sbom, name.to_string(), path.to_string()))
463 .collect();
464
465 let mut incremental_diffs: Vec<DiffResult> = vec![];
468 let mut incremental_pairs: Vec<crate::diff::TimelinePair> = vec![];
469 for i in 0..sboms.len().saturating_sub(1) {
470 let diff = self.cached_diff(sboms[i].0, sboms[i + 1].0)?;
471 incremental_diffs.push(diff);
472 incremental_pairs.push(crate::diff::TimelinePair {
473 from_index: i,
474 to_index: i + 1,
475 from_name: sbom_infos[i].name.clone(),
476 to_name: sbom_infos[i + 1].name.clone(),
477 });
478 }
479
480 let mut cumulative_from_first: Vec<DiffResult> = vec![];
482 let mut cumulative_pairs: Vec<crate::diff::TimelinePair> = vec![];
483 if !sboms.is_empty() {
484 for i in 1..sboms.len() {
485 let diff = self.cached_diff(sboms[0].0, sboms[i].0)?;
486 cumulative_from_first.push(diff);
487 cumulative_pairs.push(crate::diff::TimelinePair {
488 from_index: 0,
489 to_index: i,
490 from_name: sbom_infos[0].name.clone(),
491 to_name: sbom_infos[i].name.clone(),
492 });
493 }
494 }
495
496 let evolution_summary =
498 self.build_evolution_summary(sboms, &sbom_infos, &incremental_diffs);
499
500 Ok(TimelineResult {
501 sboms: sbom_infos,
502 incremental_diffs,
503 incremental_pairs,
504 cumulative_from_first,
505 cumulative_pairs,
506 evolution_summary,
507 })
508 }
509
510 fn build_evolution_summary(
511 &self,
512 sboms: &[(&NormalizedSbom, &str, &str)],
513 sbom_infos: &[SbomInfo],
514 _incremental_diffs: &[DiffResult],
515 ) -> EvolutionSummary {
516 let mut version_history: HashMap<String, Vec<VersionAtPoint>> = HashMap::new();
518 let mut components_added: Vec<ComponentEvolution> = vec![];
519 let mut components_removed: Vec<ComponentEvolution> = vec![];
520 let mut all_components: HashSet<String> = HashSet::new();
521
522 let mut sbom_maps: Vec<HashMap<&str, &crate::model::Component>> =
530 Vec::with_capacity(sboms.len());
531 for (sbom, _, _) in sboms {
532 for (id, _) in &sbom.components {
533 all_components.insert(strip_purl_version(id.value()).to_string());
534 }
535 sbom_maps.push(
536 sbom.components
537 .iter()
538 .map(|(id, c)| (strip_purl_version(id.value()), c))
539 .collect(),
540 );
541 }
542
543 for comp_id in &all_components {
545 let mut history: Vec<VersionAtPoint> = vec![];
546 let mut first_seen: Option<(usize, String)> = None;
547 let mut last_seen: Option<usize> = None;
548 let mut prev_version: Option<String> = None;
549 let mut was_present = false;
555 let mut version_change_count: usize = 0;
556
557 for (i, (_, name, _)) in sboms.iter().enumerate() {
558 let comp = sbom_maps[i].get(comp_id.as_str()).copied();
559
560 let (version, change_type) = if let Some(c) = comp {
561 let ver = c.version.clone();
562 let change = if first_seen.is_none() {
563 first_seen = Some((i, ver.clone().unwrap_or_default()));
564 VersionChangeType::Initial
565 } else if !was_present {
566 VersionChangeType::Initial
568 } else {
569 let ct = classify_version_change(prev_version.as_ref(), ver.as_ref());
570 if !matches!(ct, VersionChangeType::Unchanged | VersionChangeType::Absent) {
572 version_change_count += 1;
573 }
574 ct
575 };
576 last_seen = Some(i);
577 prev_version.clone_from(&ver);
578 was_present = true;
579 (ver, change)
580 } else {
581 let change = if was_present {
582 VersionChangeType::Removed
583 } else {
584 VersionChangeType::Absent
585 };
586 was_present = false;
587 prev_version = None;
588 (None, change)
589 };
590
591 history.push(VersionAtPoint {
592 sbom_index: i,
593 sbom_name: name.to_string(),
594 version,
595 change_type,
596 });
597 }
598
599 version_history.insert(comp_id.clone(), history);
600
601 if let Some((first_idx, first_ver)) = first_seen {
603 let still_present = last_seen == Some(sboms.len() - 1);
604 let current_version = if still_present {
605 sbom_maps
606 .last()
607 .and_then(|map| map.get(comp_id.as_str()))
608 .and_then(|c| c.version.clone())
609 } else {
610 None
611 };
612
613 let name = sbom_maps
614 .iter()
615 .find_map(|map| map.get(comp_id.as_str()).map(|c| c.name.clone()))
616 .unwrap_or_else(|| comp_id.clone());
617
618 let evolution = ComponentEvolution {
619 id: comp_id.clone(),
620 name,
621 first_seen_index: first_idx,
622 first_seen_version: first_ver,
623 last_seen_index: if still_present { None } else { last_seen },
624 current_version,
625 version_change_count,
626 };
627
628 if first_idx > 0 {
629 components_added.push(evolution.clone());
630 }
631 if !still_present {
632 components_removed.push(evolution);
633 }
634 }
635 }
636
637 let vulnerability_trend: Vec<VulnerabilitySnapshot> = sbom_infos
639 .iter()
640 .enumerate()
641 .map(|(i, info)| VulnerabilitySnapshot {
642 sbom_index: i,
643 sbom_name: info.name.clone(),
644 counts: info.vulnerability_counts.clone(),
645 new_vulnerabilities: vec![],
646 resolved_vulnerabilities: vec![],
647 })
648 .collect();
649
650 let dependency_trend: Vec<DependencySnapshot> = sboms
652 .iter()
653 .enumerate()
654 .map(|(i, (sbom, _, _))| {
655 let total_edges = sbom.edges.len();
656 let targets: HashSet<_> = sbom.edges.iter().map(|e| &e.to).collect();
658 let sources: HashSet<_> = sbom.edges.iter().map(|e| &e.from).collect();
659 let roots: HashSet<_> = sources.difference(&targets).collect();
660 let direct = sbom
661 .edges
662 .iter()
663 .filter(|e| roots.contains(&&e.from))
664 .count();
665 let transitive = total_edges.saturating_sub(direct);
666
667 DependencySnapshot {
668 sbom_index: i,
669 sbom_name: sbom_infos[i].name.clone(),
670 direct_dependencies: direct,
671 transitive_dependencies: transitive,
672 total_edges,
673 }
674 })
675 .collect();
676
677 let compliance_trend: Vec<ComplianceSnapshot> = sboms
679 .iter()
680 .enumerate()
681 .map(|(i, (sbom, name, _))| {
682 use crate::quality::{ComplianceChecker, ComplianceLevel};
683 let scores = ComplianceLevel::all()
684 .iter()
685 .map(|level| {
686 let result = ComplianceChecker::new(*level).check(sbom);
687 ComplianceScoreEntry {
688 standard: level.name().to_string(),
689 error_count: result.error_count,
690 warning_count: result.warning_count,
691 info_count: result.info_count,
692 is_compliant: result.is_compliant,
693 }
694 })
695 .collect();
696 ComplianceSnapshot {
697 sbom_index: i,
698 sbom_name: name.to_string(),
699 scores,
700 }
701 })
702 .collect();
703
704 components_added.sort_by(|a, b| a.id.cmp(&b.id));
705 components_removed.sort_by(|a, b| a.id.cmp(&b.id));
706
707 EvolutionSummary {
708 components_added,
709 components_removed,
710 version_history: version_history.into_iter().collect(),
711 vulnerability_trend,
712 license_changes: vec![],
713 dependency_trend,
714 compliance_trend,
715 }
716 }
717
718 pub fn matrix(
724 &mut self,
725 sboms: &[(&NormalizedSbom, &str, &str)], similarity_threshold: Option<f64>,
727 ) -> Result<MatrixResult, SbomDiffError> {
728 let sbom_infos: Vec<SbomInfo> = sboms
729 .iter()
730 .map(|(sbom, name, path)| SbomInfo::from_sbom(sbom, name.to_string(), path.to_string()))
731 .collect();
732
733 let n = sboms.len();
734 let num_pairs = n * (n - 1) / 2;
735
736 let mut diffs: Vec<Option<DiffResult>> = vec![None; num_pairs];
737 let mut similarity_scores: Vec<f64> = vec![0.0; num_pairs];
738
739 let mut idx = 0;
741 for i in 0..n {
742 for j in (i + 1)..n {
743 let diff = self.cached_diff(sboms[i].0, sboms[j].0)?;
744 let similarity = diff.semantic_score / 100.0;
745 similarity_scores[idx] = similarity;
746 diffs[idx] = Some(diff);
747 idx += 1;
748 }
749 }
750
751 let clustering = similarity_threshold
753 .map(|threshold| self.cluster_sboms(&sbom_infos, &similarity_scores, threshold));
754
755 Ok(MatrixResult {
756 sboms: sbom_infos,
757 diffs,
758 similarity_scores,
759 clustering,
760 })
761 }
762
763 fn cluster_sboms(
764 &self,
765 sboms: &[SbomInfo],
766 similarity_scores: &[f64],
767 threshold: f64,
768 ) -> SbomClustering {
769 let n = sboms.len();
770 let mut clusters: Vec<SbomCluster> = vec![];
771 let mut assigned: HashSet<usize> = HashSet::new();
772
773 for i in 0..n {
778 if assigned.contains(&i) {
779 continue;
780 }
781
782 let mut cluster_members = vec![i];
783
784 for j in (i + 1)..n {
785 if assigned.contains(&j) {
786 continue;
787 }
788
789 let idx = i * (2 * n - i - 1) / 2 + (j - i - 1);
791 let similarity = similarity_scores.get(idx).copied().unwrap_or(0.0);
792
793 if similarity >= threshold {
794 cluster_members.push(j);
795 }
796 }
797
798 if cluster_members.len() > 1 {
799 for &member in &cluster_members {
800 assigned.insert(member);
801 }
802 let mut total_sim = 0.0;
804 let mut count = 0;
805 for (mi, &a) in cluster_members.iter().enumerate() {
806 for &b in cluster_members.iter().skip(mi + 1) {
807 let (x, y) = if a < b { (a, b) } else { (b, a) };
808 let idx = x * (2 * n - x - 1) / 2 + (y - x - 1);
809 total_sim += similarity_scores.get(idx).copied().unwrap_or(0.0);
810 count += 1;
811 }
812 }
813
814 clusters.push(SbomCluster {
815 members: cluster_members.clone(),
816 centroid_index: cluster_members[0],
817 internal_similarity: if count > 0 {
818 total_sim / f64::from(count)
819 } else {
820 1.0
821 },
822 label: None,
823 });
824 }
825 }
826
827 let outliers: Vec<usize> = (0..n).filter(|i| !assigned.contains(i)).collect();
829
830 SbomClustering {
831 clusters,
832 outliers,
833 algorithm: "greedy".to_string(),
834 threshold,
835 }
836 }
837}
838
839impl Default for MultiDiffEngine {
840 fn default() -> Self {
841 Self::new()
842 }
843}
844
845pub(crate) fn strip_purl_version(id: &str) -> &str {
857 if !id.starts_with("pkg:") {
858 return id;
859 }
860 let core_end = id.find(['?', '#']).unwrap_or(id.len());
862 let core = &id[..core_end];
863 match core.rfind('@') {
864 Some(pos) if pos > 0 && !core[..pos].ends_with('/') => &id[..pos],
867 _ => id,
868 }
869}
870
871fn classify_security_impact(name: &str) -> SecurityImpact {
873 let name_lower = name.to_lowercase();
874 let critical_components = [
875 "openssl",
876 "curl",
877 "libcurl",
878 "gnutls",
879 "mbedtls",
880 "wolfssl",
881 "boringssl",
882 ];
883 let high_components = [
884 "zlib", "libssh", "openssh", "gnupg", "gpg", "sqlite", "kernel", "glibc",
885 ];
886
887 if critical_components.iter().any(|c| name_lower.contains(c)) {
888 SecurityImpact::Critical
889 } else if high_components.iter().any(|c| name_lower.contains(c)) {
890 SecurityImpact::High
891 } else {
892 SecurityImpact::Low
893 }
894}
895
896fn calculate_major_version_spread(versions: &[String]) -> u32 {
898 let mut major_versions: HashSet<u64> = HashSet::new();
899
900 for version in versions {
901 if let Ok(v) = semver::Version::parse(version) {
903 major_versions.insert(v.major);
904 } else {
905 if let Some(major_str) = version.split(['.', '-', '_']).next()
907 && let Ok(major) = major_str.parse::<u64>()
908 {
909 major_versions.insert(major);
910 }
911 }
912 }
913
914 match (major_versions.iter().min(), major_versions.iter().max()) {
915 (Some(&min), Some(&max)) => (max - min) as u32,
916 _ => 0,
917 }
918}
919
920fn compute_vulnerability_matrix(
922 baseline: &NormalizedSbom,
923 baseline_name: &str,
924 targets: &[(&NormalizedSbom, &str, &str)],
925) -> VulnerabilityMatrix {
926 let mut vuln_sets: HashMap<String, HashSet<String>> = HashMap::new();
928 let mut per_sbom: HashMap<String, VulnerabilityCounts> = HashMap::new();
929
930 let baseline_vulns: HashSet<String> = baseline
932 .all_vulnerabilities()
933 .iter()
934 .map(|(_, v)| v.id.clone())
935 .collect();
936 vuln_sets.insert(baseline_name.to_string(), baseline_vulns);
937 per_sbom.insert(baseline_name.to_string(), baseline.vulnerability_counts());
938
939 for (sbom, name, _) in targets {
941 let target_vulns: HashSet<String> = sbom
942 .all_vulnerabilities()
943 .iter()
944 .map(|(_, v)| v.id.clone())
945 .collect();
946 vuln_sets.insert(name.to_string(), target_vulns);
947 per_sbom.insert(name.to_string(), sbom.vulnerability_counts());
948 }
949
950 let mut common_vulnerabilities: HashSet<String> =
952 vuln_sets.values().next().cloned().unwrap_or_default();
953
954 for vulns in vuln_sets.values() {
955 common_vulnerabilities = common_vulnerabilities
956 .intersection(vulns)
957 .cloned()
958 .collect();
959 }
960
961 let mut unique_vulnerabilities: HashMap<String, Vec<String>> = HashMap::new();
963
964 for (sbom_name, vulns) in &vuln_sets {
965 let mut unique: HashSet<String> = vulns.clone();
966
967 for (other_name, other_vulns) in &vuln_sets {
969 if other_name != sbom_name {
970 unique = unique.difference(other_vulns).cloned().collect();
971 }
972 }
973
974 if !unique.is_empty() {
975 unique_vulnerabilities.insert(sbom_name.clone(), unique.into_iter().collect());
976 }
977 }
978
979 let mut common: Vec<String> = common_vulnerabilities.into_iter().collect();
980 common.sort_unstable();
981 VulnerabilityMatrix {
982 per_sbom: per_sbom.into_iter().collect(),
983 unique_vulnerabilities: unique_vulnerabilities
984 .into_iter()
985 .map(|(k, mut v)| {
986 v.sort_unstable();
987 (k, v)
988 })
989 .collect(),
990 common_vulnerabilities: common,
991 }
992}
993
994fn classify_version_change(old: Option<&String>, new: Option<&String>) -> VersionChangeType {
1004 match (old, new) {
1005 (None, Some(_)) => VersionChangeType::Initial,
1006 (Some(_), None) => VersionChangeType::Removed,
1007 (Some(o), Some(n)) if o == n => VersionChangeType::Unchanged,
1008 (Some(o), Some(n)) => classify_version_strings(o, n),
1009 (None, None) => VersionChangeType::Absent,
1010 }
1011}
1012
1013pub(crate) fn classify_version_strings(old: &str, new: &str) -> VersionChangeType {
1014 use std::cmp::Ordering;
1015
1016 if let (Some(old_v), Some(new_v)) = (parse_semver_lenient(old), parse_semver_lenient(new)) {
1017 return match new_v.cmp_precedence(&old_v) {
1022 Ordering::Equal => VersionChangeType::Unchanged,
1023 Ordering::Less => VersionChangeType::Downgrade,
1024 Ordering::Greater => {
1025 if new_v.major > old_v.major {
1026 VersionChangeType::MajorUpgrade
1027 } else if new_v.minor > old_v.minor {
1028 VersionChangeType::MinorUpgrade
1029 } else {
1030 VersionChangeType::PatchUpgrade
1033 }
1034 }
1035 };
1036 }
1037
1038 if let Some(change) = classify_numeric_segments(old, new) {
1041 return change;
1042 }
1043
1044 VersionChangeType::Changed
1045}
1046
1047fn parse_semver_lenient(version: &str) -> Option<semver::Version> {
1050 let version = version.trim();
1051 let version = version.strip_prefix(['v', 'V']).unwrap_or(version);
1052 if let Ok(v) = semver::Version::parse(version) {
1053 return Some(v);
1054 }
1055 let split_at = version.find(['-', '+']).unwrap_or(version.len());
1057 let (core, rest) = version.split_at(split_at);
1058 let padded = match core.matches('.').count() {
1059 0 => format!("{core}.0.0{rest}"),
1060 1 => format!("{core}.0{rest}"),
1061 _ => return None,
1062 };
1063 semver::Version::parse(&padded).ok()
1064}
1065
1066fn classify_numeric_segments(old: &str, new: &str) -> Option<VersionChangeType> {
1069 let old = old.trim();
1070 let old = old.strip_prefix(['v', 'V']).unwrap_or(old);
1071 let new = new.trim();
1072 let new = new.strip_prefix(['v', 'V']).unwrap_or(new);
1073 let old_segments: Vec<&str> = old.split('.').collect();
1074 let new_segments: Vec<&str> = new.split('.').collect();
1075 let len = old_segments.len().max(new_segments.len());
1076
1077 for position in 0..len {
1078 let old_seg = old_segments.get(position).copied().unwrap_or("0");
1079 let new_seg = new_segments.get(position).copied().unwrap_or("0");
1080 if old_seg == new_seg {
1081 continue;
1082 }
1083 let (old_num, new_num) = (old_seg.parse::<u64>().ok()?, new_seg.parse::<u64>().ok()?);
1084 if old_num == new_num {
1085 continue; }
1087 let upgrade = new_num > old_num;
1088 return Some(match (upgrade, position) {
1089 (false, _) => VersionChangeType::Downgrade,
1090 (true, 0) => VersionChangeType::MajorUpgrade,
1091 (true, 1) => VersionChangeType::MinorUpgrade,
1092 (true, _) => VersionChangeType::PatchUpgrade,
1093 });
1094 }
1095
1096 Some(VersionChangeType::Unchanged)
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102 use super::*;
1103 use crate::model::{Component, DocumentMetadata};
1104
1105 fn classify(old: &str, new: &str) -> VersionChangeType {
1106 classify_version_change(Some(&old.to_string()), Some(&new.to_string()))
1107 }
1108
1109 #[test]
1113 fn classify_version_change_matrix() {
1114 use VersionChangeType::{
1115 Absent, Changed, Downgrade, Initial, MajorUpgrade, MinorUpgrade, PatchUpgrade, Removed,
1116 Unchanged,
1117 };
1118
1119 assert_eq!(classify("1.0.0", "2.0.0"), MajorUpgrade);
1121 assert_eq!(classify("1.2.0", "1.3.0"), MinorUpgrade);
1122 assert_eq!(classify("1.2.3", "1.2.4"), PatchUpgrade);
1123 assert_eq!(classify("2.0.0", "1.9.9"), Downgrade);
1124
1125 assert_eq!(classify("1.0.0-alpha", "1.0.0"), PatchUpgrade);
1127 assert_eq!(classify("1.0.0-alpha", "1.0.0-beta"), PatchUpgrade);
1128 assert_eq!(classify("1.0.0", "1.0.0-alpha"), Downgrade);
1129
1130 assert_eq!(classify("1.0.0", "1.0.0+build2"), Unchanged);
1132
1133 assert_eq!(classify("9.0", "10.0"), MajorUpgrade);
1136 assert_eq!(classify("10.0", "9.0"), Downgrade);
1137 assert_eq!(classify("1.2.3.4", "1.2.3.5"), PatchUpgrade);
1138 assert_eq!(classify("1.02", "1.2"), Unchanged);
1139
1140 assert_eq!(classify("v1.2.3", "v2.0.0"), MajorUpgrade);
1142 assert_eq!(classify("1.2", "1.3"), MinorUpgrade);
1143 assert_eq!(classify("2", "3"), MajorUpgrade);
1144
1145 assert_eq!(classify("abc", "def"), Changed);
1147 assert_eq!(classify("release-A", "release-B"), Changed);
1148
1149 assert_eq!(
1151 classify_version_change(None, Some(&"1.0.0".to_string())),
1152 Initial
1153 );
1154 assert_eq!(
1155 classify_version_change(Some(&"1.0.0".to_string()), None),
1156 Removed
1157 );
1158 assert_eq!(classify_version_change(None, None), Absent);
1159 assert_eq!(classify("1.0.0", "1.0.0"), Unchanged);
1160 }
1161
1162 fn info(name: &str) -> SbomInfo {
1163 let sbom = NormalizedSbom::new(DocumentMetadata::default());
1164 SbomInfo::from_sbom(&sbom, name.to_string(), format!("{name}.json"))
1165 }
1166
1167 #[test]
1171 fn cluster_sboms_reports_singletons_as_outliers() {
1172 let engine = MultiDiffEngine::new();
1173 let sboms = vec![info("a"), info("b"), info("c")];
1174
1175 let scores = vec![0.95, 0.10, 0.10];
1177 let clustering = engine.cluster_sboms(&sboms, &scores, 0.9);
1178 assert_eq!(clustering.clusters.len(), 1);
1179 assert_eq!(clustering.clusters[0].members, vec![0, 1]);
1180 assert_eq!(
1181 clustering.outliers,
1182 vec![2],
1183 "the dissimilar SBOM must be an outlier"
1184 );
1185
1186 let scores = vec![0.1, 0.1, 0.1];
1188 let clustering = engine.cluster_sboms(&sboms, &scores, 0.9);
1189 assert!(clustering.clusters.is_empty());
1190 assert_eq!(clustering.outliers, vec![0, 1, 2]);
1191 }
1192
1193 fn timeline_sbom(component_version: Option<&str>) -> NormalizedSbom {
1194 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1195 let mut anchor = Component::new("anchor".to_string(), "pkg:npm/anchor@1.0.0".to_string());
1197 anchor.version = Some("1.0.0".to_string());
1198 anchor.calculate_content_hash();
1199 sbom.add_component(anchor);
1200 if let Some(version) = component_version {
1201 let mut c = Component::new("libgap".to_string(), "pkg:npm/libgap".to_string());
1202 c.version = Some(version.to_string());
1203 c.calculate_content_hash();
1204 sbom.add_component(c);
1205 }
1206 sbom.calculate_content_hash();
1207 sbom
1208 }
1209
1210 #[test]
1213 fn timeline_upgrade_is_one_evolution_not_added_plus_removed() {
1214 let v1 = purl_sbom(&[("lodash", "4.17.20"), ("react", "18.0.0")]);
1215 let v2 = purl_sbom(&[("lodash", "4.17.21"), ("react", "18.0.0")]);
1216
1217 let mut engine = MultiDiffEngine::new();
1218 let sboms: Vec<(&NormalizedSbom, &str, &str)> =
1219 vec![(&v1, "v1", "v1.json"), (&v2, "v2", "v2.json")];
1220 let result = engine.timeline(&sboms).expect("timeline");
1221 let summary = &result.evolution_summary;
1222
1223 assert!(
1224 summary.components_added.is_empty(),
1225 "nothing appeared after v1: {:?}",
1226 summary.components_added
1227 );
1228 assert!(
1229 summary.components_removed.is_empty(),
1230 "nothing is absent from the latest version: {:?}",
1231 summary.components_removed
1232 );
1233
1234 let history = summary
1235 .version_history
1236 .get("pkg:npm/lodash")
1237 .expect("logical lodash history");
1238 let changes: Vec<_> = history.iter().map(|p| p.change_type.clone()).collect();
1239 assert_eq!(
1240 changes,
1241 vec![VersionChangeType::Initial, VersionChangeType::PatchUpgrade],
1242 "the upgrade must be visible as a version change in ONE history"
1243 );
1244 }
1245
1246 #[test]
1252 fn timeline_gap_and_reappearance_handling() {
1253 let r0 = timeline_sbom(Some("1.0.0"));
1254 let r1 = timeline_sbom(None);
1255 let r2 = timeline_sbom(None);
1256 let r3 = timeline_sbom(Some("2.0.0"));
1257
1258 let mut engine = MultiDiffEngine::new();
1259 let sboms: Vec<(&NormalizedSbom, &str, &str)> = vec![
1260 (&r0, "r0", "r0.json"),
1261 (&r1, "r1", "r1.json"),
1262 (&r2, "r2", "r2.json"),
1263 (&r3, "r3", "r3.json"),
1264 ];
1265 let result = engine.timeline(&sboms).expect("timeline");
1266
1267 let history = result
1268 .evolution_summary
1269 .version_history
1270 .iter()
1271 .find(|(id, _)| id.contains("libgap"))
1272 .map(|(_, h)| h)
1273 .expect("libgap history");
1274
1275 let changes: Vec<_> = history.iter().map(|p| p.change_type.clone()).collect();
1276 assert_eq!(
1277 changes,
1278 vec![
1279 VersionChangeType::Initial,
1280 VersionChangeType::Removed,
1281 VersionChangeType::Absent,
1282 VersionChangeType::Initial,
1283 ],
1284 "gap must be Removed-then-Absent and reappearance must be Initial"
1285 );
1286 }
1287
1288 #[test]
1291 fn strip_purl_version_matrix() {
1292 assert_eq!(
1293 strip_purl_version("pkg:npm/lodash@4.17.20"),
1294 "pkg:npm/lodash"
1295 );
1296 assert_eq!(
1297 strip_purl_version("pkg:npm/@scope/name@1.2.3"),
1298 "pkg:npm/@scope/name"
1299 );
1300 assert_eq!(
1302 strip_purl_version("pkg:npm/@scope/name"),
1303 "pkg:npm/@scope/name"
1304 );
1305 assert_eq!(
1306 strip_purl_version("pkg:maven/org.apache/log4j@2.17.0?type=jar"),
1307 "pkg:maven/org.apache/log4j"
1308 );
1309 assert_eq!(strip_purl_version("pkg:npm/lodash"), "pkg:npm/lodash");
1310 assert_eq!(strip_purl_version("acme-webapp"), "acme-webapp");
1312 assert_eq!(strip_purl_version("SPDXRef-Package-a"), "SPDXRef-Package-a");
1313 assert_eq!(strip_purl_version("name@1.0"), "name@1.0");
1314 }
1315
1316 fn purl_sbom(entries: &[(&str, &str)]) -> NormalizedSbom {
1317 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1318 for (name, version) in entries {
1319 let mut c = Component::new((*name).to_string(), format!("pkg:npm/{name}@{version}"));
1320 c.version = Some((*version).to_string());
1321 c.calculate_content_hash();
1322 sbom.add_component(c);
1323 }
1324 sbom.calculate_content_hash();
1325 sbom
1326 }
1327
1328 #[test]
1334 fn similarity_and_deviation_scales_match_their_documented_contract() {
1335 let a = purl_sbom(&[("lodash", "4.17.20"), ("react", "18.0.0")]);
1336 let b = purl_sbom(&[("lodash", "4.17.21"), ("zod", "3.0.0")]);
1337
1338 let mut engine = MultiDiffEngine::new();
1339 let matrix = engine
1340 .matrix(&[(&a, "a", "a.json"), (&b, "b", "b.json")], None)
1341 .expect("matrix must succeed");
1342
1343 let similarity = matrix.similarity_scores[0];
1344 assert!(
1345 (0.0..=1.0).contains(&similarity),
1346 "matrix similarity must be a 0-1 fraction, got {similarity}"
1347 );
1348 let embedded = matrix.diffs[0]
1349 .as_ref()
1350 .expect("pair diff present")
1351 .semantic_score;
1352 assert!(
1353 (0.0..=100.0).contains(&embedded),
1354 "embedded semantic_score must stay on the 0-100 scale, got {embedded}"
1355 );
1356 assert!(
1357 (similarity - embedded / 100.0).abs() < 1e-9,
1358 "similarity ({similarity}) must equal semantic_score/100 ({})",
1359 embedded / 100.0
1360 );
1361
1362 let multi = engine
1363 .diff_multi(&a, "a", "a.json", &[(&b, "b", "b.json")])
1364 .expect("diff_multi must succeed");
1365 let deviation = multi.summary.deviation_scores["b"];
1366 assert!(
1367 (0.0..=1.0).contains(&deviation),
1368 "deviation must be a 0-1 fraction, got {deviation}"
1369 );
1370 let pair_score = multi.comparisons[0].diff.semantic_score;
1371 assert!(
1372 (deviation - (1.0 - pair_score / 100.0)).abs() < 1e-9,
1373 "deviation ({deviation}) must equal 1 - semantic_score/100"
1374 );
1375 }
1376
1377 #[test]
1382 fn version_bump_is_variable_not_double_inconsistent_and_deviation_is_fraction() {
1383 let baseline = purl_sbom(&[("lodash", "4.17.20"), ("react", "18.0.0")]);
1384 let target = purl_sbom(&[("lodash", "4.17.21"), ("react", "18.0.0")]);
1385
1386 let mut engine = MultiDiffEngine::new();
1387 let result = engine
1388 .diff_multi(
1389 &baseline,
1390 "baseline",
1391 "baseline.json",
1392 &[(&target, "target", "target.json")],
1393 )
1394 .expect("diff_multi");
1395
1396 let summary = &result.summary;
1397 assert!(
1398 summary.inconsistent_components.is_empty(),
1399 "a version bump is not a presence inconsistency: {:?}",
1400 summary.inconsistent_components
1401 );
1402 assert_eq!(
1403 summary
1404 .variable_components
1405 .iter()
1406 .map(|vc| vc.name.as_str())
1407 .collect::<Vec<_>>(),
1408 vec!["lodash"],
1409 "the bumped package must surface exactly once as variable"
1410 );
1411 let lodash = &summary.variable_components[0];
1412 assert_eq!(lodash.id, "pkg:npm/lodash", "id must be version-stripped");
1413 assert_eq!(
1414 lodash.targets_with_component.len(),
1415 2,
1416 "present (at some version) in baseline and target"
1417 );
1418 assert_eq!(
1420 summary.universal_components,
1421 vec!["pkg:npm/lodash", "pkg:npm/react"]
1422 );
1423
1424 assert!(
1426 summary.max_deviation >= 0.0 && summary.max_deviation <= 1.0,
1427 "deviation must be a 0-1 fraction, got {}",
1428 summary.max_deviation
1429 );
1430 for (name, dev) in &summary.deviation_scores {
1431 assert!(
1432 (0.0..=1.0).contains(dev),
1433 "deviation for {name} must be a 0-1 fraction, got {dev}"
1434 );
1435 }
1436
1437 let divergent = &result.comparisons[0].divergent_components;
1439 assert_eq!(divergent.len(), 1, "only lodash diverges: {divergent:?}");
1440 assert_eq!(
1441 divergent[0].divergence_type,
1442 DivergenceType::VersionMismatch
1443 );
1444 assert_eq!(divergent[0].baseline_version.as_deref(), Some("4.17.20"));
1445 assert_eq!(divergent[0].target_version, "4.17.21");
1446 }
1447
1448 #[test]
1451 fn deviation_bounds_identical_and_disjoint() {
1452 let baseline = purl_sbom(&[("a", "1.0.0"), ("b", "1.0.0")]);
1453 let same = purl_sbom(&[("a", "1.0.0"), ("b", "1.0.0")]);
1454 let disjoint = purl_sbom(&[("x", "1.0.0"), ("y", "1.0.0")]);
1455
1456 let mut engine = MultiDiffEngine::new();
1457 let result = engine
1458 .diff_multi(
1459 &baseline,
1460 "baseline",
1461 "baseline.json",
1462 &[
1463 (&same, "same", "same.json"),
1464 (&disjoint, "disjoint", "disjoint.json"),
1465 ],
1466 )
1467 .expect("diff_multi");
1468
1469 let same_dev = result.summary.deviation_scores["same"];
1470 let disjoint_dev = result.summary.deviation_scores["disjoint"];
1471 assert!(
1472 same_dev.abs() < f64::EPSILON,
1473 "identical target must deviate 0.0, got {same_dev}"
1474 );
1475 assert!(
1476 disjoint_dev > same_dev && disjoint_dev <= 1.0,
1477 "disjoint target deviation must be in (0, 1], got {disjoint_dev}"
1478 );
1479 assert!(result.summary.max_deviation <= 1.0);
1480 }
1481
1482 #[test]
1485 fn versionless_baseline_component_is_not_added() {
1486 let make = |version: Option<&str>| {
1487 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1488 let mut c = Component::new("libfoo".to_string(), "SPDXRef-Package-libfoo".to_string());
1489 c.version = version.map(str::to_string);
1490 c.calculate_content_hash();
1491 sbom.add_component(c);
1492 sbom.calculate_content_hash();
1493 sbom
1494 };
1495
1496 let baseline = make(None);
1497 let same = make(None);
1498 let versioned = make(Some("2.0.0"));
1499
1500 let engine = MultiDiffEngine::new();
1501 let all_versions = HashMap::new();
1502
1503 let divergent = engine.find_divergent_components(&baseline, &same, "same", &all_versions);
1504 assert!(
1505 divergent.is_empty(),
1506 "identical versionless components must not diverge: {divergent:?}"
1507 );
1508
1509 let divergent =
1510 engine.find_divergent_components(&baseline, &versioned, "versioned", &all_versions);
1511 assert_eq!(divergent.len(), 1);
1512 assert_eq!(
1513 divergent[0].divergence_type,
1514 DivergenceType::VersionMismatch,
1515 "present-but-versionless baseline is a version mismatch, not Added"
1516 );
1517 }
1518}