1#![doc = include_str!("../readme.md")]
2
3use sbom_model::{Component, ComponentId, Sbom};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet, HashSet};
6
7pub mod renderer;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
11pub struct EcosystemCounts {
12 pub added: usize,
13 pub removed: usize,
14 pub changed: usize,
15}
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22pub struct Diff {
23 pub added: Vec<Component>,
25 pub removed: Vec<Component>,
27 pub changed: Vec<ComponentChange>,
29 pub edge_diffs: Vec<EdgeDiff>,
31 pub metadata_changed: bool,
33 pub old_total: usize,
35 pub new_total: usize,
37 pub unchanged: usize,
39 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
44 pub component_names: BTreeMap<ComponentId, String>,
45}
46
47impl Diff {
48 pub fn is_empty(&self) -> bool {
50 self.added.is_empty()
51 && self.removed.is_empty()
52 && self.changed.is_empty()
53 && self.edge_diffs.is_empty()
54 && !self.metadata_changed
55 }
56
57 pub fn display_name<'a>(&'a self, id: &'a ComponentId) -> &'a str {
61 self.component_names
62 .get(id)
63 .map(String::as_str)
64 .unwrap_or_else(|| id.as_str())
65 }
66
67 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
71 let mut breakdown: BTreeMap<String, EcosystemCounts> = BTreeMap::new();
72
73 for comp in &self.added {
74 let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
75 breakdown.entry(eco).or_default().added += 1;
76 }
77
78 for comp in &self.removed {
79 let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
80 breakdown.entry(eco).or_default().removed += 1;
81 }
82
83 for change in &self.changed {
84 let eco = change
85 .new
86 .ecosystem
87 .as_deref()
88 .unwrap_or("unknown")
89 .to_string();
90 breakdown.entry(eco).or_default().changed += 1;
91 }
92
93 breakdown
94 }
95
96 pub fn group_by_ecosystem(&self) -> GroupedDiff {
100 let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
101
102 for c in &self.added {
103 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
104 ecosystems.entry(eco).or_default().added.push(c.clone());
105 }
106 for c in &self.removed {
107 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
108 ecosystems.entry(eco).or_default().removed.push(c.clone());
109 }
110 for c in &self.changed {
111 let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
112 ecosystems.entry(eco).or_default().changed.push(c.clone());
113 }
114
115 GroupedDiff {
116 by_ecosystem: ecosystems,
117 edge_diffs: self.edge_diffs.clone(),
118 metadata_changed: self.metadata_changed,
119 }
120 }
121
122 pub fn into_group_by_ecosystem(self) -> GroupedDiff {
125 let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
126
127 for c in self.added {
128 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
129 ecosystems.entry(eco).or_default().added.push(c);
130 }
131 for c in self.removed {
132 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
133 ecosystems.entry(eco).or_default().removed.push(c);
134 }
135 for c in self.changed {
136 let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
137 ecosystems.entry(eco).or_default().changed.push(c);
138 }
139
140 GroupedDiff {
141 by_ecosystem: ecosystems,
142 edge_diffs: self.edge_diffs,
143 metadata_changed: self.metadata_changed,
144 }
145 }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct GroupedDiff {
151 pub by_ecosystem: BTreeMap<String, EcosystemDiff>,
152 pub edge_diffs: Vec<EdgeDiff>,
153 pub metadata_changed: bool,
154}
155
156impl GroupedDiff {
157 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
163 self.by_ecosystem
164 .iter()
165 .map(|(eco, eco_diff)| {
166 (
167 eco.clone(),
168 EcosystemCounts {
169 added: eco_diff.added.len(),
170 removed: eco_diff.removed.len(),
171 changed: eco_diff.changed.len(),
172 },
173 )
174 })
175 .collect()
176 }
177}
178
179#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct EcosystemDiff {
182 pub added: Vec<Component>,
183 pub removed: Vec<Component>,
184 pub changed: Vec<ComponentChange>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct ComponentChange {
190 pub id: ComponentId,
192 pub old: Component,
194 pub new: Component,
196 pub changes: Vec<FieldChange>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct EdgeDiff {
203 pub parent: ComponentId,
205 pub added: BTreeSet<ComponentId>,
207 pub removed: BTreeSet<ComponentId>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
213pub enum FieldChange {
214 Version(String, String),
216 License(BTreeSet<String>, BTreeSet<String>),
218 Supplier(Option<String>, Option<String>),
220 Purl(Option<String>, Option<String>),
222 Description(Option<String>, Option<String>),
224 Hashes(BTreeMap<String, String>, BTreeMap<String, String>),
226}
227
228#[derive(Debug, Copy, Clone, PartialEq, Eq)]
232pub enum Field {
233 Version,
235 License,
237 Supplier,
239 Purl,
241 Description,
243 Hashes,
245 Deps,
247}
248
249pub struct Differ;
254
255impl Differ {
256 pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
283 let mut old = old.clone();
284 let mut new = new.clone();
285
286 let metadata_changed = old.metadata != new.metadata;
288
289 old.normalize();
290 new.normalize();
291
292 let mut added = Vec::new();
293 let mut removed = Vec::new();
294 let mut changed = Vec::new();
295
296 let mut processed_old = HashSet::new();
297 let mut processed_new = HashSet::new();
298
299 let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
301
302 for (id, new_comp) in &new.components {
304 if let Some(old_comp) = old.components.get(id) {
305 processed_old.insert(id.clone());
306 processed_new.insert(id.clone());
307 id_mapping.insert(id.clone(), id.clone());
308
309 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
310 changed.push(change);
311 }
312 }
313 }
314
315 let mut old_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
324 BTreeMap::new();
325 for (id, comp) in &old.components {
326 if !processed_old.contains(id) {
327 old_identity_map
328 .entry(comp.name.clone())
329 .or_default()
330 .entry(comp.ecosystem.clone())
331 .or_default()
332 .push(id.clone());
333 }
334 }
335
336 for (id, new_comp) in &new.components {
337 if processed_new.contains(id) {
338 continue;
339 }
340
341 let matched_old_id = old_identity_map
346 .get_mut(&new_comp.name)
347 .and_then(|eco_map| {
348 eco_map
350 .get_mut(&new_comp.ecosystem)
351 .and_then(|ids| ids.pop())
352 .or_else(|| {
353 if new_comp.ecosystem.is_some() {
354 eco_map.get_mut(&None).and_then(|ids| ids.pop())
356 } else {
357 eco_map.values_mut().find_map(|ids| ids.pop())
359 }
360 })
361 });
362
363 if let Some(old_id) = matched_old_id {
364 if let Some(old_comp) = old.components.get(&old_id) {
365 processed_old.insert(old_id.clone());
366 processed_new.insert(id.clone());
367 id_mapping.insert(old_id.clone(), id.clone());
368
369 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
370 changed.push(change);
371 }
372 continue;
373 }
374 }
375
376 added.push(new_comp.clone());
377 processed_new.insert(id.clone());
378 }
379
380 for (id, old_comp) in &old.components {
381 if !processed_old.contains(id) {
382 removed.push(old_comp.clone());
383 }
384 }
385
386 let old_total = old.components.len();
388 let new_total = new.components.len();
389 let matched = processed_old.len();
391 let unchanged = matched - changed.len();
392
393 let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
395 let edge_diffs = if should_include_deps {
396 Self::compute_edge_diffs(&old, &new, &id_mapping)
397 } else {
398 Vec::new()
399 };
400
401 let component_names = Self::build_component_names(&old, &new, &edge_diffs);
403
404 Diff {
405 added,
406 removed,
407 changed,
408 edge_diffs,
409 metadata_changed,
410 old_total,
411 new_total,
412 unchanged,
413 component_names,
414 }
415 }
416
417 fn compute_edge_diffs(
422 old: &Sbom,
423 new: &Sbom,
424 id_mapping: &BTreeMap<ComponentId, ComponentId>,
425 ) -> Vec<EdgeDiff> {
426 let mut edge_diffs = Vec::new();
427
428 let reverse_mapping: BTreeMap<ComponentId, ComponentId> = id_mapping
432 .iter()
433 .map(|(old_id, new_id)| (new_id.clone(), old_id.clone()))
434 .collect();
435
436 let translate_id = |old_id: &ComponentId| -> ComponentId {
438 id_mapping
439 .get(old_id)
440 .cloned()
441 .unwrap_or_else(|| old_id.clone())
442 };
443
444 let mut all_parents: BTreeSet<ComponentId> = new.dependencies.keys().cloned().collect();
447
448 for old_parent in old.dependencies.keys() {
450 all_parents.insert(translate_id(old_parent));
451 }
452
453 for parent_id in all_parents {
454 let new_children: BTreeSet<ComponentId> = new
456 .dependencies
457 .get(&parent_id)
458 .cloned()
459 .unwrap_or_default();
460
461 let old_parent_id = reverse_mapping
464 .get(&parent_id)
465 .cloned()
466 .unwrap_or_else(|| parent_id.clone());
467
468 let old_children: BTreeSet<ComponentId> = old
469 .dependencies
470 .get(&old_parent_id)
471 .map(|children| children.iter().map(&translate_id).collect())
472 .unwrap_or_default();
473
474 let added: BTreeSet<ComponentId> =
476 new_children.difference(&old_children).cloned().collect();
477 let removed: BTreeSet<ComponentId> =
478 old_children.difference(&new_children).cloned().collect();
479
480 if !added.is_empty() || !removed.is_empty() {
481 edge_diffs.push(EdgeDiff {
482 parent: parent_id,
483 added,
484 removed,
485 });
486 }
487 }
488
489 edge_diffs
490 }
491
492 fn build_component_names(
497 old: &Sbom,
498 new: &Sbom,
499 edge_diffs: &[EdgeDiff],
500 ) -> BTreeMap<ComponentId, String> {
501 let mut names = BTreeMap::new();
502
503 let mut ids = BTreeSet::new();
505 for edge in edge_diffs {
506 ids.insert(&edge.parent);
507 ids.extend(&edge.added);
508 ids.extend(&edge.removed);
509 }
510
511 for id in ids {
513 if !id.as_str().starts_with("h:") {
514 continue;
515 }
516
517 let comp = new.components.get(id).or_else(|| old.components.get(id));
519 if let Some(comp) = comp {
520 let display = match &comp.version {
521 Some(v) => format!("{}@{}", comp.name, v),
522 None => comp.name.clone(),
523 };
524 names.insert(id.clone(), display);
525 }
526 }
527
528 names
529 }
530
531 fn compute_change(
532 old: &Component,
533 new: &Component,
534 only: Option<&[Field]>,
535 ) -> Option<ComponentChange> {
536 let mut changes = Vec::new();
537
538 let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
539
540 if should_include(Field::Version) && old.version != new.version {
541 changes.push(FieldChange::Version(
542 old.version.clone().unwrap_or_default(),
543 new.version.clone().unwrap_or_default(),
544 ));
545 }
546
547 if should_include(Field::License) && old.licenses != new.licenses {
548 changes.push(FieldChange::License(
549 old.licenses.clone(),
550 new.licenses.clone(),
551 ));
552 }
553
554 if should_include(Field::Supplier) && old.supplier != new.supplier {
555 changes.push(FieldChange::Supplier(
556 old.supplier.clone(),
557 new.supplier.clone(),
558 ));
559 }
560
561 if should_include(Field::Purl) && old.purl != new.purl {
562 changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
563 }
564
565 if should_include(Field::Description) && old.description != new.description {
566 changes.push(FieldChange::Description(
567 old.description.clone(),
568 new.description.clone(),
569 ));
570 }
571
572 if should_include(Field::Hashes) && old.hashes != new.hashes {
573 changes.push(FieldChange::Hashes(old.hashes.clone(), new.hashes.clone()));
574 }
575
576 if changes.is_empty() {
577 None
578 } else {
579 Some(ComponentChange {
580 id: new.id.clone(),
581 old: old.clone(),
582 new: new.clone(),
583 changes,
584 })
585 }
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592
593 #[test]
594 fn test_diff_added_removed() {
595 let mut old = Sbom::default();
596 let mut new = Sbom::default();
597
598 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
599 let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
600
601 old.components.insert(c1.id.clone(), c1);
602 new.components.insert(c2.id.clone(), c2);
603
604 let diff = Differ::diff(&old, &new, None);
605 assert_eq!(diff.added.len(), 1);
606 assert_eq!(diff.removed.len(), 1);
607 assert_eq!(diff.changed.len(), 0);
608 }
609
610 #[test]
611 fn test_diff_changed() {
612 let mut old = Sbom::default();
613 let mut new = Sbom::default();
614
615 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
616 let mut c2 = c1.clone();
617 c2.version = Some("1.1".to_string());
618
619 old.components.insert(c1.id.clone(), c1);
620 new.components.insert(c2.id.clone(), c2);
621
622 let diff = Differ::diff(&old, &new, None);
623 assert_eq!(diff.added.len(), 0);
624 assert_eq!(diff.removed.len(), 0);
625 assert_eq!(diff.changed.len(), 1);
626 assert!(matches!(
627 diff.changed[0].changes[0],
628 FieldChange::Version(_, _)
629 ));
630 }
631
632 #[test]
633 fn test_diff_identity_reconciliation() {
634 let mut old = Sbom::default();
635 let mut new = Sbom::default();
636
637 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
638 let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
639
640 old.components.insert(c1.id.clone(), c1);
641 new.components.insert(c2.id.clone(), c2);
642
643 let diff = Differ::diff(&old, &new, None);
644 assert_eq!(diff.changed.len(), 1);
645 assert_eq!(diff.added.len(), 0);
646 }
647
648 #[test]
649 fn test_diff_license_change() {
650 let mut old = Sbom::default();
651 let mut new = Sbom::default();
652
653 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
654 c1.licenses.insert("MIT".into());
655 let mut c2 = c1.clone();
656 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
657
658 old.components.insert(c1.id.clone(), c1);
659 new.components.insert(c2.id.clone(), c2);
660
661 let diff = Differ::diff(&old, &new, None);
662 assert_eq!(diff.changed.len(), 1);
663 assert!(diff.changed[0]
664 .changes
665 .iter()
666 .any(|c| matches!(c, FieldChange::License(_, _))));
667 }
668
669 #[test]
670 fn test_diff_supplier_change() {
671 let mut old = Sbom::default();
672 let mut new = Sbom::default();
673
674 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
675 c1.supplier = Some("Acme Corp".into());
676 let mut c2 = c1.clone();
677 c2.supplier = Some("New Corp".into());
678
679 old.components.insert(c1.id.clone(), c1);
680 new.components.insert(c2.id.clone(), c2);
681
682 let diff = Differ::diff(&old, &new, None);
683 assert_eq!(diff.changed.len(), 1);
684 assert!(diff.changed[0]
685 .changes
686 .iter()
687 .any(|c| matches!(c, FieldChange::Supplier(_, _))));
688 }
689
690 #[test]
691 fn test_diff_hashes_change() {
692 let mut old = Sbom::default();
693 let mut new = Sbom::default();
694
695 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
696 c1.hashes.insert("sha256".into(), "aaa".into());
697 let mut c2 = c1.clone();
698 c2.hashes.insert("sha256".into(), "bbb".into());
699
700 old.components.insert(c1.id.clone(), c1);
701 new.components.insert(c2.id.clone(), c2);
702
703 let diff = Differ::diff(&old, &new, None);
704 assert_eq!(diff.changed.len(), 1);
705 assert!(diff.changed[0]
706 .changes
707 .iter()
708 .any(|c| matches!(c, FieldChange::Hashes(_, _))));
709 }
710
711 #[test]
712 fn test_diff_description_change() {
713 let mut old = Sbom::default();
714 let mut new = Sbom::default();
715
716 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
717 c1.description = Some("Old description".into());
718 let mut c2 = c1.clone();
719 c2.description = Some("New description".into());
720
721 old.components.insert(c1.id.clone(), c1);
722 new.components.insert(c2.id.clone(), c2);
723
724 let diff = Differ::diff(&old, &new, None);
725 assert_eq!(diff.changed.len(), 1);
726 assert!(diff.changed[0]
727 .changes
728 .iter()
729 .any(|c| matches!(c, FieldChange::Description(_, _))));
730 }
731
732 #[test]
733 fn test_diff_description_added() {
734 let mut old = Sbom::default();
735 let mut new = Sbom::default();
736
737 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
738 let mut c2 = c1.clone();
739 c2.description = Some("A new description".into());
740
741 old.components.insert(c1.id.clone(), c1);
742 new.components.insert(c2.id.clone(), c2);
743
744 let diff = Differ::diff(&old, &new, None);
745 assert_eq!(diff.changed.len(), 1);
746 assert!(diff.changed[0]
747 .changes
748 .iter()
749 .any(|c| matches!(c, FieldChange::Description(None, Some(_)))));
750 }
751
752 #[test]
753 fn test_diff_description_removed() {
754 let mut old = Sbom::default();
755 let mut new = Sbom::default();
756
757 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
758 c1.description = Some("Had a description".into());
759 let mut c2 = c1.clone();
760 c2.description = None;
761
762 old.components.insert(c1.id.clone(), c1);
763 new.components.insert(c2.id.clone(), c2);
764
765 let diff = Differ::diff(&old, &new, None);
766 assert_eq!(diff.changed.len(), 1);
767 assert!(diff.changed[0]
768 .changes
769 .iter()
770 .any(|c| matches!(c, FieldChange::Description(Some(_), None))));
771 }
772
773 #[test]
774 fn test_diff_description_unchanged() {
775 let mut old = Sbom::default();
776 let mut new = Sbom::default();
777
778 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
779 c1.description = Some("Same description".into());
780 let c2 = c1.clone();
781
782 old.components.insert(c1.id.clone(), c1);
783 new.components.insert(c2.id.clone(), c2);
784
785 let diff = Differ::diff(&old, &new, None);
786 assert!(diff.changed.is_empty());
787 }
788
789 #[test]
790 fn test_diff_description_filtering() {
791 let mut old = Sbom::default();
792 let mut new = Sbom::default();
793
794 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
795 c1.description = Some("Old".into());
796 let mut c2 = c1.clone();
797 c2.version = Some("2.0".into());
798 c2.description = Some("New".into());
799
800 old.components.insert(c1.id.clone(), c1);
801 new.components.insert(c2.id.clone(), c2);
802
803 let diff = Differ::diff(&old, &new, Some(&[Field::Description]));
805 assert_eq!(diff.changed.len(), 1);
806 assert_eq!(diff.changed[0].changes.len(), 1);
807 assert!(matches!(
808 diff.changed[0].changes[0],
809 FieldChange::Description(_, _)
810 ));
811
812 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
814 assert_eq!(diff.changed.len(), 1);
815 assert_eq!(diff.changed[0].changes.len(), 1);
816 assert!(matches!(
817 diff.changed[0].changes[0],
818 FieldChange::Version(_, _)
819 ));
820 }
821
822 #[test]
823 fn test_diff_multiple_field_changes() {
824 let mut old = Sbom::default();
825 let mut new = Sbom::default();
826
827 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
828 c1.licenses.insert("MIT".into());
829 c1.supplier = Some("Old Corp".into());
830 c1.hashes.insert("sha256".into(), "aaa".into());
831
832 let mut c2 = c1.clone();
833 c2.version = Some("2.0".into());
834 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
835 c2.supplier = Some("New Corp".into());
836 c2.hashes.insert("sha256".into(), "bbb".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_eq!(diff.changed[0].changes.len(), 4);
844 }
845
846 #[test]
847 fn test_diff_no_changes() {
848 let mut old = Sbom::default();
849 let mut new = Sbom::default();
850
851 let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
852 old.components.insert(c.id.clone(), c.clone());
853 new.components.insert(c.id.clone(), c);
854
855 let diff = Differ::diff(&old, &new, None);
856 assert!(diff.added.is_empty());
857 assert!(diff.removed.is_empty());
858 assert!(diff.changed.is_empty());
859 assert!(diff.edge_diffs.is_empty());
860 }
861
862 #[test]
863 fn test_diff_metadata_changed_timestamp() {
864 let mut old = Sbom::default();
865 let mut new = Sbom::default();
866
867 old.metadata.timestamp = Some("2024-01-01".into());
868 new.metadata.timestamp = Some("2024-01-02".into());
869
870 let diff = Differ::diff(&old, &new, None);
871 assert!(diff.metadata_changed);
872 assert!(!diff.is_empty());
873 }
874
875 #[test]
876 fn test_diff_metadata_changed_tools() {
877 let mut old = Sbom::default();
878 let mut new = Sbom::default();
879
880 old.metadata.tools = vec!["syft".into()];
881 new.metadata.tools = vec!["trivy".into()];
882
883 let diff = Differ::diff(&old, &new, None);
884 assert!(diff.metadata_changed);
885 }
886
887 #[test]
888 fn test_diff_metadata_changed_authors() {
889 let mut old = Sbom::default();
890 let mut new = Sbom::default();
891
892 old.metadata.authors = vec!["alice".into()];
893 new.metadata.authors = vec!["bob".into()];
894
895 let diff = Differ::diff(&old, &new, None);
896 assert!(diff.metadata_changed);
897 }
898
899 #[test]
900 fn test_diff_metadata_unchanged() {
901 let mut old = Sbom::default();
902 let mut new = Sbom::default();
903
904 old.metadata.timestamp = Some("2024-01-01".into());
905 new.metadata.timestamp = Some("2024-01-01".into());
906 old.metadata.tools = vec!["syft".into()];
907 new.metadata.tools = vec!["syft".into()];
908
909 let diff = Differ::diff(&old, &new, None);
910 assert!(!diff.metadata_changed);
911 }
912
913 #[test]
914 fn test_diff_filtering() {
915 let mut old = Sbom::default();
916 let mut new = Sbom::default();
917
918 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
919 c1.licenses.insert("MIT".into());
920
921 let mut c2 = c1.clone();
922 c2.version = Some("1.1".to_string());
923 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
924
925 old.components.insert(c1.id.clone(), c1);
926 new.components.insert(c2.id.clone(), c2);
927
928 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
929 assert_eq!(diff.changed.len(), 1);
930 assert_eq!(diff.changed[0].changes.len(), 1);
931 assert!(matches!(
932 diff.changed[0].changes[0],
933 FieldChange::Version(_, _)
934 ));
935 }
936
937 #[test]
938 fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
939 let mut old = Sbom::default();
942 let mut new = Sbom::default();
943
944 let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
946 c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
947 c_old.ecosystem = Some("npm".to_string());
948 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
949
950 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
952 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
953 c_new.ecosystem = Some("npm".to_string());
954 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
955
956 old.components.insert(c_old.id.clone(), c_old);
957 new.components.insert(c_new.id.clone(), c_new);
958
959 let diff = Differ::diff(&old, &new, None);
960
961 assert_eq!(diff.added.len(), 0, "Should not have added components");
963 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
964
965 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
967
968 let changes = &diff.changed[0].changes;
970 assert!(changes
971 .iter()
972 .any(|c| matches!(c, FieldChange::Version(_, _))));
973 assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
974 }
975
976 #[test]
977 fn test_purl_removed_is_change() {
978 let mut old = Sbom::default();
981 let mut new = Sbom::default();
982
983 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
984 c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
985 c_old.ecosystem = Some("npm".to_string()); c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
987
988 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
990 c_new.purl = None;
991 c_new.ecosystem = None; c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
994
995 old.components.insert(c_old.id.clone(), c_old);
996 new.components.insert(c_new.id.clone(), c_new);
997
998 let diff = Differ::diff(&old, &new, None);
999
1000 assert_eq!(diff.added.len(), 0, "Should not have added components");
1001 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1002 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1003
1004 assert!(diff.changed[0]
1006 .changes
1007 .iter()
1008 .any(|c| matches!(c, FieldChange::Purl(_, _))));
1009 }
1010
1011 #[test]
1012 fn test_purl_added_is_change() {
1013 let mut old = Sbom::default();
1016 let mut new = Sbom::default();
1017
1018 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1019 c_old.purl = None;
1020 c_old.ecosystem = None; c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1022
1023 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1024 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1025 c_new.ecosystem = Some("npm".to_string()); c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1027
1028 old.components.insert(c_old.id.clone(), c_old);
1029 new.components.insert(c_new.id.clone(), c_new);
1030
1031 let diff = Differ::diff(&old, &new, None);
1032
1033 assert_eq!(diff.added.len(), 0, "Should not have added components");
1034 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1035 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1036 }
1037
1038 #[test]
1039 fn test_same_name_different_ecosystems_not_matched() {
1040 let mut old = Sbom::default();
1042 let mut new = Sbom::default();
1043
1044 let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1046 c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
1047 c_old.ecosystem = Some("npm".to_string());
1048 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1049
1050 let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1052 c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
1053 c_new.ecosystem = Some("pypi".to_string());
1054 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1055
1056 old.components.insert(c_old.id.clone(), c_old);
1057 new.components.insert(c_new.id.clone(), c_new);
1058
1059 let diff = Differ::diff(&old, &new, None);
1060
1061 assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
1063 assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
1064 assert_eq!(
1065 diff.changed.len(),
1066 0,
1067 "Should not match different ecosystems"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_same_name_both_no_ecosystem_matched() {
1073 let mut old = Sbom::default();
1076 let mut new = Sbom::default();
1077
1078 let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
1079 c_old.ecosystem = None;
1080
1081 let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
1082 c_new.ecosystem = None;
1083
1084 old.components.insert(c_old.id.clone(), c_old);
1085 new.components.insert(c_new.id.clone(), c_new);
1086
1087 let diff = Differ::diff(&old, &new, None);
1088
1089 assert_eq!(diff.added.len(), 0);
1090 assert_eq!(diff.removed.len(), 0);
1091 assert_eq!(
1092 diff.changed.len(),
1093 1,
1094 "Same name with None ecosystems should match"
1095 );
1096 }
1097
1098 #[test]
1099 fn test_edge_diff_added_removed() {
1100 let mut old = Sbom::default();
1101 let mut new = Sbom::default();
1102
1103 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1104 let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
1105 let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
1106
1107 let parent_id = c1.id.clone();
1108 let child_a_id = c2.id.clone();
1109 let child_b_id = c3.id.clone();
1110
1111 old.components.insert(c1.id.clone(), c1.clone());
1113 old.components.insert(c2.id.clone(), c2.clone());
1114 old.components.insert(c3.id.clone(), c3.clone());
1115
1116 new.components.insert(c1.id.clone(), c1);
1117 new.components.insert(c2.id.clone(), c2);
1118 new.components.insert(c3.id.clone(), c3);
1119
1120 old.dependencies
1122 .entry(parent_id.clone())
1123 .or_default()
1124 .insert(child_a_id.clone());
1125
1126 new.dependencies
1128 .entry(parent_id.clone())
1129 .or_default()
1130 .insert(child_b_id.clone());
1131
1132 let diff = Differ::diff(&old, &new, None);
1133
1134 assert_eq!(diff.edge_diffs.len(), 1);
1135 assert_eq!(diff.edge_diffs[0].parent, parent_id);
1136 assert!(diff.edge_diffs[0].added.contains(&child_b_id));
1137 assert!(diff.edge_diffs[0].removed.contains(&child_a_id));
1138 }
1139
1140 #[test]
1141 fn test_edge_diff_with_identity_reconciliation() {
1142 let mut old = Sbom::default();
1145 let mut new = Sbom::default();
1146
1147 let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
1149 parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
1150 parent_old.ecosystem = Some("npm".to_string());
1151 parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
1152
1153 let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
1155 parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
1156 parent_new.ecosystem = Some("npm".to_string());
1157 parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
1158
1159 let child = Component::new("child".to_string(), Some("1.0".to_string()));
1161
1162 old.components
1163 .insert(parent_old.id.clone(), parent_old.clone());
1164 old.components.insert(child.id.clone(), child.clone());
1165
1166 new.components
1167 .insert(parent_new.id.clone(), parent_new.clone());
1168 new.components.insert(child.id.clone(), child.clone());
1169
1170 old.dependencies
1172 .entry(parent_old.id.clone())
1173 .or_default()
1174 .insert(child.id.clone());
1175
1176 new.dependencies
1178 .entry(parent_new.id.clone())
1179 .or_default()
1180 .insert(child.id.clone());
1181
1182 let diff = Differ::diff(&old, &new, None);
1183
1184 assert_eq!(
1187 diff.edge_diffs.len(),
1188 0,
1189 "No edge changes expected when parent is reconciled by identity"
1190 );
1191 }
1192
1193 #[test]
1194 fn test_edge_diff_filtering() {
1195 let mut old = Sbom::default();
1197 let mut new = Sbom::default();
1198
1199 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1200 let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
1201
1202 let parent_id = c1.id.clone();
1203 let child_id = c2.id.clone();
1204
1205 old.components.insert(c1.id.clone(), c1.clone());
1206 old.components.insert(c2.id.clone(), c2.clone());
1207
1208 new.components.insert(c1.id.clone(), c1);
1209 new.components.insert(c2.id.clone(), c2);
1210
1211 new.dependencies
1213 .entry(parent_id.clone())
1214 .or_default()
1215 .insert(child_id);
1216
1217 let diff = Differ::diff(&old, &new, None);
1219 assert_eq!(diff.edge_diffs.len(), 1);
1220
1221 let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
1223 assert_eq!(diff_filtered.edge_diffs.len(), 0);
1224
1225 let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
1227 assert_eq!(diff_with_deps.edge_diffs.len(), 1);
1228 }
1229
1230 #[test]
1231 fn test_ecosystem_breakdown() {
1232 let mut old = Sbom::default();
1233 let mut new = Sbom::default();
1234
1235 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1237 c1.ecosystem = Some("npm".into());
1238 old.components.insert(c1.id.clone(), c1);
1239
1240 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1242 c2.ecosystem = Some("npm".into());
1243 new.components.insert(c2.id.clone(), c2);
1244
1245 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1247 c3.ecosystem = Some("cargo".into());
1248 new.components.insert(c3.id.clone(), c3);
1249
1250 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1252 c4_old.ecosystem = Some("npm".into());
1253 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1254 c4_new.ecosystem = Some("npm".into());
1255 old.components.insert(c4_old.id.clone(), c4_old);
1256 new.components.insert(c4_new.id.clone(), c4_new);
1257
1258 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1260 new.components.insert(c5.id.clone(), c5);
1261
1262 let diff = Differ::diff(&old, &new, None);
1263 let breakdown = diff.ecosystem_breakdown();
1264
1265 let npm = breakdown.get("npm").unwrap();
1266 assert_eq!(npm.added, 1);
1267 assert_eq!(npm.removed, 1);
1268 assert_eq!(npm.changed, 1);
1269
1270 let cargo = breakdown.get("cargo").unwrap();
1271 assert_eq!(cargo.added, 1);
1272 assert_eq!(cargo.removed, 0);
1273 assert_eq!(cargo.changed, 0);
1274
1275 let unknown = breakdown.get("unknown").unwrap();
1276 assert_eq!(unknown.added, 1);
1277 assert_eq!(unknown.removed, 0);
1278 assert_eq!(unknown.changed, 0);
1279 }
1280
1281 #[test]
1282 fn test_ecosystem_breakdown_empty_diff() {
1283 let old = Sbom::default();
1284 let new = Sbom::default();
1285
1286 let diff = Differ::diff(&old, &new, None);
1287 assert!(diff.is_empty());
1288 assert!(diff.ecosystem_breakdown().is_empty());
1289 }
1290
1291 #[test]
1292 fn test_group_by_ecosystem_empty_diff() {
1293 let old = Sbom::default();
1294 let new = Sbom::default();
1295
1296 let diff = Differ::diff(&old, &new, None);
1297 let grouped = diff.group_by_ecosystem();
1298 assert!(grouped.by_ecosystem.is_empty());
1299 assert!(grouped.edge_diffs.is_empty());
1300 assert!(!grouped.metadata_changed);
1301 assert!(grouped.ecosystem_breakdown().is_empty());
1302 }
1303
1304 #[test]
1305 fn test_group_by_ecosystem_groups_correctly() {
1306 let mut old = Sbom::default();
1307 let mut new = Sbom::default();
1308
1309 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1311 c1.ecosystem = Some("npm".into());
1312 old.components.insert(c1.id.clone(), c1);
1313
1314 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1316 c2.ecosystem = Some("npm".into());
1317 new.components.insert(c2.id.clone(), c2);
1318
1319 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1321 c3.ecosystem = Some("cargo".into());
1322 new.components.insert(c3.id.clone(), c3);
1323
1324 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1326 c4_old.ecosystem = Some("npm".into());
1327 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1328 c4_new.ecosystem = Some("npm".into());
1329 old.components.insert(c4_old.id.clone(), c4_old);
1330 new.components.insert(c4_new.id.clone(), c4_new);
1331
1332 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1334 new.components.insert(c5.id.clone(), c5);
1335
1336 let diff = Differ::diff(&old, &new, None);
1337 let grouped = diff.group_by_ecosystem();
1338
1339 let npm = grouped.by_ecosystem.get("npm").unwrap();
1340 assert_eq!(npm.added.len(), 1);
1341 assert_eq!(npm.removed.len(), 1);
1342 assert_eq!(npm.changed.len(), 1);
1343
1344 let cargo = grouped.by_ecosystem.get("cargo").unwrap();
1345 assert_eq!(cargo.added.len(), 1);
1346 assert_eq!(cargo.removed.len(), 0);
1347 assert_eq!(cargo.changed.len(), 0);
1348
1349 let unknown = grouped.by_ecosystem.get("unknown").unwrap();
1350 assert_eq!(unknown.added.len(), 1);
1351 assert_eq!(unknown.removed.len(), 0);
1352 assert_eq!(unknown.changed.len(), 0);
1353
1354 let grouped_counts = grouped.ecosystem_breakdown();
1356 let direct_counts = diff.ecosystem_breakdown();
1357 assert_eq!(grouped_counts, direct_counts);
1358 }
1359
1360 #[test]
1361 fn test_totals_no_changes() {
1362 let mut old = Sbom::default();
1363 let mut new = Sbom::default();
1364
1365 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1366 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1367
1368 old.components.insert(c1.id.clone(), c1.clone());
1369 old.components.insert(c2.id.clone(), c2.clone());
1370 new.components.insert(c1.id.clone(), c1);
1371 new.components.insert(c2.id.clone(), c2);
1372
1373 let diff = Differ::diff(&old, &new, None);
1374 assert_eq!(diff.old_total, 2);
1375 assert_eq!(diff.new_total, 2);
1376 assert_eq!(diff.unchanged, 2);
1377 }
1378
1379 #[test]
1380 fn test_totals_with_changes() {
1381 let mut old = Sbom::default();
1382 let mut new = Sbom::default();
1383
1384 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1385 let mut c1_updated = c1.clone();
1386 c1_updated.version = Some("1.1".to_string());
1387 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1388 let c3 = Component::new("pkg-c".to_string(), Some("3.0".to_string()));
1389 let c4 = Component::new("pkg-d".to_string(), Some("4.0".to_string()));
1390
1391 old.components.insert(c1.id.clone(), c1);
1392 old.components.insert(c2.id.clone(), c2.clone());
1393 old.components.insert(c3.id.clone(), c3);
1394 new.components.insert(c1_updated.id.clone(), c1_updated);
1395 new.components.insert(c2.id.clone(), c2);
1396 new.components.insert(c4.id.clone(), c4);
1397
1398 let diff = Differ::diff(&old, &new, None);
1399 assert_eq!(diff.old_total, 3);
1400 assert_eq!(diff.new_total, 3);
1401 assert_eq!(diff.added.len(), 1); assert_eq!(diff.removed.len(), 1); assert_eq!(diff.changed.len(), 1); assert_eq!(diff.unchanged, 1); }
1406
1407 #[test]
1408 fn test_component_names_for_hash_ids_in_edge_diffs() {
1409 let mut old = Sbom::default();
1410 let mut new = Sbom::default();
1411
1412 let parent = Component::new("my-app".to_string(), Some("1.0".to_string()));
1414 let child_a = Component::new("dep-old".to_string(), Some("0.1".to_string()));
1415 let child_b = Component::new("dep-new".to_string(), Some("0.2".to_string()));
1416
1417 old.components.insert(parent.id.clone(), parent.clone());
1418 old.components.insert(child_a.id.clone(), child_a.clone());
1419 new.components.insert(parent.id.clone(), parent.clone());
1420 new.components.insert(child_b.id.clone(), child_b.clone());
1421
1422 old.dependencies
1424 .insert(parent.id.clone(), BTreeSet::from([child_a.id.clone()]));
1425 new.dependencies
1426 .insert(parent.id.clone(), BTreeSet::from([child_b.id.clone()]));
1427
1428 let diff = Differ::diff(&old, &new, None);
1429
1430 assert!(diff.edge_diffs[0].parent.as_str().starts_with("h:"));
1432
1433 assert_eq!(diff.display_name(&diff.edge_diffs[0].parent), "my-app@1.0");
1435 for added in &diff.edge_diffs[0].added {
1436 assert!(!diff.display_name(added).starts_with("h:"));
1437 }
1438 for removed in &diff.edge_diffs[0].removed {
1439 assert!(!diff.display_name(removed).starts_with("h:"));
1440 }
1441 }
1442
1443 #[test]
1444 fn test_component_names_skips_purl_ids() {
1445 let mut old = Sbom::default();
1446 let mut new = Sbom::default();
1447
1448 let mut parent = Component::new("parent".to_string(), Some("1.0".to_string()));
1449 parent.purl = Some("pkg:npm/parent@1.0".to_string());
1450 parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
1451
1452 let mut child_a = Component::new("child-a".to_string(), Some("1.0".to_string()));
1453 child_a.purl = Some("pkg:npm/child-a@1.0".to_string());
1454 child_a.id = ComponentId::new(child_a.purl.as_deref(), &[]);
1455
1456 let mut child_b = Component::new("child-b".to_string(), Some("1.0".to_string()));
1457 child_b.purl = Some("pkg:npm/child-b@1.0".to_string());
1458 child_b.id = ComponentId::new(child_b.purl.as_deref(), &[]);
1459
1460 old.components.insert(parent.id.clone(), parent.clone());
1461 old.components.insert(child_a.id.clone(), child_a.clone());
1462 new.components.insert(parent.id.clone(), parent.clone());
1463 new.components.insert(child_b.id.clone(), child_b.clone());
1464
1465 old.dependencies
1466 .insert(parent.id.clone(), BTreeSet::from([child_a.id.clone()]));
1467 new.dependencies
1468 .insert(parent.id.clone(), BTreeSet::from([child_b.id.clone()]));
1469
1470 let diff = Differ::diff(&old, &new, None);
1471
1472 assert!(diff.component_names.is_empty());
1474
1475 assert!(diff
1477 .display_name(&diff.edge_diffs[0].parent)
1478 .starts_with("pkg:npm/parent@"));
1479 }
1480
1481 #[test]
1482 fn test_display_name_fallback() {
1483 let diff = Diff::default();
1484 let unknown_id = ComponentId::new(None, &[("name", "mystery")]);
1485 assert_eq!(diff.display_name(&unknown_id), unknown_id.as_str());
1487 }
1488}