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, 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}
34
35impl Diff {
36 pub fn is_empty(&self) -> bool {
38 self.added.is_empty()
39 && self.removed.is_empty()
40 && self.changed.is_empty()
41 && self.edge_diffs.is_empty()
42 && !self.metadata_changed
43 }
44
45 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
49 let mut breakdown: BTreeMap<String, EcosystemCounts> = BTreeMap::new();
50
51 for comp in &self.added {
52 let eco = comp.ecosystem.clone().unwrap_or_else(|| "unknown".into());
53 breakdown.entry(eco).or_default().added += 1;
54 }
55
56 for comp in &self.removed {
57 let eco = comp.ecosystem.clone().unwrap_or_else(|| "unknown".into());
58 breakdown.entry(eco).or_default().removed += 1;
59 }
60
61 for change in &self.changed {
62 let eco = change
63 .new
64 .ecosystem
65 .clone()
66 .unwrap_or_else(|| "unknown".into());
67 breakdown.entry(eco).or_default().changed += 1;
68 }
69
70 breakdown
71 }
72
73 pub fn group_by_ecosystem(&self) -> GroupedDiff {
77 let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
78
79 for c in &self.added {
80 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
81 ecosystems.entry(eco).or_default().added.push(c.clone());
82 }
83 for c in &self.removed {
84 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
85 ecosystems.entry(eco).or_default().removed.push(c.clone());
86 }
87 for c in &self.changed {
88 let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
89 ecosystems.entry(eco).or_default().changed.push(c.clone());
90 }
91
92 GroupedDiff {
93 by_ecosystem: ecosystems,
94 edge_diffs: self.edge_diffs.clone(),
95 metadata_changed: self.metadata_changed,
96 }
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct GroupedDiff {
103 pub by_ecosystem: BTreeMap<String, EcosystemDiff>,
104 pub edge_diffs: Vec<EdgeDiff>,
105 pub metadata_changed: bool,
106}
107
108impl GroupedDiff {
109 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
115 self.by_ecosystem
116 .iter()
117 .map(|(eco, eco_diff)| {
118 (
119 eco.clone(),
120 EcosystemCounts {
121 added: eco_diff.added.len(),
122 removed: eco_diff.removed.len(),
123 changed: eco_diff.changed.len(),
124 },
125 )
126 })
127 .collect()
128 }
129}
130
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct EcosystemDiff {
134 pub added: Vec<Component>,
135 pub removed: Vec<Component>,
136 pub changed: Vec<ComponentChange>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ComponentChange {
142 pub id: ComponentId,
144 pub old: Component,
146 pub new: Component,
148 pub changes: Vec<FieldChange>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct EdgeDiff {
155 pub parent: ComponentId,
157 pub added: BTreeSet<ComponentId>,
159 pub removed: BTreeSet<ComponentId>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub enum FieldChange {
166 Version(String, String),
168 License(BTreeSet<String>, BTreeSet<String>),
170 Supplier(Option<String>, Option<String>),
172 Purl(Option<String>, Option<String>),
174 Description(Option<String>, Option<String>),
176 Hashes(BTreeMap<String, String>, BTreeMap<String, String>),
178}
179
180#[derive(Debug, Copy, Clone, PartialEq, Eq)]
184pub enum Field {
185 Version,
187 License,
189 Supplier,
191 Purl,
193 Description,
195 Hashes,
197 Deps,
199}
200
201pub struct Differ;
206
207impl Differ {
208 pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
235 let mut old = old.clone();
236 let mut new = new.clone();
237
238 old.normalize();
239 new.normalize();
240
241 let mut added = Vec::new();
242 let mut removed = Vec::new();
243 let mut changed = Vec::new();
244
245 let mut processed_old = HashSet::new();
246 let mut processed_new = HashSet::new();
247
248 let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
250
251 for (id, new_comp) in &new.components {
253 if let Some(old_comp) = old.components.get(id) {
254 processed_old.insert(id.clone());
255 processed_new.insert(id.clone());
256 id_mapping.insert(id.clone(), id.clone());
257
258 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
259 changed.push(change);
260 }
261 }
262 }
263
264 let mut old_identity_map: BTreeMap<(Option<String>, String), Vec<ComponentId>> =
268 BTreeMap::new();
269 for (id, comp) in &old.components {
270 if !processed_old.contains(id) {
271 let identity = (comp.ecosystem.clone(), comp.name.clone());
272 old_identity_map
273 .entry(identity)
274 .or_default()
275 .push(id.clone());
276 }
277 }
278
279 for (id, new_comp) in &new.components {
280 if processed_new.contains(id) {
281 continue;
282 }
283
284 let identity = (new_comp.ecosystem.clone(), new_comp.name.clone());
285
286 let matched_old_id = old_identity_map
291 .get_mut(&identity)
292 .and_then(|ids| ids.pop())
293 .or_else(|| {
294 if new_comp.ecosystem.is_some() {
295 old_identity_map
297 .get_mut(&(None, new_comp.name.clone()))
298 .and_then(|ids| ids.pop())
299 } else {
300 old_identity_map
302 .iter_mut()
303 .find(|((_, name), ids)| name == &new_comp.name && !ids.is_empty())
304 .and_then(|(_, ids)| ids.pop())
305 }
306 });
307
308 if let Some(old_id) = matched_old_id {
309 if let Some(old_comp) = old.components.get(&old_id) {
310 processed_old.insert(old_id.clone());
311 processed_new.insert(id.clone());
312 id_mapping.insert(old_id.clone(), id.clone());
313
314 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
315 changed.push(change);
316 }
317 continue;
318 }
319 }
320
321 added.push(new_comp.clone());
322 processed_new.insert(id.clone());
323 }
324
325 for (id, old_comp) in &old.components {
326 if !processed_old.contains(id) {
327 removed.push(old_comp.clone());
328 }
329 }
330
331 let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
333 let edge_diffs = if should_include_deps {
334 Self::compute_edge_diffs(&old, &new, &id_mapping)
335 } else {
336 Vec::new()
337 };
338
339 Diff {
340 added,
341 removed,
342 changed,
343 edge_diffs,
344 metadata_changed: old.metadata != new.metadata,
345 }
346 }
347
348 fn compute_edge_diffs(
353 old: &Sbom,
354 new: &Sbom,
355 id_mapping: &BTreeMap<ComponentId, ComponentId>,
356 ) -> Vec<EdgeDiff> {
357 let mut edge_diffs = Vec::new();
358
359 let reverse_mapping: BTreeMap<ComponentId, ComponentId> = id_mapping
363 .iter()
364 .map(|(old_id, new_id)| (new_id.clone(), old_id.clone()))
365 .collect();
366
367 let translate_id = |old_id: &ComponentId| -> ComponentId {
369 id_mapping
370 .get(old_id)
371 .cloned()
372 .unwrap_or_else(|| old_id.clone())
373 };
374
375 let mut all_parents: BTreeSet<ComponentId> = new.dependencies.keys().cloned().collect();
378
379 for old_parent in old.dependencies.keys() {
381 all_parents.insert(translate_id(old_parent));
382 }
383
384 for parent_id in all_parents {
385 let new_children: BTreeSet<ComponentId> = new
387 .dependencies
388 .get(&parent_id)
389 .cloned()
390 .unwrap_or_default();
391
392 let old_parent_id = reverse_mapping
395 .get(&parent_id)
396 .cloned()
397 .unwrap_or_else(|| parent_id.clone());
398
399 let old_children: BTreeSet<ComponentId> = old
400 .dependencies
401 .get(&old_parent_id)
402 .map(|children| children.iter().map(&translate_id).collect())
403 .unwrap_or_default();
404
405 let added: BTreeSet<ComponentId> =
407 new_children.difference(&old_children).cloned().collect();
408 let removed: BTreeSet<ComponentId> =
409 old_children.difference(&new_children).cloned().collect();
410
411 if !added.is_empty() || !removed.is_empty() {
412 edge_diffs.push(EdgeDiff {
413 parent: parent_id,
414 added,
415 removed,
416 });
417 }
418 }
419
420 edge_diffs
421 }
422
423 fn compute_change(
424 old: &Component,
425 new: &Component,
426 only: Option<&[Field]>,
427 ) -> Option<ComponentChange> {
428 let mut changes = Vec::new();
429
430 let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
431
432 if should_include(Field::Version) && old.version != new.version {
433 changes.push(FieldChange::Version(
434 old.version.clone().unwrap_or_default(),
435 new.version.clone().unwrap_or_default(),
436 ));
437 }
438
439 if should_include(Field::License) && old.licenses != new.licenses {
440 changes.push(FieldChange::License(
441 old.licenses.clone(),
442 new.licenses.clone(),
443 ));
444 }
445
446 if should_include(Field::Supplier) && old.supplier != new.supplier {
447 changes.push(FieldChange::Supplier(
448 old.supplier.clone(),
449 new.supplier.clone(),
450 ));
451 }
452
453 if should_include(Field::Purl) && old.purl != new.purl {
454 changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
455 }
456
457 if should_include(Field::Description) && old.description != new.description {
458 changes.push(FieldChange::Description(
459 old.description.clone(),
460 new.description.clone(),
461 ));
462 }
463
464 if should_include(Field::Hashes) && old.hashes != new.hashes {
465 changes.push(FieldChange::Hashes(old.hashes.clone(), new.hashes.clone()));
466 }
467
468 if changes.is_empty() {
469 None
470 } else {
471 Some(ComponentChange {
472 id: new.id.clone(),
473 old: old.clone(),
474 new: new.clone(),
475 changes,
476 })
477 }
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
486 fn test_diff_added_removed() {
487 let mut old = Sbom::default();
488 let mut new = Sbom::default();
489
490 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
491 let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
492
493 old.components.insert(c1.id.clone(), c1);
494 new.components.insert(c2.id.clone(), c2);
495
496 let diff = Differ::diff(&old, &new, None);
497 assert_eq!(diff.added.len(), 1);
498 assert_eq!(diff.removed.len(), 1);
499 assert_eq!(diff.changed.len(), 0);
500 }
501
502 #[test]
503 fn test_diff_changed() {
504 let mut old = Sbom::default();
505 let mut new = Sbom::default();
506
507 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
508 let mut c2 = c1.clone();
509 c2.version = Some("1.1".to_string());
510
511 old.components.insert(c1.id.clone(), c1);
512 new.components.insert(c2.id.clone(), c2);
513
514 let diff = Differ::diff(&old, &new, None);
515 assert_eq!(diff.added.len(), 0);
516 assert_eq!(diff.removed.len(), 0);
517 assert_eq!(diff.changed.len(), 1);
518 assert!(matches!(
519 diff.changed[0].changes[0],
520 FieldChange::Version(_, _)
521 ));
522 }
523
524 #[test]
525 fn test_diff_identity_reconciliation() {
526 let mut old = Sbom::default();
527 let mut new = Sbom::default();
528
529 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
530 let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
531
532 old.components.insert(c1.id.clone(), c1);
533 new.components.insert(c2.id.clone(), c2);
534
535 let diff = Differ::diff(&old, &new, None);
536 assert_eq!(diff.changed.len(), 1);
537 assert_eq!(diff.added.len(), 0);
538 }
539
540 #[test]
541 fn test_diff_license_change() {
542 let mut old = Sbom::default();
543 let mut new = Sbom::default();
544
545 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
546 c1.licenses.insert("MIT".into());
547 let mut c2 = c1.clone();
548 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
549
550 old.components.insert(c1.id.clone(), c1);
551 new.components.insert(c2.id.clone(), c2);
552
553 let diff = Differ::diff(&old, &new, None);
554 assert_eq!(diff.changed.len(), 1);
555 assert!(diff.changed[0]
556 .changes
557 .iter()
558 .any(|c| matches!(c, FieldChange::License(_, _))));
559 }
560
561 #[test]
562 fn test_diff_supplier_change() {
563 let mut old = Sbom::default();
564 let mut new = Sbom::default();
565
566 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
567 c1.supplier = Some("Acme Corp".into());
568 let mut c2 = c1.clone();
569 c2.supplier = Some("New Corp".into());
570
571 old.components.insert(c1.id.clone(), c1);
572 new.components.insert(c2.id.clone(), c2);
573
574 let diff = Differ::diff(&old, &new, None);
575 assert_eq!(diff.changed.len(), 1);
576 assert!(diff.changed[0]
577 .changes
578 .iter()
579 .any(|c| matches!(c, FieldChange::Supplier(_, _))));
580 }
581
582 #[test]
583 fn test_diff_hashes_change() {
584 let mut old = Sbom::default();
585 let mut new = Sbom::default();
586
587 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
588 c1.hashes.insert("sha256".into(), "aaa".into());
589 let mut c2 = c1.clone();
590 c2.hashes.insert("sha256".into(), "bbb".into());
591
592 old.components.insert(c1.id.clone(), c1);
593 new.components.insert(c2.id.clone(), c2);
594
595 let diff = Differ::diff(&old, &new, None);
596 assert_eq!(diff.changed.len(), 1);
597 assert!(diff.changed[0]
598 .changes
599 .iter()
600 .any(|c| matches!(c, FieldChange::Hashes(_, _))));
601 }
602
603 #[test]
604 fn test_diff_description_change() {
605 let mut old = Sbom::default();
606 let mut new = Sbom::default();
607
608 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
609 c1.description = Some("Old description".into());
610 let mut c2 = c1.clone();
611 c2.description = Some("New description".into());
612
613 old.components.insert(c1.id.clone(), c1);
614 new.components.insert(c2.id.clone(), c2);
615
616 let diff = Differ::diff(&old, &new, None);
617 assert_eq!(diff.changed.len(), 1);
618 assert!(diff.changed[0]
619 .changes
620 .iter()
621 .any(|c| matches!(c, FieldChange::Description(_, _))));
622 }
623
624 #[test]
625 fn test_diff_description_added() {
626 let mut old = Sbom::default();
627 let mut new = Sbom::default();
628
629 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
630 let mut c2 = c1.clone();
631 c2.description = Some("A new description".into());
632
633 old.components.insert(c1.id.clone(), c1);
634 new.components.insert(c2.id.clone(), c2);
635
636 let diff = Differ::diff(&old, &new, None);
637 assert_eq!(diff.changed.len(), 1);
638 assert!(diff.changed[0]
639 .changes
640 .iter()
641 .any(|c| matches!(c, FieldChange::Description(None, Some(_)))));
642 }
643
644 #[test]
645 fn test_diff_description_removed() {
646 let mut old = Sbom::default();
647 let mut new = Sbom::default();
648
649 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
650 c1.description = Some("Had a description".into());
651 let mut c2 = c1.clone();
652 c2.description = None;
653
654 old.components.insert(c1.id.clone(), c1);
655 new.components.insert(c2.id.clone(), c2);
656
657 let diff = Differ::diff(&old, &new, None);
658 assert_eq!(diff.changed.len(), 1);
659 assert!(diff.changed[0]
660 .changes
661 .iter()
662 .any(|c| matches!(c, FieldChange::Description(Some(_), None))));
663 }
664
665 #[test]
666 fn test_diff_description_unchanged() {
667 let mut old = Sbom::default();
668 let mut new = Sbom::default();
669
670 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
671 c1.description = Some("Same description".into());
672 let c2 = c1.clone();
673
674 old.components.insert(c1.id.clone(), c1);
675 new.components.insert(c2.id.clone(), c2);
676
677 let diff = Differ::diff(&old, &new, None);
678 assert!(diff.changed.is_empty());
679 }
680
681 #[test]
682 fn test_diff_description_filtering() {
683 let mut old = Sbom::default();
684 let mut new = Sbom::default();
685
686 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
687 c1.description = Some("Old".into());
688 let mut c2 = c1.clone();
689 c2.version = Some("2.0".into());
690 c2.description = Some("New".into());
691
692 old.components.insert(c1.id.clone(), c1);
693 new.components.insert(c2.id.clone(), c2);
694
695 let diff = Differ::diff(&old, &new, Some(&[Field::Description]));
697 assert_eq!(diff.changed.len(), 1);
698 assert_eq!(diff.changed[0].changes.len(), 1);
699 assert!(matches!(
700 diff.changed[0].changes[0],
701 FieldChange::Description(_, _)
702 ));
703
704 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
706 assert_eq!(diff.changed.len(), 1);
707 assert_eq!(diff.changed[0].changes.len(), 1);
708 assert!(matches!(
709 diff.changed[0].changes[0],
710 FieldChange::Version(_, _)
711 ));
712 }
713
714 #[test]
715 fn test_diff_multiple_field_changes() {
716 let mut old = Sbom::default();
717 let mut new = Sbom::default();
718
719 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
720 c1.licenses.insert("MIT".into());
721 c1.supplier = Some("Old Corp".into());
722 c1.hashes.insert("sha256".into(), "aaa".into());
723
724 let mut c2 = c1.clone();
725 c2.version = Some("2.0".into());
726 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
727 c2.supplier = Some("New Corp".into());
728 c2.hashes.insert("sha256".into(), "bbb".into());
729
730 old.components.insert(c1.id.clone(), c1);
731 new.components.insert(c2.id.clone(), c2);
732
733 let diff = Differ::diff(&old, &new, None);
734 assert_eq!(diff.changed.len(), 1);
735 assert_eq!(diff.changed[0].changes.len(), 4);
736 }
737
738 #[test]
739 fn test_diff_no_changes() {
740 let mut old = Sbom::default();
741 let mut new = Sbom::default();
742
743 let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
744 old.components.insert(c.id.clone(), c.clone());
745 new.components.insert(c.id.clone(), c);
746
747 let diff = Differ::diff(&old, &new, None);
748 assert!(diff.added.is_empty());
749 assert!(diff.removed.is_empty());
750 assert!(diff.changed.is_empty());
751 assert!(diff.edge_diffs.is_empty());
752 }
753
754 #[test]
755 fn test_diff_metadata_changed() {
756 let mut old = Sbom::default();
757 let mut new = Sbom::default();
758
759 old.metadata.timestamp = Some("2024-01-01".into());
762 new.metadata.timestamp = Some("2024-01-02".into());
763
764 let diff = Differ::diff(&old, &new, None);
765 assert!(!diff.metadata_changed);
767 }
768
769 #[test]
770 fn test_diff_filtering() {
771 let mut old = Sbom::default();
772 let mut new = Sbom::default();
773
774 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
775 c1.licenses.insert("MIT".into());
776
777 let mut c2 = c1.clone();
778 c2.version = Some("1.1".to_string());
779 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
780
781 old.components.insert(c1.id.clone(), c1);
782 new.components.insert(c2.id.clone(), c2);
783
784 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
785 assert_eq!(diff.changed.len(), 1);
786 assert_eq!(diff.changed[0].changes.len(), 1);
787 assert!(matches!(
788 diff.changed[0].changes[0],
789 FieldChange::Version(_, _)
790 ));
791 }
792
793 #[test]
794 fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
795 let mut old = Sbom::default();
798 let mut new = Sbom::default();
799
800 let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
802 c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
803 c_old.ecosystem = Some("npm".to_string());
804 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
805
806 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
808 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
809 c_new.ecosystem = Some("npm".to_string());
810 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
811
812 old.components.insert(c_old.id.clone(), c_old);
813 new.components.insert(c_new.id.clone(), c_new);
814
815 let diff = Differ::diff(&old, &new, None);
816
817 assert_eq!(diff.added.len(), 0, "Should not have added components");
819 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
820
821 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
823
824 let changes = &diff.changed[0].changes;
826 assert!(changes
827 .iter()
828 .any(|c| matches!(c, FieldChange::Version(_, _))));
829 assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
830 }
831
832 #[test]
833 fn test_purl_removed_is_change() {
834 let mut old = Sbom::default();
837 let mut new = Sbom::default();
838
839 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
840 c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
841 c_old.ecosystem = Some("npm".to_string()); c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
843
844 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
846 c_new.purl = None;
847 c_new.ecosystem = None; c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
850
851 old.components.insert(c_old.id.clone(), c_old);
852 new.components.insert(c_new.id.clone(), c_new);
853
854 let diff = Differ::diff(&old, &new, None);
855
856 assert_eq!(diff.added.len(), 0, "Should not have added components");
857 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
858 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
859
860 assert!(diff.changed[0]
862 .changes
863 .iter()
864 .any(|c| matches!(c, FieldChange::Purl(_, _))));
865 }
866
867 #[test]
868 fn test_purl_added_is_change() {
869 let mut old = Sbom::default();
872 let mut new = Sbom::default();
873
874 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
875 c_old.purl = None;
876 c_old.ecosystem = None; c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
878
879 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
880 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
881 c_new.ecosystem = Some("npm".to_string()); c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
883
884 old.components.insert(c_old.id.clone(), c_old);
885 new.components.insert(c_new.id.clone(), c_new);
886
887 let diff = Differ::diff(&old, &new, None);
888
889 assert_eq!(diff.added.len(), 0, "Should not have added components");
890 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
891 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
892 }
893
894 #[test]
895 fn test_same_name_different_ecosystems_not_matched() {
896 let mut old = Sbom::default();
898 let mut new = Sbom::default();
899
900 let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
902 c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
903 c_old.ecosystem = Some("npm".to_string());
904 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
905
906 let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
908 c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
909 c_new.ecosystem = Some("pypi".to_string());
910 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
911
912 old.components.insert(c_old.id.clone(), c_old);
913 new.components.insert(c_new.id.clone(), c_new);
914
915 let diff = Differ::diff(&old, &new, None);
916
917 assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
919 assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
920 assert_eq!(
921 diff.changed.len(),
922 0,
923 "Should not match different ecosystems"
924 );
925 }
926
927 #[test]
928 fn test_same_name_both_no_ecosystem_matched() {
929 let mut old = Sbom::default();
932 let mut new = Sbom::default();
933
934 let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
935 c_old.ecosystem = None;
936
937 let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
938 c_new.ecosystem = None;
939
940 old.components.insert(c_old.id.clone(), c_old);
941 new.components.insert(c_new.id.clone(), c_new);
942
943 let diff = Differ::diff(&old, &new, None);
944
945 assert_eq!(diff.added.len(), 0);
946 assert_eq!(diff.removed.len(), 0);
947 assert_eq!(
948 diff.changed.len(),
949 1,
950 "Same name with None ecosystems should match"
951 );
952 }
953
954 #[test]
955 fn test_edge_diff_added_removed() {
956 let mut old = Sbom::default();
957 let mut new = Sbom::default();
958
959 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
960 let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
961 let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
962
963 let parent_id = c1.id.clone();
964 let child_a_id = c2.id.clone();
965 let child_b_id = c3.id.clone();
966
967 old.components.insert(c1.id.clone(), c1.clone());
969 old.components.insert(c2.id.clone(), c2.clone());
970 old.components.insert(c3.id.clone(), c3.clone());
971
972 new.components.insert(c1.id.clone(), c1);
973 new.components.insert(c2.id.clone(), c2);
974 new.components.insert(c3.id.clone(), c3);
975
976 old.dependencies
978 .entry(parent_id.clone())
979 .or_default()
980 .insert(child_a_id.clone());
981
982 new.dependencies
984 .entry(parent_id.clone())
985 .or_default()
986 .insert(child_b_id.clone());
987
988 let diff = Differ::diff(&old, &new, None);
989
990 assert_eq!(diff.edge_diffs.len(), 1);
991 assert_eq!(diff.edge_diffs[0].parent, parent_id);
992 assert!(diff.edge_diffs[0].added.contains(&child_b_id));
993 assert!(diff.edge_diffs[0].removed.contains(&child_a_id));
994 }
995
996 #[test]
997 fn test_edge_diff_with_identity_reconciliation() {
998 let mut old = Sbom::default();
1001 let mut new = Sbom::default();
1002
1003 let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
1005 parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
1006 parent_old.ecosystem = Some("npm".to_string());
1007 parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
1008
1009 let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
1011 parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
1012 parent_new.ecosystem = Some("npm".to_string());
1013 parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
1014
1015 let child = Component::new("child".to_string(), Some("1.0".to_string()));
1017
1018 old.components
1019 .insert(parent_old.id.clone(), parent_old.clone());
1020 old.components.insert(child.id.clone(), child.clone());
1021
1022 new.components
1023 .insert(parent_new.id.clone(), parent_new.clone());
1024 new.components.insert(child.id.clone(), child.clone());
1025
1026 old.dependencies
1028 .entry(parent_old.id.clone())
1029 .or_default()
1030 .insert(child.id.clone());
1031
1032 new.dependencies
1034 .entry(parent_new.id.clone())
1035 .or_default()
1036 .insert(child.id.clone());
1037
1038 let diff = Differ::diff(&old, &new, None);
1039
1040 assert_eq!(
1043 diff.edge_diffs.len(),
1044 0,
1045 "No edge changes expected when parent is reconciled by identity"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_edge_diff_filtering() {
1051 let mut old = Sbom::default();
1053 let mut new = Sbom::default();
1054
1055 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1056 let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
1057
1058 let parent_id = c1.id.clone();
1059 let child_id = c2.id.clone();
1060
1061 old.components.insert(c1.id.clone(), c1.clone());
1062 old.components.insert(c2.id.clone(), c2.clone());
1063
1064 new.components.insert(c1.id.clone(), c1);
1065 new.components.insert(c2.id.clone(), c2);
1066
1067 new.dependencies
1069 .entry(parent_id.clone())
1070 .or_default()
1071 .insert(child_id);
1072
1073 let diff = Differ::diff(&old, &new, None);
1075 assert_eq!(diff.edge_diffs.len(), 1);
1076
1077 let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
1079 assert_eq!(diff_filtered.edge_diffs.len(), 0);
1080
1081 let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
1083 assert_eq!(diff_with_deps.edge_diffs.len(), 1);
1084 }
1085
1086 #[test]
1087 fn test_ecosystem_breakdown() {
1088 let mut old = Sbom::default();
1089 let mut new = Sbom::default();
1090
1091 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1093 c1.ecosystem = Some("npm".into());
1094 old.components.insert(c1.id.clone(), c1);
1095
1096 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1098 c2.ecosystem = Some("npm".into());
1099 new.components.insert(c2.id.clone(), c2);
1100
1101 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1103 c3.ecosystem = Some("cargo".into());
1104 new.components.insert(c3.id.clone(), c3);
1105
1106 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1108 c4_old.ecosystem = Some("npm".into());
1109 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1110 c4_new.ecosystem = Some("npm".into());
1111 old.components.insert(c4_old.id.clone(), c4_old);
1112 new.components.insert(c4_new.id.clone(), c4_new);
1113
1114 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1116 new.components.insert(c5.id.clone(), c5);
1117
1118 let diff = Differ::diff(&old, &new, None);
1119 let breakdown = diff.ecosystem_breakdown();
1120
1121 let npm = breakdown.get("npm").unwrap();
1122 assert_eq!(npm.added, 1);
1123 assert_eq!(npm.removed, 1);
1124 assert_eq!(npm.changed, 1);
1125
1126 let cargo = breakdown.get("cargo").unwrap();
1127 assert_eq!(cargo.added, 1);
1128 assert_eq!(cargo.removed, 0);
1129 assert_eq!(cargo.changed, 0);
1130
1131 let unknown = breakdown.get("unknown").unwrap();
1132 assert_eq!(unknown.added, 1);
1133 assert_eq!(unknown.removed, 0);
1134 assert_eq!(unknown.changed, 0);
1135 }
1136}