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, Serialize, Deserialize)]
14pub struct Diff {
15 pub added: Vec<Component>,
17 pub removed: Vec<Component>,
19 pub changed: Vec<ComponentChange>,
21 pub edge_diffs: Vec<EdgeDiff>,
23 pub metadata_changed: bool,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ComponentChange {
30 pub id: ComponentId,
32 pub old: Component,
34 pub new: Component,
36 pub changes: Vec<FieldChange>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct EdgeDiff {
43 pub parent: ComponentId,
45 pub added: BTreeSet<ComponentId>,
47 pub removed: BTreeSet<ComponentId>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub enum FieldChange {
54 Version(String, String),
56 License(BTreeSet<String>, BTreeSet<String>),
58 Supplier(Option<String>, Option<String>),
60 Purl(Option<String>, Option<String>),
62 Hashes,
64}
65
66#[derive(Debug, Copy, Clone, PartialEq, Eq)]
70pub enum Field {
71 Version,
73 License,
75 Supplier,
77 Purl,
79 Hashes,
81 Deps,
83}
84
85pub struct Differ;
90
91impl Differ {
92 pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
119 let mut old = old.clone();
120 let mut new = new.clone();
121
122 old.normalize();
123 new.normalize();
124
125 let mut added = Vec::new();
126 let mut removed = Vec::new();
127 let mut changed = Vec::new();
128
129 let mut processed_old = HashSet::new();
130 let mut processed_new = HashSet::new();
131
132 let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
134
135 for (id, new_comp) in &new.components {
137 if let Some(old_comp) = old.components.get(id) {
138 processed_old.insert(id.clone());
139 processed_new.insert(id.clone());
140 id_mapping.insert(id.clone(), id.clone());
141
142 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
143 changed.push(change);
144 }
145 }
146 }
147
148 let mut old_identity_map: BTreeMap<(Option<String>, String), Vec<ComponentId>> =
152 BTreeMap::new();
153 for (id, comp) in &old.components {
154 if !processed_old.contains(id) {
155 let identity = (comp.ecosystem.clone(), comp.name.clone());
156 old_identity_map
157 .entry(identity)
158 .or_default()
159 .push(id.clone());
160 }
161 }
162
163 for (id, new_comp) in &new.components {
164 if processed_new.contains(id) {
165 continue;
166 }
167
168 let identity = (new_comp.ecosystem.clone(), new_comp.name.clone());
169
170 let matched_old_id = old_identity_map
175 .get_mut(&identity)
176 .and_then(|ids| ids.pop())
177 .or_else(|| {
178 if new_comp.ecosystem.is_some() {
179 old_identity_map
181 .get_mut(&(None, new_comp.name.clone()))
182 .and_then(|ids| ids.pop())
183 } else {
184 old_identity_map
186 .iter_mut()
187 .find(|((_, name), ids)| name == &new_comp.name && !ids.is_empty())
188 .and_then(|(_, ids)| ids.pop())
189 }
190 });
191
192 if let Some(old_id) = matched_old_id {
193 if let Some(old_comp) = old.components.get(&old_id) {
194 processed_old.insert(old_id.clone());
195 processed_new.insert(id.clone());
196 id_mapping.insert(old_id.clone(), id.clone());
197
198 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
199 changed.push(change);
200 }
201 continue;
202 }
203 }
204
205 added.push(new_comp.clone());
206 processed_new.insert(id.clone());
207 }
208
209 for (id, old_comp) in &old.components {
210 if !processed_old.contains(id) {
211 removed.push(old_comp.clone());
212 }
213 }
214
215 let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
217 let edge_diffs = if should_include_deps {
218 Self::compute_edge_diffs(&old, &new, &id_mapping)
219 } else {
220 Vec::new()
221 };
222
223 Diff {
224 added,
225 removed,
226 changed,
227 edge_diffs,
228 metadata_changed: old.metadata != new.metadata,
229 }
230 }
231
232 fn compute_edge_diffs(
237 old: &Sbom,
238 new: &Sbom,
239 id_mapping: &BTreeMap<ComponentId, ComponentId>,
240 ) -> Vec<EdgeDiff> {
241 let mut edge_diffs = Vec::new();
242
243 let translate_id = |old_id: &ComponentId| -> ComponentId {
245 id_mapping
246 .get(old_id)
247 .cloned()
248 .unwrap_or_else(|| old_id.clone())
249 };
250
251 let mut all_parents: BTreeSet<ComponentId> = new.dependencies.keys().cloned().collect();
254
255 for old_parent in old.dependencies.keys() {
257 all_parents.insert(translate_id(old_parent));
258 }
259
260 for parent_id in all_parents {
261 let new_children: BTreeSet<ComponentId> = new
263 .dependencies
264 .get(&parent_id)
265 .cloned()
266 .unwrap_or_default();
267
268 let old_parent_id = id_mapping
271 .iter()
272 .find(|(_, new_id)| *new_id == &parent_id)
273 .map(|(old_id, _)| old_id.clone())
274 .unwrap_or_else(|| parent_id.clone());
275
276 let old_children: BTreeSet<ComponentId> = old
277 .dependencies
278 .get(&old_parent_id)
279 .map(|children| children.iter().map(&translate_id).collect())
280 .unwrap_or_default();
281
282 let added: BTreeSet<ComponentId> =
284 new_children.difference(&old_children).cloned().collect();
285 let removed: BTreeSet<ComponentId> =
286 old_children.difference(&new_children).cloned().collect();
287
288 if !added.is_empty() || !removed.is_empty() {
289 edge_diffs.push(EdgeDiff {
290 parent: parent_id,
291 added,
292 removed,
293 });
294 }
295 }
296
297 edge_diffs
298 }
299
300 fn compute_change(
301 old: &Component,
302 new: &Component,
303 only: Option<&[Field]>,
304 ) -> Option<ComponentChange> {
305 let mut changes = Vec::new();
306
307 let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
308
309 if should_include(Field::Version) && old.version != new.version {
310 changes.push(FieldChange::Version(
311 old.version.clone().unwrap_or_default(),
312 new.version.clone().unwrap_or_default(),
313 ));
314 }
315
316 if should_include(Field::License) && old.licenses != new.licenses {
317 changes.push(FieldChange::License(
318 old.licenses.clone(),
319 new.licenses.clone(),
320 ));
321 }
322
323 if should_include(Field::Supplier) && old.supplier != new.supplier {
324 changes.push(FieldChange::Supplier(
325 old.supplier.clone(),
326 new.supplier.clone(),
327 ));
328 }
329
330 if should_include(Field::Purl) && old.purl != new.purl {
331 changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
332 }
333
334 if should_include(Field::Hashes) && old.hashes != new.hashes {
335 changes.push(FieldChange::Hashes);
336 }
337
338 if changes.is_empty() {
339 None
340 } else {
341 Some(ComponentChange {
342 id: new.id.clone(),
343 old: old.clone(),
344 new: new.clone(),
345 changes,
346 })
347 }
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn test_diff_added_removed() {
357 let mut old = Sbom::default();
358 let mut new = Sbom::default();
359
360 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
361 let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
362
363 old.components.insert(c1.id.clone(), c1);
364 new.components.insert(c2.id.clone(), c2);
365
366 let diff = Differ::diff(&old, &new, None);
367 assert_eq!(diff.added.len(), 1);
368 assert_eq!(diff.removed.len(), 1);
369 assert_eq!(diff.changed.len(), 0);
370 }
371
372 #[test]
373 fn test_diff_changed() {
374 let mut old = Sbom::default();
375 let mut new = Sbom::default();
376
377 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
378 let mut c2 = c1.clone();
379 c2.version = Some("1.1".to_string());
380
381 old.components.insert(c1.id.clone(), c1);
382 new.components.insert(c2.id.clone(), c2);
383
384 let diff = Differ::diff(&old, &new, None);
385 assert_eq!(diff.added.len(), 0);
386 assert_eq!(diff.removed.len(), 0);
387 assert_eq!(diff.changed.len(), 1);
388 assert!(matches!(
389 diff.changed[0].changes[0],
390 FieldChange::Version(_, _)
391 ));
392 }
393
394 #[test]
395 fn test_diff_identity_reconciliation() {
396 let mut old = Sbom::default();
397 let mut new = Sbom::default();
398
399 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
400 let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
401
402 old.components.insert(c1.id.clone(), c1);
403 new.components.insert(c2.id.clone(), c2);
404
405 let diff = Differ::diff(&old, &new, None);
406 assert_eq!(diff.changed.len(), 1);
407 assert_eq!(diff.added.len(), 0);
408 }
409
410 #[test]
411 fn test_diff_filtering() {
412 let mut old = Sbom::default();
413 let mut new = Sbom::default();
414
415 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
416 c1.licenses.insert("MIT".into());
417
418 let mut c2 = c1.clone();
419 c2.version = Some("1.1".to_string());
420 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
421
422 old.components.insert(c1.id.clone(), c1);
423 new.components.insert(c2.id.clone(), c2);
424
425 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
426 assert_eq!(diff.changed.len(), 1);
427 assert_eq!(diff.changed[0].changes.len(), 1);
428 assert!(matches!(
429 diff.changed[0].changes[0],
430 FieldChange::Version(_, _)
431 ));
432 }
433
434 #[test]
435 fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
436 let mut old = Sbom::default();
439 let mut new = Sbom::default();
440
441 let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
443 c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
444 c_old.ecosystem = Some("npm".to_string());
445 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
446
447 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
449 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
450 c_new.ecosystem = Some("npm".to_string());
451 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
452
453 old.components.insert(c_old.id.clone(), c_old);
454 new.components.insert(c_new.id.clone(), c_new);
455
456 let diff = Differ::diff(&old, &new, None);
457
458 assert_eq!(diff.added.len(), 0, "Should not have added components");
460 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
461
462 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
464
465 let changes = &diff.changed[0].changes;
467 assert!(changes
468 .iter()
469 .any(|c| matches!(c, FieldChange::Version(_, _))));
470 assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
471 }
472
473 #[test]
474 fn test_purl_removed_is_change() {
475 let mut old = Sbom::default();
478 let mut new = Sbom::default();
479
480 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
481 c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
482 c_old.ecosystem = Some("npm".to_string()); c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
484
485 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
487 c_new.purl = None;
488 c_new.ecosystem = None; c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
491
492 old.components.insert(c_old.id.clone(), c_old);
493 new.components.insert(c_new.id.clone(), c_new);
494
495 let diff = Differ::diff(&old, &new, None);
496
497 assert_eq!(diff.added.len(), 0, "Should not have added components");
498 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
499 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
500
501 assert!(diff.changed[0]
503 .changes
504 .iter()
505 .any(|c| matches!(c, FieldChange::Purl(_, _))));
506 }
507
508 #[test]
509 fn test_purl_added_is_change() {
510 let mut old = Sbom::default();
513 let mut new = Sbom::default();
514
515 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
516 c_old.purl = None;
517 c_old.ecosystem = None; c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
519
520 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
521 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
522 c_new.ecosystem = Some("npm".to_string()); c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
524
525 old.components.insert(c_old.id.clone(), c_old);
526 new.components.insert(c_new.id.clone(), c_new);
527
528 let diff = Differ::diff(&old, &new, None);
529
530 assert_eq!(diff.added.len(), 0, "Should not have added components");
531 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
532 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
533 }
534
535 #[test]
536 fn test_same_name_different_ecosystems_not_matched() {
537 let mut old = Sbom::default();
539 let mut new = Sbom::default();
540
541 let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
543 c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
544 c_old.ecosystem = Some("npm".to_string());
545 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
546
547 let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
549 c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
550 c_new.ecosystem = Some("pypi".to_string());
551 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
552
553 old.components.insert(c_old.id.clone(), c_old);
554 new.components.insert(c_new.id.clone(), c_new);
555
556 let diff = Differ::diff(&old, &new, None);
557
558 assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
560 assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
561 assert_eq!(
562 diff.changed.len(),
563 0,
564 "Should not match different ecosystems"
565 );
566 }
567
568 #[test]
569 fn test_same_name_both_no_ecosystem_matched() {
570 let mut old = Sbom::default();
573 let mut new = Sbom::default();
574
575 let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
576 c_old.ecosystem = None;
577
578 let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
579 c_new.ecosystem = None;
580
581 old.components.insert(c_old.id.clone(), c_old);
582 new.components.insert(c_new.id.clone(), c_new);
583
584 let diff = Differ::diff(&old, &new, None);
585
586 assert_eq!(diff.added.len(), 0);
587 assert_eq!(diff.removed.len(), 0);
588 assert_eq!(
589 diff.changed.len(),
590 1,
591 "Same name with None ecosystems should match"
592 );
593 }
594
595 #[test]
596 fn test_edge_diff_added_removed() {
597 let mut old = Sbom::default();
598 let mut new = Sbom::default();
599
600 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
601 let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
602 let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
603
604 let parent_id = c1.id.clone();
605 let child_a_id = c2.id.clone();
606 let child_b_id = c3.id.clone();
607
608 old.components.insert(c1.id.clone(), c1.clone());
610 old.components.insert(c2.id.clone(), c2.clone());
611 old.components.insert(c3.id.clone(), c3.clone());
612
613 new.components.insert(c1.id.clone(), c1);
614 new.components.insert(c2.id.clone(), c2);
615 new.components.insert(c3.id.clone(), c3);
616
617 old.dependencies
619 .entry(parent_id.clone())
620 .or_default()
621 .insert(child_a_id.clone());
622
623 new.dependencies
625 .entry(parent_id.clone())
626 .or_default()
627 .insert(child_b_id.clone());
628
629 let diff = Differ::diff(&old, &new, None);
630
631 assert_eq!(diff.edge_diffs.len(), 1);
632 assert_eq!(diff.edge_diffs[0].parent, parent_id);
633 assert!(diff.edge_diffs[0].added.contains(&child_b_id));
634 assert!(diff.edge_diffs[0].removed.contains(&child_a_id));
635 }
636
637 #[test]
638 fn test_edge_diff_with_identity_reconciliation() {
639 let mut old = Sbom::default();
642 let mut new = Sbom::default();
643
644 let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
646 parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
647 parent_old.ecosystem = Some("npm".to_string());
648 parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
649
650 let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
652 parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
653 parent_new.ecosystem = Some("npm".to_string());
654 parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
655
656 let child = Component::new("child".to_string(), Some("1.0".to_string()));
658
659 old.components
660 .insert(parent_old.id.clone(), parent_old.clone());
661 old.components.insert(child.id.clone(), child.clone());
662
663 new.components
664 .insert(parent_new.id.clone(), parent_new.clone());
665 new.components.insert(child.id.clone(), child.clone());
666
667 old.dependencies
669 .entry(parent_old.id.clone())
670 .or_default()
671 .insert(child.id.clone());
672
673 new.dependencies
675 .entry(parent_new.id.clone())
676 .or_default()
677 .insert(child.id.clone());
678
679 let diff = Differ::diff(&old, &new, None);
680
681 assert_eq!(
684 diff.edge_diffs.len(),
685 0,
686 "No edge changes expected when parent is reconciled by identity"
687 );
688 }
689
690 #[test]
691 fn test_edge_diff_filtering() {
692 let mut old = Sbom::default();
694 let mut new = Sbom::default();
695
696 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
697 let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
698
699 let parent_id = c1.id.clone();
700 let child_id = c2.id.clone();
701
702 old.components.insert(c1.id.clone(), c1.clone());
703 old.components.insert(c2.id.clone(), c2.clone());
704
705 new.components.insert(c1.id.clone(), c1);
706 new.components.insert(c2.id.clone(), c2);
707
708 new.dependencies
710 .entry(parent_id.clone())
711 .or_default()
712 .insert(child_id);
713
714 let diff = Differ::diff(&old, &new, None);
716 assert_eq!(diff.edge_diffs.len(), 1);
717
718 let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
720 assert_eq!(diff_filtered.edge_diffs.len(), 0);
721
722 let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
724 assert_eq!(diff_with_deps.edge_diffs.len(), 1);
725 }
726}