1#![doc = include_str!("../readme.md")]
2
3use sbom_model::versions::{is_version_downgrade, Version};
4use sbom_model::{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
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct EdgeDiff {
301 pub parent: ComponentId,
303 pub added: BTreeMap<ComponentId, DependencyKind>,
305 pub removed: BTreeMap<ComponentId, DependencyKind>,
307 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
309 pub kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)>,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
314pub enum FieldChange {
315 Version(Option<String>, Option<String>),
317 License(BTreeSet<String>, BTreeSet<String>),
319 Supplier(Option<String>, Option<String>),
321 Purl(Option<String>, Option<String>),
323 Description(Option<String>, Option<String>),
325 Hashes(BTreeMap<String, String>, BTreeMap<String, String>),
327 Ecosystem(Option<String>, Option<String>),
329}
330
331#[derive(Debug, Copy, Clone, PartialEq, Eq, clap::ValueEnum)]
335pub enum Field {
336 Version,
338 License,
340 Supplier,
342 Purl,
344 Description,
346 Hashes,
348 Ecosystem,
350 Deps,
352}
353
354const MAX_ALIGNED_CANDIDATES: usize = 256;
357
358pub struct Differ;
363
364impl Differ {
365 pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
394 Self::diff_owned(old.clone(), new.clone(), only)
395 }
396
397 pub fn diff_owned(mut old: Sbom, mut new: Sbom, only: Option<&[Field]>) -> Diff {
400 let metadata_changed = {
402 let mut mc = MetadataChange {
403 timestamp: None,
404 tools: None,
405 authors: None,
406 };
407 if old.metadata.timestamp != new.metadata.timestamp {
408 mc.timestamp = Some((
409 old.metadata.timestamp.clone(),
410 new.metadata.timestamp.clone(),
411 ));
412 }
413 if old.metadata.tools != new.metadata.tools {
414 mc.tools = Some((old.metadata.tools.clone(), new.metadata.tools.clone()));
415 }
416 if old.metadata.authors != new.metadata.authors {
417 mc.authors = Some((old.metadata.authors.clone(), new.metadata.authors.clone()));
418 }
419 if mc.is_empty() {
420 None
421 } else {
422 Some(mc)
423 }
424 };
425
426 old.normalize();
427 new.normalize();
428
429 let mut changed_pairs: Vec<(ComponentId, ComponentId, Vec<FieldChange>)> = Vec::new();
433 let mut matched_old: HashSet<ComponentId> = HashSet::new();
434 let mut matched_new: HashSet<ComponentId> = HashSet::new();
435
436 let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
438
439 for (id, new_comp) in &new.components {
441 if let Some(old_comp) = old.components.get(id) {
442 matched_old.insert(id.clone());
443 matched_new.insert(id.clone());
444 id_mapping.insert(id.clone(), id.clone());
445
446 let fields = Self::compute_fields(old_comp, new_comp, only);
447 if !fields.is_empty() {
448 changed_pairs.push((id.clone(), id.clone(), fields));
449 }
450 }
451 }
452
453 let mut old_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
462 BTreeMap::new();
463 for (id, comp) in &old.components {
464 if !matched_old.contains(id) {
465 old_identity_map
466 .entry(comp.name.clone())
467 .or_default()
468 .entry(comp.ecosystem.clone())
469 .or_default()
470 .push(id.clone());
471 }
472 }
473 let mut new_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
474 BTreeMap::new();
475 for (id, comp) in &new.components {
476 if !matched_new.contains(id) {
477 new_identity_map
478 .entry(comp.name.clone())
479 .or_default()
480 .entry(comp.ecosystem.clone())
481 .or_default()
482 .push(id.clone());
483 }
484 }
485
486 let mut identity_pairs: Vec<(ComponentId, ComponentId)> = Vec::new();
489 for (name, new_eco_map) in &new_identity_map {
490 let Some(old_eco_map) = old_identity_map.get_mut(name) else {
491 continue;
492 };
493 for (ecosystem, new_ids) in new_eco_map {
494 let Some(old_ids) = old_eco_map.get_mut(ecosystem) else {
495 continue;
496 };
497 let pairs = Self::align_by_version(old_ids, new_ids, &old, &new);
498 let consumed: HashSet<ComponentId> =
499 pairs.iter().map(|(old_id, _)| old_id.clone()).collect();
500 old_ids.retain(|id| !consumed.contains(id));
501 identity_pairs.extend(pairs);
502 }
503 }
504 for (_, new_id) in &identity_pairs {
505 matched_new.insert(new_id.clone());
506 }
507
508 for (name, new_eco_map) in &new_identity_map {
513 let Some(old_eco_map) = old_identity_map.get_mut(name) else {
514 continue;
515 };
516 for (ecosystem, new_ids) in new_eco_map {
517 let new_ids: Vec<ComponentId> = new_ids
518 .iter()
519 .filter(|id| !matched_new.contains(*id))
520 .cloned()
521 .collect();
522 if new_ids.is_empty() {
523 continue;
524 }
525 let old_ids: Vec<ComponentId> = if ecosystem.is_some() {
526 old_eco_map.get(&None).cloned().unwrap_or_default()
527 } else {
528 old_eco_map.values().flatten().cloned().collect()
529 };
530 let pairs = Self::align_by_version(&old_ids, &new_ids, &old, &new);
531 let consumed: HashSet<ComponentId> =
532 pairs.iter().map(|(old_id, _)| old_id.clone()).collect();
533 for ids in old_eco_map.values_mut() {
534 ids.retain(|id| !consumed.contains(id));
535 }
536 for (_, new_id) in &pairs {
537 matched_new.insert(new_id.clone());
538 }
539 identity_pairs.extend(pairs);
540 }
541 }
542
543 identity_pairs.sort_by(|a, b| a.1.cmp(&b.1));
544 for (old_id, new_id) in identity_pairs {
545 let (Some(old_comp), Some(new_comp)) =
546 (old.components.get(&old_id), new.components.get(&new_id))
547 else {
548 continue;
549 };
550 matched_old.insert(old_id.clone());
551 matched_new.insert(new_id.clone());
552 id_mapping.insert(old_id.clone(), new_id.clone());
553
554 let fields = Self::compute_fields(old_comp, new_comp, only);
555 if !fields.is_empty() {
556 changed_pairs.push((old_id, new_id, fields));
557 }
558 }
559
560 let old_total = old.components.len();
562 let new_total = new.components.len();
563 let matched = matched_old.len();
564 let unchanged = matched - changed_pairs.len();
565
566 let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
568 let edge_diffs = if should_include_deps {
569 Self::compute_edge_diffs(&old, &new, &id_mapping)
570 } else {
571 Vec::new()
572 };
573
574 let component_names = Self::build_component_names(&old, &new, &edge_diffs);
576
577 let mut changed = Vec::with_capacity(changed_pairs.len());
582 for (old_id, new_id, fields) in changed_pairs {
583 let old_comp = old.components.swap_remove(&old_id).unwrap();
584 let new_comp = new.components.swap_remove(&new_id).unwrap();
585 let downgrade = fields.iter().any(|f| match f {
586 FieldChange::Version(Some(old_ver), Some(new_ver)) => {
587 is_version_downgrade(old_ver, new_ver)
588 }
589 _ => false,
590 });
591 changed.push(ComponentChange {
592 id: new_comp.id.clone(),
593 old: old_comp,
594 new: new_comp,
595 changes: fields,
596 is_downgrade: downgrade,
597 });
598 }
599
600 for id in &matched_old {
603 old.components.swap_remove(id);
604 }
605 for id in &matched_new {
606 new.components.swap_remove(id);
607 }
608
609 let added: Vec<Component> = new.components.into_values().collect();
611 let removed: Vec<Component> = old.components.into_values().collect();
612
613 Diff {
614 added,
615 removed,
616 changed,
617 edge_diffs,
618 metadata_changed,
619 old_total,
620 new_total,
621 unchanged,
622 component_names,
623 }
624 }
625
626 fn align_by_version(
634 old_ids: &[ComponentId],
635 new_ids: &[ComponentId],
636 old: &Sbom,
637 new: &Sbom,
638 ) -> Vec<(ComponentId, ComponentId)> {
639 let by_id = || -> Vec<(ComponentId, ComponentId)> {
640 new_ids
641 .iter()
642 .zip(old_ids.iter().rev())
643 .map(|(new_id, old_id)| (old_id.clone(), new_id.clone()))
644 .collect()
645 };
646
647 if old_ids.is_empty() || new_ids.is_empty() {
648 return Vec::new();
649 }
650 if old_ids.len() > MAX_ALIGNED_CANDIDATES || new_ids.len() > MAX_ALIGNED_CANDIDATES {
651 return by_id();
652 }
653
654 let mut merged: Vec<(Version, u8, &ComponentId)> =
655 Vec::with_capacity(old_ids.len() + new_ids.len());
656 for (side, ids, sbom) in [(0u8, old_ids, old), (1u8, new_ids, new)] {
657 for id in ids {
658 let Some(version) = sbom.components.get(id).and_then(|c| c.version.as_deref())
659 else {
660 return by_id();
661 };
662 merged.push((Version::parse_lenient(version), side, id));
663 }
664 }
665 let first = &merged[0].0;
668 if first.partial_cmp_lenient(first).is_none()
669 || merged
670 .iter()
671 .any(|(version, ..)| first.partial_cmp_lenient(version).is_none())
672 {
673 return by_id();
674 }
675 let (mut numeric, mut prerelease) = (false, false);
678 for (version, ..) in &merged {
679 match version {
680 Version::Numeric(_) => numeric = true,
681 Version::Semver(v) => prerelease |= !v.pre.is_empty(),
682 _ => {}
683 }
684 }
685 if numeric && prerelease {
686 return by_id();
687 }
688 merged.sort_by(|a, b| {
689 a.0.partial_cmp_lenient(&b.0)
690 .unwrap_or(Ordering::Equal)
691 .then_with(|| a.1.cmp(&b.1))
692 .then_with(|| a.2.cmp(b.2))
693 });
694
695 let (mut old_ranked, mut new_ranked) = (Vec::new(), Vec::new());
696 for (rank, (version, side, id)) in merged.iter().enumerate() {
697 if *side == 0 {
698 old_ranked.push((rank, *id, version));
699 } else {
700 new_ranked.push((rank, *id, version));
701 }
702 }
703
704 let (n, m) = (old_ranked.len(), new_ranked.len());
705 let cost = |i: usize, j: usize| {
706 (
707 old_ranked[i].0.abs_diff(new_ranked[j].0),
708 usize::from(old_ranked[i].2.is_downgrade(new_ranked[j].2)),
709 )
710 };
711 let better = |a: (usize, usize, usize), b: (usize, usize, usize)| {
712 a.0 > b.0 || (a.0 == b.0 && (a.1, a.2) < (b.1, b.2))
713 };
714 let paired_with = |(pairs, distance, downgrades): (usize, usize, usize), i, j| {
715 let (extra_distance, extra_downgrade) = cost(i, j);
716 (
717 pairs + 1,
718 distance + extra_distance,
719 downgrades + extra_downgrade,
720 )
721 };
722
723 let mut score = vec![vec![(0usize, 0usize, 0usize); m + 1]; n + 1];
725 for i in (0..n).rev() {
726 for j in (0..m).rev() {
727 let mut best = paired_with(score[i + 1][j + 1], i, j);
728 if better(score[i + 1][j], best) {
729 best = score[i + 1][j];
730 }
731 if better(score[i][j + 1], best) {
732 best = score[i][j + 1];
733 }
734 score[i][j] = best;
735 }
736 }
737
738 let mut pairs = Vec::with_capacity(n.min(m));
739 let (mut i, mut j) = (0, 0);
740 while i < n && j < m {
741 let take = paired_with(score[i + 1][j + 1], i, j);
742 if !better(score[i + 1][j], take) && !better(score[i][j + 1], take) {
743 pairs.push((old_ranked[i].1.clone(), new_ranked[j].1.clone()));
744 i += 1;
745 j += 1;
746 } else if better(score[i + 1][j], score[i][j + 1]) {
747 i += 1;
748 } else {
749 j += 1;
750 }
751 }
752 pairs
753 }
754
755 fn compute_edge_diffs(
762 old: &Sbom,
763 new: &Sbom,
764 id_mapping: &BTreeMap<ComponentId, ComponentId>,
765 ) -> Vec<EdgeDiff> {
766 let mut edge_diffs = Vec::new();
767
768 let reverse_mapping: BTreeMap<&ComponentId, &ComponentId> = id_mapping
770 .iter()
771 .map(|(old_id, new_id)| (new_id, old_id))
772 .collect();
773
774 let mut all_parents: BTreeSet<&ComponentId> = new.dependencies.keys().collect();
776 for old_parent in old.dependencies.keys() {
777 all_parents.insert(id_mapping.get(old_parent).unwrap_or(old_parent));
778 }
779
780 let empty_deps = BTreeMap::new();
781
782 for parent_id in all_parents {
783 let new_children = new.dependencies.get(parent_id).unwrap_or(&empty_deps);
785
786 let old_parent_id = reverse_mapping.get(parent_id).copied().unwrap_or(parent_id);
787
788 let old_children: BTreeMap<&ComponentId, DependencyKind> = old
790 .dependencies
791 .get(old_parent_id)
792 .map(|children| {
793 children
794 .iter()
795 .map(|(id, &kind)| (id_mapping.get(id).unwrap_or(id), kind))
796 .collect()
797 })
798 .unwrap_or_default();
799
800 let new_keys: BTreeSet<&ComponentId> = new_children.keys().collect();
801 let old_keys: BTreeSet<&ComponentId> = old_children.keys().copied().collect();
802
803 let added: BTreeMap<ComponentId, DependencyKind> = new_keys
805 .difference(&old_keys)
806 .map(|&id| (id.clone(), new_children[id]))
807 .collect();
808 let removed: BTreeMap<ComponentId, DependencyKind> = old_keys
809 .difference(&new_keys)
810 .map(|&id| (id.clone(), old_children[id]))
811 .collect();
812
813 let kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)> = new_keys
814 .intersection(&old_keys)
815 .filter_map(|&id| {
816 let old_kind = old_children[id];
817 let new_kind = new_children[id];
818 if old_kind != new_kind {
819 Some((id.clone(), (old_kind, new_kind)))
820 } else {
821 None
822 }
823 })
824 .collect();
825
826 if !added.is_empty() || !removed.is_empty() || !kind_changed.is_empty() {
827 edge_diffs.push(EdgeDiff {
828 parent: parent_id.clone(),
829 added,
830 removed,
831 kind_changed,
832 });
833 }
834 }
835
836 edge_diffs
837 }
838
839 fn build_component_names(
844 old: &Sbom,
845 new: &Sbom,
846 edge_diffs: &[EdgeDiff],
847 ) -> BTreeMap<ComponentId, String> {
848 let mut names = BTreeMap::new();
849
850 let mut ids = BTreeSet::new();
852 for edge in edge_diffs {
853 ids.insert(&edge.parent);
854 ids.extend(edge.added.keys());
855 ids.extend(edge.removed.keys());
856 ids.extend(edge.kind_changed.keys());
857 }
858
859 for id in ids {
861 if !id.as_str().starts_with("h:") {
862 continue;
863 }
864
865 let comp = new.components.get(id).or_else(|| old.components.get(id));
867 if let Some(comp) = comp {
868 let display = match &comp.version {
869 Some(v) => format!("{}@{}", comp.name, v),
870 None => comp.name.clone(),
871 };
872 names.insert(id.clone(), display);
873 }
874 }
875
876 names
877 }
878
879 fn compute_fields(
887 old: &Component,
888 new: &Component,
889 only: Option<&[Field]>,
890 ) -> Vec<FieldChange> {
891 let mut changes = Vec::new();
892
893 let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
894
895 if should_include(Field::Version) && old.version != new.version {
896 changes.push(FieldChange::Version(
897 old.version.clone(),
898 new.version.clone(),
899 ));
900 }
901
902 if should_include(Field::License) && old.licenses != new.licenses {
903 changes.push(FieldChange::License(
904 old.licenses.clone(),
905 new.licenses.clone(),
906 ));
907 }
908
909 if should_include(Field::Supplier) && old.supplier != new.supplier {
910 changes.push(FieldChange::Supplier(
911 old.supplier.clone(),
912 new.supplier.clone(),
913 ));
914 }
915
916 if should_include(Field::Purl) && old.purl != new.purl {
917 changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
918 }
919
920 if should_include(Field::Description) && old.description != new.description {
921 changes.push(FieldChange::Description(
922 old.description.clone(),
923 new.description.clone(),
924 ));
925 }
926
927 if should_include(Field::Hashes) && old.hashes != new.hashes {
928 changes.push(FieldChange::Hashes(old.hashes.clone(), new.hashes.clone()));
929 }
930
931 if should_include(Field::Ecosystem) && old.ecosystem != new.ecosystem {
932 changes.push(FieldChange::Ecosystem(
933 old.ecosystem.clone(),
934 new.ecosystem.clone(),
935 ));
936 }
937
938 changes
939 }
940}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945
946 #[test]
947 fn test_diff_added_removed() {
948 let mut old = Sbom::default();
949 let mut new = Sbom::default();
950
951 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
952 let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
953
954 old.components.insert(c1.id.clone(), c1);
955 new.components.insert(c2.id.clone(), c2);
956
957 let diff = Differ::diff(&old, &new, None);
958 assert_eq!(diff.added.len(), 1);
959 assert_eq!(diff.removed.len(), 1);
960 assert_eq!(diff.changed.len(), 0);
961 }
962
963 #[test]
964 fn test_diff_changed() {
965 let mut old = Sbom::default();
966 let mut new = Sbom::default();
967
968 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
969 let mut c2 = c1.clone();
970 c2.version = Some("1.1".to_string());
971
972 old.components.insert(c1.id.clone(), c1);
973 new.components.insert(c2.id.clone(), c2);
974
975 let diff = Differ::diff(&old, &new, None);
976 assert_eq!(diff.added.len(), 0);
977 assert_eq!(diff.removed.len(), 0);
978 assert_eq!(diff.changed.len(), 1);
979 assert!(matches!(
980 diff.changed[0].changes[0],
981 FieldChange::Version(_, _)
982 ));
983 }
984
985 #[test]
986 fn test_diff_identity_reconciliation() {
987 let mut old = Sbom::default();
988 let mut new = Sbom::default();
989
990 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
991 let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
992
993 old.components.insert(c1.id.clone(), c1);
994 new.components.insert(c2.id.clone(), c2);
995
996 let diff = Differ::diff(&old, &new, None);
997 assert_eq!(diff.changed.len(), 1);
998 assert_eq!(diff.added.len(), 0);
999 }
1000
1001 #[test]
1002 fn test_diff_license_change() {
1003 let mut old = Sbom::default();
1004 let mut new = Sbom::default();
1005
1006 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1007 c1.licenses.insert("MIT".into());
1008 let mut c2 = c1.clone();
1009 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1010
1011 old.components.insert(c1.id.clone(), c1);
1012 new.components.insert(c2.id.clone(), c2);
1013
1014 let diff = Differ::diff(&old, &new, None);
1015 assert_eq!(diff.changed.len(), 1);
1016 assert!(diff.changed[0]
1017 .changes
1018 .iter()
1019 .any(|c| matches!(c, FieldChange::License(_, _))));
1020 }
1021
1022 #[test]
1023 fn test_diff_supplier_change() {
1024 let mut old = Sbom::default();
1025 let mut new = Sbom::default();
1026
1027 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1028 c1.supplier = Some("Acme Corp".into());
1029 let mut c2 = c1.clone();
1030 c2.supplier = Some("New Corp".into());
1031
1032 old.components.insert(c1.id.clone(), c1);
1033 new.components.insert(c2.id.clone(), c2);
1034
1035 let diff = Differ::diff(&old, &new, None);
1036 assert_eq!(diff.changed.len(), 1);
1037 assert!(diff.changed[0]
1038 .changes
1039 .iter()
1040 .any(|c| matches!(c, FieldChange::Supplier(_, _))));
1041 }
1042
1043 #[test]
1044 fn test_diff_hashes_change() {
1045 let mut old = Sbom::default();
1046 let mut new = Sbom::default();
1047
1048 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1049 c1.hashes.insert("sha256".into(), "aaa".into());
1050 let mut c2 = c1.clone();
1051 c2.hashes.insert("sha256".into(), "bbb".into());
1052
1053 old.components.insert(c1.id.clone(), c1);
1054 new.components.insert(c2.id.clone(), c2);
1055
1056 let diff = Differ::diff(&old, &new, None);
1057 assert_eq!(diff.changed.len(), 1);
1058 assert!(diff.changed[0]
1059 .changes
1060 .iter()
1061 .any(|c| matches!(c, FieldChange::Hashes(_, _))));
1062 }
1063
1064 #[test]
1065 fn test_diff_description_change() {
1066 let mut old = Sbom::default();
1067 let mut new = Sbom::default();
1068
1069 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1070 c1.description = Some("Old description".into());
1071 let mut c2 = c1.clone();
1072 c2.description = Some("New description".into());
1073
1074 old.components.insert(c1.id.clone(), c1);
1075 new.components.insert(c2.id.clone(), c2);
1076
1077 let diff = Differ::diff(&old, &new, None);
1078 assert_eq!(diff.changed.len(), 1);
1079 assert!(diff.changed[0]
1080 .changes
1081 .iter()
1082 .any(|c| matches!(c, FieldChange::Description(_, _))));
1083 }
1084
1085 #[test]
1086 fn test_diff_description_added() {
1087 let mut old = Sbom::default();
1088 let mut new = Sbom::default();
1089
1090 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1091 let mut c2 = c1.clone();
1092 c2.description = Some("A new description".into());
1093
1094 old.components.insert(c1.id.clone(), c1);
1095 new.components.insert(c2.id.clone(), c2);
1096
1097 let diff = Differ::diff(&old, &new, None);
1098 assert_eq!(diff.changed.len(), 1);
1099 assert!(diff.changed[0]
1100 .changes
1101 .iter()
1102 .any(|c| matches!(c, FieldChange::Description(None, Some(_)))));
1103 }
1104
1105 #[test]
1106 fn test_diff_description_removed() {
1107 let mut old = Sbom::default();
1108 let mut new = Sbom::default();
1109
1110 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1111 c1.description = Some("Had a description".into());
1112 let mut c2 = c1.clone();
1113 c2.description = None;
1114
1115 old.components.insert(c1.id.clone(), c1);
1116 new.components.insert(c2.id.clone(), c2);
1117
1118 let diff = Differ::diff(&old, &new, None);
1119 assert_eq!(diff.changed.len(), 1);
1120 assert!(diff.changed[0]
1121 .changes
1122 .iter()
1123 .any(|c| matches!(c, FieldChange::Description(Some(_), None))));
1124 }
1125
1126 #[test]
1127 fn test_diff_description_unchanged() {
1128 let mut old = Sbom::default();
1129 let mut new = Sbom::default();
1130
1131 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1132 c1.description = Some("Same description".into());
1133 let c2 = c1.clone();
1134
1135 old.components.insert(c1.id.clone(), c1);
1136 new.components.insert(c2.id.clone(), c2);
1137
1138 let diff = Differ::diff(&old, &new, None);
1139 assert!(diff.changed.is_empty());
1140 }
1141
1142 #[test]
1143 fn test_diff_description_filtering() {
1144 let mut old = Sbom::default();
1145 let mut new = Sbom::default();
1146
1147 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1148 c1.description = Some("Old".into());
1149 let mut c2 = c1.clone();
1150 c2.version = Some("2.0".into());
1151 c2.description = Some("New".into());
1152
1153 old.components.insert(c1.id.clone(), c1);
1154 new.components.insert(c2.id.clone(), c2);
1155
1156 let diff = Differ::diff(&old, &new, Some(&[Field::Description]));
1158 assert_eq!(diff.changed.len(), 1);
1159 assert_eq!(diff.changed[0].changes.len(), 1);
1160 assert!(matches!(
1161 diff.changed[0].changes[0],
1162 FieldChange::Description(_, _)
1163 ));
1164
1165 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1167 assert_eq!(diff.changed.len(), 1);
1168 assert_eq!(diff.changed[0].changes.len(), 1);
1169 assert!(matches!(
1170 diff.changed[0].changes[0],
1171 FieldChange::Version(_, _)
1172 ));
1173 }
1174
1175 #[test]
1176 fn test_diff_ecosystem_change() {
1177 let mut old = Sbom::default();
1178 let mut new = Sbom::default();
1179
1180 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1181 c1.ecosystem = Some("npm".to_string());
1182 let mut c2 = c1.clone();
1183 c2.ecosystem = Some("cargo".to_string());
1184
1185 old.components.insert(c1.id.clone(), c1);
1186 new.components.insert(c2.id.clone(), c2);
1187
1188 let diff = Differ::diff(&old, &new, None);
1189 assert_eq!(diff.changed.len(), 1);
1190 assert_eq!(diff.changed[0].changes.len(), 1);
1191 assert!(matches!(
1192 diff.changed[0].changes[0],
1193 FieldChange::Ecosystem(_, _)
1194 ));
1195
1196 if let FieldChange::Ecosystem(ref o, ref n) = diff.changed[0].changes[0] {
1197 assert_eq!(o.as_deref(), Some("npm"));
1198 assert_eq!(n.as_deref(), Some("cargo"));
1199 }
1200 }
1201
1202 #[test]
1203 fn test_diff_ecosystem_change_from_none() {
1204 let mut old = Sbom::default();
1205 let mut new = Sbom::default();
1206
1207 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1208 let mut c2 = c1.clone();
1209 c2.ecosystem = Some("npm".to_string());
1210
1211 old.components.insert(c1.id.clone(), c1);
1212 new.components.insert(c2.id.clone(), c2);
1213
1214 let diff = Differ::diff(&old, &new, None);
1215 assert_eq!(diff.changed.len(), 1);
1216 assert_eq!(diff.changed[0].changes.len(), 1);
1217 assert!(matches!(
1218 diff.changed[0].changes[0],
1219 FieldChange::Ecosystem(None, Some(_))
1220 ));
1221 }
1222
1223 #[test]
1224 fn test_diff_ecosystem_filtering() {
1225 let mut old = Sbom::default();
1226 let mut new = Sbom::default();
1227
1228 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1229 c1.ecosystem = Some("npm".to_string());
1230 let mut c2 = c1.clone();
1231 c2.version = Some("2.0".into());
1232 c2.ecosystem = Some("cargo".to_string());
1233
1234 old.components.insert(c1.id.clone(), c1);
1235 new.components.insert(c2.id.clone(), c2);
1236
1237 let diff = Differ::diff(&old, &new, Some(&[Field::Ecosystem]));
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(_, _)
1244 ));
1245
1246 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1248 assert_eq!(diff.changed.len(), 1);
1249 assert_eq!(diff.changed[0].changes.len(), 1);
1250 assert!(matches!(
1251 diff.changed[0].changes[0],
1252 FieldChange::Version(_, _)
1253 ));
1254 }
1255
1256 #[test]
1257 fn test_diff_ecosystem_no_change() {
1258 let mut old = Sbom::default();
1259 let mut new = Sbom::default();
1260
1261 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1262 c1.ecosystem = Some("npm".to_string());
1263 let c2 = c1.clone();
1264
1265 old.components.insert(c1.id.clone(), c1);
1266 new.components.insert(c2.id.clone(), c2);
1267
1268 let diff = Differ::diff(&old, &new, None);
1269 assert!(diff.changed.is_empty());
1270 }
1271
1272 #[test]
1273 fn test_diff_multiple_field_changes() {
1274 let mut old = Sbom::default();
1275 let mut new = Sbom::default();
1276
1277 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1278 c1.licenses.insert("MIT".into());
1279 c1.supplier = Some("Old Corp".into());
1280 c1.hashes.insert("sha256".into(), "aaa".into());
1281
1282 let mut c2 = c1.clone();
1283 c2.version = Some("2.0".into());
1284 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1285 c2.supplier = Some("New Corp".into());
1286 c2.hashes.insert("sha256".into(), "bbb".into());
1287
1288 old.components.insert(c1.id.clone(), c1);
1289 new.components.insert(c2.id.clone(), c2);
1290
1291 let diff = Differ::diff(&old, &new, None);
1292 assert_eq!(diff.changed.len(), 1);
1293 assert_eq!(diff.changed[0].changes.len(), 4);
1294 }
1295
1296 #[test]
1297 fn test_diff_no_changes() {
1298 let mut old = Sbom::default();
1299 let mut new = Sbom::default();
1300
1301 let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1302 old.components.insert(c.id.clone(), c.clone());
1303 new.components.insert(c.id.clone(), c);
1304
1305 let diff = Differ::diff(&old, &new, None);
1306 assert!(diff.added.is_empty());
1307 assert!(diff.removed.is_empty());
1308 assert!(diff.changed.is_empty());
1309 assert!(diff.edge_diffs.is_empty());
1310 }
1311
1312 #[test]
1313 fn test_diff_metadata_changed_timestamp() {
1314 let mut old = Sbom::default();
1315 let mut new = Sbom::default();
1316
1317 old.metadata.timestamp = Some("2024-01-01".into());
1318 new.metadata.timestamp = Some("2024-01-02".into());
1319
1320 let diff = Differ::diff(&old, &new, None);
1321 let mc = diff.metadata_changed.as_ref().unwrap();
1322 assert_eq!(
1323 mc.timestamp,
1324 Some((Some("2024-01-01".into()), Some("2024-01-02".into())))
1325 );
1326 assert!(mc.tools.is_none());
1327 assert!(mc.authors.is_none());
1328 assert!(!diff.is_empty());
1329 }
1330
1331 #[test]
1332 fn test_diff_metadata_changed_tools() {
1333 let mut old = Sbom::default();
1334 let mut new = Sbom::default();
1335
1336 old.metadata.tools = vec!["syft".into()];
1337 new.metadata.tools = vec!["trivy".into()];
1338
1339 let diff = Differ::diff(&old, &new, None);
1340 let mc = diff.metadata_changed.as_ref().unwrap();
1341 assert!(mc.timestamp.is_none());
1342 assert_eq!(mc.tools, Some((vec!["syft".into()], vec!["trivy".into()])));
1343 assert!(mc.authors.is_none());
1344 }
1345
1346 #[test]
1347 fn test_diff_metadata_changed_authors() {
1348 let mut old = Sbom::default();
1349 let mut new = Sbom::default();
1350
1351 old.metadata.authors = vec!["alice".into()];
1352 new.metadata.authors = vec!["bob".into()];
1353
1354 let diff = Differ::diff(&old, &new, None);
1355 let mc = diff.metadata_changed.as_ref().unwrap();
1356 assert!(mc.timestamp.is_none());
1357 assert!(mc.tools.is_none());
1358 assert_eq!(mc.authors, Some((vec!["alice".into()], vec!["bob".into()])));
1359 }
1360
1361 #[test]
1362 fn test_diff_metadata_unchanged() {
1363 let mut old = Sbom::default();
1364 let mut new = Sbom::default();
1365
1366 old.metadata.timestamp = Some("2024-01-01".into());
1367 new.metadata.timestamp = Some("2024-01-01".into());
1368 old.metadata.tools = vec!["syft".into()];
1369 new.metadata.tools = vec!["syft".into()];
1370
1371 let diff = Differ::diff(&old, &new, None);
1372 assert!(diff.metadata_changed.is_none());
1373 }
1374
1375 #[test]
1376 fn test_diff_metadata_changed_multiple_fields() {
1377 let mut old = Sbom::default();
1378 let mut new = Sbom::default();
1379
1380 old.metadata.timestamp = Some("2024-01-01".into());
1381 new.metadata.timestamp = Some("2024-01-02".into());
1382 old.metadata.tools = vec!["syft".into()];
1383 new.metadata.tools = vec!["trivy".into()];
1384 old.metadata.authors = vec!["alice".into()];
1385 new.metadata.authors = vec!["bob".into()];
1386
1387 let diff = Differ::diff(&old, &new, None);
1388 let mc = diff.metadata_changed.as_ref().unwrap();
1389 assert!(mc.timestamp.is_some());
1390 assert!(mc.tools.is_some());
1391 assert!(mc.authors.is_some());
1392 }
1393
1394 #[test]
1395 fn test_diff_filtering() {
1396 let mut old = Sbom::default();
1397 let mut new = Sbom::default();
1398
1399 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1400 c1.licenses.insert("MIT".into());
1401
1402 let mut c2 = c1.clone();
1403 c2.version = Some("1.1".to_string());
1404 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1405
1406 old.components.insert(c1.id.clone(), c1);
1407 new.components.insert(c2.id.clone(), c2);
1408
1409 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1410 assert_eq!(diff.changed.len(), 1);
1411 assert_eq!(diff.changed[0].changes.len(), 1);
1412 assert!(matches!(
1413 diff.changed[0].changes[0],
1414 FieldChange::Version(_, _)
1415 ));
1416 }
1417
1418 #[test]
1419 fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
1420 let mut old = Sbom::default();
1423 let mut new = Sbom::default();
1424
1425 let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
1427 c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
1428 c_old.ecosystem = Some("npm".to_string());
1429 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1430
1431 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1433 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1434 c_new.ecosystem = Some("npm".to_string());
1435 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1436
1437 old.components.insert(c_old.id.clone(), c_old);
1438 new.components.insert(c_new.id.clone(), c_new);
1439
1440 let diff = Differ::diff(&old, &new, None);
1441
1442 assert_eq!(diff.added.len(), 0, "Should not have added components");
1443 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1444
1445 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1446
1447 let changes = &diff.changed[0].changes;
1448 assert!(changes
1449 .iter()
1450 .any(|c| matches!(c, FieldChange::Version(_, _))));
1451 assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
1452 }
1453
1454 #[test]
1455 fn test_purl_removed_is_change() {
1456 let mut old = Sbom::default();
1459 let mut new = Sbom::default();
1460
1461 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1462 c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1463 c_old.ecosystem = Some("npm".to_string()); c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1465
1466 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1468 c_new.purl = None;
1469 c_new.ecosystem = None; c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1472
1473 old.components.insert(c_old.id.clone(), c_old);
1474 new.components.insert(c_new.id.clone(), c_new);
1475
1476 let diff = Differ::diff(&old, &new, None);
1477
1478 assert_eq!(diff.added.len(), 0, "Should not have added components");
1479 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1480 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1481
1482 assert!(diff.changed[0]
1483 .changes
1484 .iter()
1485 .any(|c| matches!(c, FieldChange::Purl(_, _))));
1486 }
1487
1488 #[test]
1489 fn test_purl_added_is_change() {
1490 let mut old = Sbom::default();
1493 let mut new = Sbom::default();
1494
1495 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1496 c_old.purl = None;
1497 c_old.ecosystem = None; c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1499
1500 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1501 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1502 c_new.ecosystem = Some("npm".to_string()); c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1504
1505 old.components.insert(c_old.id.clone(), c_old);
1506 new.components.insert(c_new.id.clone(), c_new);
1507
1508 let diff = Differ::diff(&old, &new, None);
1509
1510 assert_eq!(diff.added.len(), 0, "Should not have added components");
1511 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1512 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1513 }
1514
1515 #[test]
1516 fn test_same_name_different_ecosystems_not_matched() {
1517 let mut old = Sbom::default();
1519 let mut new = Sbom::default();
1520
1521 let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1523 c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
1524 c_old.ecosystem = Some("npm".to_string());
1525 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1526
1527 let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1529 c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
1530 c_new.ecosystem = Some("pypi".to_string());
1531 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1532
1533 old.components.insert(c_old.id.clone(), c_old);
1534 new.components.insert(c_new.id.clone(), c_new);
1535
1536 let diff = Differ::diff(&old, &new, None);
1537
1538 assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
1539 assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
1540 assert_eq!(
1541 diff.changed.len(),
1542 0,
1543 "Should not match different ecosystems"
1544 );
1545 }
1546
1547 #[test]
1548 fn test_same_name_both_no_ecosystem_matched() {
1549 let mut old = Sbom::default();
1552 let mut new = Sbom::default();
1553
1554 let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
1555 c_old.ecosystem = None;
1556
1557 let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
1558 c_new.ecosystem = None;
1559
1560 old.components.insert(c_old.id.clone(), c_old);
1561 new.components.insert(c_new.id.clone(), c_new);
1562
1563 let diff = Differ::diff(&old, &new, None);
1564
1565 assert_eq!(diff.added.len(), 0);
1566 assert_eq!(diff.removed.len(), 0);
1567 assert_eq!(
1568 diff.changed.len(),
1569 1,
1570 "Same name with None ecosystems should match"
1571 );
1572 }
1573
1574 #[test]
1575 fn test_edge_diff_added_removed() {
1576 let mut old = Sbom::default();
1577 let mut new = Sbom::default();
1578
1579 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1580 let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
1581 let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
1582
1583 let parent_id = c1.id.clone();
1584 let child_a_id = c2.id.clone();
1585 let child_b_id = c3.id.clone();
1586
1587 old.components.insert(c1.id.clone(), c1.clone());
1589 old.components.insert(c2.id.clone(), c2.clone());
1590 old.components.insert(c3.id.clone(), c3.clone());
1591
1592 new.components.insert(c1.id.clone(), c1);
1593 new.components.insert(c2.id.clone(), c2);
1594 new.components.insert(c3.id.clone(), c3);
1595
1596 old.dependencies
1598 .entry(parent_id.clone())
1599 .or_default()
1600 .insert(child_a_id.clone(), DependencyKind::Runtime);
1601
1602 new.dependencies
1604 .entry(parent_id.clone())
1605 .or_default()
1606 .insert(child_b_id.clone(), DependencyKind::Runtime);
1607
1608 let diff = Differ::diff(&old, &new, None);
1609
1610 assert_eq!(diff.edge_diffs.len(), 1);
1611 assert_eq!(diff.edge_diffs[0].parent, parent_id);
1612 assert!(diff.edge_diffs[0].added.contains_key(&child_b_id));
1613 assert!(diff.edge_diffs[0].removed.contains_key(&child_a_id));
1614 }
1615
1616 #[test]
1617 fn test_edge_diff_with_identity_reconciliation() {
1618 let mut old = Sbom::default();
1621 let mut new = Sbom::default();
1622
1623 let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
1625 parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
1626 parent_old.ecosystem = Some("npm".to_string());
1627 parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
1628
1629 let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
1631 parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
1632 parent_new.ecosystem = Some("npm".to_string());
1633 parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
1634
1635 let child = Component::new("child".to_string(), Some("1.0".to_string()));
1637
1638 old.components
1639 .insert(parent_old.id.clone(), parent_old.clone());
1640 old.components.insert(child.id.clone(), child.clone());
1641
1642 new.components
1643 .insert(parent_new.id.clone(), parent_new.clone());
1644 new.components.insert(child.id.clone(), child.clone());
1645
1646 old.dependencies
1648 .entry(parent_old.id.clone())
1649 .or_default()
1650 .insert(child.id.clone(), DependencyKind::Runtime);
1651
1652 new.dependencies
1654 .entry(parent_new.id.clone())
1655 .or_default()
1656 .insert(child.id.clone(), DependencyKind::Runtime);
1657
1658 let diff = Differ::diff(&old, &new, None);
1659
1660 assert_eq!(
1663 diff.edge_diffs.len(),
1664 0,
1665 "No edge changes expected when parent is reconciled by identity"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_edge_diff_filtering() {
1671 let mut old = Sbom::default();
1673 let mut new = Sbom::default();
1674
1675 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1676 let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
1677
1678 let parent_id = c1.id.clone();
1679 let child_id = c2.id.clone();
1680
1681 old.components.insert(c1.id.clone(), c1.clone());
1682 old.components.insert(c2.id.clone(), c2.clone());
1683
1684 new.components.insert(c1.id.clone(), c1);
1685 new.components.insert(c2.id.clone(), c2);
1686
1687 new.dependencies
1689 .entry(parent_id.clone())
1690 .or_default()
1691 .insert(child_id, DependencyKind::Runtime);
1692
1693 let diff = Differ::diff(&old, &new, None);
1695 assert_eq!(diff.edge_diffs.len(), 1);
1696
1697 let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
1699 assert_eq!(diff_filtered.edge_diffs.len(), 0);
1700
1701 let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
1703 assert_eq!(diff_with_deps.edge_diffs.len(), 1);
1704 }
1705
1706 #[test]
1707 fn test_ecosystem_breakdown() {
1708 let mut old = Sbom::default();
1709 let mut new = Sbom::default();
1710
1711 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1713 c1.ecosystem = Some("npm".into());
1714 old.components.insert(c1.id.clone(), c1);
1715
1716 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1718 c2.ecosystem = Some("npm".into());
1719 new.components.insert(c2.id.clone(), c2);
1720
1721 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1723 c3.ecosystem = Some("cargo".into());
1724 new.components.insert(c3.id.clone(), c3);
1725
1726 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1728 c4_old.ecosystem = Some("npm".into());
1729 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1730 c4_new.ecosystem = Some("npm".into());
1731 old.components.insert(c4_old.id.clone(), c4_old);
1732 new.components.insert(c4_new.id.clone(), c4_new);
1733
1734 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1736 new.components.insert(c5.id.clone(), c5);
1737
1738 let diff = Differ::diff(&old, &new, None);
1739 let breakdown = diff.ecosystem_breakdown();
1740
1741 let npm = breakdown.get("npm").unwrap();
1742 assert_eq!(npm.added, 1);
1743 assert_eq!(npm.removed, 1);
1744 assert_eq!(npm.changed, 1);
1745
1746 let cargo = breakdown.get("cargo").unwrap();
1747 assert_eq!(cargo.added, 1);
1748 assert_eq!(cargo.removed, 0);
1749 assert_eq!(cargo.changed, 0);
1750
1751 let unknown = breakdown.get("unknown").unwrap();
1752 assert_eq!(unknown.added, 1);
1753 assert_eq!(unknown.removed, 0);
1754 assert_eq!(unknown.changed, 0);
1755 }
1756
1757 #[test]
1758 fn test_ecosystem_breakdown_empty_diff() {
1759 let old = Sbom::default();
1760 let new = Sbom::default();
1761
1762 let diff = Differ::diff(&old, &new, None);
1763 assert!(diff.is_empty());
1764 assert!(diff.ecosystem_breakdown().is_empty());
1765 }
1766
1767 #[test]
1768 fn test_group_by_ecosystem_empty_diff() {
1769 let old = Sbom::default();
1770 let new = Sbom::default();
1771
1772 let diff = Differ::diff(&old, &new, None);
1773 let grouped = diff.group_by_ecosystem();
1774 assert!(grouped.by_ecosystem.is_empty());
1775 assert!(grouped.edge_diffs.is_empty());
1776 assert!(grouped.metadata_changed.is_none());
1777 assert!(grouped.ecosystem_breakdown().is_empty());
1778 }
1779
1780 #[test]
1781 fn test_group_by_ecosystem_groups_correctly() {
1782 let mut old = Sbom::default();
1783 let mut new = Sbom::default();
1784
1785 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1787 c1.ecosystem = Some("npm".into());
1788 old.components.insert(c1.id.clone(), c1);
1789
1790 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1792 c2.ecosystem = Some("npm".into());
1793 new.components.insert(c2.id.clone(), c2);
1794
1795 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1797 c3.ecosystem = Some("cargo".into());
1798 new.components.insert(c3.id.clone(), c3);
1799
1800 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1802 c4_old.ecosystem = Some("npm".into());
1803 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1804 c4_new.ecosystem = Some("npm".into());
1805 old.components.insert(c4_old.id.clone(), c4_old);
1806 new.components.insert(c4_new.id.clone(), c4_new);
1807
1808 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1810 new.components.insert(c5.id.clone(), c5);
1811
1812 let diff = Differ::diff(&old, &new, None);
1813 let grouped = diff.group_by_ecosystem();
1814
1815 let npm = grouped.by_ecosystem.get("npm").unwrap();
1816 assert_eq!(npm.added.len(), 1);
1817 assert_eq!(npm.removed.len(), 1);
1818 assert_eq!(npm.changed.len(), 1);
1819
1820 let cargo = grouped.by_ecosystem.get("cargo").unwrap();
1821 assert_eq!(cargo.added.len(), 1);
1822 assert_eq!(cargo.removed.len(), 0);
1823 assert_eq!(cargo.changed.len(), 0);
1824
1825 let unknown = grouped.by_ecosystem.get("unknown").unwrap();
1826 assert_eq!(unknown.added.len(), 1);
1827 assert_eq!(unknown.removed.len(), 0);
1828 assert_eq!(unknown.changed.len(), 0);
1829
1830 let grouped_counts = grouped.ecosystem_breakdown();
1832 let direct_counts = diff.ecosystem_breakdown();
1833 assert_eq!(grouped_counts, direct_counts);
1834 }
1835
1836 #[test]
1837 fn test_totals_no_changes() {
1838 let mut old = Sbom::default();
1839 let mut new = Sbom::default();
1840
1841 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1842 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1843
1844 old.components.insert(c1.id.clone(), c1.clone());
1845 old.components.insert(c2.id.clone(), c2.clone());
1846 new.components.insert(c1.id.clone(), c1);
1847 new.components.insert(c2.id.clone(), c2);
1848
1849 let diff = Differ::diff(&old, &new, None);
1850 assert_eq!(diff.old_total, 2);
1851 assert_eq!(diff.new_total, 2);
1852 assert_eq!(diff.unchanged, 2);
1853 }
1854
1855 #[test]
1856 fn test_totals_with_changes() {
1857 let mut old = Sbom::default();
1858 let mut new = Sbom::default();
1859
1860 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1861 let mut c1_updated = c1.clone();
1862 c1_updated.version = Some("1.1".to_string());
1863 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1864 let c3 = Component::new("pkg-c".to_string(), Some("3.0".to_string()));
1865 let c4 = Component::new("pkg-d".to_string(), Some("4.0".to_string()));
1866
1867 old.components.insert(c1.id.clone(), c1);
1868 old.components.insert(c2.id.clone(), c2.clone());
1869 old.components.insert(c3.id.clone(), c3);
1870 new.components.insert(c1_updated.id.clone(), c1_updated);
1871 new.components.insert(c2.id.clone(), c2);
1872 new.components.insert(c4.id.clone(), c4);
1873
1874 let diff = Differ::diff(&old, &new, None);
1875 assert_eq!(diff.old_total, 3);
1876 assert_eq!(diff.new_total, 3);
1877 assert_eq!(diff.added.len(), 1); assert_eq!(diff.removed.len(), 1); assert_eq!(diff.changed.len(), 1); assert_eq!(diff.unchanged, 1); }
1882
1883 #[test]
1884 fn test_component_names_for_hash_ids_in_edge_diffs() {
1885 let mut old = Sbom::default();
1886 let mut new = Sbom::default();
1887
1888 let parent = Component::new("my-app".to_string(), Some("1.0".to_string()));
1890 let child_a = Component::new("dep-old".to_string(), Some("0.1".to_string()));
1891 let child_b = Component::new("dep-new".to_string(), Some("0.2".to_string()));
1892
1893 old.components.insert(parent.id.clone(), parent.clone());
1894 old.components.insert(child_a.id.clone(), child_a.clone());
1895 new.components.insert(parent.id.clone(), parent.clone());
1896 new.components.insert(child_b.id.clone(), child_b.clone());
1897
1898 old.dependencies.insert(
1900 parent.id.clone(),
1901 BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1902 );
1903 new.dependencies.insert(
1904 parent.id.clone(),
1905 BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1906 );
1907
1908 let diff = Differ::diff(&old, &new, None);
1909
1910 assert!(diff.edge_diffs[0].parent.as_str().starts_with("h:"));
1912
1913 assert_eq!(diff.display_name(&diff.edge_diffs[0].parent), "my-app@1.0");
1915 for added in diff.edge_diffs[0].added.keys() {
1916 assert!(!diff.display_name(added).starts_with("h:"));
1917 }
1918 for removed in diff.edge_diffs[0].removed.keys() {
1919 assert!(!diff.display_name(removed).starts_with("h:"));
1920 }
1921 }
1922
1923 #[test]
1924 fn test_component_names_skips_purl_ids() {
1925 let mut old = Sbom::default();
1926 let mut new = Sbom::default();
1927
1928 let mut parent = Component::new("parent".to_string(), Some("1.0".to_string()));
1929 parent.purl = Some("pkg:npm/parent@1.0".to_string());
1930 parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
1931
1932 let mut child_a = Component::new("child-a".to_string(), Some("1.0".to_string()));
1933 child_a.purl = Some("pkg:npm/child-a@1.0".to_string());
1934 child_a.id = ComponentId::new(child_a.purl.as_deref(), &[]);
1935
1936 let mut child_b = Component::new("child-b".to_string(), Some("1.0".to_string()));
1937 child_b.purl = Some("pkg:npm/child-b@1.0".to_string());
1938 child_b.id = ComponentId::new(child_b.purl.as_deref(), &[]);
1939
1940 old.components.insert(parent.id.clone(), parent.clone());
1941 old.components.insert(child_a.id.clone(), child_a.clone());
1942 new.components.insert(parent.id.clone(), parent.clone());
1943 new.components.insert(child_b.id.clone(), child_b.clone());
1944
1945 old.dependencies.insert(
1946 parent.id.clone(),
1947 BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1948 );
1949 new.dependencies.insert(
1950 parent.id.clone(),
1951 BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1952 );
1953
1954 let diff = Differ::diff(&old, &new, None);
1955
1956 assert!(diff.component_names.is_empty());
1958
1959 assert!(diff
1961 .display_name(&diff.edge_diffs[0].parent)
1962 .starts_with("pkg:npm/parent@"));
1963 }
1964
1965 #[test]
1966 fn test_display_name_fallback() {
1967 let diff = Diff::default();
1968 let unknown_id = ComponentId::new(None, &[("name", "mystery")]);
1969 assert_eq!(diff.display_name(&unknown_id), unknown_id.as_str());
1971 }
1972
1973 #[test]
1974 fn test_filter_by_ecosystem_include() {
1975 let mut old = Sbom::default();
1976 let mut new = Sbom::default();
1977
1978 let mut npm1 = Component::new("express".into(), Some("4.18.0".into()));
1980 npm1.ecosystem = Some("npm".into());
1981 let mut npm2 = Component::new("lodash".into(), Some("4.17.21".into()));
1982 npm2.ecosystem = Some("npm".into());
1983
1984 let mut cargo1 = Component::new("serde".into(), Some("1.0.0".into()));
1986 cargo1.ecosystem = Some("cargo".into());
1987
1988 let mut pypi1 = Component::new("requests".into(), Some("2.28.0".into()));
1990 pypi1.ecosystem = Some("pypi".into());
1991
1992 old.components.insert(npm2.id.clone(), npm2.clone());
1993 old.components.insert(cargo1.id.clone(), cargo1.clone());
1994
1995 new.components.insert(npm1.id.clone(), npm1);
1996 new.components.insert(npm2.id.clone(), npm2);
1997 new.components.insert(pypi1.id.clone(), pypi1);
1998
1999 let mut diff = Differ::diff(&old, &new, None);
2004
2005 diff.filter_by_ecosystem(
2007 &|eco| eco == Some("npm"),
2008 1, 2, &BTreeMap::new(),
2011 );
2012
2013 assert_eq!(diff.added.len(), 1); assert_eq!(diff.added[0].name, "express");
2015 assert_eq!(diff.removed.len(), 0); assert_eq!(diff.changed.len(), 0);
2017 assert_eq!(diff.old_total, 1);
2018 assert_eq!(diff.new_total, 2);
2019 assert_eq!(diff.unchanged, 1); }
2021
2022 #[test]
2023 fn test_filter_by_ecosystem_exclude() {
2024 let mut old = Sbom::default();
2025 let mut new = Sbom::default();
2026
2027 let mut npm1 = Component::new("express".into(), Some("4.18.0".into()));
2028 npm1.ecosystem = Some("npm".into());
2029 let mut cargo1 = Component::new("serde".into(), Some("1.0.0".into()));
2030 cargo1.ecosystem = Some("cargo".into());
2031 let mut cargo2 = Component::new("tokio".into(), Some("1.0.0".into()));
2032 cargo2.ecosystem = Some("cargo".into());
2033
2034 old.components.insert(cargo1.id.clone(), cargo1.clone());
2035 new.components.insert(npm1.id.clone(), npm1);
2036 new.components.insert(cargo2.id.clone(), cargo2);
2037
2038 let mut diff = Differ::diff(&old, &new, None);
2040 diff.filter_by_ecosystem(
2041 &|eco| eco != Some("npm"),
2042 1, 1, &BTreeMap::new(),
2045 );
2046
2047 assert_eq!(diff.added.len(), 1); assert_eq!(diff.added[0].name, "tokio");
2049 assert_eq!(diff.removed.len(), 1); assert_eq!(diff.removed[0].name, "serde");
2051 }
2052
2053 #[test]
2054 fn test_filter_by_ecosystem_unknown() {
2055 let old = Sbom::default();
2057 let mut new = Sbom::default();
2058
2059 let no_eco = Component::new("mystery".into(), Some("1.0".into()));
2060 let mut npm = Component::new("express".into(), Some("4.18.0".into()));
2061 npm.ecosystem = Some("npm".into());
2062
2063 new.components.insert(no_eco.id.clone(), no_eco);
2064 new.components.insert(npm.id.clone(), npm);
2065
2066 let mut diff = Differ::diff(&old, &new, None);
2067
2068 diff.filter_by_ecosystem(
2070 &|eco| eco.is_none(),
2071 0,
2072 1, &BTreeMap::new(),
2074 );
2075
2076 assert_eq!(diff.added.len(), 1);
2077 assert_eq!(diff.added[0].name, "mystery");
2078 }
2079
2080 #[test]
2081 fn test_filter_by_ecosystem_changed_uses_new_ecosystem() {
2082 let mut old = Sbom::default();
2085 let mut new = Sbom::default();
2086
2087 let c_old = Component::new("pkg".into(), Some("1.0".into()));
2089 let mut c_new = Component::new("pkg".into(), Some("2.0".into()));
2091 c_new.ecosystem = Some("npm".into());
2092
2093 old.components.insert(c_old.id.clone(), c_old);
2094 new.components.insert(c_new.id.clone(), c_new);
2095
2096 let mut diff = Differ::diff(&old, &new, None);
2097 assert_eq!(diff.changed.len(), 1);
2098
2099 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 0, 1, &BTreeMap::new());
2101 assert_eq!(diff.changed.len(), 1);
2102
2103 let old2 = {
2105 let mut s = Sbom::default();
2106 let c = Component::new("pkg".into(), Some("1.0".into()));
2107 s.components.insert(c.id.clone(), c);
2108 s
2109 };
2110 let new2 = {
2111 let mut s = Sbom::default();
2112 let mut c = Component::new("pkg".into(), Some("2.0".into()));
2113 c.ecosystem = Some("npm".into());
2114 s.components.insert(c.id.clone(), c);
2115 s
2116 };
2117 let mut diff = Differ::diff(&old2, &new2, None);
2118 diff.filter_by_ecosystem(&|eco| eco == Some("cargo"), 0, 0, &BTreeMap::new());
2119 assert_eq!(diff.changed.len(), 0);
2120 }
2121
2122 #[test]
2123 fn test_filter_by_ecosystem_empty_diff() {
2124 let mut diff = Diff::default();
2125 diff.filter_by_ecosystem(&|_| true, 0, 0, &BTreeMap::new());
2126 assert!(diff.is_empty());
2127 }
2128
2129 #[test]
2130 fn test_filter_by_ecosystem_totals_adjusted() {
2131 let mut old = Sbom::default();
2132 let mut new = Sbom::default();
2133
2134 let mut n1 = Component::new("a".into(), Some("1".into()));
2136 n1.ecosystem = Some("npm".into());
2137 let mut n2 = Component::new("b".into(), Some("1".into()));
2138 n2.ecosystem = Some("npm".into());
2139 let mut c1 = Component::new("c".into(), Some("1".into()));
2140 c1.ecosystem = Some("cargo".into());
2141
2142 old.components.insert(n1.id.clone(), n1.clone());
2143 old.components.insert(n2.id.clone(), n2.clone());
2144 old.components.insert(c1.id.clone(), c1);
2145
2146 new.components.insert(n1.id.clone(), n1);
2148 new.components.insert(n2.id.clone(), n2);
2149
2150 let mut diff = Differ::diff(&old, &new, None);
2151 assert_eq!(diff.old_total, 3);
2152 assert_eq!(diff.new_total, 2);
2153
2154 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 2, 2, &BTreeMap::new());
2156
2157 assert_eq!(diff.old_total, 2);
2158 assert_eq!(diff.new_total, 2);
2159 assert_eq!(diff.unchanged, 2);
2160 assert_eq!(diff.added.len(), 0);
2161 assert_eq!(diff.removed.len(), 0);
2162 assert_eq!(diff.changed.len(), 0);
2163 }
2164
2165 #[test]
2166 fn test_filter_by_ecosystem_matched_pair_changes_ecosystem() {
2167 let mut old = Sbom::default();
2173 let mut new = Sbom::default();
2174
2175 let mut mig_old = Component::new("migrator".into(), Some("1.0".into()));
2177 mig_old.ecosystem = Some("npm".into());
2178 let mut mig_new = Component::new("migrator".into(), Some("1.0".into()));
2179 mig_new.ecosystem = None; assert_eq!(mig_old.id, mig_new.id, "same name+version share a hash id");
2181
2182 let mut lp = Component::new("left-pad".into(), Some("2.0".into()));
2184 lp.ecosystem = Some("npm".into());
2185
2186 old.components.insert(mig_old.id.clone(), mig_old);
2187 old.components.insert(lp.id.clone(), lp.clone());
2188 new.components.insert(mig_new.id.clone(), mig_new);
2189 new.components.insert(lp.id.clone(), lp);
2190
2191 let mut diff = Differ::diff(&old, &new, None);
2192 assert_eq!(diff.changed.len(), 1); assert_eq!(diff.unchanged, 1); diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 2, 1, &BTreeMap::new());
2198
2199 assert_eq!(diff.changed.len(), 0); assert_eq!(diff.added.len(), 0);
2201 assert_eq!(diff.removed.len(), 0);
2202 assert_eq!(diff.unchanged, 1); assert_eq!(diff.new_total, 1);
2204 assert!(
2206 diff.unchanged <= diff.new_total,
2207 "unchanged ({}) exceeds new_total ({})",
2208 diff.unchanged,
2209 diff.new_total
2210 );
2211 }
2212
2213 #[test]
2214 fn test_filter_by_ecosystem_no_ecosystem_change_unaffected() {
2215 let mut old = Sbom::default();
2218 let mut new = Sbom::default();
2219
2220 let mut mig_old = Component::new("migrator".into(), Some("1.0".into()));
2222 mig_old.ecosystem = Some("npm".into());
2223 let mut mig_new = Component::new("migrator".into(), Some("1.1".into()));
2224 mig_new.ecosystem = Some("npm".into());
2225
2226 let mut lp = Component::new("left-pad".into(), Some("2.0".into()));
2228 lp.ecosystem = Some("npm".into());
2229
2230 old.components.insert(mig_old.id.clone(), mig_old);
2231 old.components.insert(lp.id.clone(), lp.clone());
2232 new.components.insert(mig_new.id.clone(), mig_new);
2233 new.components.insert(lp.id.clone(), lp);
2234
2235 let mut diff = Differ::diff(&old, &new, None);
2236 assert_eq!(diff.changed.len(), 1);
2237
2238 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 2, 2, &BTreeMap::new());
2240
2241 assert_eq!(diff.changed.len(), 1);
2242 assert_eq!(diff.unchanged, 1); assert_eq!(diff.new_total, 2);
2244 assert!(diff.unchanged <= diff.new_total);
2245 }
2246
2247 #[test]
2248 fn test_filter_by_ecosystem_filters_edge_diffs() {
2249 let mut old = Sbom::default();
2250 let mut new = Sbom::default();
2251
2252 let mut npm_parent = Component::new("npm-app".into(), Some("1.0".into()));
2254 npm_parent.ecosystem = Some("npm".into());
2255 let npm_child_old = Component::new("npm-dep-old".into(), Some("1.0".into()));
2256 let npm_child_new = Component::new("npm-dep-new".into(), Some("1.0".into()));
2257
2258 let mut cargo_parent = Component::new("cargo-app".into(), Some("1.0".into()));
2260 cargo_parent.ecosystem = Some("cargo".into());
2261 let cargo_child = Component::new("cargo-dep".into(), Some("1.0".into()));
2262
2263 old.components
2265 .insert(npm_parent.id.clone(), npm_parent.clone());
2266 old.components
2267 .insert(npm_child_old.id.clone(), npm_child_old.clone());
2268 old.components
2269 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2270
2271 new.components
2273 .insert(npm_parent.id.clone(), npm_parent.clone());
2274 new.components
2275 .insert(npm_child_new.id.clone(), npm_child_new.clone());
2276 new.components
2277 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2278 new.components
2279 .insert(cargo_child.id.clone(), cargo_child.clone());
2280
2281 old.dependencies.insert(
2282 npm_parent.id.clone(),
2283 BTreeMap::from([(npm_child_old.id.clone(), DependencyKind::Runtime)]),
2284 );
2285 new.dependencies.insert(
2286 npm_parent.id.clone(),
2287 BTreeMap::from([(npm_child_new.id.clone(), DependencyKind::Runtime)]),
2288 );
2289 new.dependencies.insert(
2290 cargo_parent.id.clone(),
2291 BTreeMap::from([(cargo_child.id.clone(), DependencyKind::Runtime)]),
2292 );
2293
2294 let mut eco_map: BTreeMap<ComponentId, Option<String>> = BTreeMap::new();
2296 for (id, comp) in old.components.iter().chain(new.components.iter()) {
2297 eco_map.insert(id.clone(), comp.ecosystem.clone());
2298 }
2299
2300 let mut diff = Differ::diff(&old, &new, None);
2301 assert!(diff.edge_diffs.len() >= 2);
2303
2304 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 1, 1, &eco_map);
2306
2307 assert_eq!(diff.edge_diffs.len(), 1);
2309 assert_eq!(diff.edge_diffs[0].parent, npm_parent.id);
2310 }
2311
2312 #[test]
2313 fn test_filter_by_ecosystem_prunes_component_names() {
2314 let mut old = Sbom::default();
2315 let mut new = Sbom::default();
2316
2317 let mut npm_parent = Component::new("npm-app".into(), Some("1.0".into()));
2319 npm_parent.ecosystem = Some("npm".into());
2320 let npm_child = Component::new("npm-dep".into(), Some("1.0".into()));
2321
2322 let mut cargo_parent = Component::new("cargo-app".into(), Some("1.0".into()));
2324 cargo_parent.ecosystem = Some("cargo".into());
2325 let cargo_child = Component::new("cargo-dep".into(), Some("1.0".into()));
2326
2327 old.components
2328 .insert(npm_parent.id.clone(), npm_parent.clone());
2329 old.components
2330 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2331
2332 new.components
2333 .insert(npm_parent.id.clone(), npm_parent.clone());
2334 new.components
2335 .insert(npm_child.id.clone(), npm_child.clone());
2336 new.components
2337 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2338 new.components
2339 .insert(cargo_child.id.clone(), cargo_child.clone());
2340
2341 new.dependencies.insert(
2342 npm_parent.id.clone(),
2343 BTreeMap::from([(npm_child.id.clone(), DependencyKind::Runtime)]),
2344 );
2345 new.dependencies.insert(
2346 cargo_parent.id.clone(),
2347 BTreeMap::from([(cargo_child.id.clone(), DependencyKind::Runtime)]),
2348 );
2349
2350 let mut eco_map: BTreeMap<ComponentId, Option<String>> = BTreeMap::new();
2351 for (id, comp) in old.components.iter().chain(new.components.iter()) {
2352 eco_map.insert(id.clone(), comp.ecosystem.clone());
2353 }
2354
2355 let mut diff = Differ::diff(&old, &new, None);
2356 let names_before = diff.component_names.len();
2357 assert!(names_before > 0, "should have component names for hash IDs");
2358
2359 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 1, 1, &eco_map);
2360
2361 assert!(diff.component_names.len() <= names_before);
2363 for id in diff.component_names.keys() {
2364 let referenced = diff.edge_diffs.iter().any(|e| {
2366 &e.parent == id
2367 || e.added.contains_key(id)
2368 || e.removed.contains_key(id)
2369 || e.kind_changed.contains_key(id)
2370 });
2371 assert!(referenced, "stale component_name entry for {}", id);
2372 }
2373 }
2374
2375 #[test]
2376 fn test_diff_owned_identity() {
2377 let mut sbom = Sbom::default();
2378
2379 let mut parent = Component::new("my-app".to_string(), Some("2.0.0".to_string()));
2381 parent.purl = Some("pkg:cargo/my-app@2.0.0".to_string());
2382 parent.ecosystem = Some("cargo".to_string());
2383 parent.licenses.insert("MIT".into());
2384 parent.supplier = Some("Acme Corp".into());
2385 parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
2386
2387 let mut dep_a = Component::new("dep-a".to_string(), Some("1.0.0".to_string()));
2388 dep_a.purl = Some("pkg:cargo/dep-a@1.0.0".to_string());
2389 dep_a.ecosystem = Some("cargo".to_string());
2390 dep_a.licenses.insert("Apache-2.0".into());
2391 dep_a
2392 .hashes
2393 .insert("sha256".into(), "abcdef1234567890".into());
2394 dep_a.id = ComponentId::new(dep_a.purl.as_deref(), &[]);
2395
2396 let mut dep_b = Component::new("dep-b".to_string(), Some("0.5.0".to_string()));
2397 dep_b.ecosystem = Some("cargo".to_string());
2398 dep_b.description = Some("A helper library".into());
2399
2400 sbom.components.insert(parent.id.clone(), parent.clone());
2401 sbom.components.insert(dep_a.id.clone(), dep_a.clone());
2402 sbom.components.insert(dep_b.id.clone(), dep_b.clone());
2403
2404 sbom.dependencies
2406 .entry(parent.id.clone())
2407 .or_default()
2408 .insert(dep_a.id.clone(), DependencyKind::Runtime);
2409 sbom.dependencies
2410 .entry(parent.id.clone())
2411 .or_default()
2412 .insert(dep_b.id.clone(), DependencyKind::Dev);
2413
2414 let copy = sbom.clone();
2415 let diff = Differ::diff_owned(sbom, copy, None);
2416
2417 assert_eq!(
2418 diff.added.len(),
2419 0,
2420 "identical SBOMs should have no added components"
2421 );
2422 assert_eq!(
2423 diff.removed.len(),
2424 0,
2425 "identical SBOMs should have no removed components"
2426 );
2427 assert_eq!(
2428 diff.changed.len(),
2429 0,
2430 "identical SBOMs should have no changed components"
2431 );
2432 assert_eq!(
2433 diff.edge_diffs.len(),
2434 0,
2435 "identical SBOMs should have no edge diffs"
2436 );
2437 assert_eq!(
2438 diff.metadata_changed, None,
2439 "identical SBOMs should have no metadata changes"
2440 );
2441 assert_eq!(diff.old_total, 3);
2442 assert_eq!(diff.new_total, 3);
2443 assert_eq!(diff.unchanged, 3);
2444 }
2445
2446 #[test]
2447 fn test_diff_detects_version_downgrade() {
2448 let mut old = Sbom::default();
2449 let mut new = Sbom::default();
2450
2451 let c1 = Component::new("pkg-a".to_string(), Some("2.0.0".to_string()));
2452 let mut c2 = c1.clone();
2453 c2.version = Some("1.0.0".to_string());
2454
2455 old.components.insert(c1.id.clone(), c1);
2456 new.components.insert(c2.id.clone(), c2);
2457
2458 let diff = Differ::diff(&old, &new, None);
2459 assert_eq!(diff.changed.len(), 1);
2460 assert!(diff.changed[0].is_downgrade);
2461 }
2462
2463 #[test]
2464 fn test_diff_upgrade_not_marked_as_downgrade() {
2465 let mut old = Sbom::default();
2466 let mut new = Sbom::default();
2467
2468 let c1 = Component::new("pkg-a".to_string(), Some("1.0.0".to_string()));
2469 let mut c2 = c1.clone();
2470 c2.version = Some("2.0.0".to_string());
2471
2472 old.components.insert(c1.id.clone(), c1);
2473 new.components.insert(c2.id.clone(), c2);
2474
2475 let diff = Differ::diff(&old, &new, None);
2476 assert_eq!(diff.changed.len(), 1);
2477 assert!(!diff.changed[0].is_downgrade);
2478 }
2479
2480 fn purl_component(ecosystem: &str, name: &str, version: &str) -> Component {
2481 let purl = format!("pkg:{ecosystem}/{name}@{version}");
2482 let mut comp = Component::new(name.to_string(), Some(version.to_string()));
2483 comp.ecosystem = Some(ecosystem.to_string());
2484 comp.id = ComponentId::new(Some(&purl), &[]);
2485 comp.purl = Some(purl);
2486 comp
2487 }
2488
2489 fn npm_component(name: &str, version: &str) -> Component {
2490 purl_component("npm", name, version)
2491 }
2492
2493 fn plain_component(name: &str, version: &str) -> Component {
2494 Component::new(name.to_string(), Some(version.to_string()))
2495 }
2496
2497 fn sbom_of(components: Vec<Component>) -> Sbom {
2498 let mut sbom = Sbom::default();
2499 for comp in components {
2500 sbom.components.insert(comp.id.clone(), comp);
2501 }
2502 sbom
2503 }
2504
2505 fn version_pairs(diff: &Diff) -> Vec<(String, String)> {
2507 let mut pairs: Vec<(String, String)> = diff
2508 .changed
2509 .iter()
2510 .map(|c| {
2511 (
2512 c.old.version.clone().unwrap_or_default(),
2513 c.new.version.clone().unwrap_or_default(),
2514 )
2515 })
2516 .collect();
2517 pairs.sort();
2518 pairs
2519 }
2520
2521 fn expect_pairs(expected: &[(&str, &str)]) -> Vec<(String, String)> {
2522 let mut pairs: Vec<(String, String)> = expected
2523 .iter()
2524 .map(|(o, n)| (o.to_string(), n.to_string()))
2525 .collect();
2526 pairs.sort();
2527 pairs
2528 }
2529
2530 #[test]
2531 fn test_identity_reconciliation_pairs_same_version_line() {
2532 for (old_versions, new_versions, expected) in [
2533 (
2534 ["9.0.0", "10.0.0"],
2535 ["9.0.1", "10.0.1"],
2536 [("9.0.0", "9.0.1"), ("10.0.0", "10.0.1")],
2537 ),
2538 (
2539 ["1.0.0", "2.0.0"],
2540 ["1.1.0", "2.1.0"],
2541 [("1.0.0", "1.1.0"), ("2.0.0", "2.1.0")],
2542 ),
2543 ] {
2544 let old = sbom_of(
2545 old_versions
2546 .iter()
2547 .map(|v| npm_component("libfoo", v))
2548 .collect(),
2549 );
2550 let new = sbom_of(
2551 new_versions
2552 .iter()
2553 .map(|v| npm_component("libfoo", v))
2554 .collect(),
2555 );
2556
2557 let diff = Differ::diff(&old, &new, None);
2558 assert_eq!(version_pairs(&diff), expect_pairs(&expected));
2559 assert_eq!(diff.added.len(), 0);
2560 assert_eq!(diff.removed.len(), 0);
2561 assert!(
2562 !diff.changed.iter().any(|c| c.is_downgrade),
2563 "upgrading both version lines must not report a downgrade, got {:?}",
2564 version_pairs(&diff)
2565 );
2566 }
2567 }
2568
2569 #[test]
2570 fn test_identity_reconciliation_without_purls_pairs_same_version_line() {
2571 let versions = |vs: [&str; 3]| {
2572 sbom_of(
2573 vs.iter()
2574 .map(|v| Component::new("libfoo".to_string(), Some(v.to_string())))
2575 .collect(),
2576 )
2577 };
2578 let old = versions(["1.0.0", "2.0.0", "3.0.0"]);
2579 let new = versions(["1.0.1", "2.0.1", "3.0.1"]);
2580
2581 let diff = Differ::diff(&old, &new, None);
2582 assert_eq!(
2583 version_pairs(&diff),
2584 expect_pairs(&[("1.0.0", "1.0.1"), ("2.0.0", "2.0.1"), ("3.0.0", "3.0.1")])
2585 );
2586 assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2587 }
2588
2589 #[test]
2590 fn test_identity_reconciliation_more_old_than_new() {
2591 for (survivor, expected) in [("3.0.1", ("3.0.0", "3.0.1")), ("1.0.1", ("1.0.0", "1.0.1"))] {
2592 let old = sbom_of(
2593 ["1.0.0", "2.0.0", "3.0.0"]
2594 .iter()
2595 .map(|v| npm_component("libfoo", v))
2596 .collect(),
2597 );
2598 let new = sbom_of(vec![npm_component("libfoo", survivor)]);
2599
2600 let diff = Differ::diff(&old, &new, None);
2601 assert_eq!(version_pairs(&diff), expect_pairs(&[expected]));
2602 assert_eq!(diff.removed.len(), 2);
2603 assert_eq!(diff.added.len(), 0);
2604 assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2605 }
2606 }
2607
2608 #[test]
2609 fn test_identity_reconciliation_more_new_than_old() {
2610 for (survivor, others, expected) in [
2611 ("1.0.0", ["1.0.1", "2.0.0", "3.0.0"], ("1.0.0", "1.0.1")),
2612 ("3.0.0", ["1.0.0", "2.0.0", "3.0.1"], ("3.0.0", "3.0.1")),
2613 ] {
2614 let old = sbom_of(vec![npm_component("libfoo", survivor)]);
2615 let new = sbom_of(others.iter().map(|v| npm_component("libfoo", v)).collect());
2616
2617 let diff = Differ::diff(&old, &new, None);
2618 assert_eq!(version_pairs(&diff), expect_pairs(&[expected]));
2619 assert_eq!(diff.added.len(), 2);
2620 assert_eq!(diff.removed.len(), 0);
2621 assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2622 }
2623 }
2624
2625 #[test]
2626 fn test_identity_reconciliation_opaque_versions_stay_deterministic() {
2627 let old = sbom_of(vec![
2628 npm_component("libfoo", "nightly-zeta"),
2629 npm_component("libfoo", "nightly-alpha"),
2630 ]);
2631 let new = sbom_of(vec![
2632 npm_component("libfoo", "nightly-omega"),
2633 npm_component("libfoo", "nightly-beta"),
2634 ]);
2635
2636 let diff = Differ::diff(&old, &new, None);
2637 assert_eq!(
2638 version_pairs(&diff),
2639 expect_pairs(&[
2640 ("nightly-zeta", "nightly-beta"),
2641 ("nightly-alpha", "nightly-omega"),
2642 ])
2643 );
2644 assert_eq!(diff.added.len(), 0);
2645 assert_eq!(diff.removed.len(), 0);
2646 }
2647
2648 #[test]
2651 fn test_identity_reconciliation_mixed_version_variants_do_not_panic() {
2652 let deb_and_semver = |new: bool| -> Vec<Component> {
2653 (0..14)
2654 .map(|k| {
2655 let (i, suffix) = (2 * k + 1, u8::from(new));
2656 let version = if k % 2 == 0 {
2657 format!("{i}.0.{suffix}")
2658 } else {
2659 format!("{i}:{}.0-{suffix}", i + 1)
2660 };
2661 npm_component("libfoo", &version)
2662 })
2663 .collect()
2664 };
2665 let opaque_and_semver = |new: bool| -> Vec<Component> {
2666 (0..14)
2667 .map(|k| {
2668 let (i, side) = (2 * k + 1, if new { 'b' } else { 'a' });
2669 let version = if k % 2 == 0 {
2670 format!("{i}.0.{}", u8::from(new))
2671 } else {
2672 format!("{side}{i:07x}deadbeef")
2673 };
2674 npm_component("libfoo", &version)
2675 })
2676 .collect()
2677 };
2678
2679 let builders: [&dyn Fn(bool) -> Vec<Component>; 2] = [&deb_and_semver, &opaque_and_semver];
2680 for build in builders {
2681 let old = sbom_of(build(false));
2682 let new = sbom_of(build(true));
2683
2684 let diff = Differ::diff(&old, &new, None);
2685 assert_eq!(diff.changed.len(), 14);
2686 assert_eq!(diff.added.len(), 0);
2687 assert_eq!(diff.removed.len(), 0);
2688 }
2689 }
2690
2691 #[test]
2694 fn test_identity_reconciliation_prerelease_with_numeric_does_not_panic() {
2695 for groups in [12, 26] {
2696 let old = sbom_of(
2697 (1..=groups)
2698 .flat_map(|i| {
2699 [
2700 npm_component("libfoo", &format!("{i}.2.3")),
2701 npm_component("libfoo", &format!("{i}.2.3.0")),
2702 ]
2703 })
2704 .collect(),
2705 );
2706 let new = sbom_of(
2707 (1..=groups)
2708 .map(|i| npm_component("libfoo", &format!("{i}.2.3-rc.1")))
2709 .collect(),
2710 );
2711
2712 let diff = Differ::diff(&old, &new, None);
2713 assert_eq!(diff.changed.len(), groups);
2714 assert_eq!(diff.added.len(), 0);
2715 assert_eq!(diff.removed.len(), groups);
2716 }
2717 }
2718
2719 #[test]
2720 fn test_identity_reconciliation_ignores_insertion_order() {
2721 for versions in [["9.0.0", "10.0.0"], ["nightly-zeta", "nightly-alpha"]] {
2722 let forward = Differ::diff(
2723 &sbom_of(vec![
2724 npm_component("libfoo", versions[0]),
2725 npm_component("libfoo", versions[1]),
2726 ]),
2727 &sbom_of(vec![
2728 npm_component("libfoo", "4.0.0"),
2729 npm_component("libfoo", "5.0.0"),
2730 ]),
2731 None,
2732 );
2733 let reversed = Differ::diff(
2734 &sbom_of(vec![
2735 npm_component("libfoo", versions[1]),
2736 npm_component("libfoo", versions[0]),
2737 ]),
2738 &sbom_of(vec![
2739 npm_component("libfoo", "5.0.0"),
2740 npm_component("libfoo", "4.0.0"),
2741 ]),
2742 None,
2743 );
2744 assert_eq!(version_pairs(&forward), version_pairs(&reversed));
2745 }
2746 }
2747
2748 #[test]
2749 fn test_edge_diff_with_two_versions_of_one_package() {
2750 let child_a = Component::new("child-a".to_string(), Some("1.0.0".to_string()));
2751 let child_b = Component::new("child-b".to_string(), Some("1.0.0".to_string()));
2752
2753 let build = |parent_versions: [&str; 2]| {
2754 let parents = parent_versions.map(|v| npm_component("libfoo", v));
2755 let mut sbom = sbom_of(vec![
2756 parents[0].clone(),
2757 parents[1].clone(),
2758 child_a.clone(),
2759 child_b.clone(),
2760 ]);
2761 for (parent, child) in parents.iter().zip([&child_a, &child_b]) {
2762 sbom.dependencies
2763 .entry(parent.id.clone())
2764 .or_default()
2765 .insert(child.id.clone(), DependencyKind::Runtime);
2766 }
2767 sbom
2768 };
2769
2770 let diff = Differ::diff(&build(["1.0.0", "2.0.0"]), &build(["1.0.1", "2.0.1"]), None);
2771 assert_eq!(
2772 diff.edge_diffs.len(),
2773 0,
2774 "both parents kept their dependency, got {:?}",
2775 diff.edge_diffs
2776 );
2777 }
2778
2779 #[test]
2782 fn test_wildcard_reconciliation_pairs_same_version_line() {
2783 for (old_comps, new_comps) in [
2784 (
2785 vec![
2786 npm_component("libfoo", "1.0.0"),
2787 npm_component("libfoo", "2.0.0"),
2788 ],
2789 vec![
2790 plain_component("libfoo", "1.0.0"),
2791 plain_component("libfoo", "2.0.0"),
2792 ],
2793 ),
2794 (
2795 vec![
2796 plain_component("libfoo", "1.0.0"),
2797 plain_component("libfoo", "2.0.0"),
2798 ],
2799 vec![
2800 npm_component("libfoo", "1.0.0"),
2801 npm_component("libfoo", "2.0.0"),
2802 ],
2803 ),
2804 ] {
2805 let diff = Differ::diff(&sbom_of(old_comps), &sbom_of(new_comps), None);
2806 assert_eq!(
2807 version_pairs(&diff),
2808 expect_pairs(&[("1.0.0", "1.0.0"), ("2.0.0", "2.0.0")])
2809 );
2810 assert_eq!(diff.added.len(), 0);
2811 assert_eq!(diff.removed.len(), 0);
2812 assert!(
2813 !diff.changed.iter().any(|c| c.is_downgrade),
2814 "re-serializing the same versions must not report a downgrade, got {:?}",
2815 version_pairs(&diff)
2816 );
2817 }
2818 }
2819
2820 #[test]
2821 fn test_wildcard_reconciliation_prefers_version_nearest_candidate() {
2822 let old = sbom_of(vec![
2823 npm_component("libfoo", "2.0.0"),
2824 purl_component("pypi", "libfoo", "1.0.0"),
2825 ]);
2826 let new = sbom_of(vec![plain_component("libfoo", "1.0.1")]);
2827
2828 let diff = Differ::diff(&old, &new, None);
2829 assert_eq!(version_pairs(&diff), expect_pairs(&[("1.0.0", "1.0.1")]));
2830 assert_eq!(diff.removed.len(), 1);
2831 assert_eq!(diff.added.len(), 0);
2832 assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2833 }
2834
2835 #[test]
2836 fn test_wildcard_reconciliation_breaks_version_ties_deterministically() {
2837 let old = sbom_of(
2838 ["cargo", "npm", "pypi"]
2839 .iter()
2840 .map(|eco| purl_component(eco, "libfoo", "1.0.0"))
2841 .collect(),
2842 );
2843 let new = sbom_of(vec![plain_component("libfoo", "1.0.1")]);
2844
2845 let diff = Differ::diff(&old, &new, None);
2846 assert_eq!(diff.changed.len(), 1);
2847 assert_eq!(
2848 diff.changed[0].old.id.as_str(),
2849 "pkg:pypi/libfoo@1.0.0",
2850 "equal versions must resolve by merged version order, not ecosystem name"
2851 );
2852 assert_eq!(diff.removed.len(), 2);
2853 assert_eq!(diff.added.len(), 0);
2854 }
2855
2856 #[test]
2857 fn test_exact_ecosystem_match_beats_a_nearer_wildcard() {
2858 let old = sbom_of(vec![
2859 npm_component("libfoo", "1.0.0"),
2860 plain_component("libfoo", "2.0.0"),
2861 ]);
2862 let new = sbom_of(vec![npm_component("libfoo", "2.0.1")]);
2863
2864 let diff = Differ::diff(&old, &new, None);
2865 assert_eq!(version_pairs(&diff), expect_pairs(&[("1.0.0", "2.0.1")]));
2866 assert_eq!(diff.removed.len(), 1);
2867 assert_eq!(diff.added.len(), 0);
2868 }
2869}