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