1use std::collections::{HashMap, HashSet, VecDeque};
11
12use crate::model::{CanonicalId, DependencyScope, DependencyType, NormalizedSbom};
13
14use super::result::{
15 DependencyChangeType, DependencyGraphChange, GraphChangeImpact, GraphChangeSummary,
16};
17
18const CYCLIC_SENTINEL_DEPTH: u32 = u32::MAX;
22
23#[derive(Debug, Clone)]
25pub struct GraphDiffConfig {
26 pub detect_reparenting: bool,
28 pub detect_depth_changes: bool,
30 pub max_depth: u32,
32 pub relation_filter: Vec<String>,
34}
35
36impl Default for GraphDiffConfig {
37 fn default() -> Self {
38 Self {
39 detect_reparenting: true,
40 detect_depth_changes: true,
41 max_depth: 0,
42 relation_filter: Vec::new(),
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49struct EdgeAttrs {
50 relationship: DependencyType,
51 scope: Option<DependencyScope>,
52}
53
54struct DependencyGraph<'a> {
56 sbom: &'a NormalizedSbom,
58 edges: HashMap<CanonicalId, Vec<CanonicalId>>,
60 reverse_edges: HashMap<CanonicalId, Vec<CanonicalId>>,
62 edge_attrs: HashMap<(CanonicalId, CanonicalId), EdgeAttrs>,
64 depths: HashMap<CanonicalId, u32>,
67 vulnerable_components: HashSet<CanonicalId>,
69}
70
71impl<'a> DependencyGraph<'a> {
72 fn from_sbom(sbom: &'a NormalizedSbom, config: &GraphDiffConfig) -> Self {
73 let mut edges: HashMap<CanonicalId, Vec<CanonicalId>> = HashMap::new();
74 let mut reverse_edges: HashMap<CanonicalId, Vec<CanonicalId>> = HashMap::new();
75 let mut edge_attrs: HashMap<(CanonicalId, CanonicalId), EdgeAttrs> = HashMap::new();
76 let mut vulnerable_components = HashSet::new();
77
78 for edge in &sbom.edges {
80 if !config.relation_filter.is_empty()
81 && !config
82 .relation_filter
83 .iter()
84 .any(|f| f.eq_ignore_ascii_case(&edge.relationship.to_string()))
85 {
86 continue; }
88
89 edges
90 .entry(edge.from.clone())
91 .or_default()
92 .push(edge.to.clone());
93
94 reverse_edges
95 .entry(edge.to.clone())
96 .or_default()
97 .push(edge.from.clone());
98
99 edge_attrs.insert(
100 (edge.from.clone(), edge.to.clone()),
101 EdgeAttrs {
102 relationship: edge.relationship.clone(),
103 scope: edge.scope.clone(),
104 },
105 );
106 }
107
108 for (id, comp) in &sbom.components {
110 if !comp.vulnerabilities.is_empty() {
111 vulnerable_components.insert(id.clone());
112 }
113 }
114
115 let all_components: HashSet<_> = sbom.components.keys().cloned().collect();
117 let depths =
118 Self::calculate_depths(&edges, &reverse_edges, &all_components, config.max_depth);
119
120 Self {
121 sbom,
122 edges,
123 reverse_edges,
124 edge_attrs,
125 depths,
126 vulnerable_components,
127 }
128 }
129
130 fn calculate_depths(
136 edges: &HashMap<CanonicalId, Vec<CanonicalId>>,
137 reverse_edges: &HashMap<CanonicalId, Vec<CanonicalId>>,
138 all_components: &HashSet<CanonicalId>,
139 max_depth: u32,
140 ) -> HashMap<CanonicalId, u32> {
141 let mut depths = HashMap::new();
142
143 let mut queue: VecDeque<(CanonicalId, u32)> = all_components
146 .iter()
147 .filter(|id| reverse_edges.get(*id).is_none_or(std::vec::Vec::is_empty))
148 .cloned()
149 .map(|id| (id, 1))
150 .collect();
151
152 while let Some((id, depth)) = queue.pop_front() {
153 if let Some(&existing_depth) = depths.get(&id)
155 && depth >= existing_depth
156 {
157 continue; }
159
160 depths.insert(id.clone(), depth);
162
163 if max_depth > 0 && depth >= max_depth {
165 continue;
166 }
167
168 if let Some(children) = edges.get(&id) {
169 for child_id in children {
170 let child_depth = depth + 1;
171 let dominated = depths.get(child_id).is_some_and(|&d| d <= child_depth);
173 if !dominated {
174 queue.push_back((child_id.clone(), child_depth));
175 }
176 }
177 }
178 }
179
180 for id in all_components {
184 depths.entry(id.clone()).or_insert(CYCLIC_SENTINEL_DEPTH);
185 }
186
187 depths
188 }
189
190 fn get_parents(&self, component_id: &CanonicalId) -> Vec<CanonicalId> {
191 self.reverse_edges
192 .get(component_id)
193 .cloned()
194 .unwrap_or_default()
195 }
196
197 fn get_children(&self, component_id: &CanonicalId) -> Vec<CanonicalId> {
198 self.edges.get(component_id).cloned().unwrap_or_default()
199 }
200
201 fn get_edge_attrs(&self, from: &CanonicalId, to: &CanonicalId) -> Option<&EdgeAttrs> {
202 self.edge_attrs.get(&(from.clone(), to.clone()))
203 }
204
205 fn get_depth(&self, component_id: &CanonicalId) -> Option<u32> {
206 self.depths.get(component_id).copied()
207 }
208
209 fn is_vulnerable(&self, component_id: &CanonicalId) -> bool {
210 self.vulnerable_components.contains(component_id)
211 }
212
213 fn get_component_name(&self, component_id: &CanonicalId) -> String {
214 self.sbom.components.get(component_id).map_or_else(
215 || component_id.to_string(),
216 |c| {
217 c.version
218 .as_ref()
219 .map_or_else(|| c.name.clone(), |v| format!("{}@{}", c.name, v))
220 },
221 )
222 }
223}
224
225#[allow(clippy::implicit_hasher)]
227#[must_use]
228pub fn diff_dependency_graph(
229 old_sbom: &NormalizedSbom,
230 new_sbom: &NormalizedSbom,
231 component_matches: &HashMap<CanonicalId, Option<CanonicalId>>,
232 config: &GraphDiffConfig,
233) -> (Vec<DependencyGraphChange>, GraphChangeSummary) {
234 let old_graph = DependencyGraph::from_sbom(old_sbom, config);
235 let new_graph = DependencyGraph::from_sbom(new_sbom, config);
236
237 let mut changes = Vec::new();
238
239 for (old_id, new_id_option) in component_matches {
241 if let Some(new_id) = new_id_option {
242 let component_name = new_graph.get_component_name(new_id);
243
244 let old_children_mapped: HashSet<CanonicalId> = old_graph
249 .get_children(old_id)
250 .into_iter()
251 .filter_map(|old_child| {
252 component_matches
253 .get(&old_child)
254 .and_then(|opt| opt.clone())
255 })
256 .collect();
257 let new_children: HashSet<_> = new_graph.get_children(new_id).into_iter().collect();
258
259 let old_child_to_new: HashMap<CanonicalId, CanonicalId> = old_graph
261 .get_children(old_id)
262 .into_iter()
263 .filter_map(|old_child| {
264 component_matches
265 .get(&old_child)
266 .and_then(|opt| opt.clone())
267 .map(|new_child_id| (new_child_id, old_child))
268 })
269 .collect();
270
271 for child_id in new_children.difference(&old_children_mapped) {
273 let dep_name = new_graph.get_component_name(child_id);
274 let impact = assess_impact_added(&new_graph, child_id);
275
276 changes.push(DependencyGraphChange {
277 component_id: new_id.clone(),
278 component_name: component_name.clone(),
279 change: DependencyChangeType::DependencyAdded {
280 dependency_id: child_id.clone(),
281 dependency_name: dep_name,
282 },
283 impact,
284 });
285 }
286
287 for child_id in old_children_mapped.difference(&new_children) {
289 let dep_name = new_graph.get_component_name(child_id);
290
291 changes.push(DependencyGraphChange {
292 component_id: new_id.clone(),
293 component_name: component_name.clone(),
294 change: DependencyChangeType::DependencyRemoved {
295 dependency_id: child_id.clone(),
296 dependency_name: dep_name,
297 },
298 impact: GraphChangeImpact::Low,
299 });
300 }
301
302 for child_id in old_children_mapped.intersection(&new_children) {
304 let old_attrs = old_child_to_new
306 .get(child_id)
307 .and_then(|old_child_id| old_graph.get_edge_attrs(old_id, old_child_id));
308 let new_attrs = new_graph.get_edge_attrs(new_id, child_id);
309
310 if let (Some(old_a), Some(new_a)) = (old_attrs, new_attrs)
311 && old_a != new_a
312 {
313 let dep_name = new_graph.get_component_name(child_id);
314 changes.push(DependencyGraphChange {
315 component_id: new_id.clone(),
316 component_name: component_name.clone(),
317 change: DependencyChangeType::RelationshipChanged {
318 dependency_id: child_id.clone(),
319 dependency_name: dep_name,
320 old_relationship: old_a.relationship.to_string(),
321 new_relationship: new_a.relationship.to_string(),
322 old_scope: old_a.scope.as_ref().map(ToString::to_string),
323 new_scope: new_a.scope.as_ref().map(ToString::to_string),
324 },
325 impact: GraphChangeImpact::Medium,
326 });
327 }
328 }
329 }
330 }
331
332 if config.detect_depth_changes {
334 detect_depth_changes(&old_graph, &new_graph, component_matches, &mut changes);
335 }
336
337 if config.detect_reparenting {
339 detect_reparenting(&old_graph, &new_graph, component_matches, &mut changes);
340 }
341
342 changes.sort_by(|a, b| {
344 let impact_order = |i: &GraphChangeImpact| match i {
345 GraphChangeImpact::Critical => 0,
346 GraphChangeImpact::High => 1,
347 GraphChangeImpact::Medium => 2,
348 GraphChangeImpact::Low => 3,
349 };
350 impact_order(&a.impact).cmp(&impact_order(&b.impact))
351 });
352
353 let summary = GraphChangeSummary::from_changes(&changes);
354 (changes, summary)
355}
356
357fn assess_impact_added(graph: &DependencyGraph, component_id: &CanonicalId) -> GraphChangeImpact {
363 let depth = graph
364 .get_depth(component_id)
365 .unwrap_or(CYCLIC_SENTINEL_DEPTH);
366 let is_direct = depth > 0 && depth <= 2 && depth != CYCLIC_SENTINEL_DEPTH;
367
368 if graph.is_vulnerable(component_id) {
369 if is_direct {
370 GraphChangeImpact::Critical
371 } else {
372 GraphChangeImpact::High
373 }
374 } else if is_direct {
375 GraphChangeImpact::Medium
376 } else {
377 GraphChangeImpact::Low
378 }
379}
380
381fn detect_depth_changes(
386 old_graph: &DependencyGraph,
387 new_graph: &DependencyGraph,
388 matches: &HashMap<CanonicalId, Option<CanonicalId>>,
389 changes: &mut Vec<DependencyGraphChange>,
390) {
391 for (old_id, new_id_opt) in matches {
392 if let Some(new_id) = new_id_opt {
393 let old_depth = old_graph.get_depth(old_id);
394 let new_depth = new_graph.get_depth(new_id);
395
396 if let (Some(od), Some(nd)) = (old_depth, new_depth)
397 && od != nd
398 {
399 if od == CYCLIC_SENTINEL_DEPTH && nd == CYCLIC_SENTINEL_DEPTH {
401 continue;
402 }
403
404 let component_name = new_graph.get_component_name(new_id);
405
406 let impact =
407 if nd < od && nd != CYCLIC_SENTINEL_DEPTH && new_graph.is_vulnerable(new_id) {
408 GraphChangeImpact::High
410 } else if nd <= 2 && (od > 2 || od == CYCLIC_SENTINEL_DEPTH) {
411 GraphChangeImpact::Medium
413 } else {
414 GraphChangeImpact::Low
415 };
416
417 changes.push(DependencyGraphChange {
418 component_id: new_id.clone(),
419 component_name,
420 change: DependencyChangeType::DepthChanged {
421 old_depth: od,
422 new_depth: nd,
423 },
424 impact,
425 });
426 }
427 }
428 }
429}
430
431fn detect_reparenting(
440 old_graph: &DependencyGraph,
441 new_graph: &DependencyGraph,
442 matches: &HashMap<CanonicalId, Option<CanonicalId>>,
443 changes: &mut Vec<DependencyGraphChange>,
444) {
445 let mut suppress_added: HashMap<CanonicalId, HashSet<CanonicalId>> = HashMap::new();
452 let mut suppress_removed: HashMap<CanonicalId, HashSet<CanonicalId>> = HashMap::new();
453
454 for (old_id, new_id_opt) in matches {
455 if let Some(new_id) = new_id_opt {
456 let old_parents = old_graph.get_parents(old_id);
457 let new_parents = new_graph.get_parents(new_id);
458
459 if old_parents.is_empty() && new_parents.is_empty() {
461 continue;
462 }
463
464 let old_parents_mapped: HashSet<CanonicalId> = old_parents
467 .iter()
468 .filter_map(|old_parent| matches.get(old_parent).and_then(|opt| opt.clone()))
469 .collect();
470 let new_parents_set: HashSet<CanonicalId> = new_parents.into_iter().collect();
471
472 if old_parents_mapped == new_parents_set {
474 continue;
475 }
476
477 let removed_parents: Vec<_> = old_parents_mapped.difference(&new_parents_set).collect();
479 let added_parents: Vec<_> = new_parents_set.difference(&old_parents_mapped).collect();
480
481 if removed_parents.is_empty() || added_parents.is_empty() {
485 continue;
486 }
487
488 let old_parent = removed_parents[0];
489 let new_parent = added_parents[0];
490
491 let component_name = new_graph.get_component_name(new_id);
492 let old_parent_name = new_graph.get_component_name(old_parent);
493 let new_parent_name = new_graph.get_component_name(new_parent);
494
495 suppress_added
500 .entry(new_id.clone())
501 .or_default()
502 .insert(new_parent.clone());
503 suppress_removed
504 .entry(new_id.clone())
505 .or_default()
506 .insert(old_parent.clone());
507
508 changes.push(DependencyGraphChange {
509 component_id: new_id.clone(),
510 component_name,
511 change: DependencyChangeType::Reparented {
512 dependency_id: new_id.clone(),
513 dependency_name: new_graph.get_component_name(new_id),
514 old_parent_id: old_parent.clone(),
515 old_parent_name,
516 new_parent_id: new_parent.clone(),
517 new_parent_name,
518 },
519 impact: GraphChangeImpact::Medium,
520 });
521 }
522 }
523
524 if !suppress_added.is_empty() || !suppress_removed.is_empty() {
525 changes.retain(|c| match &c.change {
526 DependencyChangeType::DependencyAdded { dependency_id, .. } => !suppress_added
527 .get(dependency_id)
528 .is_some_and(|parents| parents.contains(&c.component_id)),
529 DependencyChangeType::DependencyRemoved { dependency_id, .. } => !suppress_removed
530 .get(dependency_id)
531 .is_some_and(|parents| parents.contains(&c.component_id)),
532 _ => true,
533 });
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::model::{
541 Component, DependencyEdge, DependencyType, NormalizedSbom, VulnerabilityRef,
542 VulnerabilitySource,
543 };
544
545 fn make_component(name: &str) -> Component {
547 Component::new(name.to_string(), name.to_string())
548 }
549
550 fn make_component_v(name: &str, version: &str) -> Component {
552 Component::new(name.to_string(), format!("{name}@{version}"))
553 .with_version(version.to_string())
554 }
555
556 fn make_sbom(
558 components: Vec<Component>,
559 edges: Vec<(CanonicalId, CanonicalId)>,
560 ) -> NormalizedSbom {
561 let mut sbom = NormalizedSbom::default();
562 for comp in components {
563 sbom.add_component(comp);
564 }
565 for (from, to) in edges {
566 sbom.add_edge(DependencyEdge::new(from, to, DependencyType::DependsOn));
567 }
568 sbom
569 }
570
571 fn make_sbom_with_rel(
573 components: Vec<Component>,
574 edges: Vec<(CanonicalId, CanonicalId, DependencyType)>,
575 ) -> NormalizedSbom {
576 let mut sbom = NormalizedSbom::default();
577 for comp in components {
578 sbom.add_component(comp);
579 }
580 for (from, to, rel) in edges {
581 sbom.add_edge(DependencyEdge::new(from, to, rel));
582 }
583 sbom
584 }
585
586 #[test]
587 fn test_graph_diff_config_default() {
588 let config = GraphDiffConfig::default();
589 assert!(config.detect_reparenting);
590 assert!(config.detect_depth_changes);
591 assert_eq!(config.max_depth, 0);
592 }
593
594 #[test]
595 fn test_graph_change_impact_display() {
596 assert_eq!(GraphChangeImpact::Critical.as_str(), "critical");
597 assert_eq!(GraphChangeImpact::High.as_str(), "high");
598 assert_eq!(GraphChangeImpact::Medium.as_str(), "medium");
599 assert_eq!(GraphChangeImpact::Low.as_str(), "low");
600 }
601
602 #[test]
603 fn test_children_mapped_through_component_matches() {
604 let a_old = make_component("a-old");
606 let b_old = make_component("b-old");
607 let a_old_id = a_old.canonical_id.clone();
608 let b_old_id = b_old.canonical_id.clone();
609
610 let old_sbom = make_sbom(
611 vec![a_old, b_old],
612 vec![(a_old_id.clone(), b_old_id.clone())],
613 );
614
615 let a_new = make_component("a-new");
617 let b_new = make_component("b-new");
618 let a_new_id = a_new.canonical_id.clone();
619 let b_new_id = b_new.canonical_id.clone();
620
621 let new_sbom = make_sbom(
622 vec![a_new, b_new],
623 vec![(a_new_id.clone(), b_new_id.clone())],
624 );
625
626 let mut matches = HashMap::new();
628 matches.insert(a_old_id, Some(a_new_id));
629 matches.insert(b_old_id, Some(b_new_id));
630
631 let config = GraphDiffConfig::default();
632 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
633
634 assert_eq!(summary.dependencies_added, 0, "No false add: {changes:?}");
636 assert_eq!(
637 summary.dependencies_removed, 0,
638 "No false remove: {changes:?}"
639 );
640 }
641
642 #[test]
643 fn test_depth_linear_chain() {
644 let a = make_component("a");
646 let b = make_component("b");
647 let c = make_component("c");
648 let d = make_component("d");
649
650 let ids: Vec<_> = [&a, &b, &c, &d]
651 .iter()
652 .map(|c| c.canonical_id.clone())
653 .collect();
654 let sbom = make_sbom(
655 vec![a, b, c, d],
656 vec![
657 (ids[0].clone(), ids[1].clone()),
658 (ids[1].clone(), ids[2].clone()),
659 (ids[2].clone(), ids[3].clone()),
660 ],
661 );
662
663 let config = GraphDiffConfig::default();
664 let graph = DependencyGraph::from_sbom(&sbom, &config);
665
666 assert_eq!(graph.get_depth(&ids[0]), Some(1)); assert_eq!(graph.get_depth(&ids[1]), Some(2));
668 assert_eq!(graph.get_depth(&ids[2]), Some(3));
669 assert_eq!(graph.get_depth(&ids[3]), Some(4));
670 }
671
672 #[test]
673 fn test_depth_diamond_dependency() {
674 let a = make_component("a");
677 let b = make_component("b");
678 let c = make_component("c");
679 let d = make_component("d");
680
681 let ids: Vec<_> = [&a, &b, &c, &d]
682 .iter()
683 .map(|c| c.canonical_id.clone())
684 .collect();
685 let sbom = make_sbom(
686 vec![a, b, c, d],
687 vec![
688 (ids[0].clone(), ids[1].clone()),
689 (ids[0].clone(), ids[2].clone()),
690 (ids[1].clone(), ids[3].clone()),
691 (ids[2].clone(), ids[3].clone()),
692 ],
693 );
694
695 let config = GraphDiffConfig::default();
696 let graph = DependencyGraph::from_sbom(&sbom, &config);
697
698 assert_eq!(graph.get_depth(&ids[0]), Some(1));
699 assert_eq!(graph.get_depth(&ids[1]), Some(2));
700 assert_eq!(graph.get_depth(&ids[2]), Some(2));
701 assert_eq!(graph.get_depth(&ids[3]), Some(3)); }
703
704 #[test]
705 fn test_depth_rootless_cycle() {
706 let a = make_component("a");
708 let b = make_component("b");
709 let c = make_component("c");
710
711 let ids: Vec<_> = [&a, &b, &c]
712 .iter()
713 .map(|c| c.canonical_id.clone())
714 .collect();
715 let sbom = make_sbom(
716 vec![a, b, c],
717 vec![
718 (ids[0].clone(), ids[1].clone()),
719 (ids[1].clone(), ids[2].clone()),
720 (ids[2].clone(), ids[0].clone()),
721 ],
722 );
723
724 let config = GraphDiffConfig::default();
725 let graph = DependencyGraph::from_sbom(&sbom, &config);
726
727 for (i, id) in ids.iter().enumerate() {
729 let depth = graph.get_depth(id);
730 assert!(depth.is_some(), "Node {i} should have depth");
731 assert_eq!(
732 depth.unwrap(),
733 CYCLIC_SENTINEL_DEPTH,
734 "Cyclic node {i} should get sentinel depth, not 0"
735 );
736 }
737 }
738
739 #[test]
740 fn test_depth_cycle_reachable_from_root() {
741 let root = make_component("root");
743 let a = make_component("a");
744 let b = make_component("b");
745 let c = make_component("c");
746
747 let ids: Vec<_> = [&root, &a, &b, &c]
748 .iter()
749 .map(|comp| comp.canonical_id.clone())
750 .collect();
751 let sbom = make_sbom(
752 vec![root, a, b, c],
753 vec![
754 (ids[0].clone(), ids[1].clone()), (ids[1].clone(), ids[2].clone()), (ids[2].clone(), ids[3].clone()), (ids[3].clone(), ids[2].clone()), ],
759 );
760
761 let config = GraphDiffConfig::default();
762 let graph = DependencyGraph::from_sbom(&sbom, &config);
763
764 assert_eq!(graph.get_depth(&ids[0]), Some(1)); assert_eq!(graph.get_depth(&ids[1]), Some(2)); assert_eq!(graph.get_depth(&ids[2]), Some(3)); assert_eq!(graph.get_depth(&ids[3]), Some(4)); }
771
772 #[test]
773 fn test_depth_disconnected_subgraphs() {
774 let r1 = make_component("r1");
778 let a = make_component("a");
779 let r2 = make_component("r2");
780 let b = make_component("b");
781 let c = make_component("c");
782
783 let ids: Vec<_> = [&r1, &a, &r2, &b, &c]
784 .iter()
785 .map(|comp| comp.canonical_id.clone())
786 .collect();
787 let sbom = make_sbom(
788 vec![r1, a, r2, b, c],
789 vec![
790 (ids[0].clone(), ids[1].clone()), (ids[2].clone(), ids[3].clone()), (ids[3].clone(), ids[4].clone()), ],
794 );
795
796 let config = GraphDiffConfig::default();
797 let graph = DependencyGraph::from_sbom(&sbom, &config);
798
799 assert_eq!(graph.get_depth(&ids[0]), Some(1)); assert_eq!(graph.get_depth(&ids[1]), Some(2)); assert_eq!(graph.get_depth(&ids[2]), Some(1)); assert_eq!(graph.get_depth(&ids[3]), Some(2)); assert_eq!(graph.get_depth(&ids[4]), Some(3)); }
805
806 #[test]
807 fn test_self_referencing_edge_no_infinite_loop() {
808 let a = make_component("a");
810 let a_id = a.canonical_id.clone();
811
812 let sbom = make_sbom(vec![a], vec![(a_id.clone(), a_id.clone())]);
813
814 let config = GraphDiffConfig::default();
815 let graph = DependencyGraph::from_sbom(&sbom, &config);
816
817 let depth = graph.get_depth(&a_id);
822 assert!(depth.is_some(), "A should have a depth");
823 assert_eq!(
825 depth.unwrap(),
826 CYCLIC_SENTINEL_DEPTH,
827 "Self-referencing node should get sentinel depth"
828 );
829 }
830
831 #[test]
832 fn test_depth_max_depth_limit() {
833 let a = make_component("a");
835 let b = make_component("b");
836 let c = make_component("c");
837 let d = make_component("d");
838
839 let ids: Vec<_> = [&a, &b, &c, &d]
840 .iter()
841 .map(|c| c.canonical_id.clone())
842 .collect();
843 let sbom = make_sbom(
844 vec![a, b, c, d],
845 vec![
846 (ids[0].clone(), ids[1].clone()),
847 (ids[1].clone(), ids[2].clone()),
848 (ids[2].clone(), ids[3].clone()),
849 ],
850 );
851
852 let config = GraphDiffConfig {
853 max_depth: 2,
854 ..Default::default()
855 };
856 let graph = DependencyGraph::from_sbom(&sbom, &config);
857
858 assert_eq!(graph.get_depth(&ids[0]), Some(1));
859 assert_eq!(graph.get_depth(&ids[1]), Some(2));
860 assert_eq!(graph.get_depth(&ids[2]), Some(CYCLIC_SENTINEL_DEPTH));
863 assert_eq!(graph.get_depth(&ids[3]), Some(CYCLIC_SENTINEL_DEPTH));
864 }
865
866 #[test]
867 fn test_reparenting_single_parent() {
868 let p1 = make_component("p1");
872 let p2 = make_component("p2");
873 let child = make_component("child");
874
875 let p1_id = p1.canonical_id.clone();
876 let p2_id = p2.canonical_id.clone();
877 let child_id = child.canonical_id.clone();
878
879 let old_sbom = make_sbom(
880 vec![p1.clone(), p2.clone(), child.clone()],
881 vec![(p1_id.clone(), child_id.clone())],
882 );
883 let new_sbom = make_sbom(
884 vec![p1.clone(), p2.clone(), child.clone()],
885 vec![(p2_id.clone(), child_id.clone())],
886 );
887
888 let mut matches = HashMap::new();
890 matches.insert(p1_id.clone(), Some(p1_id));
891 matches.insert(p2_id.clone(), Some(p2_id));
892 matches.insert(child_id.clone(), Some(child_id));
893
894 let config = GraphDiffConfig::default();
895 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
896
897 assert!(
898 summary.reparented > 0,
899 "Should detect reparenting: {changes:?}"
900 );
901 }
902
903 #[test]
904 fn test_renamed_parent_is_not_reparenting() {
905 let p1 = make_component("p1");
908 let p2 = make_component("p2");
909 let child = make_component("child");
910
911 let p1_id = p1.canonical_id.clone();
912 let p2_id = p2.canonical_id.clone();
913 let child_id = child.canonical_id.clone();
914
915 let old_sbom = make_sbom(
916 vec![p1, child.clone()],
917 vec![(p1_id.clone(), child_id.clone())],
918 );
919 let new_sbom = make_sbom(
920 vec![p2, child.clone()],
921 vec![(p2_id.clone(), child_id.clone())],
922 );
923
924 let mut matches = HashMap::new();
925 matches.insert(p1_id, Some(p2_id));
926 matches.insert(child_id.clone(), Some(child_id));
927
928 let config = GraphDiffConfig::default();
929 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
930
931 assert_eq!(
932 summary.reparented, 0,
933 "Renamed parent should not be reparenting: {changes:?}"
934 );
935 }
936
937 #[test]
938 fn test_reparenting_multi_parent() {
939 let p1 = make_component("p1");
944 let p2 = make_component("p2");
945 let p3 = make_component("p3");
946 let child = make_component("child");
947
948 let p1_id = p1.canonical_id.clone();
949 let p2_id = p2.canonical_id.clone();
950 let p3_id = p3.canonical_id.clone();
951 let child_id = child.canonical_id.clone();
952
953 let old_sbom = make_sbom(
954 vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
955 vec![
956 (p1_id.clone(), child_id.clone()),
957 (p2_id.clone(), child_id.clone()),
958 ],
959 );
960 let new_sbom = make_sbom(
961 vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
962 vec![
963 (p1_id.clone(), child_id.clone()),
964 (p3_id.clone(), child_id.clone()),
965 ],
966 );
967
968 let mut matches = HashMap::new();
970 matches.insert(p1_id.clone(), Some(p1_id));
971 matches.insert(p2_id.clone(), Some(p2_id));
972 matches.insert(p3_id.clone(), Some(p3_id));
973 matches.insert(child_id.clone(), Some(child_id));
974
975 let config = GraphDiffConfig::default();
976 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
977
978 assert!(
979 summary.reparented > 0,
980 "Should detect multi-parent reparenting: {changes:?}"
981 );
982 }
983
984 #[test]
985 fn test_vulnerable_direct_dep_is_critical() {
986 let a = make_component("root");
988 let mut vuln_comp = make_component("vuln-lib");
989 vuln_comp.vulnerabilities.push(VulnerabilityRef {
990 id: "CVE-2024-0001".to_string(),
991 source: VulnerabilitySource::Osv,
992 severity: None,
993 cvss: vec![],
994 affected_versions: vec![],
995 remediation: None,
996 description: None,
997 cwes: vec![],
998 published: None,
999 modified: None,
1000 is_kev: false,
1001 kev_info: None,
1002 epss_score: None,
1003 epss_percentile: None,
1004 vex_status: None,
1005 });
1006
1007 let a_id = a.canonical_id.clone();
1008 let v_id = vuln_comp.canonical_id.clone();
1009
1010 let old_sbom = make_sbom(vec![a.clone()], vec![]);
1011 let new_sbom = make_sbom(
1012 vec![a.clone(), vuln_comp],
1013 vec![(a_id.clone(), v_id.clone())],
1014 );
1015
1016 let mut matches = HashMap::new();
1017 matches.insert(a_id.clone(), Some(a_id));
1018
1019 let config = GraphDiffConfig::default();
1020 let (changes, _) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1021
1022 let critical = changes
1023 .iter()
1024 .any(|c| c.impact == GraphChangeImpact::Critical);
1025 assert!(
1026 critical,
1027 "Vulnerable direct dep should be critical impact: {changes:?}"
1028 );
1029 }
1030
1031 #[test]
1032 fn test_empty_sboms_no_changes() {
1033 let old_sbom = NormalizedSbom::default();
1034 let new_sbom = NormalizedSbom::default();
1035 let matches = HashMap::new();
1036 let config = GraphDiffConfig::default();
1037
1038 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1039 assert!(changes.is_empty());
1040 assert_eq!(summary.total_changes, 0);
1041 }
1042
1043 #[test]
1044 fn test_identical_graphs_no_changes() {
1045 let a = make_component("a");
1046 let b = make_component("b");
1047 let a_id = a.canonical_id.clone();
1048 let b_id = b.canonical_id.clone();
1049
1050 let sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1051
1052 let mut matches = HashMap::new();
1053 matches.insert(a_id.clone(), Some(a_id));
1054 matches.insert(b_id.clone(), Some(b_id));
1055
1056 let config = GraphDiffConfig::default();
1057 let (changes, summary) = diff_dependency_graph(&sbom, &sbom, &matches, &config);
1058
1059 assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1061 assert_eq!(
1062 summary.dependencies_removed, 0,
1063 "No false removes: {changes:?}"
1064 );
1065 }
1066
1067 #[test]
1068 fn test_removed_child_not_false_positive() {
1069 let a = make_component("a");
1073 let b_v1 = make_component_v("b", "1.0");
1074 let b_v2 = make_component_v("b", "2.0");
1075
1076 let a_id = a.canonical_id.clone();
1077 let b_v1_id = b_v1.canonical_id.clone();
1078 let b_v2_id = b_v2.canonical_id.clone();
1079
1080 let old_sbom = make_sbom(vec![a.clone(), b_v1], vec![(a_id.clone(), b_v1_id.clone())]);
1081 let new_sbom = make_sbom(vec![a.clone(), b_v2], vec![(a_id.clone(), b_v2_id.clone())]);
1082
1083 let mut matches = HashMap::new();
1084 matches.insert(a_id.clone(), Some(a_id));
1085 matches.insert(b_v1_id, Some(b_v2_id));
1086
1087 let config = GraphDiffConfig::default();
1088 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1089
1090 assert_eq!(
1091 summary.dependencies_added, 0,
1092 "Version bump should not be false add: {changes:?}"
1093 );
1094 assert_eq!(
1095 summary.dependencies_removed, 0,
1096 "Version bump should not be false remove: {changes:?}"
1097 );
1098 }
1099
1100 #[test]
1101 fn test_unmatched_old_child_excluded_from_comparison() {
1102 let a = make_component("a");
1105 let b = make_component("b");
1106 let c = make_component("c");
1107
1108 let a_id = a.canonical_id.clone();
1109 let b_id = b.canonical_id.clone();
1110 let c_id = c.canonical_id.clone();
1111
1112 let old_sbom = make_sbom(
1113 vec![a.clone(), b.clone(), c],
1114 vec![(a_id.clone(), b_id.clone()), (a_id.clone(), c_id.clone())],
1115 );
1116 let new_sbom = make_sbom(
1117 vec![a.clone(), b.clone()],
1118 vec![(a_id.clone(), b_id.clone())],
1119 );
1120
1121 let mut matches = HashMap::new();
1122 matches.insert(a_id.clone(), Some(a_id));
1123 matches.insert(b_id.clone(), Some(b_id));
1124 matches.insert(c_id, None); let config = GraphDiffConfig::default();
1127 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1128
1129 assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1135 }
1136
1137 #[test]
1138 fn test_reparenting_with_removed_parent() {
1139 let p1 = make_component("p1");
1142 let p2 = make_component("p2");
1143 let child = make_component("child");
1144
1145 let p1_id = p1.canonical_id.clone();
1146 let p2_id = p2.canonical_id.clone();
1147 let child_id = child.canonical_id.clone();
1148
1149 let old_sbom = make_sbom(
1150 vec![p1.clone(), p2, child.clone()],
1151 vec![
1152 (p1_id.clone(), child_id.clone()),
1153 (p2_id.clone(), child_id.clone()),
1154 ],
1155 );
1156 let new_sbom = make_sbom(
1157 vec![p1.clone(), child.clone()],
1158 vec![(p1_id.clone(), child_id.clone())],
1159 );
1160
1161 let mut matches = HashMap::new();
1162 matches.insert(p1_id.clone(), Some(p1_id));
1163 matches.insert(p2_id, None); matches.insert(child_id.clone(), Some(child_id));
1165
1166 let config = GraphDiffConfig::default();
1167 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1168
1169 assert_eq!(
1170 summary.reparented, 0,
1171 "Removed parent should not trigger reparenting: {changes:?}"
1172 );
1173 }
1174
1175 #[test]
1176 fn test_relationship_change_detected() {
1177 let a = make_component("a");
1180 let b = make_component("b");
1181 let a_id = a.canonical_id.clone();
1182 let b_id = b.canonical_id.clone();
1183
1184 let old_sbom = make_sbom_with_rel(
1185 vec![a.clone(), b.clone()],
1186 vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1187 );
1188 let new_sbom = make_sbom_with_rel(
1189 vec![a, b],
1190 vec![(a_id.clone(), b_id.clone(), DependencyType::DevDependsOn)],
1191 );
1192
1193 let mut matches = HashMap::new();
1194 matches.insert(a_id.clone(), Some(a_id));
1195 matches.insert(b_id.clone(), Some(b_id));
1196
1197 let config = GraphDiffConfig::default();
1198 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1199
1200 assert!(
1201 summary.relationship_changed > 0,
1202 "Should detect relationship change: {changes:?}"
1203 );
1204 assert_eq!(
1206 summary.dependencies_added, 0,
1207 "Relationship change is not an add: {changes:?}"
1208 );
1209 assert_eq!(
1210 summary.dependencies_removed, 0,
1211 "Relationship change is not a remove: {changes:?}"
1212 );
1213 }
1214
1215 #[test]
1216 fn test_scope_change_detected() {
1217 use crate::model::DependencyScope;
1220
1221 let a = make_component("a");
1222 let b = make_component("b");
1223 let a_id = a.canonical_id.clone();
1224 let b_id = b.canonical_id.clone();
1225
1226 let mut old_sbom = NormalizedSbom::default();
1227 old_sbom.add_component(a.clone());
1228 old_sbom.add_component(b.clone());
1229 old_sbom.add_edge(
1230 DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1231 .with_scope(DependencyScope::Required),
1232 );
1233
1234 let mut new_sbom = NormalizedSbom::default();
1235 new_sbom.add_component(a);
1236 new_sbom.add_component(b);
1237 new_sbom.add_edge(
1238 DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1239 .with_scope(DependencyScope::Optional),
1240 );
1241
1242 let mut matches = HashMap::new();
1243 matches.insert(a_id.clone(), Some(a_id));
1244 matches.insert(b_id.clone(), Some(b_id));
1245
1246 let config = GraphDiffConfig::default();
1247 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1248
1249 assert!(
1250 summary.relationship_changed > 0,
1251 "Should detect scope change: {changes:?}"
1252 );
1253 }
1254
1255 #[test]
1256 fn test_reparenting_does_not_suppress_unrelated_add() {
1257 let p1 = make_component("p1");
1262 let p2 = make_component("p2");
1263 let p3 = make_component("p3");
1264 let child = make_component("child");
1265
1266 let p1_id = p1.canonical_id.clone();
1267 let p2_id = p2.canonical_id.clone();
1268 let p3_id = p3.canonical_id.clone();
1269 let child_id = child.canonical_id.clone();
1270
1271 let old_sbom = make_sbom(
1272 vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1273 vec![(p1_id.clone(), child_id.clone())],
1274 );
1275 let new_sbom = make_sbom(
1276 vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1277 vec![
1278 (p2_id.clone(), child_id.clone()),
1279 (p3_id.clone(), child_id.clone()),
1280 ],
1281 );
1282
1283 let mut matches = HashMap::new();
1284 matches.insert(p1_id.clone(), Some(p1_id));
1285 matches.insert(p2_id.clone(), Some(p2_id.clone()));
1286 matches.insert(p3_id.clone(), Some(p3_id.clone()));
1287 matches.insert(child_id.clone(), Some(child_id.clone()));
1288
1289 let config = GraphDiffConfig::default();
1290 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1291
1292 assert!(
1294 summary.reparented > 0,
1295 "Should detect reparenting: {changes:?}"
1296 );
1297
1298 let reparent = changes
1301 .iter()
1302 .find(|c| matches!(&c.change, DependencyChangeType::Reparented { .. }))
1303 .expect("Should have a reparent entry");
1304 let reparent_new_parent = match &reparent.change {
1305 DependencyChangeType::Reparented { new_parent_id, .. } => new_parent_id.clone(),
1306 _ => unreachable!(),
1307 };
1308 let other_parent = if reparent_new_parent == p2_id {
1309 &p3_id
1310 } else {
1311 &p2_id
1312 };
1313
1314 let other_added = changes.iter().any(|c| {
1315 c.component_id == *other_parent
1316 && matches!(
1317 &c.change,
1318 DependencyChangeType::DependencyAdded { dependency_id, .. }
1319 if *dependency_id == child_id
1320 )
1321 });
1322 assert!(
1323 other_added,
1324 "The non-reparented parent's add should not be suppressed: {changes:?}"
1325 );
1326 }
1327
1328 #[test]
1329 fn test_root_promotion_not_skipped() {
1330 let p1 = make_component("p1");
1335 let child = make_component("child");
1336
1337 let p1_id = p1.canonical_id.clone();
1338 let child_id = child.canonical_id.clone();
1339
1340 let old_sbom = make_sbom(
1341 vec![p1.clone(), child.clone()],
1342 vec![(p1_id.clone(), child_id.clone())],
1343 );
1344 let new_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1345
1346 let mut matches = HashMap::new();
1347 matches.insert(p1_id.clone(), Some(p1_id.clone()));
1348 matches.insert(child_id.clone(), Some(child_id));
1349
1350 let config = GraphDiffConfig::default();
1351 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1352
1353 assert!(
1355 summary.dependencies_removed > 0,
1356 "Root promotion: dependency removal should be detected: {changes:?}"
1357 );
1358 assert_eq!(
1360 summary.reparented, 0,
1361 "Root promotion is not reparenting: {changes:?}"
1362 );
1363 }
1364
1365 #[test]
1366 fn test_root_demotion_not_skipped() {
1367 let p1 = make_component("p1");
1371 let child = make_component("child");
1372
1373 let p1_id = p1.canonical_id.clone();
1374 let child_id = child.canonical_id.clone();
1375
1376 let old_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1377 let new_sbom = make_sbom(
1378 vec![p1.clone(), child.clone()],
1379 vec![(p1_id.clone(), child_id.clone())],
1380 );
1381
1382 let mut matches = HashMap::new();
1383 matches.insert(p1_id.clone(), Some(p1_id.clone()));
1384 matches.insert(child_id.clone(), Some(child_id));
1385
1386 let config = GraphDiffConfig::default();
1387 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1388
1389 assert!(
1391 summary.dependencies_added > 0,
1392 "Root demotion: dependency addition should be detected: {changes:?}"
1393 );
1394 assert_eq!(
1396 summary.reparented, 0,
1397 "Root demotion is not reparenting: {changes:?}"
1398 );
1399 }
1400
1401 #[test]
1402 fn test_parent_added_multi_parent_not_reparenting() {
1403 let p1 = make_component("p1");
1406 let p2 = make_component("p2");
1407 let child = make_component("child");
1408
1409 let p1_id = p1.canonical_id.clone();
1410 let p2_id = p2.canonical_id.clone();
1411 let child_id = child.canonical_id.clone();
1412
1413 let old_sbom = make_sbom(
1414 vec![p1.clone(), p2.clone(), child.clone()],
1415 vec![(p1_id.clone(), child_id.clone())],
1416 );
1417 let new_sbom = make_sbom(
1418 vec![p1.clone(), p2.clone(), child.clone()],
1419 vec![
1420 (p1_id.clone(), child_id.clone()),
1421 (p2_id.clone(), child_id.clone()),
1422 ],
1423 );
1424
1425 let mut matches = HashMap::new();
1426 matches.insert(p1_id.clone(), Some(p1_id));
1427 matches.insert(p2_id.clone(), Some(p2_id));
1428 matches.insert(child_id.clone(), Some(child_id));
1429
1430 let config = GraphDiffConfig::default();
1431 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1432
1433 assert_eq!(
1434 summary.reparented, 0,
1435 "Adding a new parent while keeping old is not reparenting: {changes:?}"
1436 );
1437 assert!(
1439 summary.dependencies_added > 0,
1440 "P2→C should be detected as added: {changes:?}"
1441 );
1442 }
1443
1444 #[test]
1445 fn test_same_relationship_no_change() {
1446 let a = make_component("a");
1449 let b = make_component("b");
1450 let a_id = a.canonical_id.clone();
1451 let b_id = b.canonical_id.clone();
1452
1453 let old_sbom = make_sbom_with_rel(
1454 vec![a.clone(), b.clone()],
1455 vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1456 );
1457 let new_sbom = make_sbom_with_rel(
1458 vec![a, b],
1459 vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1460 );
1461
1462 let mut matches = HashMap::new();
1463 matches.insert(a_id.clone(), Some(a_id));
1464 matches.insert(b_id.clone(), Some(b_id));
1465
1466 let config = GraphDiffConfig::default();
1467 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1468
1469 assert_eq!(
1470 summary.relationship_changed, 0,
1471 "Same relationship should not be a change: {changes:?}"
1472 );
1473 }
1474
1475 #[test]
1476 fn test_duplicate_edges_different_types() {
1477 let a = make_component("a");
1480 let b = make_component("b");
1481 let a_id = a.canonical_id.clone();
1482 let b_id = b.canonical_id.clone();
1483
1484 let mut sbom = NormalizedSbom::default();
1485 sbom.add_component(a);
1486 sbom.add_component(b);
1487 sbom.add_edge(DependencyEdge::new(
1488 a_id.clone(),
1489 b_id.clone(),
1490 DependencyType::DependsOn,
1491 ));
1492 sbom.add_edge(DependencyEdge::new(
1493 a_id.clone(),
1494 b_id.clone(),
1495 DependencyType::DevDependsOn,
1496 ));
1497
1498 let config = GraphDiffConfig::default();
1499 let graph = DependencyGraph::from_sbom(&sbom, &config);
1500
1501 let children = graph.get_children(&a_id);
1503 assert!(children.contains(&b_id), "B should be a child of A");
1504
1505 let attrs = graph.get_edge_attrs(&a_id, &b_id);
1507 assert!(attrs.is_some(), "Should have edge attrs for A→B");
1508 }
1509
1510 #[test]
1511 fn test_large_graph_completes() {
1512 let mut components = Vec::new();
1514 let mut edges = Vec::new();
1515 let mut ids = Vec::new();
1516
1517 for i in 0..500 {
1518 let comp = make_component(&format!("node-{i}"));
1519 ids.push(comp.canonical_id.clone());
1520 components.push(comp);
1521 }
1522 for i in 0..499 {
1523 edges.push((ids[i].clone(), ids[i + 1].clone()));
1524 }
1525
1526 let sbom = make_sbom(components, edges);
1527 let config = GraphDiffConfig::default();
1528 let graph = DependencyGraph::from_sbom(&sbom, &config);
1529
1530 assert_eq!(graph.get_depth(&ids[0]), Some(1));
1532 assert_eq!(graph.get_depth(&ids[499]), Some(500));
1533 }
1534
1535 #[test]
1536 fn test_empty_vs_nonempty_graph() {
1537 let a = make_component("a");
1539 let b = make_component("b");
1540 let a_id = a.canonical_id.clone();
1541 let b_id = b.canonical_id.clone();
1542
1543 let old_sbom = make_sbom(vec![a.clone(), b.clone()], vec![]);
1544 let new_sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1545
1546 let mut matches = HashMap::new();
1547 matches.insert(a_id.clone(), Some(a_id));
1548 matches.insert(b_id.clone(), Some(b_id));
1549
1550 let config = GraphDiffConfig::default();
1551 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1552
1553 assert!(
1554 summary.dependencies_added > 0,
1555 "Should detect added dependency: {changes:?}"
1556 );
1557 assert_eq!(
1558 summary.dependencies_removed, 0,
1559 "No false removes: {changes:?}"
1560 );
1561 }
1562
1563 #[test]
1564 fn test_nonempty_vs_empty_graph() {
1565 let a = make_component("a");
1567 let b = make_component("b");
1568 let a_id = a.canonical_id.clone();
1569 let b_id = b.canonical_id.clone();
1570
1571 let old_sbom = make_sbom(
1572 vec![a.clone(), b.clone()],
1573 vec![(a_id.clone(), b_id.clone())],
1574 );
1575 let new_sbom = make_sbom(vec![a, b], vec![]);
1576
1577 let mut matches = HashMap::new();
1578 matches.insert(a_id.clone(), Some(a_id));
1579 matches.insert(b_id.clone(), Some(b_id));
1580
1581 let config = GraphDiffConfig::default();
1582 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1583
1584 assert!(
1585 summary.dependencies_removed > 0,
1586 "Should detect removed dependency: {changes:?}"
1587 );
1588 assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1589 }
1590
1591 #[test]
1592 fn test_relation_filter() {
1593 let a = make_component("a");
1596 let b = make_component("b");
1597 let c = make_component("c");
1598 let a_id = a.canonical_id.clone();
1599 let b_id = b.canonical_id.clone();
1600 let c_id = c.canonical_id.clone();
1601
1602 let mut sbom = NormalizedSbom::default();
1603 sbom.add_component(a);
1604 sbom.add_component(b);
1605 sbom.add_component(c);
1606 sbom.add_edge(DependencyEdge::new(
1607 a_id.clone(),
1608 b_id.clone(),
1609 DependencyType::DependsOn,
1610 ));
1611 sbom.add_edge(DependencyEdge::new(
1612 a_id.clone(),
1613 c_id.clone(),
1614 DependencyType::DevDependsOn,
1615 ));
1616
1617 let config = GraphDiffConfig {
1618 relation_filter: vec!["depends-on".to_string()],
1619 ..Default::default()
1620 };
1621 let graph = DependencyGraph::from_sbom(&sbom, &config);
1622
1623 let children = graph.get_children(&a_id);
1624 assert!(
1625 children.contains(&b_id),
1626 "DependsOn edge should be included"
1627 );
1628 assert!(
1629 !children.contains(&c_id),
1630 "DevDependsOn edge should be excluded by filter"
1631 );
1632 }
1633}