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 for (old_id, new_id_opt) in matches {
446 if let Some(new_id) = new_id_opt {
447 let old_parents = old_graph.get_parents(old_id);
448 let new_parents = new_graph.get_parents(new_id);
449
450 if old_parents.is_empty() && new_parents.is_empty() {
452 continue;
453 }
454
455 let old_parents_mapped: HashSet<CanonicalId> = old_parents
458 .iter()
459 .filter_map(|old_parent| matches.get(old_parent).and_then(|opt| opt.clone()))
460 .collect();
461 let new_parents_set: HashSet<CanonicalId> = new_parents.into_iter().collect();
462
463 if old_parents_mapped == new_parents_set {
465 continue;
466 }
467
468 let removed_parents: Vec<_> = old_parents_mapped.difference(&new_parents_set).collect();
470 let added_parents: Vec<_> = new_parents_set.difference(&old_parents_mapped).collect();
471
472 if removed_parents.is_empty() || added_parents.is_empty() {
476 continue;
477 }
478
479 let old_parent = removed_parents[0];
480 let new_parent = added_parents[0];
481
482 let component_name = new_graph.get_component_name(new_id);
483 let old_parent_name = new_graph.get_component_name(old_parent);
484 let new_parent_name = new_graph.get_component_name(new_parent);
485
486 changes.retain(|c| match &c.change {
491 DependencyChangeType::DependencyAdded { dependency_id, .. } => {
492 !(dependency_id == new_id && c.component_id == *new_parent)
493 }
494 DependencyChangeType::DependencyRemoved { dependency_id, .. } => {
495 !(dependency_id == new_id && c.component_id == *old_parent)
496 }
497 _ => true,
498 });
499
500 changes.push(DependencyGraphChange {
501 component_id: new_id.clone(),
502 component_name,
503 change: DependencyChangeType::Reparented {
504 dependency_id: new_id.clone(),
505 dependency_name: new_graph.get_component_name(new_id),
506 old_parent_id: old_parent.clone(),
507 old_parent_name,
508 new_parent_id: new_parent.clone(),
509 new_parent_name,
510 },
511 impact: GraphChangeImpact::Medium,
512 });
513 }
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::model::{
521 Component, DependencyEdge, DependencyType, NormalizedSbom, VulnerabilityRef,
522 VulnerabilitySource,
523 };
524
525 fn make_component(name: &str) -> Component {
527 Component::new(name.to_string(), name.to_string())
528 }
529
530 fn make_component_v(name: &str, version: &str) -> Component {
532 Component::new(name.to_string(), format!("{name}@{version}"))
533 .with_version(version.to_string())
534 }
535
536 fn make_sbom(
538 components: Vec<Component>,
539 edges: Vec<(CanonicalId, CanonicalId)>,
540 ) -> NormalizedSbom {
541 let mut sbom = NormalizedSbom::default();
542 for comp in components {
543 sbom.add_component(comp);
544 }
545 for (from, to) in edges {
546 sbom.add_edge(DependencyEdge::new(from, to, DependencyType::DependsOn));
547 }
548 sbom
549 }
550
551 fn make_sbom_with_rel(
553 components: Vec<Component>,
554 edges: Vec<(CanonicalId, CanonicalId, DependencyType)>,
555 ) -> NormalizedSbom {
556 let mut sbom = NormalizedSbom::default();
557 for comp in components {
558 sbom.add_component(comp);
559 }
560 for (from, to, rel) in edges {
561 sbom.add_edge(DependencyEdge::new(from, to, rel));
562 }
563 sbom
564 }
565
566 #[test]
567 fn test_graph_diff_config_default() {
568 let config = GraphDiffConfig::default();
569 assert!(config.detect_reparenting);
570 assert!(config.detect_depth_changes);
571 assert_eq!(config.max_depth, 0);
572 }
573
574 #[test]
575 fn test_graph_change_impact_display() {
576 assert_eq!(GraphChangeImpact::Critical.as_str(), "critical");
577 assert_eq!(GraphChangeImpact::High.as_str(), "high");
578 assert_eq!(GraphChangeImpact::Medium.as_str(), "medium");
579 assert_eq!(GraphChangeImpact::Low.as_str(), "low");
580 }
581
582 #[test]
583 fn test_children_mapped_through_component_matches() {
584 let a_old = make_component("a-old");
586 let b_old = make_component("b-old");
587 let a_old_id = a_old.canonical_id.clone();
588 let b_old_id = b_old.canonical_id.clone();
589
590 let old_sbom = make_sbom(
591 vec![a_old, b_old],
592 vec![(a_old_id.clone(), b_old_id.clone())],
593 );
594
595 let a_new = make_component("a-new");
597 let b_new = make_component("b-new");
598 let a_new_id = a_new.canonical_id.clone();
599 let b_new_id = b_new.canonical_id.clone();
600
601 let new_sbom = make_sbom(
602 vec![a_new, b_new],
603 vec![(a_new_id.clone(), b_new_id.clone())],
604 );
605
606 let mut matches = HashMap::new();
608 matches.insert(a_old_id, Some(a_new_id));
609 matches.insert(b_old_id, Some(b_new_id));
610
611 let config = GraphDiffConfig::default();
612 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
613
614 assert_eq!(summary.dependencies_added, 0, "No false add: {changes:?}");
616 assert_eq!(
617 summary.dependencies_removed, 0,
618 "No false remove: {changes:?}"
619 );
620 }
621
622 #[test]
623 fn test_depth_linear_chain() {
624 let a = make_component("a");
626 let b = make_component("b");
627 let c = make_component("c");
628 let d = make_component("d");
629
630 let ids: Vec<_> = [&a, &b, &c, &d]
631 .iter()
632 .map(|c| c.canonical_id.clone())
633 .collect();
634 let sbom = make_sbom(
635 vec![a, b, c, d],
636 vec![
637 (ids[0].clone(), ids[1].clone()),
638 (ids[1].clone(), ids[2].clone()),
639 (ids[2].clone(), ids[3].clone()),
640 ],
641 );
642
643 let config = GraphDiffConfig::default();
644 let graph = DependencyGraph::from_sbom(&sbom, &config);
645
646 assert_eq!(graph.get_depth(&ids[0]), Some(1)); assert_eq!(graph.get_depth(&ids[1]), Some(2));
648 assert_eq!(graph.get_depth(&ids[2]), Some(3));
649 assert_eq!(graph.get_depth(&ids[3]), Some(4));
650 }
651
652 #[test]
653 fn test_depth_diamond_dependency() {
654 let a = make_component("a");
657 let b = make_component("b");
658 let c = make_component("c");
659 let d = make_component("d");
660
661 let ids: Vec<_> = [&a, &b, &c, &d]
662 .iter()
663 .map(|c| c.canonical_id.clone())
664 .collect();
665 let sbom = make_sbom(
666 vec![a, b, c, d],
667 vec![
668 (ids[0].clone(), ids[1].clone()),
669 (ids[0].clone(), ids[2].clone()),
670 (ids[1].clone(), ids[3].clone()),
671 (ids[2].clone(), ids[3].clone()),
672 ],
673 );
674
675 let config = GraphDiffConfig::default();
676 let graph = DependencyGraph::from_sbom(&sbom, &config);
677
678 assert_eq!(graph.get_depth(&ids[0]), Some(1));
679 assert_eq!(graph.get_depth(&ids[1]), Some(2));
680 assert_eq!(graph.get_depth(&ids[2]), Some(2));
681 assert_eq!(graph.get_depth(&ids[3]), Some(3)); }
683
684 #[test]
685 fn test_depth_rootless_cycle() {
686 let a = make_component("a");
688 let b = make_component("b");
689 let c = make_component("c");
690
691 let ids: Vec<_> = [&a, &b, &c]
692 .iter()
693 .map(|c| c.canonical_id.clone())
694 .collect();
695 let sbom = make_sbom(
696 vec![a, b, c],
697 vec![
698 (ids[0].clone(), ids[1].clone()),
699 (ids[1].clone(), ids[2].clone()),
700 (ids[2].clone(), ids[0].clone()),
701 ],
702 );
703
704 let config = GraphDiffConfig::default();
705 let graph = DependencyGraph::from_sbom(&sbom, &config);
706
707 for (i, id) in ids.iter().enumerate() {
709 let depth = graph.get_depth(id);
710 assert!(depth.is_some(), "Node {i} should have depth");
711 assert_eq!(
712 depth.unwrap(),
713 CYCLIC_SENTINEL_DEPTH,
714 "Cyclic node {i} should get sentinel depth, not 0"
715 );
716 }
717 }
718
719 #[test]
720 fn test_depth_cycle_reachable_from_root() {
721 let root = make_component("root");
723 let a = make_component("a");
724 let b = make_component("b");
725 let c = make_component("c");
726
727 let ids: Vec<_> = [&root, &a, &b, &c]
728 .iter()
729 .map(|comp| comp.canonical_id.clone())
730 .collect();
731 let sbom = make_sbom(
732 vec![root, a, b, c],
733 vec![
734 (ids[0].clone(), ids[1].clone()), (ids[1].clone(), ids[2].clone()), (ids[2].clone(), ids[3].clone()), (ids[3].clone(), ids[2].clone()), ],
739 );
740
741 let config = GraphDiffConfig::default();
742 let graph = DependencyGraph::from_sbom(&sbom, &config);
743
744 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)); }
751
752 #[test]
753 fn test_depth_disconnected_subgraphs() {
754 let r1 = make_component("r1");
758 let a = make_component("a");
759 let r2 = make_component("r2");
760 let b = make_component("b");
761 let c = make_component("c");
762
763 let ids: Vec<_> = [&r1, &a, &r2, &b, &c]
764 .iter()
765 .map(|comp| comp.canonical_id.clone())
766 .collect();
767 let sbom = make_sbom(
768 vec![r1, a, r2, b, c],
769 vec![
770 (ids[0].clone(), ids[1].clone()), (ids[2].clone(), ids[3].clone()), (ids[3].clone(), ids[4].clone()), ],
774 );
775
776 let config = GraphDiffConfig::default();
777 let graph = DependencyGraph::from_sbom(&sbom, &config);
778
779 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)); }
785
786 #[test]
787 fn test_self_referencing_edge_no_infinite_loop() {
788 let a = make_component("a");
790 let a_id = a.canonical_id.clone();
791
792 let sbom = make_sbom(vec![a], vec![(a_id.clone(), a_id.clone())]);
793
794 let config = GraphDiffConfig::default();
795 let graph = DependencyGraph::from_sbom(&sbom, &config);
796
797 let depth = graph.get_depth(&a_id);
802 assert!(depth.is_some(), "A should have a depth");
803 assert_eq!(
805 depth.unwrap(),
806 CYCLIC_SENTINEL_DEPTH,
807 "Self-referencing node should get sentinel depth"
808 );
809 }
810
811 #[test]
812 fn test_depth_max_depth_limit() {
813 let a = make_component("a");
815 let b = make_component("b");
816 let c = make_component("c");
817 let d = make_component("d");
818
819 let ids: Vec<_> = [&a, &b, &c, &d]
820 .iter()
821 .map(|c| c.canonical_id.clone())
822 .collect();
823 let sbom = make_sbom(
824 vec![a, b, c, d],
825 vec![
826 (ids[0].clone(), ids[1].clone()),
827 (ids[1].clone(), ids[2].clone()),
828 (ids[2].clone(), ids[3].clone()),
829 ],
830 );
831
832 let config = GraphDiffConfig {
833 max_depth: 2,
834 ..Default::default()
835 };
836 let graph = DependencyGraph::from_sbom(&sbom, &config);
837
838 assert_eq!(graph.get_depth(&ids[0]), Some(1));
839 assert_eq!(graph.get_depth(&ids[1]), Some(2));
840 assert_eq!(graph.get_depth(&ids[2]), Some(CYCLIC_SENTINEL_DEPTH));
843 assert_eq!(graph.get_depth(&ids[3]), Some(CYCLIC_SENTINEL_DEPTH));
844 }
845
846 #[test]
847 fn test_reparenting_single_parent() {
848 let p1 = make_component("p1");
852 let p2 = make_component("p2");
853 let child = make_component("child");
854
855 let p1_id = p1.canonical_id.clone();
856 let p2_id = p2.canonical_id.clone();
857 let child_id = child.canonical_id.clone();
858
859 let old_sbom = make_sbom(
860 vec![p1.clone(), p2.clone(), child.clone()],
861 vec![(p1_id.clone(), child_id.clone())],
862 );
863 let new_sbom = make_sbom(
864 vec![p1.clone(), p2.clone(), child.clone()],
865 vec![(p2_id.clone(), child_id.clone())],
866 );
867
868 let mut matches = HashMap::new();
870 matches.insert(p1_id.clone(), Some(p1_id));
871 matches.insert(p2_id.clone(), Some(p2_id));
872 matches.insert(child_id.clone(), Some(child_id));
873
874 let config = GraphDiffConfig::default();
875 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
876
877 assert!(
878 summary.reparented > 0,
879 "Should detect reparenting: {changes:?}"
880 );
881 }
882
883 #[test]
884 fn test_renamed_parent_is_not_reparenting() {
885 let p1 = make_component("p1");
888 let p2 = make_component("p2");
889 let child = make_component("child");
890
891 let p1_id = p1.canonical_id.clone();
892 let p2_id = p2.canonical_id.clone();
893 let child_id = child.canonical_id.clone();
894
895 let old_sbom = make_sbom(
896 vec![p1, child.clone()],
897 vec![(p1_id.clone(), child_id.clone())],
898 );
899 let new_sbom = make_sbom(
900 vec![p2, child.clone()],
901 vec![(p2_id.clone(), child_id.clone())],
902 );
903
904 let mut matches = HashMap::new();
905 matches.insert(p1_id, Some(p2_id));
906 matches.insert(child_id.clone(), Some(child_id));
907
908 let config = GraphDiffConfig::default();
909 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
910
911 assert_eq!(
912 summary.reparented, 0,
913 "Renamed parent should not be reparenting: {changes:?}"
914 );
915 }
916
917 #[test]
918 fn test_reparenting_multi_parent() {
919 let p1 = make_component("p1");
924 let p2 = make_component("p2");
925 let p3 = make_component("p3");
926 let child = make_component("child");
927
928 let p1_id = p1.canonical_id.clone();
929 let p2_id = p2.canonical_id.clone();
930 let p3_id = p3.canonical_id.clone();
931 let child_id = child.canonical_id.clone();
932
933 let old_sbom = make_sbom(
934 vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
935 vec![
936 (p1_id.clone(), child_id.clone()),
937 (p2_id.clone(), child_id.clone()),
938 ],
939 );
940 let new_sbom = make_sbom(
941 vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
942 vec![
943 (p1_id.clone(), child_id.clone()),
944 (p3_id.clone(), child_id.clone()),
945 ],
946 );
947
948 let mut matches = HashMap::new();
950 matches.insert(p1_id.clone(), Some(p1_id));
951 matches.insert(p2_id.clone(), Some(p2_id));
952 matches.insert(p3_id.clone(), Some(p3_id));
953 matches.insert(child_id.clone(), Some(child_id));
954
955 let config = GraphDiffConfig::default();
956 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
957
958 assert!(
959 summary.reparented > 0,
960 "Should detect multi-parent reparenting: {changes:?}"
961 );
962 }
963
964 #[test]
965 fn test_vulnerable_direct_dep_is_critical() {
966 let a = make_component("root");
968 let mut vuln_comp = make_component("vuln-lib");
969 vuln_comp.vulnerabilities.push(VulnerabilityRef {
970 id: "CVE-2024-0001".to_string(),
971 source: VulnerabilitySource::Osv,
972 severity: None,
973 cvss: vec![],
974 affected_versions: vec![],
975 remediation: None,
976 description: None,
977 cwes: vec![],
978 published: None,
979 modified: None,
980 is_kev: false,
981 kev_info: None,
982 epss_score: None,
983 epss_percentile: None,
984 vex_status: None,
985 });
986
987 let a_id = a.canonical_id.clone();
988 let v_id = vuln_comp.canonical_id.clone();
989
990 let old_sbom = make_sbom(vec![a.clone()], vec![]);
991 let new_sbom = make_sbom(
992 vec![a.clone(), vuln_comp],
993 vec![(a_id.clone(), v_id.clone())],
994 );
995
996 let mut matches = HashMap::new();
997 matches.insert(a_id.clone(), Some(a_id));
998
999 let config = GraphDiffConfig::default();
1000 let (changes, _) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1001
1002 let critical = changes
1003 .iter()
1004 .any(|c| c.impact == GraphChangeImpact::Critical);
1005 assert!(
1006 critical,
1007 "Vulnerable direct dep should be critical impact: {changes:?}"
1008 );
1009 }
1010
1011 #[test]
1012 fn test_empty_sboms_no_changes() {
1013 let old_sbom = NormalizedSbom::default();
1014 let new_sbom = NormalizedSbom::default();
1015 let matches = HashMap::new();
1016 let config = GraphDiffConfig::default();
1017
1018 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1019 assert!(changes.is_empty());
1020 assert_eq!(summary.total_changes, 0);
1021 }
1022
1023 #[test]
1024 fn test_identical_graphs_no_changes() {
1025 let a = make_component("a");
1026 let b = make_component("b");
1027 let a_id = a.canonical_id.clone();
1028 let b_id = b.canonical_id.clone();
1029
1030 let sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1031
1032 let mut matches = HashMap::new();
1033 matches.insert(a_id.clone(), Some(a_id));
1034 matches.insert(b_id.clone(), Some(b_id));
1035
1036 let config = GraphDiffConfig::default();
1037 let (changes, summary) = diff_dependency_graph(&sbom, &sbom, &matches, &config);
1038
1039 assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1041 assert_eq!(
1042 summary.dependencies_removed, 0,
1043 "No false removes: {changes:?}"
1044 );
1045 }
1046
1047 #[test]
1048 fn test_removed_child_not_false_positive() {
1049 let a = make_component("a");
1053 let b_v1 = make_component_v("b", "1.0");
1054 let b_v2 = make_component_v("b", "2.0");
1055
1056 let a_id = a.canonical_id.clone();
1057 let b_v1_id = b_v1.canonical_id.clone();
1058 let b_v2_id = b_v2.canonical_id.clone();
1059
1060 let old_sbom = make_sbom(vec![a.clone(), b_v1], vec![(a_id.clone(), b_v1_id.clone())]);
1061 let new_sbom = make_sbom(vec![a.clone(), b_v2], vec![(a_id.clone(), b_v2_id.clone())]);
1062
1063 let mut matches = HashMap::new();
1064 matches.insert(a_id.clone(), Some(a_id));
1065 matches.insert(b_v1_id, Some(b_v2_id));
1066
1067 let config = GraphDiffConfig::default();
1068 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1069
1070 assert_eq!(
1071 summary.dependencies_added, 0,
1072 "Version bump should not be false add: {changes:?}"
1073 );
1074 assert_eq!(
1075 summary.dependencies_removed, 0,
1076 "Version bump should not be false remove: {changes:?}"
1077 );
1078 }
1079
1080 #[test]
1081 fn test_unmatched_old_child_excluded_from_comparison() {
1082 let a = make_component("a");
1085 let b = make_component("b");
1086 let c = make_component("c");
1087
1088 let a_id = a.canonical_id.clone();
1089 let b_id = b.canonical_id.clone();
1090 let c_id = c.canonical_id.clone();
1091
1092 let old_sbom = make_sbom(
1093 vec![a.clone(), b.clone(), c],
1094 vec![(a_id.clone(), b_id.clone()), (a_id.clone(), c_id.clone())],
1095 );
1096 let new_sbom = make_sbom(
1097 vec![a.clone(), b.clone()],
1098 vec![(a_id.clone(), b_id.clone())],
1099 );
1100
1101 let mut matches = HashMap::new();
1102 matches.insert(a_id.clone(), Some(a_id));
1103 matches.insert(b_id.clone(), Some(b_id));
1104 matches.insert(c_id, None); let config = GraphDiffConfig::default();
1107 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1108
1109 assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1115 }
1116
1117 #[test]
1118 fn test_reparenting_with_removed_parent() {
1119 let p1 = make_component("p1");
1122 let p2 = make_component("p2");
1123 let child = make_component("child");
1124
1125 let p1_id = p1.canonical_id.clone();
1126 let p2_id = p2.canonical_id.clone();
1127 let child_id = child.canonical_id.clone();
1128
1129 let old_sbom = make_sbom(
1130 vec![p1.clone(), p2, child.clone()],
1131 vec![
1132 (p1_id.clone(), child_id.clone()),
1133 (p2_id.clone(), child_id.clone()),
1134 ],
1135 );
1136 let new_sbom = make_sbom(
1137 vec![p1.clone(), child.clone()],
1138 vec![(p1_id.clone(), child_id.clone())],
1139 );
1140
1141 let mut matches = HashMap::new();
1142 matches.insert(p1_id.clone(), Some(p1_id));
1143 matches.insert(p2_id, None); matches.insert(child_id.clone(), Some(child_id));
1145
1146 let config = GraphDiffConfig::default();
1147 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1148
1149 assert_eq!(
1150 summary.reparented, 0,
1151 "Removed parent should not trigger reparenting: {changes:?}"
1152 );
1153 }
1154
1155 #[test]
1156 fn test_relationship_change_detected() {
1157 let a = make_component("a");
1160 let b = make_component("b");
1161 let a_id = a.canonical_id.clone();
1162 let b_id = b.canonical_id.clone();
1163
1164 let old_sbom = make_sbom_with_rel(
1165 vec![a.clone(), b.clone()],
1166 vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1167 );
1168 let new_sbom = make_sbom_with_rel(
1169 vec![a, b],
1170 vec![(a_id.clone(), b_id.clone(), DependencyType::DevDependsOn)],
1171 );
1172
1173 let mut matches = HashMap::new();
1174 matches.insert(a_id.clone(), Some(a_id));
1175 matches.insert(b_id.clone(), Some(b_id));
1176
1177 let config = GraphDiffConfig::default();
1178 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1179
1180 assert!(
1181 summary.relationship_changed > 0,
1182 "Should detect relationship change: {changes:?}"
1183 );
1184 assert_eq!(
1186 summary.dependencies_added, 0,
1187 "Relationship change is not an add: {changes:?}"
1188 );
1189 assert_eq!(
1190 summary.dependencies_removed, 0,
1191 "Relationship change is not a remove: {changes:?}"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_scope_change_detected() {
1197 use crate::model::DependencyScope;
1200
1201 let a = make_component("a");
1202 let b = make_component("b");
1203 let a_id = a.canonical_id.clone();
1204 let b_id = b.canonical_id.clone();
1205
1206 let mut old_sbom = NormalizedSbom::default();
1207 old_sbom.add_component(a.clone());
1208 old_sbom.add_component(b.clone());
1209 old_sbom.add_edge(
1210 DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1211 .with_scope(DependencyScope::Required),
1212 );
1213
1214 let mut new_sbom = NormalizedSbom::default();
1215 new_sbom.add_component(a);
1216 new_sbom.add_component(b);
1217 new_sbom.add_edge(
1218 DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1219 .with_scope(DependencyScope::Optional),
1220 );
1221
1222 let mut matches = HashMap::new();
1223 matches.insert(a_id.clone(), Some(a_id));
1224 matches.insert(b_id.clone(), Some(b_id));
1225
1226 let config = GraphDiffConfig::default();
1227 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1228
1229 assert!(
1230 summary.relationship_changed > 0,
1231 "Should detect scope change: {changes:?}"
1232 );
1233 }
1234
1235 #[test]
1236 fn test_reparenting_does_not_suppress_unrelated_add() {
1237 let p1 = make_component("p1");
1242 let p2 = make_component("p2");
1243 let p3 = make_component("p3");
1244 let child = make_component("child");
1245
1246 let p1_id = p1.canonical_id.clone();
1247 let p2_id = p2.canonical_id.clone();
1248 let p3_id = p3.canonical_id.clone();
1249 let child_id = child.canonical_id.clone();
1250
1251 let old_sbom = make_sbom(
1252 vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1253 vec![(p1_id.clone(), child_id.clone())],
1254 );
1255 let new_sbom = make_sbom(
1256 vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1257 vec![
1258 (p2_id.clone(), child_id.clone()),
1259 (p3_id.clone(), child_id.clone()),
1260 ],
1261 );
1262
1263 let mut matches = HashMap::new();
1264 matches.insert(p1_id.clone(), Some(p1_id));
1265 matches.insert(p2_id.clone(), Some(p2_id.clone()));
1266 matches.insert(p3_id.clone(), Some(p3_id.clone()));
1267 matches.insert(child_id.clone(), Some(child_id.clone()));
1268
1269 let config = GraphDiffConfig::default();
1270 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1271
1272 assert!(
1274 summary.reparented > 0,
1275 "Should detect reparenting: {changes:?}"
1276 );
1277
1278 let reparent = changes
1281 .iter()
1282 .find(|c| matches!(&c.change, DependencyChangeType::Reparented { .. }))
1283 .expect("Should have a reparent entry");
1284 let reparent_new_parent = match &reparent.change {
1285 DependencyChangeType::Reparented { new_parent_id, .. } => new_parent_id.clone(),
1286 _ => unreachable!(),
1287 };
1288 let other_parent = if reparent_new_parent == p2_id {
1289 &p3_id
1290 } else {
1291 &p2_id
1292 };
1293
1294 let other_added = changes.iter().any(|c| {
1295 c.component_id == *other_parent
1296 && matches!(
1297 &c.change,
1298 DependencyChangeType::DependencyAdded { dependency_id, .. }
1299 if *dependency_id == child_id
1300 )
1301 });
1302 assert!(
1303 other_added,
1304 "The non-reparented parent's add should not be suppressed: {changes:?}"
1305 );
1306 }
1307
1308 #[test]
1309 fn test_root_promotion_not_skipped() {
1310 let p1 = make_component("p1");
1315 let child = make_component("child");
1316
1317 let p1_id = p1.canonical_id.clone();
1318 let child_id = child.canonical_id.clone();
1319
1320 let old_sbom = make_sbom(
1321 vec![p1.clone(), child.clone()],
1322 vec![(p1_id.clone(), child_id.clone())],
1323 );
1324 let new_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1325
1326 let mut matches = HashMap::new();
1327 matches.insert(p1_id.clone(), Some(p1_id.clone()));
1328 matches.insert(child_id.clone(), Some(child_id));
1329
1330 let config = GraphDiffConfig::default();
1331 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1332
1333 assert!(
1335 summary.dependencies_removed > 0,
1336 "Root promotion: dependency removal should be detected: {changes:?}"
1337 );
1338 assert_eq!(
1340 summary.reparented, 0,
1341 "Root promotion is not reparenting: {changes:?}"
1342 );
1343 }
1344
1345 #[test]
1346 fn test_root_demotion_not_skipped() {
1347 let p1 = make_component("p1");
1351 let child = make_component("child");
1352
1353 let p1_id = p1.canonical_id.clone();
1354 let child_id = child.canonical_id.clone();
1355
1356 let old_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1357 let new_sbom = make_sbom(
1358 vec![p1.clone(), child.clone()],
1359 vec![(p1_id.clone(), child_id.clone())],
1360 );
1361
1362 let mut matches = HashMap::new();
1363 matches.insert(p1_id.clone(), Some(p1_id.clone()));
1364 matches.insert(child_id.clone(), Some(child_id));
1365
1366 let config = GraphDiffConfig::default();
1367 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1368
1369 assert!(
1371 summary.dependencies_added > 0,
1372 "Root demotion: dependency addition should be detected: {changes:?}"
1373 );
1374 assert_eq!(
1376 summary.reparented, 0,
1377 "Root demotion is not reparenting: {changes:?}"
1378 );
1379 }
1380
1381 #[test]
1382 fn test_parent_added_multi_parent_not_reparenting() {
1383 let p1 = make_component("p1");
1386 let p2 = make_component("p2");
1387 let child = make_component("child");
1388
1389 let p1_id = p1.canonical_id.clone();
1390 let p2_id = p2.canonical_id.clone();
1391 let child_id = child.canonical_id.clone();
1392
1393 let old_sbom = make_sbom(
1394 vec![p1.clone(), p2.clone(), child.clone()],
1395 vec![(p1_id.clone(), child_id.clone())],
1396 );
1397 let new_sbom = make_sbom(
1398 vec![p1.clone(), p2.clone(), child.clone()],
1399 vec![
1400 (p1_id.clone(), child_id.clone()),
1401 (p2_id.clone(), child_id.clone()),
1402 ],
1403 );
1404
1405 let mut matches = HashMap::new();
1406 matches.insert(p1_id.clone(), Some(p1_id));
1407 matches.insert(p2_id.clone(), Some(p2_id));
1408 matches.insert(child_id.clone(), Some(child_id));
1409
1410 let config = GraphDiffConfig::default();
1411 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1412
1413 assert_eq!(
1414 summary.reparented, 0,
1415 "Adding a new parent while keeping old is not reparenting: {changes:?}"
1416 );
1417 assert!(
1419 summary.dependencies_added > 0,
1420 "P2→C should be detected as added: {changes:?}"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_same_relationship_no_change() {
1426 let a = make_component("a");
1429 let b = make_component("b");
1430 let a_id = a.canonical_id.clone();
1431 let b_id = b.canonical_id.clone();
1432
1433 let old_sbom = make_sbom_with_rel(
1434 vec![a.clone(), b.clone()],
1435 vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1436 );
1437 let new_sbom = make_sbom_with_rel(
1438 vec![a, b],
1439 vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1440 );
1441
1442 let mut matches = HashMap::new();
1443 matches.insert(a_id.clone(), Some(a_id));
1444 matches.insert(b_id.clone(), Some(b_id));
1445
1446 let config = GraphDiffConfig::default();
1447 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1448
1449 assert_eq!(
1450 summary.relationship_changed, 0,
1451 "Same relationship should not be a change: {changes:?}"
1452 );
1453 }
1454
1455 #[test]
1456 fn test_duplicate_edges_different_types() {
1457 let a = make_component("a");
1460 let b = make_component("b");
1461 let a_id = a.canonical_id.clone();
1462 let b_id = b.canonical_id.clone();
1463
1464 let mut sbom = NormalizedSbom::default();
1465 sbom.add_component(a);
1466 sbom.add_component(b);
1467 sbom.add_edge(DependencyEdge::new(
1468 a_id.clone(),
1469 b_id.clone(),
1470 DependencyType::DependsOn,
1471 ));
1472 sbom.add_edge(DependencyEdge::new(
1473 a_id.clone(),
1474 b_id.clone(),
1475 DependencyType::DevDependsOn,
1476 ));
1477
1478 let config = GraphDiffConfig::default();
1479 let graph = DependencyGraph::from_sbom(&sbom, &config);
1480
1481 let children = graph.get_children(&a_id);
1483 assert!(children.contains(&b_id), "B should be a child of A");
1484
1485 let attrs = graph.get_edge_attrs(&a_id, &b_id);
1487 assert!(attrs.is_some(), "Should have edge attrs for A→B");
1488 }
1489
1490 #[test]
1491 fn test_large_graph_completes() {
1492 let mut components = Vec::new();
1494 let mut edges = Vec::new();
1495 let mut ids = Vec::new();
1496
1497 for i in 0..500 {
1498 let comp = make_component(&format!("node-{i}"));
1499 ids.push(comp.canonical_id.clone());
1500 components.push(comp);
1501 }
1502 for i in 0..499 {
1503 edges.push((ids[i].clone(), ids[i + 1].clone()));
1504 }
1505
1506 let sbom = make_sbom(components, edges);
1507 let config = GraphDiffConfig::default();
1508 let graph = DependencyGraph::from_sbom(&sbom, &config);
1509
1510 assert_eq!(graph.get_depth(&ids[0]), Some(1));
1512 assert_eq!(graph.get_depth(&ids[499]), Some(500));
1513 }
1514
1515 #[test]
1516 fn test_empty_vs_nonempty_graph() {
1517 let a = make_component("a");
1519 let b = make_component("b");
1520 let a_id = a.canonical_id.clone();
1521 let b_id = b.canonical_id.clone();
1522
1523 let old_sbom = make_sbom(vec![a.clone(), b.clone()], vec![]);
1524 let new_sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1525
1526 let mut matches = HashMap::new();
1527 matches.insert(a_id.clone(), Some(a_id));
1528 matches.insert(b_id.clone(), Some(b_id));
1529
1530 let config = GraphDiffConfig::default();
1531 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1532
1533 assert!(
1534 summary.dependencies_added > 0,
1535 "Should detect added dependency: {changes:?}"
1536 );
1537 assert_eq!(
1538 summary.dependencies_removed, 0,
1539 "No false removes: {changes:?}"
1540 );
1541 }
1542
1543 #[test]
1544 fn test_nonempty_vs_empty_graph() {
1545 let a = make_component("a");
1547 let b = make_component("b");
1548 let a_id = a.canonical_id.clone();
1549 let b_id = b.canonical_id.clone();
1550
1551 let old_sbom = make_sbom(
1552 vec![a.clone(), b.clone()],
1553 vec![(a_id.clone(), b_id.clone())],
1554 );
1555 let new_sbom = make_sbom(vec![a, b], vec![]);
1556
1557 let mut matches = HashMap::new();
1558 matches.insert(a_id.clone(), Some(a_id));
1559 matches.insert(b_id.clone(), Some(b_id));
1560
1561 let config = GraphDiffConfig::default();
1562 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1563
1564 assert!(
1565 summary.dependencies_removed > 0,
1566 "Should detect removed dependency: {changes:?}"
1567 );
1568 assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1569 }
1570
1571 #[test]
1572 fn test_relation_filter() {
1573 let a = make_component("a");
1576 let b = make_component("b");
1577 let c = make_component("c");
1578 let a_id = a.canonical_id.clone();
1579 let b_id = b.canonical_id.clone();
1580 let c_id = c.canonical_id.clone();
1581
1582 let mut sbom = NormalizedSbom::default();
1583 sbom.add_component(a);
1584 sbom.add_component(b);
1585 sbom.add_component(c);
1586 sbom.add_edge(DependencyEdge::new(
1587 a_id.clone(),
1588 b_id.clone(),
1589 DependencyType::DependsOn,
1590 ));
1591 sbom.add_edge(DependencyEdge::new(
1592 a_id.clone(),
1593 c_id.clone(),
1594 DependencyType::DevDependsOn,
1595 ));
1596
1597 let config = GraphDiffConfig {
1598 relation_filter: vec!["depends-on".to_string()],
1599 ..Default::default()
1600 };
1601 let graph = DependencyGraph::from_sbom(&sbom, &config);
1602
1603 let children = graph.get_children(&a_id);
1604 assert!(
1605 children.contains(&b_id),
1606 "DependsOn edge should be included"
1607 );
1608 assert!(
1609 !children.contains(&c_id),
1610 "DevDependsOn edge should be excluded by filter"
1611 );
1612 }
1613}