1#![doc = include_str!("../readme.md")]
2
3use sbom_model::versions::{is_version_downgrade_for_ecosystem, Version};
4use sbom_model::{licensings_equivalent, Component, ComponentId, DependencyKind, Sbom};
5use serde::{Deserialize, Serialize};
6use std::cmp::Ordering;
7use std::collections::{BTreeMap, BTreeSet, HashSet};
8
9pub mod renderer;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct MetadataChange {
18 #[serde(skip_serializing_if = "Option::is_none")]
20 pub timestamp: Option<(Option<String>, Option<String>)>,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub tools: Option<(Vec<String>, Vec<String>)>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub authors: Option<(Vec<String>, Vec<String>)>,
27}
28
29impl MetadataChange {
30 pub fn is_empty(&self) -> bool {
32 self.timestamp.is_none() && self.tools.is_none() && self.authors.is_none()
33 }
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
38pub struct EcosystemCounts {
39 pub added: usize,
40 pub removed: usize,
41 pub changed: usize,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct Diff {
50 pub added: Vec<Component>,
52 pub removed: Vec<Component>,
54 pub changed: Vec<ComponentChange>,
56 pub edge_diffs: Vec<EdgeDiff>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub metadata_changed: Option<MetadataChange>,
61 pub old_total: usize,
63 pub new_total: usize,
65 pub unchanged: usize,
67 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72 pub component_names: BTreeMap<ComponentId, String>,
73}
74
75impl Diff {
76 pub fn is_empty(&self) -> bool {
78 self.added.is_empty()
79 && self.removed.is_empty()
80 && self.changed.is_empty()
81 && self.edge_diffs.is_empty()
82 && self.metadata_changed.is_none()
83 }
84
85 pub fn display_name<'a>(&'a self, id: &'a ComponentId) -> &'a str {
89 self.component_names
90 .get(id)
91 .map(String::as_str)
92 .unwrap_or_else(|| id.as_str())
93 }
94
95 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
99 let mut breakdown: BTreeMap<String, EcosystemCounts> = BTreeMap::new();
100
101 for comp in &self.added {
102 let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
103 breakdown.entry(eco).or_default().added += 1;
104 }
105
106 for comp in &self.removed {
107 let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
108 breakdown.entry(eco).or_default().removed += 1;
109 }
110
111 for change in &self.changed {
112 let eco = change
113 .new
114 .ecosystem
115 .as_deref()
116 .unwrap_or("unknown")
117 .to_string();
118 breakdown.entry(eco).or_default().changed += 1;
119 }
120
121 breakdown
122 }
123
124 pub fn group_by_ecosystem(&self) -> GroupedDiff {
131 group_components_by_ecosystem(
132 self.added.iter().cloned(),
133 self.removed.iter().cloned(),
134 self.changed.iter().cloned(),
135 self.edge_diffs.clone(),
136 self.metadata_changed.clone(),
137 )
138 }
139
140 pub fn into_group_by_ecosystem(self) -> GroupedDiff {
143 group_components_by_ecosystem(
144 self.added,
145 self.removed,
146 self.changed,
147 self.edge_diffs,
148 self.metadata_changed,
149 )
150 }
151
152 pub fn filter_by_ecosystem<F: Fn(Option<&str>) -> bool>(
164 &mut self,
165 matches: &F,
166 filtered_old_total: usize,
167 filtered_new_total: usize,
168 component_ecosystems: &BTreeMap<ComponentId, Option<String>>,
169 ) {
170 self.added.retain(|c| matches(c.ecosystem.as_deref()));
171 self.removed.retain(|c| matches(c.ecosystem.as_deref()));
172 self.changed.retain(|c| matches(c.new.ecosystem.as_deref()));
173
174 self.edge_diffs.retain(|edge| {
177 component_ecosystems
178 .get(&edge.parent)
179 .map(|eco| matches(eco.as_deref()))
180 .unwrap_or(true)
181 });
182
183 let mut referenced_ids = BTreeSet::new();
185 for edge in &self.edge_diffs {
186 referenced_ids.insert(&edge.parent);
187 referenced_ids.extend(edge.added.keys());
188 referenced_ids.extend(edge.removed.keys());
189 referenced_ids.extend(edge.kind_changed.keys());
190 }
191 self.component_names
192 .retain(|id, _| referenced_ids.contains(id));
193
194 self.old_total = filtered_old_total;
195 self.new_total = filtered_new_total;
196 self.unchanged = filtered_new_total
201 .saturating_sub(self.added.len())
202 .saturating_sub(self.changed.len());
203 }
204}
205
206fn group_components_by_ecosystem(
210 added: impl IntoIterator<Item = Component>,
211 removed: impl IntoIterator<Item = Component>,
212 changed: impl IntoIterator<Item = ComponentChange>,
213 edge_diffs: Vec<EdgeDiff>,
214 metadata_changed: Option<MetadataChange>,
215) -> GroupedDiff {
216 let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
217
218 for c in added {
219 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
220 ecosystems.entry(eco).or_default().added.push(c);
221 }
222 for c in removed {
223 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
224 ecosystems.entry(eco).or_default().removed.push(c);
225 }
226 for c in changed {
227 let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
228 ecosystems.entry(eco).or_default().changed.push(c);
229 }
230
231 GroupedDiff {
232 by_ecosystem: ecosystems,
233 edge_diffs,
234 metadata_changed,
235 }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct GroupedDiff {
241 pub by_ecosystem: BTreeMap<String, EcosystemDiff>,
242 pub edge_diffs: Vec<EdgeDiff>,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 pub metadata_changed: Option<MetadataChange>,
245}
246
247impl GroupedDiff {
248 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
254 self.by_ecosystem
255 .iter()
256 .map(|(eco, eco_diff)| {
257 (
258 eco.clone(),
259 EcosystemCounts {
260 added: eco_diff.added.len(),
261 removed: eco_diff.removed.len(),
262 changed: eco_diff.changed.len(),
263 },
264 )
265 })
266 .collect()
267 }
268}
269
270#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272pub struct EcosystemDiff {
273 pub added: Vec<Component>,
274 pub removed: Vec<Component>,
275 pub changed: Vec<ComponentChange>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct ComponentChange {
281 pub id: ComponentId,
283 pub old: Component,
285 pub new: Component,
287 pub changes: Vec<FieldChange>,
289 #[serde(default, skip_serializing_if = "is_false")]
291 pub is_downgrade: bool,
292}
293
294fn is_false(b: &bool) -> bool {
295 !b
296}
297
298pub fn pair_ecosystem<'a>(old: &'a Component, new: &'a Component) -> Option<&'a str> {
303 match (old.ecosystem.as_deref(), new.ecosystem.as_deref()) {
304 (Some(a), Some(b)) if a != b => None,
305 (a, b) => a.or(b),
306 }
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct EdgeDiff {
312 pub parent: ComponentId,
314 pub added: BTreeMap<ComponentId, DependencyKind>,
316 pub removed: BTreeMap<ComponentId, DependencyKind>,
318 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
320 pub kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)>,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
325pub enum FieldChange {
326 Version(Option<String>, Option<String>),
328 License(BTreeSet<String>, BTreeSet<String>),
330 LicenseExpression(Option<String>, Option<String>),
334 Supplier(Option<String>, Option<String>),
336 Purl(Option<String>, Option<String>),
338 Description(Option<String>, Option<String>),
340 Hashes(BTreeMap<String, String>, BTreeMap<String, String>),
342 Ecosystem(Option<String>, Option<String>),
344}
345
346#[derive(Debug, Copy, Clone, PartialEq, Eq, clap::ValueEnum)]
350pub enum Field {
351 Version,
353 License,
355 Supplier,
357 Purl,
359 Description,
361 Hashes,
363 Ecosystem,
365 Deps,
367}
368
369const MAX_ALIGNED_CANDIDATES: usize = 256;
372
373pub struct Differ;
378
379impl Differ {
380 pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
409 Self::diff_owned(old.clone(), new.clone(), only)
410 }
411
412 pub fn diff_owned(mut old: Sbom, mut new: Sbom, only: Option<&[Field]>) -> Diff {
415 let metadata_changed = {
417 let mut mc = MetadataChange {
418 timestamp: None,
419 tools: None,
420 authors: None,
421 };
422 if old.metadata.timestamp != new.metadata.timestamp {
423 mc.timestamp = Some((
424 old.metadata.timestamp.clone(),
425 new.metadata.timestamp.clone(),
426 ));
427 }
428 if old.metadata.tools != new.metadata.tools {
429 mc.tools = Some((old.metadata.tools.clone(), new.metadata.tools.clone()));
430 }
431 if old.metadata.authors != new.metadata.authors {
432 mc.authors = Some((old.metadata.authors.clone(), new.metadata.authors.clone()));
433 }
434 if mc.is_empty() {
435 None
436 } else {
437 Some(mc)
438 }
439 };
440
441 old.normalize();
442 new.normalize();
443
444 let mut changed_pairs: Vec<(ComponentId, ComponentId, Vec<FieldChange>)> = Vec::new();
448 let mut matched_old: HashSet<ComponentId> = HashSet::new();
449 let mut matched_new: HashSet<ComponentId> = HashSet::new();
450
451 let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
453
454 for (id, new_comp) in &new.components {
456 if let Some(old_comp) = old.components.get(id) {
457 matched_old.insert(id.clone());
458 matched_new.insert(id.clone());
459 id_mapping.insert(id.clone(), id.clone());
460
461 let fields = Self::compute_fields(old_comp, new_comp, only);
462 if !fields.is_empty() {
463 changed_pairs.push((id.clone(), id.clone(), fields));
464 }
465 }
466 }
467
468 let mut old_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
477 BTreeMap::new();
478 for (id, comp) in &old.components {
479 if !matched_old.contains(id) {
480 old_identity_map
481 .entry(comp.name.clone())
482 .or_default()
483 .entry(comp.ecosystem.clone())
484 .or_default()
485 .push(id.clone());
486 }
487 }
488 let mut new_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
489 BTreeMap::new();
490 for (id, comp) in &new.components {
491 if !matched_new.contains(id) {
492 new_identity_map
493 .entry(comp.name.clone())
494 .or_default()
495 .entry(comp.ecosystem.clone())
496 .or_default()
497 .push(id.clone());
498 }
499 }
500
501 let mut identity_pairs: Vec<(ComponentId, ComponentId)> = Vec::new();
504 for (name, new_eco_map) in &new_identity_map {
505 let Some(old_eco_map) = old_identity_map.get_mut(name) else {
506 continue;
507 };
508 for (ecosystem, new_ids) in new_eco_map {
509 let Some(old_ids) = old_eco_map.get_mut(ecosystem) else {
510 continue;
511 };
512 let pairs = Self::align_by_version(old_ids, new_ids, &old, &new);
513 let consumed: HashSet<ComponentId> =
514 pairs.iter().map(|(old_id, _)| old_id.clone()).collect();
515 old_ids.retain(|id| !consumed.contains(id));
516 identity_pairs.extend(pairs);
517 }
518 }
519 for (_, new_id) in &identity_pairs {
520 matched_new.insert(new_id.clone());
521 }
522
523 for (name, new_eco_map) in &new_identity_map {
528 let Some(old_eco_map) = old_identity_map.get_mut(name) else {
529 continue;
530 };
531 for (ecosystem, new_ids) in new_eco_map {
532 let new_ids: Vec<ComponentId> = new_ids
533 .iter()
534 .filter(|id| !matched_new.contains(*id))
535 .cloned()
536 .collect();
537 if new_ids.is_empty() {
538 continue;
539 }
540 let old_ids: Vec<ComponentId> = if ecosystem.is_some() {
541 old_eco_map.get(&None).cloned().unwrap_or_default()
542 } else {
543 old_eco_map.values().flatten().cloned().collect()
544 };
545 let pairs = Self::align_by_version(&old_ids, &new_ids, &old, &new);
546 let consumed: HashSet<ComponentId> =
547 pairs.iter().map(|(old_id, _)| old_id.clone()).collect();
548 for ids in old_eco_map.values_mut() {
549 ids.retain(|id| !consumed.contains(id));
550 }
551 for (_, new_id) in &pairs {
552 matched_new.insert(new_id.clone());
553 }
554 identity_pairs.extend(pairs);
555 }
556 }
557
558 identity_pairs.sort_by(|a, b| a.1.cmp(&b.1));
559 for (old_id, new_id) in identity_pairs {
560 let (Some(old_comp), Some(new_comp)) =
561 (old.components.get(&old_id), new.components.get(&new_id))
562 else {
563 continue;
564 };
565 matched_old.insert(old_id.clone());
566 matched_new.insert(new_id.clone());
567 id_mapping.insert(old_id.clone(), new_id.clone());
568
569 let fields = Self::compute_fields(old_comp, new_comp, only);
570 if !fields.is_empty() {
571 changed_pairs.push((old_id, new_id, fields));
572 }
573 }
574
575 let old_total = old.components.len();
577 let new_total = new.components.len();
578 let matched = matched_old.len();
579 let unchanged = matched - changed_pairs.len();
580
581 let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
583 let edge_diffs = if should_include_deps {
584 Self::compute_edge_diffs(&old, &new, &id_mapping)
585 } else {
586 Vec::new()
587 };
588
589 let component_names = Self::build_component_names(&old, &new, &edge_diffs);
591
592 let mut changed = Vec::with_capacity(changed_pairs.len());
597 for (old_id, new_id, fields) in changed_pairs {
598 let old_comp = old.components.swap_remove(&old_id).unwrap();
599 let new_comp = new.components.swap_remove(&new_id).unwrap();
600 let ecosystem = pair_ecosystem(&old_comp, &new_comp);
601 let downgrade = fields.iter().any(|f| match f {
602 FieldChange::Version(Some(old_ver), Some(new_ver)) => {
603 is_version_downgrade_for_ecosystem(ecosystem, old_ver, new_ver)
604 }
605 _ => false,
606 });
607 changed.push(ComponentChange {
608 id: new_comp.id.clone(),
609 old: old_comp,
610 new: new_comp,
611 changes: fields,
612 is_downgrade: downgrade,
613 });
614 }
615
616 for id in &matched_old {
619 old.components.swap_remove(id);
620 }
621 for id in &matched_new {
622 new.components.swap_remove(id);
623 }
624
625 let added: Vec<Component> = new.components.into_values().collect();
627 let removed: Vec<Component> = old.components.into_values().collect();
628
629 Diff {
630 added,
631 removed,
632 changed,
633 edge_diffs,
634 metadata_changed,
635 old_total,
636 new_total,
637 unchanged,
638 component_names,
639 }
640 }
641
642 fn align_by_version(
650 old_ids: &[ComponentId],
651 new_ids: &[ComponentId],
652 old: &Sbom,
653 new: &Sbom,
654 ) -> Vec<(ComponentId, ComponentId)> {
655 let by_id = || -> Vec<(ComponentId, ComponentId)> {
656 new_ids
657 .iter()
658 .zip(old_ids.iter().rev())
659 .map(|(new_id, old_id)| (old_id.clone(), new_id.clone()))
660 .collect()
661 };
662
663 if old_ids.is_empty() || new_ids.is_empty() {
664 return Vec::new();
665 }
666 if old_ids.len() > MAX_ALIGNED_CANDIDATES || new_ids.len() > MAX_ALIGNED_CANDIDATES {
667 return by_id();
668 }
669
670 let mut merged: Vec<(Version, u8, &ComponentId)> =
671 Vec::with_capacity(old_ids.len() + new_ids.len());
672 for (side, ids, sbom) in [(0u8, old_ids, old), (1u8, new_ids, new)] {
673 for id in ids {
674 let component = sbom.components.get(id);
675 let Some(version) = component.and_then(|c| c.version.as_deref()) else {
676 return by_id();
677 };
678 let ecosystem = component.and_then(|c| c.ecosystem.as_deref());
679 merged.push((Version::parse_for_ecosystem(ecosystem, version), side, id));
680 }
681 }
682 let first = &merged[0].0;
685 if first.partial_cmp_lenient(first).is_none()
686 || merged
687 .iter()
688 .any(|(version, ..)| first.partial_cmp_lenient(version).is_none())
689 {
690 return by_id();
691 }
692 let (mut numeric, mut prerelease) = (false, false);
695 for (version, ..) in &merged {
696 match version {
697 Version::Numeric(_) => numeric = true,
698 Version::Semver(v) => prerelease |= !v.pre.is_empty(),
699 _ => {}
700 }
701 }
702 if numeric && prerelease {
703 return by_id();
704 }
705 merged.sort_by(|a, b| {
706 a.0.partial_cmp_lenient(&b.0)
707 .unwrap_or(Ordering::Equal)
708 .then_with(|| a.1.cmp(&b.1))
709 .then_with(|| a.2.cmp(b.2))
710 });
711
712 let (mut old_ranked, mut new_ranked) = (Vec::new(), Vec::new());
713 for (rank, (version, side, id)) in merged.iter().enumerate() {
714 if *side == 0 {
715 old_ranked.push((rank, *id, version));
716 } else {
717 new_ranked.push((rank, *id, version));
718 }
719 }
720
721 let (n, m) = (old_ranked.len(), new_ranked.len());
722 let cost = |i: usize, j: usize| {
723 (
724 old_ranked[i].0.abs_diff(new_ranked[j].0),
725 usize::from(old_ranked[i].2.is_downgrade(new_ranked[j].2)),
726 )
727 };
728 let better = |a: (usize, usize, usize), b: (usize, usize, usize)| {
729 a.0 > b.0 || (a.0 == b.0 && (a.1, a.2) < (b.1, b.2))
730 };
731 let paired_with = |(pairs, distance, downgrades): (usize, usize, usize), i, j| {
732 let (extra_distance, extra_downgrade) = cost(i, j);
733 (
734 pairs + 1,
735 distance + extra_distance,
736 downgrades + extra_downgrade,
737 )
738 };
739
740 let mut score = vec![vec![(0usize, 0usize, 0usize); m + 1]; n + 1];
742 for i in (0..n).rev() {
743 for j in (0..m).rev() {
744 let mut best = paired_with(score[i + 1][j + 1], i, j);
745 if better(score[i + 1][j], best) {
746 best = score[i + 1][j];
747 }
748 if better(score[i][j + 1], best) {
749 best = score[i][j + 1];
750 }
751 score[i][j] = best;
752 }
753 }
754
755 let mut pairs = Vec::with_capacity(n.min(m));
756 let (mut i, mut j) = (0, 0);
757 while i < n && j < m {
758 let take = paired_with(score[i + 1][j + 1], i, j);
759 if !better(score[i + 1][j], take) && !better(score[i][j + 1], take) {
760 pairs.push((old_ranked[i].1.clone(), new_ranked[j].1.clone()));
761 i += 1;
762 j += 1;
763 } else if better(score[i + 1][j], score[i][j + 1]) {
764 i += 1;
765 } else {
766 j += 1;
767 }
768 }
769 pairs
770 }
771
772 fn compute_edge_diffs(
779 old: &Sbom,
780 new: &Sbom,
781 id_mapping: &BTreeMap<ComponentId, ComponentId>,
782 ) -> Vec<EdgeDiff> {
783 let mut edge_diffs = Vec::new();
784
785 let reverse_mapping: BTreeMap<&ComponentId, &ComponentId> = id_mapping
787 .iter()
788 .map(|(old_id, new_id)| (new_id, old_id))
789 .collect();
790
791 let mut all_parents: BTreeSet<&ComponentId> = new.dependencies.keys().collect();
793 for old_parent in old.dependencies.keys() {
794 all_parents.insert(id_mapping.get(old_parent).unwrap_or(old_parent));
795 }
796
797 let empty_deps = BTreeMap::new();
798
799 for parent_id in all_parents {
800 let new_children = new.dependencies.get(parent_id).unwrap_or(&empty_deps);
802
803 let old_parent_id = reverse_mapping.get(parent_id).copied().unwrap_or(parent_id);
804
805 let old_children: BTreeMap<&ComponentId, DependencyKind> = old
807 .dependencies
808 .get(old_parent_id)
809 .map(|children| {
810 children
811 .iter()
812 .map(|(id, &kind)| (id_mapping.get(id).unwrap_or(id), kind))
813 .collect()
814 })
815 .unwrap_or_default();
816
817 let new_keys: BTreeSet<&ComponentId> = new_children.keys().collect();
818 let old_keys: BTreeSet<&ComponentId> = old_children.keys().copied().collect();
819
820 let added: BTreeMap<ComponentId, DependencyKind> = new_keys
822 .difference(&old_keys)
823 .map(|&id| (id.clone(), new_children[id]))
824 .collect();
825 let removed: BTreeMap<ComponentId, DependencyKind> = old_keys
826 .difference(&new_keys)
827 .map(|&id| (id.clone(), old_children[id]))
828 .collect();
829
830 let kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)> = new_keys
831 .intersection(&old_keys)
832 .filter_map(|&id| {
833 let old_kind = old_children[id];
834 let new_kind = new_children[id];
835 if old_kind != new_kind {
836 Some((id.clone(), (old_kind, new_kind)))
837 } else {
838 None
839 }
840 })
841 .collect();
842
843 if !added.is_empty() || !removed.is_empty() || !kind_changed.is_empty() {
844 edge_diffs.push(EdgeDiff {
845 parent: parent_id.clone(),
846 added,
847 removed,
848 kind_changed,
849 });
850 }
851 }
852
853 edge_diffs
854 }
855
856 fn build_component_names(
861 old: &Sbom,
862 new: &Sbom,
863 edge_diffs: &[EdgeDiff],
864 ) -> BTreeMap<ComponentId, String> {
865 let mut names = BTreeMap::new();
866
867 let mut ids = BTreeSet::new();
869 for edge in edge_diffs {
870 ids.insert(&edge.parent);
871 ids.extend(edge.added.keys());
872 ids.extend(edge.removed.keys());
873 ids.extend(edge.kind_changed.keys());
874 }
875
876 for id in ids {
878 if !id.as_str().starts_with("h:") {
879 continue;
880 }
881
882 let comp = new.components.get(id).or_else(|| old.components.get(id));
884 if let Some(comp) = comp {
885 let display = match &comp.version {
886 Some(v) => format!("{}@{}", comp.name, v),
887 None => comp.name.clone(),
888 };
889 names.insert(id.clone(), display);
890 }
891 }
892
893 names
894 }
895
896 fn compute_fields(
904 old: &Component,
905 new: &Component,
906 only: Option<&[Field]>,
907 ) -> Vec<FieldChange> {
908 let mut changes = Vec::new();
909
910 let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
911
912 if should_include(Field::Version) && old.version != new.version {
913 changes.push(FieldChange::Version(
914 old.version.clone(),
915 new.version.clone(),
916 ));
917 }
918
919 if should_include(Field::License) {
920 if old.licenses != new.licenses {
921 changes.push(FieldChange::License(
922 old.licenses.clone(),
923 new.licenses.clone(),
924 ));
925 } else if !licensings_equivalent(old.licensing(), new.licensing()) {
926 changes.push(FieldChange::LicenseExpression(
927 old.license_expression.clone(),
928 new.license_expression.clone(),
929 ));
930 }
931 }
932
933 if should_include(Field::Supplier) && old.supplier != new.supplier {
934 changes.push(FieldChange::Supplier(
935 old.supplier.clone(),
936 new.supplier.clone(),
937 ));
938 }
939
940 if should_include(Field::Purl) && old.purl != new.purl {
941 changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
942 }
943
944 if should_include(Field::Description) && old.description != new.description {
945 changes.push(FieldChange::Description(
946 old.description.clone(),
947 new.description.clone(),
948 ));
949 }
950
951 if should_include(Field::Hashes) && old.hashes != new.hashes {
952 changes.push(FieldChange::Hashes(old.hashes.clone(), new.hashes.clone()));
953 }
954
955 if should_include(Field::Ecosystem) && old.ecosystem != new.ecosystem {
956 changes.push(FieldChange::Ecosystem(
957 old.ecosystem.clone(),
958 new.ecosystem.clone(),
959 ));
960 }
961
962 changes
963 }
964}
965
966#[cfg(test)]
967mod tests {
968 use super::*;
969
970 #[test]
971 fn test_diff_added_removed() {
972 let mut old = Sbom::default();
973 let mut new = Sbom::default();
974
975 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
976 let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
977
978 old.components.insert(c1.id.clone(), c1);
979 new.components.insert(c2.id.clone(), c2);
980
981 let diff = Differ::diff(&old, &new, None);
982 assert_eq!(diff.added.len(), 1);
983 assert_eq!(diff.removed.len(), 1);
984 assert_eq!(diff.changed.len(), 0);
985 }
986
987 #[test]
988 fn test_diff_changed() {
989 let mut old = Sbom::default();
990 let mut new = Sbom::default();
991
992 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
993 let mut c2 = c1.clone();
994 c2.version = Some("1.1".to_string());
995
996 old.components.insert(c1.id.clone(), c1);
997 new.components.insert(c2.id.clone(), c2);
998
999 let diff = Differ::diff(&old, &new, None);
1000 assert_eq!(diff.added.len(), 0);
1001 assert_eq!(diff.removed.len(), 0);
1002 assert_eq!(diff.changed.len(), 1);
1003 assert!(matches!(
1004 diff.changed[0].changes[0],
1005 FieldChange::Version(_, _)
1006 ));
1007 }
1008
1009 #[test]
1010 fn test_diff_identity_reconciliation() {
1011 let mut old = Sbom::default();
1012 let mut new = Sbom::default();
1013
1014 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1015 let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
1016
1017 old.components.insert(c1.id.clone(), c1);
1018 new.components.insert(c2.id.clone(), c2);
1019
1020 let diff = Differ::diff(&old, &new, None);
1021 assert_eq!(diff.changed.len(), 1);
1022 assert_eq!(diff.added.len(), 0);
1023 }
1024
1025 #[test]
1026 fn test_diff_license_change() {
1027 let mut old = Sbom::default();
1028 let mut new = Sbom::default();
1029
1030 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1031 c1.licenses.insert("MIT".into());
1032 let mut c2 = c1.clone();
1033 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1034
1035 old.components.insert(c1.id.clone(), c1);
1036 new.components.insert(c2.id.clone(), c2);
1037
1038 let diff = Differ::diff(&old, &new, None);
1039 assert_eq!(diff.changed.len(), 1);
1040 assert!(diff.changed[0]
1041 .changes
1042 .iter()
1043 .any(|c| matches!(c, FieldChange::License(_, _))));
1044 }
1045
1046 #[test]
1047 fn test_diff_supplier_change() {
1048 let mut old = Sbom::default();
1049 let mut new = Sbom::default();
1050
1051 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1052 c1.supplier = Some("Acme Corp".into());
1053 let mut c2 = c1.clone();
1054 c2.supplier = Some("New Corp".into());
1055
1056 old.components.insert(c1.id.clone(), c1);
1057 new.components.insert(c2.id.clone(), c2);
1058
1059 let diff = Differ::diff(&old, &new, None);
1060 assert_eq!(diff.changed.len(), 1);
1061 assert!(diff.changed[0]
1062 .changes
1063 .iter()
1064 .any(|c| matches!(c, FieldChange::Supplier(_, _))));
1065 }
1066
1067 #[test]
1068 fn test_diff_hashes_change() {
1069 let mut old = Sbom::default();
1070 let mut new = Sbom::default();
1071
1072 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1073 c1.hashes.insert("sha256".into(), "aaa".into());
1074 let mut c2 = c1.clone();
1075 c2.hashes.insert("sha256".into(), "bbb".into());
1076
1077 old.components.insert(c1.id.clone(), c1);
1078 new.components.insert(c2.id.clone(), c2);
1079
1080 let diff = Differ::diff(&old, &new, None);
1081 assert_eq!(diff.changed.len(), 1);
1082 assert!(diff.changed[0]
1083 .changes
1084 .iter()
1085 .any(|c| matches!(c, FieldChange::Hashes(_, _))));
1086 }
1087
1088 #[test]
1089 fn test_diff_description_change() {
1090 let mut old = Sbom::default();
1091 let mut new = Sbom::default();
1092
1093 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1094 c1.description = Some("Old description".into());
1095 let mut c2 = c1.clone();
1096 c2.description = Some("New description".into());
1097
1098 old.components.insert(c1.id.clone(), c1);
1099 new.components.insert(c2.id.clone(), c2);
1100
1101 let diff = Differ::diff(&old, &new, None);
1102 assert_eq!(diff.changed.len(), 1);
1103 assert!(diff.changed[0]
1104 .changes
1105 .iter()
1106 .any(|c| matches!(c, FieldChange::Description(_, _))));
1107 }
1108
1109 #[test]
1110 fn test_diff_description_added() {
1111 let mut old = Sbom::default();
1112 let mut new = Sbom::default();
1113
1114 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1115 let mut c2 = c1.clone();
1116 c2.description = Some("A new description".into());
1117
1118 old.components.insert(c1.id.clone(), c1);
1119 new.components.insert(c2.id.clone(), c2);
1120
1121 let diff = Differ::diff(&old, &new, None);
1122 assert_eq!(diff.changed.len(), 1);
1123 assert!(diff.changed[0]
1124 .changes
1125 .iter()
1126 .any(|c| matches!(c, FieldChange::Description(None, Some(_)))));
1127 }
1128
1129 #[test]
1130 fn test_diff_description_removed() {
1131 let mut old = Sbom::default();
1132 let mut new = Sbom::default();
1133
1134 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1135 c1.description = Some("Had a description".into());
1136 let mut c2 = c1.clone();
1137 c2.description = None;
1138
1139 old.components.insert(c1.id.clone(), c1);
1140 new.components.insert(c2.id.clone(), c2);
1141
1142 let diff = Differ::diff(&old, &new, None);
1143 assert_eq!(diff.changed.len(), 1);
1144 assert!(diff.changed[0]
1145 .changes
1146 .iter()
1147 .any(|c| matches!(c, FieldChange::Description(Some(_), None))));
1148 }
1149
1150 #[test]
1151 fn test_diff_description_unchanged() {
1152 let mut old = Sbom::default();
1153 let mut new = Sbom::default();
1154
1155 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1156 c1.description = Some("Same description".into());
1157 let c2 = c1.clone();
1158
1159 old.components.insert(c1.id.clone(), c1);
1160 new.components.insert(c2.id.clone(), c2);
1161
1162 let diff = Differ::diff(&old, &new, None);
1163 assert!(diff.changed.is_empty());
1164 }
1165
1166 #[test]
1167 fn test_diff_description_filtering() {
1168 let mut old = Sbom::default();
1169 let mut new = Sbom::default();
1170
1171 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1172 c1.description = Some("Old".into());
1173 let mut c2 = c1.clone();
1174 c2.version = Some("2.0".into());
1175 c2.description = Some("New".into());
1176
1177 old.components.insert(c1.id.clone(), c1);
1178 new.components.insert(c2.id.clone(), c2);
1179
1180 let diff = Differ::diff(&old, &new, Some(&[Field::Description]));
1182 assert_eq!(diff.changed.len(), 1);
1183 assert_eq!(diff.changed[0].changes.len(), 1);
1184 assert!(matches!(
1185 diff.changed[0].changes[0],
1186 FieldChange::Description(_, _)
1187 ));
1188
1189 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1191 assert_eq!(diff.changed.len(), 1);
1192 assert_eq!(diff.changed[0].changes.len(), 1);
1193 assert!(matches!(
1194 diff.changed[0].changes[0],
1195 FieldChange::Version(_, _)
1196 ));
1197 }
1198
1199 #[test]
1200 fn test_diff_ecosystem_change() {
1201 let mut old = Sbom::default();
1202 let mut new = Sbom::default();
1203
1204 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1205 c1.ecosystem = Some("npm".to_string());
1206 let mut c2 = c1.clone();
1207 c2.ecosystem = Some("cargo".to_string());
1208
1209 old.components.insert(c1.id.clone(), c1);
1210 new.components.insert(c2.id.clone(), c2);
1211
1212 let diff = Differ::diff(&old, &new, None);
1213 assert_eq!(diff.changed.len(), 1);
1214 assert_eq!(diff.changed[0].changes.len(), 1);
1215 assert!(matches!(
1216 diff.changed[0].changes[0],
1217 FieldChange::Ecosystem(_, _)
1218 ));
1219
1220 if let FieldChange::Ecosystem(ref o, ref n) = diff.changed[0].changes[0] {
1221 assert_eq!(o.as_deref(), Some("npm"));
1222 assert_eq!(n.as_deref(), Some("cargo"));
1223 }
1224 }
1225
1226 #[test]
1227 fn test_diff_ecosystem_change_from_none() {
1228 let mut old = Sbom::default();
1229 let mut new = Sbom::default();
1230
1231 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1232 let mut c2 = c1.clone();
1233 c2.ecosystem = Some("npm".to_string());
1234
1235 old.components.insert(c1.id.clone(), c1);
1236 new.components.insert(c2.id.clone(), c2);
1237
1238 let diff = Differ::diff(&old, &new, None);
1239 assert_eq!(diff.changed.len(), 1);
1240 assert_eq!(diff.changed[0].changes.len(), 1);
1241 assert!(matches!(
1242 diff.changed[0].changes[0],
1243 FieldChange::Ecosystem(None, Some(_))
1244 ));
1245 }
1246
1247 #[test]
1248 fn test_diff_ecosystem_filtering() {
1249 let mut old = Sbom::default();
1250 let mut new = Sbom::default();
1251
1252 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1253 c1.ecosystem = Some("npm".to_string());
1254 let mut c2 = c1.clone();
1255 c2.version = Some("2.0".into());
1256 c2.ecosystem = Some("cargo".to_string());
1257
1258 old.components.insert(c1.id.clone(), c1);
1259 new.components.insert(c2.id.clone(), c2);
1260
1261 let diff = Differ::diff(&old, &new, Some(&[Field::Ecosystem]));
1263 assert_eq!(diff.changed.len(), 1);
1264 assert_eq!(diff.changed[0].changes.len(), 1);
1265 assert!(matches!(
1266 diff.changed[0].changes[0],
1267 FieldChange::Ecosystem(_, _)
1268 ));
1269
1270 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1272 assert_eq!(diff.changed.len(), 1);
1273 assert_eq!(diff.changed[0].changes.len(), 1);
1274 assert!(matches!(
1275 diff.changed[0].changes[0],
1276 FieldChange::Version(_, _)
1277 ));
1278 }
1279
1280 #[test]
1281 fn test_diff_ecosystem_no_change() {
1282 let mut old = Sbom::default();
1283 let mut new = Sbom::default();
1284
1285 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1286 c1.ecosystem = Some("npm".to_string());
1287 let c2 = c1.clone();
1288
1289 old.components.insert(c1.id.clone(), c1);
1290 new.components.insert(c2.id.clone(), c2);
1291
1292 let diff = Differ::diff(&old, &new, None);
1293 assert!(diff.changed.is_empty());
1294 }
1295
1296 #[test]
1297 fn test_diff_multiple_field_changes() {
1298 let mut old = Sbom::default();
1299 let mut new = Sbom::default();
1300
1301 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1302 c1.licenses.insert("MIT".into());
1303 c1.supplier = Some("Old Corp".into());
1304 c1.hashes.insert("sha256".into(), "aaa".into());
1305
1306 let mut c2 = c1.clone();
1307 c2.version = Some("2.0".into());
1308 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1309 c2.supplier = Some("New Corp".into());
1310 c2.hashes.insert("sha256".into(), "bbb".into());
1311
1312 old.components.insert(c1.id.clone(), c1);
1313 new.components.insert(c2.id.clone(), c2);
1314
1315 let diff = Differ::diff(&old, &new, None);
1316 assert_eq!(diff.changed.len(), 1);
1317 assert_eq!(diff.changed[0].changes.len(), 4);
1318 }
1319
1320 #[test]
1321 fn test_diff_no_changes() {
1322 let mut old = Sbom::default();
1323 let mut new = Sbom::default();
1324
1325 let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1326 old.components.insert(c.id.clone(), c.clone());
1327 new.components.insert(c.id.clone(), c);
1328
1329 let diff = Differ::diff(&old, &new, None);
1330 assert!(diff.added.is_empty());
1331 assert!(diff.removed.is_empty());
1332 assert!(diff.changed.is_empty());
1333 assert!(diff.edge_diffs.is_empty());
1334 }
1335
1336 #[test]
1337 fn test_diff_metadata_changed_timestamp() {
1338 let mut old = Sbom::default();
1339 let mut new = Sbom::default();
1340
1341 old.metadata.timestamp = Some("2024-01-01".into());
1342 new.metadata.timestamp = Some("2024-01-02".into());
1343
1344 let diff = Differ::diff(&old, &new, None);
1345 let mc = diff.metadata_changed.as_ref().unwrap();
1346 assert_eq!(
1347 mc.timestamp,
1348 Some((Some("2024-01-01".into()), Some("2024-01-02".into())))
1349 );
1350 assert!(mc.tools.is_none());
1351 assert!(mc.authors.is_none());
1352 assert!(!diff.is_empty());
1353 }
1354
1355 #[test]
1356 fn test_diff_metadata_changed_tools() {
1357 let mut old = Sbom::default();
1358 let mut new = Sbom::default();
1359
1360 old.metadata.tools = vec!["syft".into()];
1361 new.metadata.tools = vec!["trivy".into()];
1362
1363 let diff = Differ::diff(&old, &new, None);
1364 let mc = diff.metadata_changed.as_ref().unwrap();
1365 assert!(mc.timestamp.is_none());
1366 assert_eq!(mc.tools, Some((vec!["syft".into()], vec!["trivy".into()])));
1367 assert!(mc.authors.is_none());
1368 }
1369
1370 #[test]
1371 fn test_diff_metadata_changed_authors() {
1372 let mut old = Sbom::default();
1373 let mut new = Sbom::default();
1374
1375 old.metadata.authors = vec!["alice".into()];
1376 new.metadata.authors = vec!["bob".into()];
1377
1378 let diff = Differ::diff(&old, &new, None);
1379 let mc = diff.metadata_changed.as_ref().unwrap();
1380 assert!(mc.timestamp.is_none());
1381 assert!(mc.tools.is_none());
1382 assert_eq!(mc.authors, Some((vec!["alice".into()], vec!["bob".into()])));
1383 }
1384
1385 #[test]
1386 fn test_diff_metadata_unchanged() {
1387 let mut old = Sbom::default();
1388 let mut new = Sbom::default();
1389
1390 old.metadata.timestamp = Some("2024-01-01".into());
1391 new.metadata.timestamp = Some("2024-01-01".into());
1392 old.metadata.tools = vec!["syft".into()];
1393 new.metadata.tools = vec!["syft".into()];
1394
1395 let diff = Differ::diff(&old, &new, None);
1396 assert!(diff.metadata_changed.is_none());
1397 }
1398
1399 #[test]
1400 fn test_diff_metadata_changed_multiple_fields() {
1401 let mut old = Sbom::default();
1402 let mut new = Sbom::default();
1403
1404 old.metadata.timestamp = Some("2024-01-01".into());
1405 new.metadata.timestamp = Some("2024-01-02".into());
1406 old.metadata.tools = vec!["syft".into()];
1407 new.metadata.tools = vec!["trivy".into()];
1408 old.metadata.authors = vec!["alice".into()];
1409 new.metadata.authors = vec!["bob".into()];
1410
1411 let diff = Differ::diff(&old, &new, None);
1412 let mc = diff.metadata_changed.as_ref().unwrap();
1413 assert!(mc.timestamp.is_some());
1414 assert!(mc.tools.is_some());
1415 assert!(mc.authors.is_some());
1416 }
1417
1418 #[test]
1419 fn test_diff_filtering() {
1420 let mut old = Sbom::default();
1421 let mut new = Sbom::default();
1422
1423 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1424 c1.licenses.insert("MIT".into());
1425
1426 let mut c2 = c1.clone();
1427 c2.version = Some("1.1".to_string());
1428 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1429
1430 old.components.insert(c1.id.clone(), c1);
1431 new.components.insert(c2.id.clone(), c2);
1432
1433 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1434 assert_eq!(diff.changed.len(), 1);
1435 assert_eq!(diff.changed[0].changes.len(), 1);
1436 assert!(matches!(
1437 diff.changed[0].changes[0],
1438 FieldChange::Version(_, _)
1439 ));
1440 }
1441
1442 #[test]
1443 fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
1444 let mut old = Sbom::default();
1447 let mut new = Sbom::default();
1448
1449 let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
1451 c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
1452 c_old.ecosystem = Some("npm".to_string());
1453 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1454
1455 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1457 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1458 c_new.ecosystem = Some("npm".to_string());
1459 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1460
1461 old.components.insert(c_old.id.clone(), c_old);
1462 new.components.insert(c_new.id.clone(), c_new);
1463
1464 let diff = Differ::diff(&old, &new, None);
1465
1466 assert_eq!(diff.added.len(), 0, "Should not have added components");
1467 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1468
1469 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1470
1471 let changes = &diff.changed[0].changes;
1472 assert!(changes
1473 .iter()
1474 .any(|c| matches!(c, FieldChange::Version(_, _))));
1475 assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
1476 }
1477
1478 #[test]
1479 fn test_purl_removed_is_change() {
1480 let mut old = Sbom::default();
1483 let mut new = Sbom::default();
1484
1485 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1486 c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1487 c_old.ecosystem = Some("npm".to_string()); c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1489
1490 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1492 c_new.purl = None;
1493 c_new.ecosystem = None; c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1496
1497 old.components.insert(c_old.id.clone(), c_old);
1498 new.components.insert(c_new.id.clone(), c_new);
1499
1500 let diff = Differ::diff(&old, &new, None);
1501
1502 assert_eq!(diff.added.len(), 0, "Should not have added components");
1503 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1504 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1505
1506 assert!(diff.changed[0]
1507 .changes
1508 .iter()
1509 .any(|c| matches!(c, FieldChange::Purl(_, _))));
1510 }
1511
1512 #[test]
1513 fn test_purl_added_is_change() {
1514 let mut old = Sbom::default();
1517 let mut new = Sbom::default();
1518
1519 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1520 c_old.purl = None;
1521 c_old.ecosystem = None; c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1523
1524 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1525 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1526 c_new.ecosystem = Some("npm".to_string()); c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1528
1529 old.components.insert(c_old.id.clone(), c_old);
1530 new.components.insert(c_new.id.clone(), c_new);
1531
1532 let diff = Differ::diff(&old, &new, None);
1533
1534 assert_eq!(diff.added.len(), 0, "Should not have added components");
1535 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1536 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1537 }
1538
1539 #[test]
1540 fn test_same_name_different_ecosystems_not_matched() {
1541 let mut old = Sbom::default();
1543 let mut new = Sbom::default();
1544
1545 let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1547 c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
1548 c_old.ecosystem = Some("npm".to_string());
1549 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1550
1551 let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1553 c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
1554 c_new.ecosystem = Some("pypi".to_string());
1555 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1556
1557 old.components.insert(c_old.id.clone(), c_old);
1558 new.components.insert(c_new.id.clone(), c_new);
1559
1560 let diff = Differ::diff(&old, &new, None);
1561
1562 assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
1563 assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
1564 assert_eq!(
1565 diff.changed.len(),
1566 0,
1567 "Should not match different ecosystems"
1568 );
1569 }
1570
1571 #[test]
1572 fn test_same_name_both_no_ecosystem_matched() {
1573 let mut old = Sbom::default();
1576 let mut new = Sbom::default();
1577
1578 let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
1579 c_old.ecosystem = None;
1580
1581 let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
1582 c_new.ecosystem = None;
1583
1584 old.components.insert(c_old.id.clone(), c_old);
1585 new.components.insert(c_new.id.clone(), c_new);
1586
1587 let diff = Differ::diff(&old, &new, None);
1588
1589 assert_eq!(diff.added.len(), 0);
1590 assert_eq!(diff.removed.len(), 0);
1591 assert_eq!(
1592 diff.changed.len(),
1593 1,
1594 "Same name with None ecosystems should match"
1595 );
1596 }
1597
1598 #[test]
1599 fn test_edge_diff_added_removed() {
1600 let mut old = Sbom::default();
1601 let mut new = Sbom::default();
1602
1603 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1604 let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
1605 let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
1606
1607 let parent_id = c1.id.clone();
1608 let child_a_id = c2.id.clone();
1609 let child_b_id = c3.id.clone();
1610
1611 old.components.insert(c1.id.clone(), c1.clone());
1613 old.components.insert(c2.id.clone(), c2.clone());
1614 old.components.insert(c3.id.clone(), c3.clone());
1615
1616 new.components.insert(c1.id.clone(), c1);
1617 new.components.insert(c2.id.clone(), c2);
1618 new.components.insert(c3.id.clone(), c3);
1619
1620 old.dependencies
1622 .entry(parent_id.clone())
1623 .or_default()
1624 .insert(child_a_id.clone(), DependencyKind::Runtime);
1625
1626 new.dependencies
1628 .entry(parent_id.clone())
1629 .or_default()
1630 .insert(child_b_id.clone(), DependencyKind::Runtime);
1631
1632 let diff = Differ::diff(&old, &new, None);
1633
1634 assert_eq!(diff.edge_diffs.len(), 1);
1635 assert_eq!(diff.edge_diffs[0].parent, parent_id);
1636 assert!(diff.edge_diffs[0].added.contains_key(&child_b_id));
1637 assert!(diff.edge_diffs[0].removed.contains_key(&child_a_id));
1638 }
1639
1640 #[test]
1641 fn test_edge_diff_with_identity_reconciliation() {
1642 let mut old = Sbom::default();
1645 let mut new = Sbom::default();
1646
1647 let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
1649 parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
1650 parent_old.ecosystem = Some("npm".to_string());
1651 parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
1652
1653 let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
1655 parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
1656 parent_new.ecosystem = Some("npm".to_string());
1657 parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
1658
1659 let child = Component::new("child".to_string(), Some("1.0".to_string()));
1661
1662 old.components
1663 .insert(parent_old.id.clone(), parent_old.clone());
1664 old.components.insert(child.id.clone(), child.clone());
1665
1666 new.components
1667 .insert(parent_new.id.clone(), parent_new.clone());
1668 new.components.insert(child.id.clone(), child.clone());
1669
1670 old.dependencies
1672 .entry(parent_old.id.clone())
1673 .or_default()
1674 .insert(child.id.clone(), DependencyKind::Runtime);
1675
1676 new.dependencies
1678 .entry(parent_new.id.clone())
1679 .or_default()
1680 .insert(child.id.clone(), DependencyKind::Runtime);
1681
1682 let diff = Differ::diff(&old, &new, None);
1683
1684 assert_eq!(
1687 diff.edge_diffs.len(),
1688 0,
1689 "No edge changes expected when parent is reconciled by identity"
1690 );
1691 }
1692
1693 #[test]
1694 fn test_edge_diff_filtering() {
1695 let mut old = Sbom::default();
1697 let mut new = Sbom::default();
1698
1699 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1700 let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
1701
1702 let parent_id = c1.id.clone();
1703 let child_id = c2.id.clone();
1704
1705 old.components.insert(c1.id.clone(), c1.clone());
1706 old.components.insert(c2.id.clone(), c2.clone());
1707
1708 new.components.insert(c1.id.clone(), c1);
1709 new.components.insert(c2.id.clone(), c2);
1710
1711 new.dependencies
1713 .entry(parent_id.clone())
1714 .or_default()
1715 .insert(child_id, DependencyKind::Runtime);
1716
1717 let diff = Differ::diff(&old, &new, None);
1719 assert_eq!(diff.edge_diffs.len(), 1);
1720
1721 let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
1723 assert_eq!(diff_filtered.edge_diffs.len(), 0);
1724
1725 let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
1727 assert_eq!(diff_with_deps.edge_diffs.len(), 1);
1728 }
1729
1730 #[test]
1731 fn test_ecosystem_breakdown() {
1732 let mut old = Sbom::default();
1733 let mut new = Sbom::default();
1734
1735 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1737 c1.ecosystem = Some("npm".into());
1738 old.components.insert(c1.id.clone(), c1);
1739
1740 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1742 c2.ecosystem = Some("npm".into());
1743 new.components.insert(c2.id.clone(), c2);
1744
1745 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1747 c3.ecosystem = Some("cargo".into());
1748 new.components.insert(c3.id.clone(), c3);
1749
1750 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1752 c4_old.ecosystem = Some("npm".into());
1753 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1754 c4_new.ecosystem = Some("npm".into());
1755 old.components.insert(c4_old.id.clone(), c4_old);
1756 new.components.insert(c4_new.id.clone(), c4_new);
1757
1758 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1760 new.components.insert(c5.id.clone(), c5);
1761
1762 let diff = Differ::diff(&old, &new, None);
1763 let breakdown = diff.ecosystem_breakdown();
1764
1765 let npm = breakdown.get("npm").unwrap();
1766 assert_eq!(npm.added, 1);
1767 assert_eq!(npm.removed, 1);
1768 assert_eq!(npm.changed, 1);
1769
1770 let cargo = breakdown.get("cargo").unwrap();
1771 assert_eq!(cargo.added, 1);
1772 assert_eq!(cargo.removed, 0);
1773 assert_eq!(cargo.changed, 0);
1774
1775 let unknown = breakdown.get("unknown").unwrap();
1776 assert_eq!(unknown.added, 1);
1777 assert_eq!(unknown.removed, 0);
1778 assert_eq!(unknown.changed, 0);
1779 }
1780
1781 #[test]
1782 fn test_ecosystem_breakdown_empty_diff() {
1783 let old = Sbom::default();
1784 let new = Sbom::default();
1785
1786 let diff = Differ::diff(&old, &new, None);
1787 assert!(diff.is_empty());
1788 assert!(diff.ecosystem_breakdown().is_empty());
1789 }
1790
1791 #[test]
1792 fn test_group_by_ecosystem_empty_diff() {
1793 let old = Sbom::default();
1794 let new = Sbom::default();
1795
1796 let diff = Differ::diff(&old, &new, None);
1797 let grouped = diff.group_by_ecosystem();
1798 assert!(grouped.by_ecosystem.is_empty());
1799 assert!(grouped.edge_diffs.is_empty());
1800 assert!(grouped.metadata_changed.is_none());
1801 assert!(grouped.ecosystem_breakdown().is_empty());
1802 }
1803
1804 #[test]
1805 fn test_group_by_ecosystem_groups_correctly() {
1806 let mut old = Sbom::default();
1807 let mut new = Sbom::default();
1808
1809 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1811 c1.ecosystem = Some("npm".into());
1812 old.components.insert(c1.id.clone(), c1);
1813
1814 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1816 c2.ecosystem = Some("npm".into());
1817 new.components.insert(c2.id.clone(), c2);
1818
1819 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1821 c3.ecosystem = Some("cargo".into());
1822 new.components.insert(c3.id.clone(), c3);
1823
1824 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1826 c4_old.ecosystem = Some("npm".into());
1827 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1828 c4_new.ecosystem = Some("npm".into());
1829 old.components.insert(c4_old.id.clone(), c4_old);
1830 new.components.insert(c4_new.id.clone(), c4_new);
1831
1832 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1834 new.components.insert(c5.id.clone(), c5);
1835
1836 let diff = Differ::diff(&old, &new, None);
1837 let grouped = diff.group_by_ecosystem();
1838
1839 let npm = grouped.by_ecosystem.get("npm").unwrap();
1840 assert_eq!(npm.added.len(), 1);
1841 assert_eq!(npm.removed.len(), 1);
1842 assert_eq!(npm.changed.len(), 1);
1843
1844 let cargo = grouped.by_ecosystem.get("cargo").unwrap();
1845 assert_eq!(cargo.added.len(), 1);
1846 assert_eq!(cargo.removed.len(), 0);
1847 assert_eq!(cargo.changed.len(), 0);
1848
1849 let unknown = grouped.by_ecosystem.get("unknown").unwrap();
1850 assert_eq!(unknown.added.len(), 1);
1851 assert_eq!(unknown.removed.len(), 0);
1852 assert_eq!(unknown.changed.len(), 0);
1853
1854 let grouped_counts = grouped.ecosystem_breakdown();
1856 let direct_counts = diff.ecosystem_breakdown();
1857 assert_eq!(grouped_counts, direct_counts);
1858 }
1859
1860 #[test]
1861 fn test_totals_no_changes() {
1862 let mut old = Sbom::default();
1863 let mut new = Sbom::default();
1864
1865 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1866 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1867
1868 old.components.insert(c1.id.clone(), c1.clone());
1869 old.components.insert(c2.id.clone(), c2.clone());
1870 new.components.insert(c1.id.clone(), c1);
1871 new.components.insert(c2.id.clone(), c2);
1872
1873 let diff = Differ::diff(&old, &new, None);
1874 assert_eq!(diff.old_total, 2);
1875 assert_eq!(diff.new_total, 2);
1876 assert_eq!(diff.unchanged, 2);
1877 }
1878
1879 #[test]
1880 fn test_totals_with_changes() {
1881 let mut old = Sbom::default();
1882 let mut new = Sbom::default();
1883
1884 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1885 let mut c1_updated = c1.clone();
1886 c1_updated.version = Some("1.1".to_string());
1887 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1888 let c3 = Component::new("pkg-c".to_string(), Some("3.0".to_string()));
1889 let c4 = Component::new("pkg-d".to_string(), Some("4.0".to_string()));
1890
1891 old.components.insert(c1.id.clone(), c1);
1892 old.components.insert(c2.id.clone(), c2.clone());
1893 old.components.insert(c3.id.clone(), c3);
1894 new.components.insert(c1_updated.id.clone(), c1_updated);
1895 new.components.insert(c2.id.clone(), c2);
1896 new.components.insert(c4.id.clone(), c4);
1897
1898 let diff = Differ::diff(&old, &new, None);
1899 assert_eq!(diff.old_total, 3);
1900 assert_eq!(diff.new_total, 3);
1901 assert_eq!(diff.added.len(), 1); assert_eq!(diff.removed.len(), 1); assert_eq!(diff.changed.len(), 1); assert_eq!(diff.unchanged, 1); }
1906
1907 #[test]
1908 fn test_component_names_for_hash_ids_in_edge_diffs() {
1909 let mut old = Sbom::default();
1910 let mut new = Sbom::default();
1911
1912 let parent = Component::new("my-app".to_string(), Some("1.0".to_string()));
1914 let child_a = Component::new("dep-old".to_string(), Some("0.1".to_string()));
1915 let child_b = Component::new("dep-new".to_string(), Some("0.2".to_string()));
1916
1917 old.components.insert(parent.id.clone(), parent.clone());
1918 old.components.insert(child_a.id.clone(), child_a.clone());
1919 new.components.insert(parent.id.clone(), parent.clone());
1920 new.components.insert(child_b.id.clone(), child_b.clone());
1921
1922 old.dependencies.insert(
1924 parent.id.clone(),
1925 BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1926 );
1927 new.dependencies.insert(
1928 parent.id.clone(),
1929 BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1930 );
1931
1932 let diff = Differ::diff(&old, &new, None);
1933
1934 assert!(diff.edge_diffs[0].parent.as_str().starts_with("h:"));
1936
1937 assert_eq!(diff.display_name(&diff.edge_diffs[0].parent), "my-app@1.0");
1939 for added in diff.edge_diffs[0].added.keys() {
1940 assert!(!diff.display_name(added).starts_with("h:"));
1941 }
1942 for removed in diff.edge_diffs[0].removed.keys() {
1943 assert!(!diff.display_name(removed).starts_with("h:"));
1944 }
1945 }
1946
1947 #[test]
1948 fn test_component_names_skips_purl_ids() {
1949 let mut old = Sbom::default();
1950 let mut new = Sbom::default();
1951
1952 let mut parent = Component::new("parent".to_string(), Some("1.0".to_string()));
1953 parent.purl = Some("pkg:npm/parent@1.0".to_string());
1954 parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
1955
1956 let mut child_a = Component::new("child-a".to_string(), Some("1.0".to_string()));
1957 child_a.purl = Some("pkg:npm/child-a@1.0".to_string());
1958 child_a.id = ComponentId::new(child_a.purl.as_deref(), &[]);
1959
1960 let mut child_b = Component::new("child-b".to_string(), Some("1.0".to_string()));
1961 child_b.purl = Some("pkg:npm/child-b@1.0".to_string());
1962 child_b.id = ComponentId::new(child_b.purl.as_deref(), &[]);
1963
1964 old.components.insert(parent.id.clone(), parent.clone());
1965 old.components.insert(child_a.id.clone(), child_a.clone());
1966 new.components.insert(parent.id.clone(), parent.clone());
1967 new.components.insert(child_b.id.clone(), child_b.clone());
1968
1969 old.dependencies.insert(
1970 parent.id.clone(),
1971 BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1972 );
1973 new.dependencies.insert(
1974 parent.id.clone(),
1975 BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1976 );
1977
1978 let diff = Differ::diff(&old, &new, None);
1979
1980 assert!(diff.component_names.is_empty());
1982
1983 assert!(diff
1985 .display_name(&diff.edge_diffs[0].parent)
1986 .starts_with("pkg:npm/parent@"));
1987 }
1988
1989 #[test]
1990 fn test_display_name_fallback() {
1991 let diff = Diff::default();
1992 let unknown_id = ComponentId::new(None, &[("name", "mystery")]);
1993 assert_eq!(diff.display_name(&unknown_id), unknown_id.as_str());
1995 }
1996
1997 #[test]
1998 fn test_filter_by_ecosystem_include() {
1999 let mut old = Sbom::default();
2000 let mut new = Sbom::default();
2001
2002 let mut npm1 = Component::new("express".into(), Some("4.18.0".into()));
2004 npm1.ecosystem = Some("npm".into());
2005 let mut npm2 = Component::new("lodash".into(), Some("4.17.21".into()));
2006 npm2.ecosystem = Some("npm".into());
2007
2008 let mut cargo1 = Component::new("serde".into(), Some("1.0.0".into()));
2010 cargo1.ecosystem = Some("cargo".into());
2011
2012 let mut pypi1 = Component::new("requests".into(), Some("2.28.0".into()));
2014 pypi1.ecosystem = Some("pypi".into());
2015
2016 old.components.insert(npm2.id.clone(), npm2.clone());
2017 old.components.insert(cargo1.id.clone(), cargo1.clone());
2018
2019 new.components.insert(npm1.id.clone(), npm1);
2020 new.components.insert(npm2.id.clone(), npm2);
2021 new.components.insert(pypi1.id.clone(), pypi1);
2022
2023 let mut diff = Differ::diff(&old, &new, None);
2028
2029 diff.filter_by_ecosystem(
2031 &|eco| eco == Some("npm"),
2032 1, 2, &BTreeMap::new(),
2035 );
2036
2037 assert_eq!(diff.added.len(), 1); assert_eq!(diff.added[0].name, "express");
2039 assert_eq!(diff.removed.len(), 0); assert_eq!(diff.changed.len(), 0);
2041 assert_eq!(diff.old_total, 1);
2042 assert_eq!(diff.new_total, 2);
2043 assert_eq!(diff.unchanged, 1); }
2045
2046 #[test]
2047 fn test_filter_by_ecosystem_exclude() {
2048 let mut old = Sbom::default();
2049 let mut new = Sbom::default();
2050
2051 let mut npm1 = Component::new("express".into(), Some("4.18.0".into()));
2052 npm1.ecosystem = Some("npm".into());
2053 let mut cargo1 = Component::new("serde".into(), Some("1.0.0".into()));
2054 cargo1.ecosystem = Some("cargo".into());
2055 let mut cargo2 = Component::new("tokio".into(), Some("1.0.0".into()));
2056 cargo2.ecosystem = Some("cargo".into());
2057
2058 old.components.insert(cargo1.id.clone(), cargo1.clone());
2059 new.components.insert(npm1.id.clone(), npm1);
2060 new.components.insert(cargo2.id.clone(), cargo2);
2061
2062 let mut diff = Differ::diff(&old, &new, None);
2064 diff.filter_by_ecosystem(
2065 &|eco| eco != Some("npm"),
2066 1, 1, &BTreeMap::new(),
2069 );
2070
2071 assert_eq!(diff.added.len(), 1); assert_eq!(diff.added[0].name, "tokio");
2073 assert_eq!(diff.removed.len(), 1); assert_eq!(diff.removed[0].name, "serde");
2075 }
2076
2077 #[test]
2078 fn test_filter_by_ecosystem_unknown() {
2079 let old = Sbom::default();
2081 let mut new = Sbom::default();
2082
2083 let no_eco = Component::new("mystery".into(), Some("1.0".into()));
2084 let mut npm = Component::new("express".into(), Some("4.18.0".into()));
2085 npm.ecosystem = Some("npm".into());
2086
2087 new.components.insert(no_eco.id.clone(), no_eco);
2088 new.components.insert(npm.id.clone(), npm);
2089
2090 let mut diff = Differ::diff(&old, &new, None);
2091
2092 diff.filter_by_ecosystem(
2094 &|eco| eco.is_none(),
2095 0,
2096 1, &BTreeMap::new(),
2098 );
2099
2100 assert_eq!(diff.added.len(), 1);
2101 assert_eq!(diff.added[0].name, "mystery");
2102 }
2103
2104 #[test]
2105 fn test_filter_by_ecosystem_changed_uses_new_ecosystem() {
2106 let mut old = Sbom::default();
2109 let mut new = Sbom::default();
2110
2111 let c_old = Component::new("pkg".into(), Some("1.0".into()));
2113 let mut c_new = Component::new("pkg".into(), Some("2.0".into()));
2115 c_new.ecosystem = Some("npm".into());
2116
2117 old.components.insert(c_old.id.clone(), c_old);
2118 new.components.insert(c_new.id.clone(), c_new);
2119
2120 let mut diff = Differ::diff(&old, &new, None);
2121 assert_eq!(diff.changed.len(), 1);
2122
2123 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 0, 1, &BTreeMap::new());
2125 assert_eq!(diff.changed.len(), 1);
2126
2127 let old2 = {
2129 let mut s = Sbom::default();
2130 let c = Component::new("pkg".into(), Some("1.0".into()));
2131 s.components.insert(c.id.clone(), c);
2132 s
2133 };
2134 let new2 = {
2135 let mut s = Sbom::default();
2136 let mut c = Component::new("pkg".into(), Some("2.0".into()));
2137 c.ecosystem = Some("npm".into());
2138 s.components.insert(c.id.clone(), c);
2139 s
2140 };
2141 let mut diff = Differ::diff(&old2, &new2, None);
2142 diff.filter_by_ecosystem(&|eco| eco == Some("cargo"), 0, 0, &BTreeMap::new());
2143 assert_eq!(diff.changed.len(), 0);
2144 }
2145
2146 #[test]
2147 fn test_filter_by_ecosystem_empty_diff() {
2148 let mut diff = Diff::default();
2149 diff.filter_by_ecosystem(&|_| true, 0, 0, &BTreeMap::new());
2150 assert!(diff.is_empty());
2151 }
2152
2153 #[test]
2154 fn test_filter_by_ecosystem_totals_adjusted() {
2155 let mut old = Sbom::default();
2156 let mut new = Sbom::default();
2157
2158 let mut n1 = Component::new("a".into(), Some("1".into()));
2160 n1.ecosystem = Some("npm".into());
2161 let mut n2 = Component::new("b".into(), Some("1".into()));
2162 n2.ecosystem = Some("npm".into());
2163 let mut c1 = Component::new("c".into(), Some("1".into()));
2164 c1.ecosystem = Some("cargo".into());
2165
2166 old.components.insert(n1.id.clone(), n1.clone());
2167 old.components.insert(n2.id.clone(), n2.clone());
2168 old.components.insert(c1.id.clone(), c1);
2169
2170 new.components.insert(n1.id.clone(), n1);
2172 new.components.insert(n2.id.clone(), n2);
2173
2174 let mut diff = Differ::diff(&old, &new, None);
2175 assert_eq!(diff.old_total, 3);
2176 assert_eq!(diff.new_total, 2);
2177
2178 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 2, 2, &BTreeMap::new());
2180
2181 assert_eq!(diff.old_total, 2);
2182 assert_eq!(diff.new_total, 2);
2183 assert_eq!(diff.unchanged, 2);
2184 assert_eq!(diff.added.len(), 0);
2185 assert_eq!(diff.removed.len(), 0);
2186 assert_eq!(diff.changed.len(), 0);
2187 }
2188
2189 #[test]
2190 fn test_filter_by_ecosystem_matched_pair_changes_ecosystem() {
2191 let mut old = Sbom::default();
2197 let mut new = Sbom::default();
2198
2199 let mut mig_old = Component::new("migrator".into(), Some("1.0".into()));
2201 mig_old.ecosystem = Some("npm".into());
2202 let mut mig_new = Component::new("migrator".into(), Some("1.0".into()));
2203 mig_new.ecosystem = None; assert_eq!(mig_old.id, mig_new.id, "same name+version share a hash id");
2205
2206 let mut lp = Component::new("left-pad".into(), Some("2.0".into()));
2208 lp.ecosystem = Some("npm".into());
2209
2210 old.components.insert(mig_old.id.clone(), mig_old);
2211 old.components.insert(lp.id.clone(), lp.clone());
2212 new.components.insert(mig_new.id.clone(), mig_new);
2213 new.components.insert(lp.id.clone(), lp);
2214
2215 let mut diff = Differ::diff(&old, &new, None);
2216 assert_eq!(diff.changed.len(), 1); assert_eq!(diff.unchanged, 1); diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 2, 1, &BTreeMap::new());
2222
2223 assert_eq!(diff.changed.len(), 0); assert_eq!(diff.added.len(), 0);
2225 assert_eq!(diff.removed.len(), 0);
2226 assert_eq!(diff.unchanged, 1); assert_eq!(diff.new_total, 1);
2228 assert!(
2230 diff.unchanged <= diff.new_total,
2231 "unchanged ({}) exceeds new_total ({})",
2232 diff.unchanged,
2233 diff.new_total
2234 );
2235 }
2236
2237 #[test]
2238 fn test_filter_by_ecosystem_no_ecosystem_change_unaffected() {
2239 let mut old = Sbom::default();
2242 let mut new = Sbom::default();
2243
2244 let mut mig_old = Component::new("migrator".into(), Some("1.0".into()));
2246 mig_old.ecosystem = Some("npm".into());
2247 let mut mig_new = Component::new("migrator".into(), Some("1.1".into()));
2248 mig_new.ecosystem = Some("npm".into());
2249
2250 let mut lp = Component::new("left-pad".into(), Some("2.0".into()));
2252 lp.ecosystem = Some("npm".into());
2253
2254 old.components.insert(mig_old.id.clone(), mig_old);
2255 old.components.insert(lp.id.clone(), lp.clone());
2256 new.components.insert(mig_new.id.clone(), mig_new);
2257 new.components.insert(lp.id.clone(), lp);
2258
2259 let mut diff = Differ::diff(&old, &new, None);
2260 assert_eq!(diff.changed.len(), 1);
2261
2262 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 2, 2, &BTreeMap::new());
2264
2265 assert_eq!(diff.changed.len(), 1);
2266 assert_eq!(diff.unchanged, 1); assert_eq!(diff.new_total, 2);
2268 assert!(diff.unchanged <= diff.new_total);
2269 }
2270
2271 #[test]
2272 fn test_filter_by_ecosystem_filters_edge_diffs() {
2273 let mut old = Sbom::default();
2274 let mut new = Sbom::default();
2275
2276 let mut npm_parent = Component::new("npm-app".into(), Some("1.0".into()));
2278 npm_parent.ecosystem = Some("npm".into());
2279 let npm_child_old = Component::new("npm-dep-old".into(), Some("1.0".into()));
2280 let npm_child_new = Component::new("npm-dep-new".into(), Some("1.0".into()));
2281
2282 let mut cargo_parent = Component::new("cargo-app".into(), Some("1.0".into()));
2284 cargo_parent.ecosystem = Some("cargo".into());
2285 let cargo_child = Component::new("cargo-dep".into(), Some("1.0".into()));
2286
2287 old.components
2289 .insert(npm_parent.id.clone(), npm_parent.clone());
2290 old.components
2291 .insert(npm_child_old.id.clone(), npm_child_old.clone());
2292 old.components
2293 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2294
2295 new.components
2297 .insert(npm_parent.id.clone(), npm_parent.clone());
2298 new.components
2299 .insert(npm_child_new.id.clone(), npm_child_new.clone());
2300 new.components
2301 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2302 new.components
2303 .insert(cargo_child.id.clone(), cargo_child.clone());
2304
2305 old.dependencies.insert(
2306 npm_parent.id.clone(),
2307 BTreeMap::from([(npm_child_old.id.clone(), DependencyKind::Runtime)]),
2308 );
2309 new.dependencies.insert(
2310 npm_parent.id.clone(),
2311 BTreeMap::from([(npm_child_new.id.clone(), DependencyKind::Runtime)]),
2312 );
2313 new.dependencies.insert(
2314 cargo_parent.id.clone(),
2315 BTreeMap::from([(cargo_child.id.clone(), DependencyKind::Runtime)]),
2316 );
2317
2318 let mut eco_map: BTreeMap<ComponentId, Option<String>> = BTreeMap::new();
2320 for (id, comp) in old.components.iter().chain(new.components.iter()) {
2321 eco_map.insert(id.clone(), comp.ecosystem.clone());
2322 }
2323
2324 let mut diff = Differ::diff(&old, &new, None);
2325 assert!(diff.edge_diffs.len() >= 2);
2327
2328 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 1, 1, &eco_map);
2330
2331 assert_eq!(diff.edge_diffs.len(), 1);
2333 assert_eq!(diff.edge_diffs[0].parent, npm_parent.id);
2334 }
2335
2336 #[test]
2337 fn test_filter_by_ecosystem_prunes_component_names() {
2338 let mut old = Sbom::default();
2339 let mut new = Sbom::default();
2340
2341 let mut npm_parent = Component::new("npm-app".into(), Some("1.0".into()));
2343 npm_parent.ecosystem = Some("npm".into());
2344 let npm_child = Component::new("npm-dep".into(), Some("1.0".into()));
2345
2346 let mut cargo_parent = Component::new("cargo-app".into(), Some("1.0".into()));
2348 cargo_parent.ecosystem = Some("cargo".into());
2349 let cargo_child = Component::new("cargo-dep".into(), Some("1.0".into()));
2350
2351 old.components
2352 .insert(npm_parent.id.clone(), npm_parent.clone());
2353 old.components
2354 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2355
2356 new.components
2357 .insert(npm_parent.id.clone(), npm_parent.clone());
2358 new.components
2359 .insert(npm_child.id.clone(), npm_child.clone());
2360 new.components
2361 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2362 new.components
2363 .insert(cargo_child.id.clone(), cargo_child.clone());
2364
2365 new.dependencies.insert(
2366 npm_parent.id.clone(),
2367 BTreeMap::from([(npm_child.id.clone(), DependencyKind::Runtime)]),
2368 );
2369 new.dependencies.insert(
2370 cargo_parent.id.clone(),
2371 BTreeMap::from([(cargo_child.id.clone(), DependencyKind::Runtime)]),
2372 );
2373
2374 let mut eco_map: BTreeMap<ComponentId, Option<String>> = BTreeMap::new();
2375 for (id, comp) in old.components.iter().chain(new.components.iter()) {
2376 eco_map.insert(id.clone(), comp.ecosystem.clone());
2377 }
2378
2379 let mut diff = Differ::diff(&old, &new, None);
2380 let names_before = diff.component_names.len();
2381 assert!(names_before > 0, "should have component names for hash IDs");
2382
2383 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 1, 1, &eco_map);
2384
2385 assert!(diff.component_names.len() <= names_before);
2387 for id in diff.component_names.keys() {
2388 let referenced = diff.edge_diffs.iter().any(|e| {
2390 &e.parent == id
2391 || e.added.contains_key(id)
2392 || e.removed.contains_key(id)
2393 || e.kind_changed.contains_key(id)
2394 });
2395 assert!(referenced, "stale component_name entry for {}", id);
2396 }
2397 }
2398
2399 #[test]
2400 fn test_diff_owned_identity() {
2401 let mut sbom = Sbom::default();
2402
2403 let mut parent = Component::new("my-app".to_string(), Some("2.0.0".to_string()));
2405 parent.purl = Some("pkg:cargo/my-app@2.0.0".to_string());
2406 parent.ecosystem = Some("cargo".to_string());
2407 parent.licenses.insert("MIT".into());
2408 parent.supplier = Some("Acme Corp".into());
2409 parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
2410
2411 let mut dep_a = Component::new("dep-a".to_string(), Some("1.0.0".to_string()));
2412 dep_a.purl = Some("pkg:cargo/dep-a@1.0.0".to_string());
2413 dep_a.ecosystem = Some("cargo".to_string());
2414 dep_a.licenses.insert("Apache-2.0".into());
2415 dep_a
2416 .hashes
2417 .insert("sha256".into(), "abcdef1234567890".into());
2418 dep_a.id = ComponentId::new(dep_a.purl.as_deref(), &[]);
2419
2420 let mut dep_b = Component::new("dep-b".to_string(), Some("0.5.0".to_string()));
2421 dep_b.ecosystem = Some("cargo".to_string());
2422 dep_b.description = Some("A helper library".into());
2423
2424 sbom.components.insert(parent.id.clone(), parent.clone());
2425 sbom.components.insert(dep_a.id.clone(), dep_a.clone());
2426 sbom.components.insert(dep_b.id.clone(), dep_b.clone());
2427
2428 sbom.dependencies
2430 .entry(parent.id.clone())
2431 .or_default()
2432 .insert(dep_a.id.clone(), DependencyKind::Runtime);
2433 sbom.dependencies
2434 .entry(parent.id.clone())
2435 .or_default()
2436 .insert(dep_b.id.clone(), DependencyKind::Dev);
2437
2438 let copy = sbom.clone();
2439 let diff = Differ::diff_owned(sbom, copy, None);
2440
2441 assert_eq!(
2442 diff.added.len(),
2443 0,
2444 "identical SBOMs should have no added components"
2445 );
2446 assert_eq!(
2447 diff.removed.len(),
2448 0,
2449 "identical SBOMs should have no removed components"
2450 );
2451 assert_eq!(
2452 diff.changed.len(),
2453 0,
2454 "identical SBOMs should have no changed components"
2455 );
2456 assert_eq!(
2457 diff.edge_diffs.len(),
2458 0,
2459 "identical SBOMs should have no edge diffs"
2460 );
2461 assert_eq!(
2462 diff.metadata_changed, None,
2463 "identical SBOMs should have no metadata changes"
2464 );
2465 assert_eq!(diff.old_total, 3);
2466 assert_eq!(diff.new_total, 3);
2467 assert_eq!(diff.unchanged, 3);
2468 }
2469
2470 #[test]
2471 fn test_diff_detects_version_downgrade() {
2472 let mut old = Sbom::default();
2473 let mut new = Sbom::default();
2474
2475 let c1 = Component::new("pkg-a".to_string(), Some("2.0.0".to_string()));
2476 let mut c2 = c1.clone();
2477 c2.version = Some("1.0.0".to_string());
2478
2479 old.components.insert(c1.id.clone(), c1);
2480 new.components.insert(c2.id.clone(), c2);
2481
2482 let diff = Differ::diff(&old, &new, None);
2483 assert_eq!(diff.changed.len(), 1);
2484 assert!(diff.changed[0].is_downgrade);
2485 }
2486
2487 #[test]
2488 fn test_diff_upgrade_not_marked_as_downgrade() {
2489 let mut old = Sbom::default();
2490 let mut new = Sbom::default();
2491
2492 let c1 = Component::new("pkg-a".to_string(), Some("1.0.0".to_string()));
2493 let mut c2 = c1.clone();
2494 c2.version = Some("2.0.0".to_string());
2495
2496 old.components.insert(c1.id.clone(), c1);
2497 new.components.insert(c2.id.clone(), c2);
2498
2499 let diff = Differ::diff(&old, &new, None);
2500 assert_eq!(diff.changed.len(), 1);
2501 assert!(!diff.changed[0].is_downgrade);
2502 }
2503
2504 #[test]
2505 fn deb_revision_upgrade_is_not_a_downgrade() {
2506 for (old_ver, new_ver) in [
2507 ("1.2.3-1ubuntu2", "1.2.3-2"),
2508 ("1.2.3-1build1", "1.2.3-2"),
2509 ("1.2.3-1+deb11u1", "1.2.3-1+deb11u2"),
2510 ("1.0~rc1", "1.0"),
2511 ("1.0", "1.0-1"),
2512 ] {
2513 let old = sbom_of(vec![deb_component("libfoo", old_ver)]);
2514 let new = sbom_of(vec![deb_component("libfoo", new_ver)]);
2515
2516 let diff = Differ::diff(&old, &new, None);
2517 assert_eq!(diff.changed.len(), 1, "{old_ver} -> {new_ver}");
2518 assert!(
2519 !diff.changed[0].is_downgrade,
2520 "{old_ver} -> {new_ver} is an upgrade per dpkg"
2521 );
2522 }
2523 }
2524
2525 #[test]
2526 fn deb_revision_downgrade_is_still_flagged() {
2527 for (old_ver, new_ver) in [
2528 ("1.2.3-2", "1.2.3-1ubuntu2"),
2529 ("1.0", "1.0~rc1"),
2530 ("2:1.0-1", "1:9.0-1"),
2531 ] {
2532 let old = sbom_of(vec![deb_component("libfoo", old_ver)]);
2533 let new = sbom_of(vec![deb_component("libfoo", new_ver)]);
2534
2535 let diff = Differ::diff(&old, &new, None);
2536 assert_eq!(diff.changed.len(), 1, "{old_ver} -> {new_ver}");
2537 assert!(
2538 diff.changed[0].is_downgrade,
2539 "{old_ver} -> {new_ver} is a downgrade per dpkg"
2540 );
2541 }
2542 }
2543
2544 #[test]
2545 fn npm_prerelease_downgrade_is_unaffected() {
2546 let old = sbom_of(vec![npm_component("libfoo", "1.0.0")]);
2547 let new = sbom_of(vec![npm_component("libfoo", "1.0.0-alpha.1")]);
2548
2549 let diff = Differ::diff(&old, &new, None);
2550 assert_eq!(diff.changed.len(), 1);
2551 assert!(diff.changed[0].is_downgrade);
2552 }
2553
2554 #[test]
2555 fn deb_versions_align_by_dpkg_order() {
2556 let versions =
2557 |vs: [&str; 2]| sbom_of(vs.iter().map(|v| deb_component("libfoo", v)).collect());
2558 let old = versions(["1.2.3-1ubuntu2", "2.0.0-1"]);
2559 let new = versions(["1.2.3-2", "2.0.1-1"]);
2560
2561 let diff = Differ::diff(&old, &new, None);
2562 assert_eq!(
2563 version_pairs(&diff),
2564 expect_pairs(&[("1.2.3-1ubuntu2", "1.2.3-2"), ("2.0.0-1", "2.0.1-1")])
2565 );
2566 assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2567 }
2568
2569 #[test]
2570 fn pair_ecosystem_needs_the_two_sides_to_agree() {
2571 let deb = deb_component("libfoo", "1.0");
2572 let npm = npm_component("libfoo", "1.0");
2573 let plain = plain_component("libfoo", "1.0");
2574
2575 assert_eq!(pair_ecosystem(&deb, &deb), Some("deb"));
2576 assert_eq!(pair_ecosystem(&deb, &plain), Some("deb"));
2577 assert_eq!(pair_ecosystem(&plain, &deb), Some("deb"));
2578 assert_eq!(pair_ecosystem(&deb, &npm), None);
2579 assert_eq!(pair_ecosystem(&plain, &plain), None);
2580 }
2581
2582 #[test]
2583 fn a_pair_whose_ecosystem_changed_keeps_the_string_only_reading() {
2584 let mut old = npm_component("libfoo", "1.2.3-1ubuntu2");
2585 let mut new = deb_component("libfoo", "1.2.3-2");
2586 old.id = ComponentId::new(None, &[("name", "libfoo")]);
2587 new.id = old.id.clone();
2588
2589 let diff = Differ::diff(&sbom_of(vec![old]), &sbom_of(vec![new]), None);
2590 assert_eq!(diff.changed.len(), 1);
2591 assert!(diff.changed[0].is_downgrade);
2592 }
2593
2594 fn deb_component(name: &str, version: &str) -> Component {
2595 purl_component("deb", name, version)
2596 }
2597
2598 fn purl_component(ecosystem: &str, name: &str, version: &str) -> Component {
2599 let purl = format!("pkg:{ecosystem}/{name}@{version}");
2600 let mut comp = Component::new(name.to_string(), Some(version.to_string()));
2601 comp.ecosystem = Some(ecosystem.to_string());
2602 comp.id = ComponentId::new(Some(&purl), &[]);
2603 comp.purl = Some(purl);
2604 comp
2605 }
2606
2607 fn npm_component(name: &str, version: &str) -> Component {
2608 purl_component("npm", name, version)
2609 }
2610
2611 fn plain_component(name: &str, version: &str) -> Component {
2612 Component::new(name.to_string(), Some(version.to_string()))
2613 }
2614
2615 fn sbom_of(components: Vec<Component>) -> Sbom {
2616 let mut sbom = Sbom::default();
2617 for comp in components {
2618 sbom.components.insert(comp.id.clone(), comp);
2619 }
2620 sbom
2621 }
2622
2623 fn version_pairs(diff: &Diff) -> Vec<(String, String)> {
2625 let mut pairs: Vec<(String, String)> = diff
2626 .changed
2627 .iter()
2628 .map(|c| {
2629 (
2630 c.old.version.clone().unwrap_or_default(),
2631 c.new.version.clone().unwrap_or_default(),
2632 )
2633 })
2634 .collect();
2635 pairs.sort();
2636 pairs
2637 }
2638
2639 fn expect_pairs(expected: &[(&str, &str)]) -> Vec<(String, String)> {
2640 let mut pairs: Vec<(String, String)> = expected
2641 .iter()
2642 .map(|(o, n)| (o.to_string(), n.to_string()))
2643 .collect();
2644 pairs.sort();
2645 pairs
2646 }
2647
2648 #[test]
2649 fn test_identity_reconciliation_pairs_same_version_line() {
2650 for (old_versions, new_versions, expected) in [
2651 (
2652 ["9.0.0", "10.0.0"],
2653 ["9.0.1", "10.0.1"],
2654 [("9.0.0", "9.0.1"), ("10.0.0", "10.0.1")],
2655 ),
2656 (
2657 ["1.0.0", "2.0.0"],
2658 ["1.1.0", "2.1.0"],
2659 [("1.0.0", "1.1.0"), ("2.0.0", "2.1.0")],
2660 ),
2661 ] {
2662 let old = sbom_of(
2663 old_versions
2664 .iter()
2665 .map(|v| npm_component("libfoo", v))
2666 .collect(),
2667 );
2668 let new = sbom_of(
2669 new_versions
2670 .iter()
2671 .map(|v| npm_component("libfoo", v))
2672 .collect(),
2673 );
2674
2675 let diff = Differ::diff(&old, &new, None);
2676 assert_eq!(version_pairs(&diff), expect_pairs(&expected));
2677 assert_eq!(diff.added.len(), 0);
2678 assert_eq!(diff.removed.len(), 0);
2679 assert!(
2680 !diff.changed.iter().any(|c| c.is_downgrade),
2681 "upgrading both version lines must not report a downgrade, got {:?}",
2682 version_pairs(&diff)
2683 );
2684 }
2685 }
2686
2687 #[test]
2688 fn test_identity_reconciliation_without_purls_pairs_same_version_line() {
2689 let versions = |vs: [&str; 3]| {
2690 sbom_of(
2691 vs.iter()
2692 .map(|v| Component::new("libfoo".to_string(), Some(v.to_string())))
2693 .collect(),
2694 )
2695 };
2696 let old = versions(["1.0.0", "2.0.0", "3.0.0"]);
2697 let new = versions(["1.0.1", "2.0.1", "3.0.1"]);
2698
2699 let diff = Differ::diff(&old, &new, None);
2700 assert_eq!(
2701 version_pairs(&diff),
2702 expect_pairs(&[("1.0.0", "1.0.1"), ("2.0.0", "2.0.1"), ("3.0.0", "3.0.1")])
2703 );
2704 assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2705 }
2706
2707 #[test]
2708 fn test_identity_reconciliation_more_old_than_new() {
2709 for (survivor, expected) in [("3.0.1", ("3.0.0", "3.0.1")), ("1.0.1", ("1.0.0", "1.0.1"))] {
2710 let old = sbom_of(
2711 ["1.0.0", "2.0.0", "3.0.0"]
2712 .iter()
2713 .map(|v| npm_component("libfoo", v))
2714 .collect(),
2715 );
2716 let new = sbom_of(vec![npm_component("libfoo", survivor)]);
2717
2718 let diff = Differ::diff(&old, &new, None);
2719 assert_eq!(version_pairs(&diff), expect_pairs(&[expected]));
2720 assert_eq!(diff.removed.len(), 2);
2721 assert_eq!(diff.added.len(), 0);
2722 assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2723 }
2724 }
2725
2726 #[test]
2727 fn test_identity_reconciliation_more_new_than_old() {
2728 for (survivor, others, expected) in [
2729 ("1.0.0", ["1.0.1", "2.0.0", "3.0.0"], ("1.0.0", "1.0.1")),
2730 ("3.0.0", ["1.0.0", "2.0.0", "3.0.1"], ("3.0.0", "3.0.1")),
2731 ] {
2732 let old = sbom_of(vec![npm_component("libfoo", survivor)]);
2733 let new = sbom_of(others.iter().map(|v| npm_component("libfoo", v)).collect());
2734
2735 let diff = Differ::diff(&old, &new, None);
2736 assert_eq!(version_pairs(&diff), expect_pairs(&[expected]));
2737 assert_eq!(diff.added.len(), 2);
2738 assert_eq!(diff.removed.len(), 0);
2739 assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2740 }
2741 }
2742
2743 #[test]
2744 fn test_identity_reconciliation_opaque_versions_stay_deterministic() {
2745 let old = sbom_of(vec![
2746 npm_component("libfoo", "nightly-zeta"),
2747 npm_component("libfoo", "nightly-alpha"),
2748 ]);
2749 let new = sbom_of(vec![
2750 npm_component("libfoo", "nightly-omega"),
2751 npm_component("libfoo", "nightly-beta"),
2752 ]);
2753
2754 let diff = Differ::diff(&old, &new, None);
2755 assert_eq!(
2756 version_pairs(&diff),
2757 expect_pairs(&[
2758 ("nightly-zeta", "nightly-beta"),
2759 ("nightly-alpha", "nightly-omega"),
2760 ])
2761 );
2762 assert_eq!(diff.added.len(), 0);
2763 assert_eq!(diff.removed.len(), 0);
2764 }
2765
2766 #[test]
2769 fn test_identity_reconciliation_mixed_version_variants_do_not_panic() {
2770 let deb_and_semver = |new: bool| -> Vec<Component> {
2771 (0..14)
2772 .map(|k| {
2773 let (i, suffix) = (2 * k + 1, u8::from(new));
2774 let version = if k % 2 == 0 {
2775 format!("{i}.0.{suffix}")
2776 } else {
2777 format!("{i}:{}.0-{suffix}", i + 1)
2778 };
2779 npm_component("libfoo", &version)
2780 })
2781 .collect()
2782 };
2783 let opaque_and_semver = |new: bool| -> Vec<Component> {
2784 (0..14)
2785 .map(|k| {
2786 let (i, side) = (2 * k + 1, if new { 'b' } else { 'a' });
2787 let version = if k % 2 == 0 {
2788 format!("{i}.0.{}", u8::from(new))
2789 } else {
2790 format!("{side}{i:07x}deadbeef")
2791 };
2792 npm_component("libfoo", &version)
2793 })
2794 .collect()
2795 };
2796
2797 let builders: [&dyn Fn(bool) -> Vec<Component>; 2] = [&deb_and_semver, &opaque_and_semver];
2798 for build in builders {
2799 let old = sbom_of(build(false));
2800 let new = sbom_of(build(true));
2801
2802 let diff = Differ::diff(&old, &new, None);
2803 assert_eq!(diff.changed.len(), 14);
2804 assert_eq!(diff.added.len(), 0);
2805 assert_eq!(diff.removed.len(), 0);
2806 }
2807 }
2808
2809 #[test]
2812 fn test_identity_reconciliation_prerelease_with_numeric_does_not_panic() {
2813 for groups in [12, 26] {
2814 let old = sbom_of(
2815 (1..=groups)
2816 .flat_map(|i| {
2817 [
2818 npm_component("libfoo", &format!("{i}.2.3")),
2819 npm_component("libfoo", &format!("{i}.2.3.0")),
2820 ]
2821 })
2822 .collect(),
2823 );
2824 let new = sbom_of(
2825 (1..=groups)
2826 .map(|i| npm_component("libfoo", &format!("{i}.2.3-rc.1")))
2827 .collect(),
2828 );
2829
2830 let diff = Differ::diff(&old, &new, None);
2831 assert_eq!(diff.changed.len(), groups);
2832 assert_eq!(diff.added.len(), 0);
2833 assert_eq!(diff.removed.len(), groups);
2834 }
2835 }
2836
2837 #[test]
2838 fn test_identity_reconciliation_ignores_insertion_order() {
2839 for versions in [["9.0.0", "10.0.0"], ["nightly-zeta", "nightly-alpha"]] {
2840 let forward = Differ::diff(
2841 &sbom_of(vec![
2842 npm_component("libfoo", versions[0]),
2843 npm_component("libfoo", versions[1]),
2844 ]),
2845 &sbom_of(vec![
2846 npm_component("libfoo", "4.0.0"),
2847 npm_component("libfoo", "5.0.0"),
2848 ]),
2849 None,
2850 );
2851 let reversed = Differ::diff(
2852 &sbom_of(vec![
2853 npm_component("libfoo", versions[1]),
2854 npm_component("libfoo", versions[0]),
2855 ]),
2856 &sbom_of(vec![
2857 npm_component("libfoo", "5.0.0"),
2858 npm_component("libfoo", "4.0.0"),
2859 ]),
2860 None,
2861 );
2862 assert_eq!(version_pairs(&forward), version_pairs(&reversed));
2863 }
2864 }
2865
2866 #[test]
2867 fn test_edge_diff_with_two_versions_of_one_package() {
2868 let child_a = Component::new("child-a".to_string(), Some("1.0.0".to_string()));
2869 let child_b = Component::new("child-b".to_string(), Some("1.0.0".to_string()));
2870
2871 let build = |parent_versions: [&str; 2]| {
2872 let parents = parent_versions.map(|v| npm_component("libfoo", v));
2873 let mut sbom = sbom_of(vec![
2874 parents[0].clone(),
2875 parents[1].clone(),
2876 child_a.clone(),
2877 child_b.clone(),
2878 ]);
2879 for (parent, child) in parents.iter().zip([&child_a, &child_b]) {
2880 sbom.dependencies
2881 .entry(parent.id.clone())
2882 .or_default()
2883 .insert(child.id.clone(), DependencyKind::Runtime);
2884 }
2885 sbom
2886 };
2887
2888 let diff = Differ::diff(&build(["1.0.0", "2.0.0"]), &build(["1.0.1", "2.0.1"]), None);
2889 assert_eq!(
2890 diff.edge_diffs.len(),
2891 0,
2892 "both parents kept their dependency, got {:?}",
2893 diff.edge_diffs
2894 );
2895 }
2896
2897 #[test]
2900 fn test_wildcard_reconciliation_pairs_same_version_line() {
2901 for (old_comps, new_comps) in [
2902 (
2903 vec![
2904 npm_component("libfoo", "1.0.0"),
2905 npm_component("libfoo", "2.0.0"),
2906 ],
2907 vec![
2908 plain_component("libfoo", "1.0.0"),
2909 plain_component("libfoo", "2.0.0"),
2910 ],
2911 ),
2912 (
2913 vec![
2914 plain_component("libfoo", "1.0.0"),
2915 plain_component("libfoo", "2.0.0"),
2916 ],
2917 vec![
2918 npm_component("libfoo", "1.0.0"),
2919 npm_component("libfoo", "2.0.0"),
2920 ],
2921 ),
2922 ] {
2923 let diff = Differ::diff(&sbom_of(old_comps), &sbom_of(new_comps), None);
2924 assert_eq!(
2925 version_pairs(&diff),
2926 expect_pairs(&[("1.0.0", "1.0.0"), ("2.0.0", "2.0.0")])
2927 );
2928 assert_eq!(diff.added.len(), 0);
2929 assert_eq!(diff.removed.len(), 0);
2930 assert!(
2931 !diff.changed.iter().any(|c| c.is_downgrade),
2932 "re-serializing the same versions must not report a downgrade, got {:?}",
2933 version_pairs(&diff)
2934 );
2935 }
2936 }
2937
2938 #[test]
2939 fn test_wildcard_reconciliation_prefers_version_nearest_candidate() {
2940 let old = sbom_of(vec![
2941 npm_component("libfoo", "2.0.0"),
2942 purl_component("pypi", "libfoo", "1.0.0"),
2943 ]);
2944 let new = sbom_of(vec![plain_component("libfoo", "1.0.1")]);
2945
2946 let diff = Differ::diff(&old, &new, None);
2947 assert_eq!(version_pairs(&diff), expect_pairs(&[("1.0.0", "1.0.1")]));
2948 assert_eq!(diff.removed.len(), 1);
2949 assert_eq!(diff.added.len(), 0);
2950 assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2951 }
2952
2953 #[test]
2954 fn test_wildcard_reconciliation_breaks_version_ties_deterministically() {
2955 let old = sbom_of(
2956 ["cargo", "npm", "pypi"]
2957 .iter()
2958 .map(|eco| purl_component(eco, "libfoo", "1.0.0"))
2959 .collect(),
2960 );
2961 let new = sbom_of(vec![plain_component("libfoo", "1.0.1")]);
2962
2963 let diff = Differ::diff(&old, &new, None);
2964 assert_eq!(diff.changed.len(), 1);
2965 assert_eq!(
2966 diff.changed[0].old.id.as_str(),
2967 "pkg:pypi/libfoo@1.0.0",
2968 "equal versions must resolve by merged version order, not ecosystem name"
2969 );
2970 assert_eq!(diff.removed.len(), 2);
2971 assert_eq!(diff.added.len(), 0);
2972 }
2973
2974 #[test]
2975 fn test_exact_ecosystem_match_beats_a_nearer_wildcard() {
2976 let old = sbom_of(vec![
2977 npm_component("libfoo", "1.0.0"),
2978 plain_component("libfoo", "2.0.0"),
2979 ]);
2980 let new = sbom_of(vec![npm_component("libfoo", "2.0.1")]);
2981
2982 let diff = Differ::diff(&old, &new, None);
2983 assert_eq!(version_pairs(&diff), expect_pairs(&[("1.0.0", "2.0.1")]));
2984 assert_eq!(diff.removed.len(), 1);
2985 assert_eq!(diff.added.len(), 0);
2986 }
2987}