1#![doc = include_str!("../readme.md")]
2
3use sbom_model::versions::is_version_downgrade;
4use sbom_model::{Component, ComponentId, DependencyKind, Sbom};
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, BTreeSet, HashSet};
7
8pub mod renderer;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct MetadataChange {
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub timestamp: Option<(Option<String>, Option<String>)>,
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub tools: Option<(Vec<String>, Vec<String>)>,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub authors: Option<(Vec<String>, Vec<String>)>,
26}
27
28impl MetadataChange {
29 pub fn is_empty(&self) -> bool {
31 self.timestamp.is_none() && self.tools.is_none() && self.authors.is_none()
32 }
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
37pub struct EcosystemCounts {
38 pub added: usize,
39 pub removed: usize,
40 pub changed: usize,
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
48pub struct Diff {
49 pub added: Vec<Component>,
51 pub removed: Vec<Component>,
53 pub changed: Vec<ComponentChange>,
55 pub edge_diffs: Vec<EdgeDiff>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub metadata_changed: Option<MetadataChange>,
60 pub old_total: usize,
62 pub new_total: usize,
64 pub unchanged: usize,
66 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
71 pub component_names: BTreeMap<ComponentId, String>,
72}
73
74impl Diff {
75 pub fn is_empty(&self) -> bool {
77 self.added.is_empty()
78 && self.removed.is_empty()
79 && self.changed.is_empty()
80 && self.edge_diffs.is_empty()
81 && self.metadata_changed.is_none()
82 }
83
84 pub fn display_name<'a>(&'a self, id: &'a ComponentId) -> &'a str {
88 self.component_names
89 .get(id)
90 .map(String::as_str)
91 .unwrap_or_else(|| id.as_str())
92 }
93
94 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
98 let mut breakdown: BTreeMap<String, EcosystemCounts> = BTreeMap::new();
99
100 for comp in &self.added {
101 let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
102 breakdown.entry(eco).or_default().added += 1;
103 }
104
105 for comp in &self.removed {
106 let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
107 breakdown.entry(eco).or_default().removed += 1;
108 }
109
110 for change in &self.changed {
111 let eco = change
112 .new
113 .ecosystem
114 .as_deref()
115 .unwrap_or("unknown")
116 .to_string();
117 breakdown.entry(eco).or_default().changed += 1;
118 }
119
120 breakdown
121 }
122
123 pub fn group_by_ecosystem(&self) -> GroupedDiff {
130 group_components_by_ecosystem(
131 self.added.iter().cloned(),
132 self.removed.iter().cloned(),
133 self.changed.iter().cloned(),
134 self.edge_diffs.clone(),
135 self.metadata_changed.clone(),
136 )
137 }
138
139 pub fn into_group_by_ecosystem(self) -> GroupedDiff {
142 group_components_by_ecosystem(
143 self.added,
144 self.removed,
145 self.changed,
146 self.edge_diffs,
147 self.metadata_changed,
148 )
149 }
150
151 pub fn filter_by_ecosystem<F: Fn(Option<&str>) -> bool>(
163 &mut self,
164 matches: &F,
165 filtered_old_total: usize,
166 filtered_new_total: usize,
167 component_ecosystems: &BTreeMap<ComponentId, Option<String>>,
168 ) {
169 self.added.retain(|c| matches(c.ecosystem.as_deref()));
170 self.removed.retain(|c| matches(c.ecosystem.as_deref()));
171 self.changed.retain(|c| matches(c.new.ecosystem.as_deref()));
172
173 self.edge_diffs.retain(|edge| {
176 component_ecosystems
177 .get(&edge.parent)
178 .map(|eco| matches(eco.as_deref()))
179 .unwrap_or(true)
180 });
181
182 let mut referenced_ids = BTreeSet::new();
184 for edge in &self.edge_diffs {
185 referenced_ids.insert(&edge.parent);
186 referenced_ids.extend(edge.added.keys());
187 referenced_ids.extend(edge.removed.keys());
188 referenced_ids.extend(edge.kind_changed.keys());
189 }
190 self.component_names
191 .retain(|id, _| referenced_ids.contains(id));
192
193 self.old_total = filtered_old_total;
194 self.new_total = filtered_new_total;
195 self.unchanged = filtered_old_total
198 .saturating_sub(self.removed.len())
199 .saturating_sub(self.changed.len());
200 }
201}
202
203fn group_components_by_ecosystem(
207 added: impl IntoIterator<Item = Component>,
208 removed: impl IntoIterator<Item = Component>,
209 changed: impl IntoIterator<Item = ComponentChange>,
210 edge_diffs: Vec<EdgeDiff>,
211 metadata_changed: Option<MetadataChange>,
212) -> GroupedDiff {
213 let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
214
215 for c in added {
216 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
217 ecosystems.entry(eco).or_default().added.push(c);
218 }
219 for c in removed {
220 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
221 ecosystems.entry(eco).or_default().removed.push(c);
222 }
223 for c in changed {
224 let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
225 ecosystems.entry(eco).or_default().changed.push(c);
226 }
227
228 GroupedDiff {
229 by_ecosystem: ecosystems,
230 edge_diffs,
231 metadata_changed,
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct GroupedDiff {
238 pub by_ecosystem: BTreeMap<String, EcosystemDiff>,
239 pub edge_diffs: Vec<EdgeDiff>,
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub metadata_changed: Option<MetadataChange>,
242}
243
244impl GroupedDiff {
245 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
251 self.by_ecosystem
252 .iter()
253 .map(|(eco, eco_diff)| {
254 (
255 eco.clone(),
256 EcosystemCounts {
257 added: eco_diff.added.len(),
258 removed: eco_diff.removed.len(),
259 changed: eco_diff.changed.len(),
260 },
261 )
262 })
263 .collect()
264 }
265}
266
267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
269pub struct EcosystemDiff {
270 pub added: Vec<Component>,
271 pub removed: Vec<Component>,
272 pub changed: Vec<ComponentChange>,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct ComponentChange {
278 pub id: ComponentId,
280 pub old: Component,
282 pub new: Component,
284 pub changes: Vec<FieldChange>,
286 #[serde(default, skip_serializing_if = "is_false")]
288 pub is_downgrade: bool,
289}
290
291fn is_false(b: &bool) -> bool {
292 !b
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct EdgeDiff {
298 pub parent: ComponentId,
300 pub added: BTreeMap<ComponentId, DependencyKind>,
302 pub removed: BTreeMap<ComponentId, DependencyKind>,
304 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
306 pub kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)>,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
311pub enum FieldChange {
312 Version(Option<String>, Option<String>),
314 License(BTreeSet<String>, BTreeSet<String>),
316 Supplier(Option<String>, Option<String>),
318 Purl(Option<String>, Option<String>),
320 Description(Option<String>, Option<String>),
322 Hashes(BTreeMap<String, String>, BTreeMap<String, String>),
324 Ecosystem(Option<String>, Option<String>),
326}
327
328#[derive(Debug, Copy, Clone, PartialEq, Eq, clap::ValueEnum)]
332pub enum Field {
333 Version,
335 License,
337 Supplier,
339 Purl,
341 Description,
343 Hashes,
345 Ecosystem,
347 Deps,
349}
350
351pub struct Differ;
356
357impl Differ {
358 pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
387 Self::diff_owned(old.clone(), new.clone(), only)
388 }
389
390 pub fn diff_owned(mut old: Sbom, mut new: Sbom, only: Option<&[Field]>) -> Diff {
398 let metadata_changed = {
400 let mut mc = MetadataChange {
401 timestamp: None,
402 tools: None,
403 authors: None,
404 };
405 if old.metadata.timestamp != new.metadata.timestamp {
406 mc.timestamp = Some((
407 old.metadata.timestamp.clone(),
408 new.metadata.timestamp.clone(),
409 ));
410 }
411 if old.metadata.tools != new.metadata.tools {
412 mc.tools = Some((old.metadata.tools.clone(), new.metadata.tools.clone()));
413 }
414 if old.metadata.authors != new.metadata.authors {
415 mc.authors = Some((old.metadata.authors.clone(), new.metadata.authors.clone()));
416 }
417 if mc.is_empty() {
418 None
419 } else {
420 Some(mc)
421 }
422 };
423
424 old.normalize();
425 new.normalize();
426
427 let mut changed_pairs: Vec<(ComponentId, ComponentId, Vec<FieldChange>)> = Vec::new();
431 let mut matched_old: HashSet<ComponentId> = HashSet::new();
432 let mut matched_new: HashSet<ComponentId> = HashSet::new();
433
434 let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
436
437 for (id, new_comp) in &new.components {
439 if let Some(old_comp) = old.components.get(id) {
440 matched_old.insert(id.clone());
441 matched_new.insert(id.clone());
442 id_mapping.insert(id.clone(), id.clone());
443
444 let fields = Self::compute_fields(old_comp, new_comp, only);
445 if !fields.is_empty() {
446 changed_pairs.push((id.clone(), id.clone(), fields));
447 }
448 }
449 }
450
451 let mut old_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
460 BTreeMap::new();
461 for (id, comp) in &old.components {
462 if !matched_old.contains(id) {
463 old_identity_map
464 .entry(comp.name.clone())
465 .or_default()
466 .entry(comp.ecosystem.clone())
467 .or_default()
468 .push(id.clone());
469 }
470 }
471
472 for (id, new_comp) in &new.components {
473 if matched_new.contains(id) {
474 continue;
475 }
476
477 let matched_old_id = old_identity_map
482 .get_mut(&new_comp.name)
483 .and_then(|eco_map| {
484 eco_map
486 .get_mut(&new_comp.ecosystem)
487 .and_then(|ids| ids.pop())
488 .or_else(|| {
489 if new_comp.ecosystem.is_some() {
490 eco_map.get_mut(&None).and_then(|ids| ids.pop())
492 } else {
493 eco_map.values_mut().find_map(|ids| ids.pop())
495 }
496 })
497 });
498
499 if let Some(old_id) = matched_old_id {
500 if let Some(old_comp) = old.components.get(&old_id) {
501 matched_old.insert(old_id.clone());
502 matched_new.insert(id.clone());
503 id_mapping.insert(old_id.clone(), id.clone());
504
505 let fields = Self::compute_fields(old_comp, new_comp, only);
506 if !fields.is_empty() {
507 changed_pairs.push((old_id, id.clone(), fields));
508 }
509 }
510 }
511 }
512
513 let old_total = old.components.len();
515 let new_total = new.components.len();
516 let matched = matched_old.len();
517 let unchanged = matched - changed_pairs.len();
518
519 let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
521 let edge_diffs = if should_include_deps {
522 Self::compute_edge_diffs(&old, &new, &id_mapping)
523 } else {
524 Vec::new()
525 };
526
527 let component_names = Self::build_component_names(&old, &new, &edge_diffs);
529
530 let mut changed = Vec::with_capacity(changed_pairs.len());
535 for (old_id, new_id, fields) in changed_pairs {
536 let old_comp = old.components.swap_remove(&old_id).unwrap();
537 let new_comp = new.components.swap_remove(&new_id).unwrap();
538 let downgrade = fields.iter().any(|f| match f {
539 FieldChange::Version(Some(old_ver), Some(new_ver)) => {
540 is_version_downgrade(old_ver, new_ver)
541 }
542 _ => false,
543 });
544 changed.push(ComponentChange {
545 id: new_comp.id.clone(),
546 old: old_comp,
547 new: new_comp,
548 changes: fields,
549 is_downgrade: downgrade,
550 });
551 }
552
553 for id in &matched_old {
556 old.components.swap_remove(id);
557 }
558 for id in &matched_new {
559 new.components.swap_remove(id);
560 }
561
562 let added: Vec<Component> = new.components.into_values().collect();
564 let removed: Vec<Component> = old.components.into_values().collect();
565
566 Diff {
567 added,
568 removed,
569 changed,
570 edge_diffs,
571 metadata_changed,
572 old_total,
573 new_total,
574 unchanged,
575 component_names,
576 }
577 }
578
579 fn compute_edge_diffs(
586 old: &Sbom,
587 new: &Sbom,
588 id_mapping: &BTreeMap<ComponentId, ComponentId>,
589 ) -> Vec<EdgeDiff> {
590 let mut edge_diffs = Vec::new();
591
592 let reverse_mapping: BTreeMap<&ComponentId, &ComponentId> = id_mapping
594 .iter()
595 .map(|(old_id, new_id)| (new_id, old_id))
596 .collect();
597
598 let mut all_parents: BTreeSet<&ComponentId> = new.dependencies.keys().collect();
600 for old_parent in old.dependencies.keys() {
601 all_parents.insert(id_mapping.get(old_parent).unwrap_or(old_parent));
602 }
603
604 let empty_deps = BTreeMap::new();
605
606 for parent_id in all_parents {
607 let new_children = new.dependencies.get(parent_id).unwrap_or(&empty_deps);
609
610 let old_parent_id = reverse_mapping.get(parent_id).copied().unwrap_or(parent_id);
611
612 let old_children: BTreeMap<&ComponentId, DependencyKind> = old
614 .dependencies
615 .get(old_parent_id)
616 .map(|children| {
617 children
618 .iter()
619 .map(|(id, &kind)| (id_mapping.get(id).unwrap_or(id), kind))
620 .collect()
621 })
622 .unwrap_or_default();
623
624 let new_keys: BTreeSet<&ComponentId> = new_children.keys().collect();
625 let old_keys: BTreeSet<&ComponentId> = old_children.keys().copied().collect();
626
627 let added: BTreeMap<ComponentId, DependencyKind> = new_keys
629 .difference(&old_keys)
630 .map(|&id| (id.clone(), new_children[id]))
631 .collect();
632 let removed: BTreeMap<ComponentId, DependencyKind> = old_keys
633 .difference(&new_keys)
634 .map(|&id| (id.clone(), old_children[id]))
635 .collect();
636
637 let kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)> = new_keys
638 .intersection(&old_keys)
639 .filter_map(|&id| {
640 let old_kind = old_children[id];
641 let new_kind = new_children[id];
642 if old_kind != new_kind {
643 Some((id.clone(), (old_kind, new_kind)))
644 } else {
645 None
646 }
647 })
648 .collect();
649
650 if !added.is_empty() || !removed.is_empty() || !kind_changed.is_empty() {
651 edge_diffs.push(EdgeDiff {
652 parent: parent_id.clone(),
653 added,
654 removed,
655 kind_changed,
656 });
657 }
658 }
659
660 edge_diffs
661 }
662
663 fn build_component_names(
668 old: &Sbom,
669 new: &Sbom,
670 edge_diffs: &[EdgeDiff],
671 ) -> BTreeMap<ComponentId, String> {
672 let mut names = BTreeMap::new();
673
674 let mut ids = BTreeSet::new();
676 for edge in edge_diffs {
677 ids.insert(&edge.parent);
678 ids.extend(edge.added.keys());
679 ids.extend(edge.removed.keys());
680 ids.extend(edge.kind_changed.keys());
681 }
682
683 for id in ids {
685 if !id.as_str().starts_with("h:") {
686 continue;
687 }
688
689 let comp = new.components.get(id).or_else(|| old.components.get(id));
691 if let Some(comp) = comp {
692 let display = match &comp.version {
693 Some(v) => format!("{}@{}", comp.name, v),
694 None => comp.name.clone(),
695 };
696 names.insert(id.clone(), display);
697 }
698 }
699
700 names
701 }
702
703 fn compute_fields(
711 old: &Component,
712 new: &Component,
713 only: Option<&[Field]>,
714 ) -> Vec<FieldChange> {
715 let mut changes = Vec::new();
716
717 let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
718
719 if should_include(Field::Version) && old.version != new.version {
720 changes.push(FieldChange::Version(
721 old.version.clone(),
722 new.version.clone(),
723 ));
724 }
725
726 if should_include(Field::License) && old.licenses != new.licenses {
727 changes.push(FieldChange::License(
728 old.licenses.clone(),
729 new.licenses.clone(),
730 ));
731 }
732
733 if should_include(Field::Supplier) && old.supplier != new.supplier {
734 changes.push(FieldChange::Supplier(
735 old.supplier.clone(),
736 new.supplier.clone(),
737 ));
738 }
739
740 if should_include(Field::Purl) && old.purl != new.purl {
741 changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
742 }
743
744 if should_include(Field::Description) && old.description != new.description {
745 changes.push(FieldChange::Description(
746 old.description.clone(),
747 new.description.clone(),
748 ));
749 }
750
751 if should_include(Field::Hashes) && old.hashes != new.hashes {
752 changes.push(FieldChange::Hashes(old.hashes.clone(), new.hashes.clone()));
753 }
754
755 if should_include(Field::Ecosystem) && old.ecosystem != new.ecosystem {
756 changes.push(FieldChange::Ecosystem(
757 old.ecosystem.clone(),
758 new.ecosystem.clone(),
759 ));
760 }
761
762 changes
763 }
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769
770 #[test]
771 fn test_diff_added_removed() {
772 let mut old = Sbom::default();
773 let mut new = Sbom::default();
774
775 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
776 let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
777
778 old.components.insert(c1.id.clone(), c1);
779 new.components.insert(c2.id.clone(), c2);
780
781 let diff = Differ::diff(&old, &new, None);
782 assert_eq!(diff.added.len(), 1);
783 assert_eq!(diff.removed.len(), 1);
784 assert_eq!(diff.changed.len(), 0);
785 }
786
787 #[test]
788 fn test_diff_changed() {
789 let mut old = Sbom::default();
790 let mut new = Sbom::default();
791
792 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
793 let mut c2 = c1.clone();
794 c2.version = Some("1.1".to_string());
795
796 old.components.insert(c1.id.clone(), c1);
797 new.components.insert(c2.id.clone(), c2);
798
799 let diff = Differ::diff(&old, &new, None);
800 assert_eq!(diff.added.len(), 0);
801 assert_eq!(diff.removed.len(), 0);
802 assert_eq!(diff.changed.len(), 1);
803 assert!(matches!(
804 diff.changed[0].changes[0],
805 FieldChange::Version(_, _)
806 ));
807 }
808
809 #[test]
810 fn test_diff_identity_reconciliation() {
811 let mut old = Sbom::default();
812 let mut new = Sbom::default();
813
814 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
815 let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
816
817 old.components.insert(c1.id.clone(), c1);
818 new.components.insert(c2.id.clone(), c2);
819
820 let diff = Differ::diff(&old, &new, None);
821 assert_eq!(diff.changed.len(), 1);
822 assert_eq!(diff.added.len(), 0);
823 }
824
825 #[test]
826 fn test_diff_license_change() {
827 let mut old = Sbom::default();
828 let mut new = Sbom::default();
829
830 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
831 c1.licenses.insert("MIT".into());
832 let mut c2 = c1.clone();
833 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
834
835 old.components.insert(c1.id.clone(), c1);
836 new.components.insert(c2.id.clone(), c2);
837
838 let diff = Differ::diff(&old, &new, None);
839 assert_eq!(diff.changed.len(), 1);
840 assert!(diff.changed[0]
841 .changes
842 .iter()
843 .any(|c| matches!(c, FieldChange::License(_, _))));
844 }
845
846 #[test]
847 fn test_diff_supplier_change() {
848 let mut old = Sbom::default();
849 let mut new = Sbom::default();
850
851 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
852 c1.supplier = Some("Acme Corp".into());
853 let mut c2 = c1.clone();
854 c2.supplier = Some("New Corp".into());
855
856 old.components.insert(c1.id.clone(), c1);
857 new.components.insert(c2.id.clone(), c2);
858
859 let diff = Differ::diff(&old, &new, None);
860 assert_eq!(diff.changed.len(), 1);
861 assert!(diff.changed[0]
862 .changes
863 .iter()
864 .any(|c| matches!(c, FieldChange::Supplier(_, _))));
865 }
866
867 #[test]
868 fn test_diff_hashes_change() {
869 let mut old = Sbom::default();
870 let mut new = Sbom::default();
871
872 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
873 c1.hashes.insert("sha256".into(), "aaa".into());
874 let mut c2 = c1.clone();
875 c2.hashes.insert("sha256".into(), "bbb".into());
876
877 old.components.insert(c1.id.clone(), c1);
878 new.components.insert(c2.id.clone(), c2);
879
880 let diff = Differ::diff(&old, &new, None);
881 assert_eq!(diff.changed.len(), 1);
882 assert!(diff.changed[0]
883 .changes
884 .iter()
885 .any(|c| matches!(c, FieldChange::Hashes(_, _))));
886 }
887
888 #[test]
889 fn test_diff_description_change() {
890 let mut old = Sbom::default();
891 let mut new = Sbom::default();
892
893 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
894 c1.description = Some("Old description".into());
895 let mut c2 = c1.clone();
896 c2.description = Some("New description".into());
897
898 old.components.insert(c1.id.clone(), c1);
899 new.components.insert(c2.id.clone(), c2);
900
901 let diff = Differ::diff(&old, &new, None);
902 assert_eq!(diff.changed.len(), 1);
903 assert!(diff.changed[0]
904 .changes
905 .iter()
906 .any(|c| matches!(c, FieldChange::Description(_, _))));
907 }
908
909 #[test]
910 fn test_diff_description_added() {
911 let mut old = Sbom::default();
912 let mut new = Sbom::default();
913
914 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
915 let mut c2 = c1.clone();
916 c2.description = Some("A new description".into());
917
918 old.components.insert(c1.id.clone(), c1);
919 new.components.insert(c2.id.clone(), c2);
920
921 let diff = Differ::diff(&old, &new, None);
922 assert_eq!(diff.changed.len(), 1);
923 assert!(diff.changed[0]
924 .changes
925 .iter()
926 .any(|c| matches!(c, FieldChange::Description(None, Some(_)))));
927 }
928
929 #[test]
930 fn test_diff_description_removed() {
931 let mut old = Sbom::default();
932 let mut new = Sbom::default();
933
934 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
935 c1.description = Some("Had a description".into());
936 let mut c2 = c1.clone();
937 c2.description = None;
938
939 old.components.insert(c1.id.clone(), c1);
940 new.components.insert(c2.id.clone(), c2);
941
942 let diff = Differ::diff(&old, &new, None);
943 assert_eq!(diff.changed.len(), 1);
944 assert!(diff.changed[0]
945 .changes
946 .iter()
947 .any(|c| matches!(c, FieldChange::Description(Some(_), None))));
948 }
949
950 #[test]
951 fn test_diff_description_unchanged() {
952 let mut old = Sbom::default();
953 let mut new = Sbom::default();
954
955 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
956 c1.description = Some("Same description".into());
957 let c2 = c1.clone();
958
959 old.components.insert(c1.id.clone(), c1);
960 new.components.insert(c2.id.clone(), c2);
961
962 let diff = Differ::diff(&old, &new, None);
963 assert!(diff.changed.is_empty());
964 }
965
966 #[test]
967 fn test_diff_description_filtering() {
968 let mut old = Sbom::default();
969 let mut new = Sbom::default();
970
971 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
972 c1.description = Some("Old".into());
973 let mut c2 = c1.clone();
974 c2.version = Some("2.0".into());
975 c2.description = Some("New".into());
976
977 old.components.insert(c1.id.clone(), c1);
978 new.components.insert(c2.id.clone(), c2);
979
980 let diff = Differ::diff(&old, &new, Some(&[Field::Description]));
982 assert_eq!(diff.changed.len(), 1);
983 assert_eq!(diff.changed[0].changes.len(), 1);
984 assert!(matches!(
985 diff.changed[0].changes[0],
986 FieldChange::Description(_, _)
987 ));
988
989 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
991 assert_eq!(diff.changed.len(), 1);
992 assert_eq!(diff.changed[0].changes.len(), 1);
993 assert!(matches!(
994 diff.changed[0].changes[0],
995 FieldChange::Version(_, _)
996 ));
997 }
998
999 #[test]
1000 fn test_diff_ecosystem_change() {
1001 let mut old = Sbom::default();
1002 let mut new = Sbom::default();
1003
1004 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1005 c1.ecosystem = Some("npm".to_string());
1006 let mut c2 = c1.clone();
1007 c2.ecosystem = Some("cargo".to_string());
1008
1009 old.components.insert(c1.id.clone(), c1);
1010 new.components.insert(c2.id.clone(), c2);
1011
1012 let diff = Differ::diff(&old, &new, None);
1013 assert_eq!(diff.changed.len(), 1);
1014 assert_eq!(diff.changed[0].changes.len(), 1);
1015 assert!(matches!(
1016 diff.changed[0].changes[0],
1017 FieldChange::Ecosystem(_, _)
1018 ));
1019
1020 if let FieldChange::Ecosystem(ref o, ref n) = diff.changed[0].changes[0] {
1021 assert_eq!(o.as_deref(), Some("npm"));
1022 assert_eq!(n.as_deref(), Some("cargo"));
1023 }
1024 }
1025
1026 #[test]
1027 fn test_diff_ecosystem_change_from_none() {
1028 let mut old = Sbom::default();
1029 let mut new = Sbom::default();
1030
1031 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1032 let mut c2 = c1.clone();
1033 c2.ecosystem = Some("npm".to_string());
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_eq!(diff.changed[0].changes.len(), 1);
1041 assert!(matches!(
1042 diff.changed[0].changes[0],
1043 FieldChange::Ecosystem(None, Some(_))
1044 ));
1045 }
1046
1047 #[test]
1048 fn test_diff_ecosystem_filtering() {
1049 let mut old = Sbom::default();
1050 let mut new = Sbom::default();
1051
1052 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1053 c1.ecosystem = Some("npm".to_string());
1054 let mut c2 = c1.clone();
1055 c2.version = Some("2.0".into());
1056 c2.ecosystem = Some("cargo".to_string());
1057
1058 old.components.insert(c1.id.clone(), c1);
1059 new.components.insert(c2.id.clone(), c2);
1060
1061 let diff = Differ::diff(&old, &new, Some(&[Field::Ecosystem]));
1063 assert_eq!(diff.changed.len(), 1);
1064 assert_eq!(diff.changed[0].changes.len(), 1);
1065 assert!(matches!(
1066 diff.changed[0].changes[0],
1067 FieldChange::Ecosystem(_, _)
1068 ));
1069
1070 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1072 assert_eq!(diff.changed.len(), 1);
1073 assert_eq!(diff.changed[0].changes.len(), 1);
1074 assert!(matches!(
1075 diff.changed[0].changes[0],
1076 FieldChange::Version(_, _)
1077 ));
1078 }
1079
1080 #[test]
1081 fn test_diff_ecosystem_no_change() {
1082 let mut old = Sbom::default();
1083 let mut new = Sbom::default();
1084
1085 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1086 c1.ecosystem = Some("npm".to_string());
1087 let c2 = c1.clone();
1088
1089 old.components.insert(c1.id.clone(), c1);
1090 new.components.insert(c2.id.clone(), c2);
1091
1092 let diff = Differ::diff(&old, &new, None);
1093 assert!(diff.changed.is_empty());
1094 }
1095
1096 #[test]
1097 fn test_diff_multiple_field_changes() {
1098 let mut old = Sbom::default();
1099 let mut new = Sbom::default();
1100
1101 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1102 c1.licenses.insert("MIT".into());
1103 c1.supplier = Some("Old Corp".into());
1104 c1.hashes.insert("sha256".into(), "aaa".into());
1105
1106 let mut c2 = c1.clone();
1107 c2.version = Some("2.0".into());
1108 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1109 c2.supplier = Some("New Corp".into());
1110 c2.hashes.insert("sha256".into(), "bbb".into());
1111
1112 old.components.insert(c1.id.clone(), c1);
1113 new.components.insert(c2.id.clone(), c2);
1114
1115 let diff = Differ::diff(&old, &new, None);
1116 assert_eq!(diff.changed.len(), 1);
1117 assert_eq!(diff.changed[0].changes.len(), 4);
1118 }
1119
1120 #[test]
1121 fn test_diff_no_changes() {
1122 let mut old = Sbom::default();
1123 let mut new = Sbom::default();
1124
1125 let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1126 old.components.insert(c.id.clone(), c.clone());
1127 new.components.insert(c.id.clone(), c);
1128
1129 let diff = Differ::diff(&old, &new, None);
1130 assert!(diff.added.is_empty());
1131 assert!(diff.removed.is_empty());
1132 assert!(diff.changed.is_empty());
1133 assert!(diff.edge_diffs.is_empty());
1134 }
1135
1136 #[test]
1137 fn test_diff_metadata_changed_timestamp() {
1138 let mut old = Sbom::default();
1139 let mut new = Sbom::default();
1140
1141 old.metadata.timestamp = Some("2024-01-01".into());
1142 new.metadata.timestamp = Some("2024-01-02".into());
1143
1144 let diff = Differ::diff(&old, &new, None);
1145 let mc = diff.metadata_changed.as_ref().unwrap();
1146 assert_eq!(
1147 mc.timestamp,
1148 Some((Some("2024-01-01".into()), Some("2024-01-02".into())))
1149 );
1150 assert!(mc.tools.is_none());
1151 assert!(mc.authors.is_none());
1152 assert!(!diff.is_empty());
1153 }
1154
1155 #[test]
1156 fn test_diff_metadata_changed_tools() {
1157 let mut old = Sbom::default();
1158 let mut new = Sbom::default();
1159
1160 old.metadata.tools = vec!["syft".into()];
1161 new.metadata.tools = vec!["trivy".into()];
1162
1163 let diff = Differ::diff(&old, &new, None);
1164 let mc = diff.metadata_changed.as_ref().unwrap();
1165 assert!(mc.timestamp.is_none());
1166 assert_eq!(mc.tools, Some((vec!["syft".into()], vec!["trivy".into()])));
1167 assert!(mc.authors.is_none());
1168 }
1169
1170 #[test]
1171 fn test_diff_metadata_changed_authors() {
1172 let mut old = Sbom::default();
1173 let mut new = Sbom::default();
1174
1175 old.metadata.authors = vec!["alice".into()];
1176 new.metadata.authors = vec!["bob".into()];
1177
1178 let diff = Differ::diff(&old, &new, None);
1179 let mc = diff.metadata_changed.as_ref().unwrap();
1180 assert!(mc.timestamp.is_none());
1181 assert!(mc.tools.is_none());
1182 assert_eq!(mc.authors, Some((vec!["alice".into()], vec!["bob".into()])));
1183 }
1184
1185 #[test]
1186 fn test_diff_metadata_unchanged() {
1187 let mut old = Sbom::default();
1188 let mut new = Sbom::default();
1189
1190 old.metadata.timestamp = Some("2024-01-01".into());
1191 new.metadata.timestamp = Some("2024-01-01".into());
1192 old.metadata.tools = vec!["syft".into()];
1193 new.metadata.tools = vec!["syft".into()];
1194
1195 let diff = Differ::diff(&old, &new, None);
1196 assert!(diff.metadata_changed.is_none());
1197 }
1198
1199 #[test]
1200 fn test_diff_metadata_changed_multiple_fields() {
1201 let mut old = Sbom::default();
1202 let mut new = Sbom::default();
1203
1204 old.metadata.timestamp = Some("2024-01-01".into());
1205 new.metadata.timestamp = Some("2024-01-02".into());
1206 old.metadata.tools = vec!["syft".into()];
1207 new.metadata.tools = vec!["trivy".into()];
1208 old.metadata.authors = vec!["alice".into()];
1209 new.metadata.authors = vec!["bob".into()];
1210
1211 let diff = Differ::diff(&old, &new, None);
1212 let mc = diff.metadata_changed.as_ref().unwrap();
1213 assert!(mc.timestamp.is_some());
1214 assert!(mc.tools.is_some());
1215 assert!(mc.authors.is_some());
1216 }
1217
1218 #[test]
1219 fn test_diff_filtering() {
1220 let mut old = Sbom::default();
1221 let mut new = Sbom::default();
1222
1223 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1224 c1.licenses.insert("MIT".into());
1225
1226 let mut c2 = c1.clone();
1227 c2.version = Some("1.1".to_string());
1228 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1229
1230 old.components.insert(c1.id.clone(), c1);
1231 new.components.insert(c2.id.clone(), c2);
1232
1233 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1234 assert_eq!(diff.changed.len(), 1);
1235 assert_eq!(diff.changed[0].changes.len(), 1);
1236 assert!(matches!(
1237 diff.changed[0].changes[0],
1238 FieldChange::Version(_, _)
1239 ));
1240 }
1241
1242 #[test]
1243 fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
1244 let mut old = Sbom::default();
1247 let mut new = Sbom::default();
1248
1249 let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
1251 c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
1252 c_old.ecosystem = Some("npm".to_string());
1253 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1254
1255 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1257 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1258 c_new.ecosystem = Some("npm".to_string());
1259 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1260
1261 old.components.insert(c_old.id.clone(), c_old);
1262 new.components.insert(c_new.id.clone(), c_new);
1263
1264 let diff = Differ::diff(&old, &new, None);
1265
1266 assert_eq!(diff.added.len(), 0, "Should not have added components");
1267 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1268
1269 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1270
1271 let changes = &diff.changed[0].changes;
1272 assert!(changes
1273 .iter()
1274 .any(|c| matches!(c, FieldChange::Version(_, _))));
1275 assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
1276 }
1277
1278 #[test]
1279 fn test_purl_removed_is_change() {
1280 let mut old = Sbom::default();
1283 let mut new = Sbom::default();
1284
1285 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1286 c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1287 c_old.ecosystem = Some("npm".to_string()); c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1289
1290 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1292 c_new.purl = None;
1293 c_new.ecosystem = None; c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1296
1297 old.components.insert(c_old.id.clone(), c_old);
1298 new.components.insert(c_new.id.clone(), c_new);
1299
1300 let diff = Differ::diff(&old, &new, None);
1301
1302 assert_eq!(diff.added.len(), 0, "Should not have added components");
1303 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1304 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1305
1306 assert!(diff.changed[0]
1307 .changes
1308 .iter()
1309 .any(|c| matches!(c, FieldChange::Purl(_, _))));
1310 }
1311
1312 #[test]
1313 fn test_purl_added_is_change() {
1314 let mut old = Sbom::default();
1317 let mut new = Sbom::default();
1318
1319 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1320 c_old.purl = None;
1321 c_old.ecosystem = None; c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1323
1324 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1325 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1326 c_new.ecosystem = Some("npm".to_string()); c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1328
1329 old.components.insert(c_old.id.clone(), c_old);
1330 new.components.insert(c_new.id.clone(), c_new);
1331
1332 let diff = Differ::diff(&old, &new, None);
1333
1334 assert_eq!(diff.added.len(), 0, "Should not have added components");
1335 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1336 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1337 }
1338
1339 #[test]
1340 fn test_same_name_different_ecosystems_not_matched() {
1341 let mut old = Sbom::default();
1343 let mut new = Sbom::default();
1344
1345 let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1347 c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
1348 c_old.ecosystem = Some("npm".to_string());
1349 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1350
1351 let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1353 c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
1354 c_new.ecosystem = Some("pypi".to_string());
1355 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1356
1357 old.components.insert(c_old.id.clone(), c_old);
1358 new.components.insert(c_new.id.clone(), c_new);
1359
1360 let diff = Differ::diff(&old, &new, None);
1361
1362 assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
1363 assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
1364 assert_eq!(
1365 diff.changed.len(),
1366 0,
1367 "Should not match different ecosystems"
1368 );
1369 }
1370
1371 #[test]
1372 fn test_same_name_both_no_ecosystem_matched() {
1373 let mut old = Sbom::default();
1376 let mut new = Sbom::default();
1377
1378 let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
1379 c_old.ecosystem = None;
1380
1381 let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
1382 c_new.ecosystem = None;
1383
1384 old.components.insert(c_old.id.clone(), c_old);
1385 new.components.insert(c_new.id.clone(), c_new);
1386
1387 let diff = Differ::diff(&old, &new, None);
1388
1389 assert_eq!(diff.added.len(), 0);
1390 assert_eq!(diff.removed.len(), 0);
1391 assert_eq!(
1392 diff.changed.len(),
1393 1,
1394 "Same name with None ecosystems should match"
1395 );
1396 }
1397
1398 #[test]
1399 fn test_edge_diff_added_removed() {
1400 let mut old = Sbom::default();
1401 let mut new = Sbom::default();
1402
1403 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1404 let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
1405 let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
1406
1407 let parent_id = c1.id.clone();
1408 let child_a_id = c2.id.clone();
1409 let child_b_id = c3.id.clone();
1410
1411 old.components.insert(c1.id.clone(), c1.clone());
1413 old.components.insert(c2.id.clone(), c2.clone());
1414 old.components.insert(c3.id.clone(), c3.clone());
1415
1416 new.components.insert(c1.id.clone(), c1);
1417 new.components.insert(c2.id.clone(), c2);
1418 new.components.insert(c3.id.clone(), c3);
1419
1420 old.dependencies
1422 .entry(parent_id.clone())
1423 .or_default()
1424 .insert(child_a_id.clone(), DependencyKind::Runtime);
1425
1426 new.dependencies
1428 .entry(parent_id.clone())
1429 .or_default()
1430 .insert(child_b_id.clone(), DependencyKind::Runtime);
1431
1432 let diff = Differ::diff(&old, &new, None);
1433
1434 assert_eq!(diff.edge_diffs.len(), 1);
1435 assert_eq!(diff.edge_diffs[0].parent, parent_id);
1436 assert!(diff.edge_diffs[0].added.contains_key(&child_b_id));
1437 assert!(diff.edge_diffs[0].removed.contains_key(&child_a_id));
1438 }
1439
1440 #[test]
1441 fn test_edge_diff_with_identity_reconciliation() {
1442 let mut old = Sbom::default();
1445 let mut new = Sbom::default();
1446
1447 let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
1449 parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
1450 parent_old.ecosystem = Some("npm".to_string());
1451 parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
1452
1453 let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
1455 parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
1456 parent_new.ecosystem = Some("npm".to_string());
1457 parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
1458
1459 let child = Component::new("child".to_string(), Some("1.0".to_string()));
1461
1462 old.components
1463 .insert(parent_old.id.clone(), parent_old.clone());
1464 old.components.insert(child.id.clone(), child.clone());
1465
1466 new.components
1467 .insert(parent_new.id.clone(), parent_new.clone());
1468 new.components.insert(child.id.clone(), child.clone());
1469
1470 old.dependencies
1472 .entry(parent_old.id.clone())
1473 .or_default()
1474 .insert(child.id.clone(), DependencyKind::Runtime);
1475
1476 new.dependencies
1478 .entry(parent_new.id.clone())
1479 .or_default()
1480 .insert(child.id.clone(), DependencyKind::Runtime);
1481
1482 let diff = Differ::diff(&old, &new, None);
1483
1484 assert_eq!(
1487 diff.edge_diffs.len(),
1488 0,
1489 "No edge changes expected when parent is reconciled by identity"
1490 );
1491 }
1492
1493 #[test]
1494 fn test_edge_diff_filtering() {
1495 let mut old = Sbom::default();
1497 let mut new = Sbom::default();
1498
1499 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1500 let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
1501
1502 let parent_id = c1.id.clone();
1503 let child_id = c2.id.clone();
1504
1505 old.components.insert(c1.id.clone(), c1.clone());
1506 old.components.insert(c2.id.clone(), c2.clone());
1507
1508 new.components.insert(c1.id.clone(), c1);
1509 new.components.insert(c2.id.clone(), c2);
1510
1511 new.dependencies
1513 .entry(parent_id.clone())
1514 .or_default()
1515 .insert(child_id, DependencyKind::Runtime);
1516
1517 let diff = Differ::diff(&old, &new, None);
1519 assert_eq!(diff.edge_diffs.len(), 1);
1520
1521 let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
1523 assert_eq!(diff_filtered.edge_diffs.len(), 0);
1524
1525 let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
1527 assert_eq!(diff_with_deps.edge_diffs.len(), 1);
1528 }
1529
1530 #[test]
1531 fn test_ecosystem_breakdown() {
1532 let mut old = Sbom::default();
1533 let mut new = Sbom::default();
1534
1535 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1537 c1.ecosystem = Some("npm".into());
1538 old.components.insert(c1.id.clone(), c1);
1539
1540 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1542 c2.ecosystem = Some("npm".into());
1543 new.components.insert(c2.id.clone(), c2);
1544
1545 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1547 c3.ecosystem = Some("cargo".into());
1548 new.components.insert(c3.id.clone(), c3);
1549
1550 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1552 c4_old.ecosystem = Some("npm".into());
1553 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1554 c4_new.ecosystem = Some("npm".into());
1555 old.components.insert(c4_old.id.clone(), c4_old);
1556 new.components.insert(c4_new.id.clone(), c4_new);
1557
1558 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1560 new.components.insert(c5.id.clone(), c5);
1561
1562 let diff = Differ::diff(&old, &new, None);
1563 let breakdown = diff.ecosystem_breakdown();
1564
1565 let npm = breakdown.get("npm").unwrap();
1566 assert_eq!(npm.added, 1);
1567 assert_eq!(npm.removed, 1);
1568 assert_eq!(npm.changed, 1);
1569
1570 let cargo = breakdown.get("cargo").unwrap();
1571 assert_eq!(cargo.added, 1);
1572 assert_eq!(cargo.removed, 0);
1573 assert_eq!(cargo.changed, 0);
1574
1575 let unknown = breakdown.get("unknown").unwrap();
1576 assert_eq!(unknown.added, 1);
1577 assert_eq!(unknown.removed, 0);
1578 assert_eq!(unknown.changed, 0);
1579 }
1580
1581 #[test]
1582 fn test_ecosystem_breakdown_empty_diff() {
1583 let old = Sbom::default();
1584 let new = Sbom::default();
1585
1586 let diff = Differ::diff(&old, &new, None);
1587 assert!(diff.is_empty());
1588 assert!(diff.ecosystem_breakdown().is_empty());
1589 }
1590
1591 #[test]
1592 fn test_group_by_ecosystem_empty_diff() {
1593 let old = Sbom::default();
1594 let new = Sbom::default();
1595
1596 let diff = Differ::diff(&old, &new, None);
1597 let grouped = diff.group_by_ecosystem();
1598 assert!(grouped.by_ecosystem.is_empty());
1599 assert!(grouped.edge_diffs.is_empty());
1600 assert!(grouped.metadata_changed.is_none());
1601 assert!(grouped.ecosystem_breakdown().is_empty());
1602 }
1603
1604 #[test]
1605 fn test_group_by_ecosystem_groups_correctly() {
1606 let mut old = Sbom::default();
1607 let mut new = Sbom::default();
1608
1609 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1611 c1.ecosystem = Some("npm".into());
1612 old.components.insert(c1.id.clone(), c1);
1613
1614 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1616 c2.ecosystem = Some("npm".into());
1617 new.components.insert(c2.id.clone(), c2);
1618
1619 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1621 c3.ecosystem = Some("cargo".into());
1622 new.components.insert(c3.id.clone(), c3);
1623
1624 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1626 c4_old.ecosystem = Some("npm".into());
1627 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1628 c4_new.ecosystem = Some("npm".into());
1629 old.components.insert(c4_old.id.clone(), c4_old);
1630 new.components.insert(c4_new.id.clone(), c4_new);
1631
1632 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1634 new.components.insert(c5.id.clone(), c5);
1635
1636 let diff = Differ::diff(&old, &new, None);
1637 let grouped = diff.group_by_ecosystem();
1638
1639 let npm = grouped.by_ecosystem.get("npm").unwrap();
1640 assert_eq!(npm.added.len(), 1);
1641 assert_eq!(npm.removed.len(), 1);
1642 assert_eq!(npm.changed.len(), 1);
1643
1644 let cargo = grouped.by_ecosystem.get("cargo").unwrap();
1645 assert_eq!(cargo.added.len(), 1);
1646 assert_eq!(cargo.removed.len(), 0);
1647 assert_eq!(cargo.changed.len(), 0);
1648
1649 let unknown = grouped.by_ecosystem.get("unknown").unwrap();
1650 assert_eq!(unknown.added.len(), 1);
1651 assert_eq!(unknown.removed.len(), 0);
1652 assert_eq!(unknown.changed.len(), 0);
1653
1654 let grouped_counts = grouped.ecosystem_breakdown();
1656 let direct_counts = diff.ecosystem_breakdown();
1657 assert_eq!(grouped_counts, direct_counts);
1658 }
1659
1660 #[test]
1661 fn test_totals_no_changes() {
1662 let mut old = Sbom::default();
1663 let mut new = Sbom::default();
1664
1665 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1666 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1667
1668 old.components.insert(c1.id.clone(), c1.clone());
1669 old.components.insert(c2.id.clone(), c2.clone());
1670 new.components.insert(c1.id.clone(), c1);
1671 new.components.insert(c2.id.clone(), c2);
1672
1673 let diff = Differ::diff(&old, &new, None);
1674 assert_eq!(diff.old_total, 2);
1675 assert_eq!(diff.new_total, 2);
1676 assert_eq!(diff.unchanged, 2);
1677 }
1678
1679 #[test]
1680 fn test_totals_with_changes() {
1681 let mut old = Sbom::default();
1682 let mut new = Sbom::default();
1683
1684 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1685 let mut c1_updated = c1.clone();
1686 c1_updated.version = Some("1.1".to_string());
1687 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1688 let c3 = Component::new("pkg-c".to_string(), Some("3.0".to_string()));
1689 let c4 = Component::new("pkg-d".to_string(), Some("4.0".to_string()));
1690
1691 old.components.insert(c1.id.clone(), c1);
1692 old.components.insert(c2.id.clone(), c2.clone());
1693 old.components.insert(c3.id.clone(), c3);
1694 new.components.insert(c1_updated.id.clone(), c1_updated);
1695 new.components.insert(c2.id.clone(), c2);
1696 new.components.insert(c4.id.clone(), c4);
1697
1698 let diff = Differ::diff(&old, &new, None);
1699 assert_eq!(diff.old_total, 3);
1700 assert_eq!(diff.new_total, 3);
1701 assert_eq!(diff.added.len(), 1); assert_eq!(diff.removed.len(), 1); assert_eq!(diff.changed.len(), 1); assert_eq!(diff.unchanged, 1); }
1706
1707 #[test]
1708 fn test_component_names_for_hash_ids_in_edge_diffs() {
1709 let mut old = Sbom::default();
1710 let mut new = Sbom::default();
1711
1712 let parent = Component::new("my-app".to_string(), Some("1.0".to_string()));
1714 let child_a = Component::new("dep-old".to_string(), Some("0.1".to_string()));
1715 let child_b = Component::new("dep-new".to_string(), Some("0.2".to_string()));
1716
1717 old.components.insert(parent.id.clone(), parent.clone());
1718 old.components.insert(child_a.id.clone(), child_a.clone());
1719 new.components.insert(parent.id.clone(), parent.clone());
1720 new.components.insert(child_b.id.clone(), child_b.clone());
1721
1722 old.dependencies.insert(
1724 parent.id.clone(),
1725 BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1726 );
1727 new.dependencies.insert(
1728 parent.id.clone(),
1729 BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1730 );
1731
1732 let diff = Differ::diff(&old, &new, None);
1733
1734 assert!(diff.edge_diffs[0].parent.as_str().starts_with("h:"));
1736
1737 assert_eq!(diff.display_name(&diff.edge_diffs[0].parent), "my-app@1.0");
1739 for added in diff.edge_diffs[0].added.keys() {
1740 assert!(!diff.display_name(added).starts_with("h:"));
1741 }
1742 for removed in diff.edge_diffs[0].removed.keys() {
1743 assert!(!diff.display_name(removed).starts_with("h:"));
1744 }
1745 }
1746
1747 #[test]
1748 fn test_component_names_skips_purl_ids() {
1749 let mut old = Sbom::default();
1750 let mut new = Sbom::default();
1751
1752 let mut parent = Component::new("parent".to_string(), Some("1.0".to_string()));
1753 parent.purl = Some("pkg:npm/parent@1.0".to_string());
1754 parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
1755
1756 let mut child_a = Component::new("child-a".to_string(), Some("1.0".to_string()));
1757 child_a.purl = Some("pkg:npm/child-a@1.0".to_string());
1758 child_a.id = ComponentId::new(child_a.purl.as_deref(), &[]);
1759
1760 let mut child_b = Component::new("child-b".to_string(), Some("1.0".to_string()));
1761 child_b.purl = Some("pkg:npm/child-b@1.0".to_string());
1762 child_b.id = ComponentId::new(child_b.purl.as_deref(), &[]);
1763
1764 old.components.insert(parent.id.clone(), parent.clone());
1765 old.components.insert(child_a.id.clone(), child_a.clone());
1766 new.components.insert(parent.id.clone(), parent.clone());
1767 new.components.insert(child_b.id.clone(), child_b.clone());
1768
1769 old.dependencies.insert(
1770 parent.id.clone(),
1771 BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1772 );
1773 new.dependencies.insert(
1774 parent.id.clone(),
1775 BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1776 );
1777
1778 let diff = Differ::diff(&old, &new, None);
1779
1780 assert!(diff.component_names.is_empty());
1782
1783 assert!(diff
1785 .display_name(&diff.edge_diffs[0].parent)
1786 .starts_with("pkg:npm/parent@"));
1787 }
1788
1789 #[test]
1790 fn test_display_name_fallback() {
1791 let diff = Diff::default();
1792 let unknown_id = ComponentId::new(None, &[("name", "mystery")]);
1793 assert_eq!(diff.display_name(&unknown_id), unknown_id.as_str());
1795 }
1796
1797 #[test]
1798 fn test_filter_by_ecosystem_include() {
1799 let mut old = Sbom::default();
1800 let mut new = Sbom::default();
1801
1802 let mut npm1 = Component::new("express".into(), Some("4.18.0".into()));
1804 npm1.ecosystem = Some("npm".into());
1805 let mut npm2 = Component::new("lodash".into(), Some("4.17.21".into()));
1806 npm2.ecosystem = Some("npm".into());
1807
1808 let mut cargo1 = Component::new("serde".into(), Some("1.0.0".into()));
1810 cargo1.ecosystem = Some("cargo".into());
1811
1812 let mut pypi1 = Component::new("requests".into(), Some("2.28.0".into()));
1814 pypi1.ecosystem = Some("pypi".into());
1815
1816 old.components.insert(npm2.id.clone(), npm2.clone());
1817 old.components.insert(cargo1.id.clone(), cargo1.clone());
1818
1819 new.components.insert(npm1.id.clone(), npm1);
1820 new.components.insert(npm2.id.clone(), npm2);
1821 new.components.insert(pypi1.id.clone(), pypi1);
1822
1823 let mut diff = Differ::diff(&old, &new, None);
1828
1829 diff.filter_by_ecosystem(
1831 &|eco| eco == Some("npm"),
1832 1, 2, &BTreeMap::new(),
1835 );
1836
1837 assert_eq!(diff.added.len(), 1); assert_eq!(diff.added[0].name, "express");
1839 assert_eq!(diff.removed.len(), 0); assert_eq!(diff.changed.len(), 0);
1841 assert_eq!(diff.old_total, 1);
1842 assert_eq!(diff.new_total, 2);
1843 assert_eq!(diff.unchanged, 1); }
1845
1846 #[test]
1847 fn test_filter_by_ecosystem_exclude() {
1848 let mut old = Sbom::default();
1849 let mut new = Sbom::default();
1850
1851 let mut npm1 = Component::new("express".into(), Some("4.18.0".into()));
1852 npm1.ecosystem = Some("npm".into());
1853 let mut cargo1 = Component::new("serde".into(), Some("1.0.0".into()));
1854 cargo1.ecosystem = Some("cargo".into());
1855 let mut cargo2 = Component::new("tokio".into(), Some("1.0.0".into()));
1856 cargo2.ecosystem = Some("cargo".into());
1857
1858 old.components.insert(cargo1.id.clone(), cargo1.clone());
1859 new.components.insert(npm1.id.clone(), npm1);
1860 new.components.insert(cargo2.id.clone(), cargo2);
1861
1862 let mut diff = Differ::diff(&old, &new, None);
1864 diff.filter_by_ecosystem(
1865 &|eco| eco != Some("npm"),
1866 1, 1, &BTreeMap::new(),
1869 );
1870
1871 assert_eq!(diff.added.len(), 1); assert_eq!(diff.added[0].name, "tokio");
1873 assert_eq!(diff.removed.len(), 1); assert_eq!(diff.removed[0].name, "serde");
1875 }
1876
1877 #[test]
1878 fn test_filter_by_ecosystem_unknown() {
1879 let old = Sbom::default();
1881 let mut new = Sbom::default();
1882
1883 let no_eco = Component::new("mystery".into(), Some("1.0".into()));
1884 let mut npm = Component::new("express".into(), Some("4.18.0".into()));
1885 npm.ecosystem = Some("npm".into());
1886
1887 new.components.insert(no_eco.id.clone(), no_eco);
1888 new.components.insert(npm.id.clone(), npm);
1889
1890 let mut diff = Differ::diff(&old, &new, None);
1891
1892 diff.filter_by_ecosystem(
1894 &|eco| eco.is_none(),
1895 0,
1896 1, &BTreeMap::new(),
1898 );
1899
1900 assert_eq!(diff.added.len(), 1);
1901 assert_eq!(diff.added[0].name, "mystery");
1902 }
1903
1904 #[test]
1905 fn test_filter_by_ecosystem_changed_uses_new_ecosystem() {
1906 let mut old = Sbom::default();
1909 let mut new = Sbom::default();
1910
1911 let c_old = Component::new("pkg".into(), Some("1.0".into()));
1913 let mut c_new = Component::new("pkg".into(), Some("2.0".into()));
1915 c_new.ecosystem = Some("npm".into());
1916
1917 old.components.insert(c_old.id.clone(), c_old);
1918 new.components.insert(c_new.id.clone(), c_new);
1919
1920 let mut diff = Differ::diff(&old, &new, None);
1921 assert_eq!(diff.changed.len(), 1);
1922
1923 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 0, 1, &BTreeMap::new());
1925 assert_eq!(diff.changed.len(), 1);
1926
1927 let old2 = {
1929 let mut s = Sbom::default();
1930 let c = Component::new("pkg".into(), Some("1.0".into()));
1931 s.components.insert(c.id.clone(), c);
1932 s
1933 };
1934 let new2 = {
1935 let mut s = Sbom::default();
1936 let mut c = Component::new("pkg".into(), Some("2.0".into()));
1937 c.ecosystem = Some("npm".into());
1938 s.components.insert(c.id.clone(), c);
1939 s
1940 };
1941 let mut diff = Differ::diff(&old2, &new2, None);
1942 diff.filter_by_ecosystem(&|eco| eco == Some("cargo"), 0, 0, &BTreeMap::new());
1943 assert_eq!(diff.changed.len(), 0);
1944 }
1945
1946 #[test]
1947 fn test_filter_by_ecosystem_empty_diff() {
1948 let mut diff = Diff::default();
1949 diff.filter_by_ecosystem(&|_| true, 0, 0, &BTreeMap::new());
1950 assert!(diff.is_empty());
1951 }
1952
1953 #[test]
1954 fn test_filter_by_ecosystem_totals_adjusted() {
1955 let mut old = Sbom::default();
1956 let mut new = Sbom::default();
1957
1958 let mut n1 = Component::new("a".into(), Some("1".into()));
1960 n1.ecosystem = Some("npm".into());
1961 let mut n2 = Component::new("b".into(), Some("1".into()));
1962 n2.ecosystem = Some("npm".into());
1963 let mut c1 = Component::new("c".into(), Some("1".into()));
1964 c1.ecosystem = Some("cargo".into());
1965
1966 old.components.insert(n1.id.clone(), n1.clone());
1967 old.components.insert(n2.id.clone(), n2.clone());
1968 old.components.insert(c1.id.clone(), c1);
1969
1970 new.components.insert(n1.id.clone(), n1);
1972 new.components.insert(n2.id.clone(), n2);
1973
1974 let mut diff = Differ::diff(&old, &new, None);
1975 assert_eq!(diff.old_total, 3);
1976 assert_eq!(diff.new_total, 2);
1977
1978 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 2, 2, &BTreeMap::new());
1980
1981 assert_eq!(diff.old_total, 2);
1982 assert_eq!(diff.new_total, 2);
1983 assert_eq!(diff.unchanged, 2);
1984 assert_eq!(diff.added.len(), 0);
1985 assert_eq!(diff.removed.len(), 0);
1986 assert_eq!(diff.changed.len(), 0);
1987 }
1988
1989 #[test]
1990 fn test_filter_by_ecosystem_filters_edge_diffs() {
1991 let mut old = Sbom::default();
1992 let mut new = Sbom::default();
1993
1994 let mut npm_parent = Component::new("npm-app".into(), Some("1.0".into()));
1996 npm_parent.ecosystem = Some("npm".into());
1997 let npm_child_old = Component::new("npm-dep-old".into(), Some("1.0".into()));
1998 let npm_child_new = Component::new("npm-dep-new".into(), Some("1.0".into()));
1999
2000 let mut cargo_parent = Component::new("cargo-app".into(), Some("1.0".into()));
2002 cargo_parent.ecosystem = Some("cargo".into());
2003 let cargo_child = Component::new("cargo-dep".into(), Some("1.0".into()));
2004
2005 old.components
2007 .insert(npm_parent.id.clone(), npm_parent.clone());
2008 old.components
2009 .insert(npm_child_old.id.clone(), npm_child_old.clone());
2010 old.components
2011 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2012
2013 new.components
2015 .insert(npm_parent.id.clone(), npm_parent.clone());
2016 new.components
2017 .insert(npm_child_new.id.clone(), npm_child_new.clone());
2018 new.components
2019 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2020 new.components
2021 .insert(cargo_child.id.clone(), cargo_child.clone());
2022
2023 old.dependencies.insert(
2024 npm_parent.id.clone(),
2025 BTreeMap::from([(npm_child_old.id.clone(), DependencyKind::Runtime)]),
2026 );
2027 new.dependencies.insert(
2028 npm_parent.id.clone(),
2029 BTreeMap::from([(npm_child_new.id.clone(), DependencyKind::Runtime)]),
2030 );
2031 new.dependencies.insert(
2032 cargo_parent.id.clone(),
2033 BTreeMap::from([(cargo_child.id.clone(), DependencyKind::Runtime)]),
2034 );
2035
2036 let mut eco_map: BTreeMap<ComponentId, Option<String>> = BTreeMap::new();
2038 for (id, comp) in old.components.iter().chain(new.components.iter()) {
2039 eco_map.insert(id.clone(), comp.ecosystem.clone());
2040 }
2041
2042 let mut diff = Differ::diff(&old, &new, None);
2043 assert!(diff.edge_diffs.len() >= 2);
2045
2046 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 1, 1, &eco_map);
2048
2049 assert_eq!(diff.edge_diffs.len(), 1);
2051 assert_eq!(diff.edge_diffs[0].parent, npm_parent.id);
2052 }
2053
2054 #[test]
2055 fn test_filter_by_ecosystem_prunes_component_names() {
2056 let mut old = Sbom::default();
2057 let mut new = Sbom::default();
2058
2059 let mut npm_parent = Component::new("npm-app".into(), Some("1.0".into()));
2061 npm_parent.ecosystem = Some("npm".into());
2062 let npm_child = Component::new("npm-dep".into(), Some("1.0".into()));
2063
2064 let mut cargo_parent = Component::new("cargo-app".into(), Some("1.0".into()));
2066 cargo_parent.ecosystem = Some("cargo".into());
2067 let cargo_child = Component::new("cargo-dep".into(), Some("1.0".into()));
2068
2069 old.components
2070 .insert(npm_parent.id.clone(), npm_parent.clone());
2071 old.components
2072 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2073
2074 new.components
2075 .insert(npm_parent.id.clone(), npm_parent.clone());
2076 new.components
2077 .insert(npm_child.id.clone(), npm_child.clone());
2078 new.components
2079 .insert(cargo_parent.id.clone(), cargo_parent.clone());
2080 new.components
2081 .insert(cargo_child.id.clone(), cargo_child.clone());
2082
2083 new.dependencies.insert(
2084 npm_parent.id.clone(),
2085 BTreeMap::from([(npm_child.id.clone(), DependencyKind::Runtime)]),
2086 );
2087 new.dependencies.insert(
2088 cargo_parent.id.clone(),
2089 BTreeMap::from([(cargo_child.id.clone(), DependencyKind::Runtime)]),
2090 );
2091
2092 let mut eco_map: BTreeMap<ComponentId, Option<String>> = BTreeMap::new();
2093 for (id, comp) in old.components.iter().chain(new.components.iter()) {
2094 eco_map.insert(id.clone(), comp.ecosystem.clone());
2095 }
2096
2097 let mut diff = Differ::diff(&old, &new, None);
2098 let names_before = diff.component_names.len();
2099 assert!(names_before > 0, "should have component names for hash IDs");
2100
2101 diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 1, 1, &eco_map);
2102
2103 assert!(diff.component_names.len() <= names_before);
2105 for id in diff.component_names.keys() {
2106 let referenced = diff.edge_diffs.iter().any(|e| {
2108 &e.parent == id
2109 || e.added.contains_key(id)
2110 || e.removed.contains_key(id)
2111 || e.kind_changed.contains_key(id)
2112 });
2113 assert!(referenced, "stale component_name entry for {}", id);
2114 }
2115 }
2116
2117 #[test]
2118 fn test_diff_owned_identity() {
2119 let mut sbom = Sbom::default();
2120
2121 let mut parent = Component::new("my-app".to_string(), Some("2.0.0".to_string()));
2123 parent.purl = Some("pkg:cargo/my-app@2.0.0".to_string());
2124 parent.ecosystem = Some("cargo".to_string());
2125 parent.licenses.insert("MIT".into());
2126 parent.supplier = Some("Acme Corp".into());
2127 parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
2128
2129 let mut dep_a = Component::new("dep-a".to_string(), Some("1.0.0".to_string()));
2130 dep_a.purl = Some("pkg:cargo/dep-a@1.0.0".to_string());
2131 dep_a.ecosystem = Some("cargo".to_string());
2132 dep_a.licenses.insert("Apache-2.0".into());
2133 dep_a
2134 .hashes
2135 .insert("sha256".into(), "abcdef1234567890".into());
2136 dep_a.id = ComponentId::new(dep_a.purl.as_deref(), &[]);
2137
2138 let mut dep_b = Component::new("dep-b".to_string(), Some("0.5.0".to_string()));
2139 dep_b.ecosystem = Some("cargo".to_string());
2140 dep_b.description = Some("A helper library".into());
2141
2142 sbom.components.insert(parent.id.clone(), parent.clone());
2143 sbom.components.insert(dep_a.id.clone(), dep_a.clone());
2144 sbom.components.insert(dep_b.id.clone(), dep_b.clone());
2145
2146 sbom.dependencies
2148 .entry(parent.id.clone())
2149 .or_default()
2150 .insert(dep_a.id.clone(), DependencyKind::Runtime);
2151 sbom.dependencies
2152 .entry(parent.id.clone())
2153 .or_default()
2154 .insert(dep_b.id.clone(), DependencyKind::Dev);
2155
2156 let copy = sbom.clone();
2157 let diff = Differ::diff_owned(sbom, copy, None);
2158
2159 assert_eq!(
2160 diff.added.len(),
2161 0,
2162 "identical SBOMs should have no added components"
2163 );
2164 assert_eq!(
2165 diff.removed.len(),
2166 0,
2167 "identical SBOMs should have no removed components"
2168 );
2169 assert_eq!(
2170 diff.changed.len(),
2171 0,
2172 "identical SBOMs should have no changed components"
2173 );
2174 assert_eq!(
2175 diff.edge_diffs.len(),
2176 0,
2177 "identical SBOMs should have no edge diffs"
2178 );
2179 assert_eq!(
2180 diff.metadata_changed, None,
2181 "identical SBOMs should have no metadata changes"
2182 );
2183 assert_eq!(diff.old_total, 3);
2184 assert_eq!(diff.new_total, 3);
2185 assert_eq!(diff.unchanged, 3);
2186 }
2187
2188 #[test]
2189 fn test_diff_detects_version_downgrade() {
2190 let mut old = Sbom::default();
2191 let mut new = Sbom::default();
2192
2193 let c1 = Component::new("pkg-a".to_string(), Some("2.0.0".to_string()));
2194 let mut c2 = c1.clone();
2195 c2.version = Some("1.0.0".to_string());
2196
2197 old.components.insert(c1.id.clone(), c1);
2198 new.components.insert(c2.id.clone(), c2);
2199
2200 let diff = Differ::diff(&old, &new, None);
2201 assert_eq!(diff.changed.len(), 1);
2202 assert!(diff.changed[0].is_downgrade);
2203 }
2204
2205 #[test]
2206 fn test_diff_upgrade_not_marked_as_downgrade() {
2207 let mut old = Sbom::default();
2208 let mut new = Sbom::default();
2209
2210 let c1 = Component::new("pkg-a".to_string(), Some("1.0.0".to_string()));
2211 let mut c2 = c1.clone();
2212 c2.version = Some("2.0.0".to_string());
2213
2214 old.components.insert(c1.id.clone(), c1);
2215 new.components.insert(c2.id.clone(), c2);
2216
2217 let diff = Differ::diff(&old, &new, None);
2218 assert_eq!(diff.changed.len(), 1);
2219 assert!(!diff.changed[0].is_downgrade);
2220 }
2221}