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 if old_a != new_a {
312 let dep_name = new_graph.get_component_name(child_id);
313 changes.push(DependencyGraphChange {
314 component_id: new_id.clone(),
315 component_name: component_name.clone(),
316 change: DependencyChangeType::RelationshipChanged {
317 dependency_id: child_id.clone(),
318 dependency_name: dep_name,
319 old_relationship: old_a.relationship.to_string(),
320 new_relationship: new_a.relationship.to_string(),
321 old_scope: old_a.scope.as_ref().map(ToString::to_string),
322 new_scope: new_a.scope.as_ref().map(ToString::to_string),
323 },
324 impact: GraphChangeImpact::Medium,
325 });
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 vex_status: None,
983 });
984
985 let a_id = a.canonical_id.clone();
986 let v_id = vuln_comp.canonical_id.clone();
987
988 let old_sbom = make_sbom(vec![a.clone()], vec![]);
989 let new_sbom = make_sbom(
990 vec![a.clone(), vuln_comp],
991 vec![(a_id.clone(), v_id.clone())],
992 );
993
994 let mut matches = HashMap::new();
995 matches.insert(a_id.clone(), Some(a_id));
996
997 let config = GraphDiffConfig::default();
998 let (changes, _) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
999
1000 let critical = changes
1001 .iter()
1002 .any(|c| c.impact == GraphChangeImpact::Critical);
1003 assert!(
1004 critical,
1005 "Vulnerable direct dep should be critical impact: {changes:?}"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_empty_sboms_no_changes() {
1011 let old_sbom = NormalizedSbom::default();
1012 let new_sbom = NormalizedSbom::default();
1013 let matches = HashMap::new();
1014 let config = GraphDiffConfig::default();
1015
1016 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1017 assert!(changes.is_empty());
1018 assert_eq!(summary.total_changes, 0);
1019 }
1020
1021 #[test]
1022 fn test_identical_graphs_no_changes() {
1023 let a = make_component("a");
1024 let b = make_component("b");
1025 let a_id = a.canonical_id.clone();
1026 let b_id = b.canonical_id.clone();
1027
1028 let sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1029
1030 let mut matches = HashMap::new();
1031 matches.insert(a_id.clone(), Some(a_id));
1032 matches.insert(b_id.clone(), Some(b_id));
1033
1034 let config = GraphDiffConfig::default();
1035 let (changes, summary) = diff_dependency_graph(&sbom, &sbom, &matches, &config);
1036
1037 assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1039 assert_eq!(
1040 summary.dependencies_removed, 0,
1041 "No false removes: {changes:?}"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_removed_child_not_false_positive() {
1047 let a = make_component("a");
1051 let b_v1 = make_component_v("b", "1.0");
1052 let b_v2 = make_component_v("b", "2.0");
1053
1054 let a_id = a.canonical_id.clone();
1055 let b_v1_id = b_v1.canonical_id.clone();
1056 let b_v2_id = b_v2.canonical_id.clone();
1057
1058 let old_sbom = make_sbom(vec![a.clone(), b_v1], vec![(a_id.clone(), b_v1_id.clone())]);
1059 let new_sbom = make_sbom(vec![a.clone(), b_v2], vec![(a_id.clone(), b_v2_id.clone())]);
1060
1061 let mut matches = HashMap::new();
1062 matches.insert(a_id.clone(), Some(a_id));
1063 matches.insert(b_v1_id, Some(b_v2_id));
1064
1065 let config = GraphDiffConfig::default();
1066 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1067
1068 assert_eq!(
1069 summary.dependencies_added, 0,
1070 "Version bump should not be false add: {changes:?}"
1071 );
1072 assert_eq!(
1073 summary.dependencies_removed, 0,
1074 "Version bump should not be false remove: {changes:?}"
1075 );
1076 }
1077
1078 #[test]
1079 fn test_unmatched_old_child_excluded_from_comparison() {
1080 let a = make_component("a");
1083 let b = make_component("b");
1084 let c = make_component("c");
1085
1086 let a_id = a.canonical_id.clone();
1087 let b_id = b.canonical_id.clone();
1088 let c_id = c.canonical_id.clone();
1089
1090 let old_sbom = make_sbom(
1091 vec![a.clone(), b.clone(), c],
1092 vec![(a_id.clone(), b_id.clone()), (a_id.clone(), c_id.clone())],
1093 );
1094 let new_sbom = make_sbom(
1095 vec![a.clone(), b.clone()],
1096 vec![(a_id.clone(), b_id.clone())],
1097 );
1098
1099 let mut matches = HashMap::new();
1100 matches.insert(a_id.clone(), Some(a_id));
1101 matches.insert(b_id.clone(), Some(b_id));
1102 matches.insert(c_id, None); let config = GraphDiffConfig::default();
1105 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1106
1107 assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1113 }
1114
1115 #[test]
1116 fn test_reparenting_with_removed_parent() {
1117 let p1 = make_component("p1");
1120 let p2 = make_component("p2");
1121 let child = make_component("child");
1122
1123 let p1_id = p1.canonical_id.clone();
1124 let p2_id = p2.canonical_id.clone();
1125 let child_id = child.canonical_id.clone();
1126
1127 let old_sbom = make_sbom(
1128 vec![p1.clone(), p2, child.clone()],
1129 vec![
1130 (p1_id.clone(), child_id.clone()),
1131 (p2_id.clone(), child_id.clone()),
1132 ],
1133 );
1134 let new_sbom = make_sbom(
1135 vec![p1.clone(), child.clone()],
1136 vec![(p1_id.clone(), child_id.clone())],
1137 );
1138
1139 let mut matches = HashMap::new();
1140 matches.insert(p1_id.clone(), Some(p1_id));
1141 matches.insert(p2_id, None); matches.insert(child_id.clone(), Some(child_id));
1143
1144 let config = GraphDiffConfig::default();
1145 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1146
1147 assert_eq!(
1148 summary.reparented, 0,
1149 "Removed parent should not trigger reparenting: {changes:?}"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_relationship_change_detected() {
1155 let a = make_component("a");
1158 let b = make_component("b");
1159 let a_id = a.canonical_id.clone();
1160 let b_id = b.canonical_id.clone();
1161
1162 let old_sbom = make_sbom_with_rel(
1163 vec![a.clone(), b.clone()],
1164 vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1165 );
1166 let new_sbom = make_sbom_with_rel(
1167 vec![a, b],
1168 vec![(a_id.clone(), b_id.clone(), DependencyType::DevDependsOn)],
1169 );
1170
1171 let mut matches = HashMap::new();
1172 matches.insert(a_id.clone(), Some(a_id));
1173 matches.insert(b_id.clone(), Some(b_id));
1174
1175 let config = GraphDiffConfig::default();
1176 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1177
1178 assert!(
1179 summary.relationship_changed > 0,
1180 "Should detect relationship change: {changes:?}"
1181 );
1182 assert_eq!(
1184 summary.dependencies_added, 0,
1185 "Relationship change is not an add: {changes:?}"
1186 );
1187 assert_eq!(
1188 summary.dependencies_removed, 0,
1189 "Relationship change is not a remove: {changes:?}"
1190 );
1191 }
1192
1193 #[test]
1194 fn test_scope_change_detected() {
1195 use crate::model::DependencyScope;
1198
1199 let a = make_component("a");
1200 let b = make_component("b");
1201 let a_id = a.canonical_id.clone();
1202 let b_id = b.canonical_id.clone();
1203
1204 let mut old_sbom = NormalizedSbom::default();
1205 old_sbom.add_component(a.clone());
1206 old_sbom.add_component(b.clone());
1207 old_sbom.add_edge(
1208 DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1209 .with_scope(DependencyScope::Required),
1210 );
1211
1212 let mut new_sbom = NormalizedSbom::default();
1213 new_sbom.add_component(a);
1214 new_sbom.add_component(b);
1215 new_sbom.add_edge(
1216 DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1217 .with_scope(DependencyScope::Optional),
1218 );
1219
1220 let mut matches = HashMap::new();
1221 matches.insert(a_id.clone(), Some(a_id));
1222 matches.insert(b_id.clone(), Some(b_id));
1223
1224 let config = GraphDiffConfig::default();
1225 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1226
1227 assert!(
1228 summary.relationship_changed > 0,
1229 "Should detect scope change: {changes:?}"
1230 );
1231 }
1232
1233 #[test]
1234 fn test_reparenting_does_not_suppress_unrelated_add() {
1235 let p1 = make_component("p1");
1240 let p2 = make_component("p2");
1241 let p3 = make_component("p3");
1242 let child = make_component("child");
1243
1244 let p1_id = p1.canonical_id.clone();
1245 let p2_id = p2.canonical_id.clone();
1246 let p3_id = p3.canonical_id.clone();
1247 let child_id = child.canonical_id.clone();
1248
1249 let old_sbom = make_sbom(
1250 vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1251 vec![(p1_id.clone(), child_id.clone())],
1252 );
1253 let new_sbom = make_sbom(
1254 vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1255 vec![
1256 (p2_id.clone(), child_id.clone()),
1257 (p3_id.clone(), child_id.clone()),
1258 ],
1259 );
1260
1261 let mut matches = HashMap::new();
1262 matches.insert(p1_id.clone(), Some(p1_id));
1263 matches.insert(p2_id.clone(), Some(p2_id.clone()));
1264 matches.insert(p3_id.clone(), Some(p3_id.clone()));
1265 matches.insert(child_id.clone(), Some(child_id.clone()));
1266
1267 let config = GraphDiffConfig::default();
1268 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1269
1270 assert!(
1272 summary.reparented > 0,
1273 "Should detect reparenting: {changes:?}"
1274 );
1275
1276 let reparent = changes
1279 .iter()
1280 .find(|c| matches!(&c.change, DependencyChangeType::Reparented { .. }))
1281 .expect("Should have a reparent entry");
1282 let reparent_new_parent = match &reparent.change {
1283 DependencyChangeType::Reparented { new_parent_id, .. } => new_parent_id.clone(),
1284 _ => unreachable!(),
1285 };
1286 let other_parent = if reparent_new_parent == p2_id {
1287 &p3_id
1288 } else {
1289 &p2_id
1290 };
1291
1292 let other_added = changes.iter().any(|c| {
1293 c.component_id == *other_parent
1294 && matches!(
1295 &c.change,
1296 DependencyChangeType::DependencyAdded { dependency_id, .. }
1297 if *dependency_id == child_id
1298 )
1299 });
1300 assert!(
1301 other_added,
1302 "The non-reparented parent's add should not be suppressed: {changes:?}"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_root_promotion_not_skipped() {
1308 let p1 = make_component("p1");
1313 let child = make_component("child");
1314
1315 let p1_id = p1.canonical_id.clone();
1316 let child_id = child.canonical_id.clone();
1317
1318 let old_sbom = make_sbom(
1319 vec![p1.clone(), child.clone()],
1320 vec![(p1_id.clone(), child_id.clone())],
1321 );
1322 let new_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1323
1324 let mut matches = HashMap::new();
1325 matches.insert(p1_id.clone(), Some(p1_id.clone()));
1326 matches.insert(child_id.clone(), Some(child_id));
1327
1328 let config = GraphDiffConfig::default();
1329 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1330
1331 assert!(
1333 summary.dependencies_removed > 0,
1334 "Root promotion: dependency removal should be detected: {changes:?}"
1335 );
1336 assert_eq!(
1338 summary.reparented, 0,
1339 "Root promotion is not reparenting: {changes:?}"
1340 );
1341 }
1342
1343 #[test]
1344 fn test_root_demotion_not_skipped() {
1345 let p1 = make_component("p1");
1349 let child = make_component("child");
1350
1351 let p1_id = p1.canonical_id.clone();
1352 let child_id = child.canonical_id.clone();
1353
1354 let old_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1355 let new_sbom = make_sbom(
1356 vec![p1.clone(), child.clone()],
1357 vec![(p1_id.clone(), child_id.clone())],
1358 );
1359
1360 let mut matches = HashMap::new();
1361 matches.insert(p1_id.clone(), Some(p1_id.clone()));
1362 matches.insert(child_id.clone(), Some(child_id));
1363
1364 let config = GraphDiffConfig::default();
1365 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1366
1367 assert!(
1369 summary.dependencies_added > 0,
1370 "Root demotion: dependency addition should be detected: {changes:?}"
1371 );
1372 assert_eq!(
1374 summary.reparented, 0,
1375 "Root demotion is not reparenting: {changes:?}"
1376 );
1377 }
1378
1379 #[test]
1380 fn test_parent_added_multi_parent_not_reparenting() {
1381 let p1 = make_component("p1");
1384 let p2 = make_component("p2");
1385 let child = make_component("child");
1386
1387 let p1_id = p1.canonical_id.clone();
1388 let p2_id = p2.canonical_id.clone();
1389 let child_id = child.canonical_id.clone();
1390
1391 let old_sbom = make_sbom(
1392 vec![p1.clone(), p2.clone(), child.clone()],
1393 vec![(p1_id.clone(), child_id.clone())],
1394 );
1395 let new_sbom = make_sbom(
1396 vec![p1.clone(), p2.clone(), child.clone()],
1397 vec![
1398 (p1_id.clone(), child_id.clone()),
1399 (p2_id.clone(), child_id.clone()),
1400 ],
1401 );
1402
1403 let mut matches = HashMap::new();
1404 matches.insert(p1_id.clone(), Some(p1_id));
1405 matches.insert(p2_id.clone(), Some(p2_id));
1406 matches.insert(child_id.clone(), Some(child_id));
1407
1408 let config = GraphDiffConfig::default();
1409 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1410
1411 assert_eq!(
1412 summary.reparented, 0,
1413 "Adding a new parent while keeping old is not reparenting: {changes:?}"
1414 );
1415 assert!(
1417 summary.dependencies_added > 0,
1418 "P2→C should be detected as added: {changes:?}"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_same_relationship_no_change() {
1424 let a = make_component("a");
1427 let b = make_component("b");
1428 let a_id = a.canonical_id.clone();
1429 let b_id = b.canonical_id.clone();
1430
1431 let old_sbom = make_sbom_with_rel(
1432 vec![a.clone(), b.clone()],
1433 vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1434 );
1435 let new_sbom = make_sbom_with_rel(
1436 vec![a, b],
1437 vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1438 );
1439
1440 let mut matches = HashMap::new();
1441 matches.insert(a_id.clone(), Some(a_id));
1442 matches.insert(b_id.clone(), Some(b_id));
1443
1444 let config = GraphDiffConfig::default();
1445 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1446
1447 assert_eq!(
1448 summary.relationship_changed, 0,
1449 "Same relationship should not be a change: {changes:?}"
1450 );
1451 }
1452
1453 #[test]
1454 fn test_duplicate_edges_different_types() {
1455 let a = make_component("a");
1458 let b = make_component("b");
1459 let a_id = a.canonical_id.clone();
1460 let b_id = b.canonical_id.clone();
1461
1462 let mut sbom = NormalizedSbom::default();
1463 sbom.add_component(a);
1464 sbom.add_component(b);
1465 sbom.add_edge(DependencyEdge::new(
1466 a_id.clone(),
1467 b_id.clone(),
1468 DependencyType::DependsOn,
1469 ));
1470 sbom.add_edge(DependencyEdge::new(
1471 a_id.clone(),
1472 b_id.clone(),
1473 DependencyType::DevDependsOn,
1474 ));
1475
1476 let config = GraphDiffConfig::default();
1477 let graph = DependencyGraph::from_sbom(&sbom, &config);
1478
1479 let children = graph.get_children(&a_id);
1481 assert!(children.contains(&b_id), "B should be a child of A");
1482
1483 let attrs = graph.get_edge_attrs(&a_id, &b_id);
1485 assert!(attrs.is_some(), "Should have edge attrs for A→B");
1486 }
1487
1488 #[test]
1489 fn test_large_graph_completes() {
1490 let mut components = Vec::new();
1492 let mut edges = Vec::new();
1493 let mut ids = Vec::new();
1494
1495 for i in 0..500 {
1496 let comp = make_component(&format!("node-{i}"));
1497 ids.push(comp.canonical_id.clone());
1498 components.push(comp);
1499 }
1500 for i in 0..499 {
1501 edges.push((ids[i].clone(), ids[i + 1].clone()));
1502 }
1503
1504 let sbom = make_sbom(components, edges);
1505 let config = GraphDiffConfig::default();
1506 let graph = DependencyGraph::from_sbom(&sbom, &config);
1507
1508 assert_eq!(graph.get_depth(&ids[0]), Some(1));
1510 assert_eq!(graph.get_depth(&ids[499]), Some(500));
1511 }
1512
1513 #[test]
1514 fn test_empty_vs_nonempty_graph() {
1515 let a = make_component("a");
1517 let b = make_component("b");
1518 let a_id = a.canonical_id.clone();
1519 let b_id = b.canonical_id.clone();
1520
1521 let old_sbom = make_sbom(vec![a.clone(), b.clone()], vec![]);
1522 let new_sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1523
1524 let mut matches = HashMap::new();
1525 matches.insert(a_id.clone(), Some(a_id));
1526 matches.insert(b_id.clone(), Some(b_id));
1527
1528 let config = GraphDiffConfig::default();
1529 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1530
1531 assert!(
1532 summary.dependencies_added > 0,
1533 "Should detect added dependency: {changes:?}"
1534 );
1535 assert_eq!(
1536 summary.dependencies_removed, 0,
1537 "No false removes: {changes:?}"
1538 );
1539 }
1540
1541 #[test]
1542 fn test_nonempty_vs_empty_graph() {
1543 let a = make_component("a");
1545 let b = make_component("b");
1546 let a_id = a.canonical_id.clone();
1547 let b_id = b.canonical_id.clone();
1548
1549 let old_sbom = make_sbom(
1550 vec![a.clone(), b.clone()],
1551 vec![(a_id.clone(), b_id.clone())],
1552 );
1553 let new_sbom = make_sbom(vec![a, b], vec![]);
1554
1555 let mut matches = HashMap::new();
1556 matches.insert(a_id.clone(), Some(a_id));
1557 matches.insert(b_id.clone(), Some(b_id));
1558
1559 let config = GraphDiffConfig::default();
1560 let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1561
1562 assert!(
1563 summary.dependencies_removed > 0,
1564 "Should detect removed dependency: {changes:?}"
1565 );
1566 assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1567 }
1568
1569 #[test]
1570 fn test_relation_filter() {
1571 let a = make_component("a");
1574 let b = make_component("b");
1575 let c = make_component("c");
1576 let a_id = a.canonical_id.clone();
1577 let b_id = b.canonical_id.clone();
1578 let c_id = c.canonical_id.clone();
1579
1580 let mut sbom = NormalizedSbom::default();
1581 sbom.add_component(a);
1582 sbom.add_component(b);
1583 sbom.add_component(c);
1584 sbom.add_edge(DependencyEdge::new(
1585 a_id.clone(),
1586 b_id.clone(),
1587 DependencyType::DependsOn,
1588 ));
1589 sbom.add_edge(DependencyEdge::new(
1590 a_id.clone(),
1591 c_id.clone(),
1592 DependencyType::DevDependsOn,
1593 ));
1594
1595 let config = GraphDiffConfig {
1596 relation_filter: vec!["depends-on".to_string()],
1597 ..Default::default()
1598 };
1599 let graph = DependencyGraph::from_sbom(&sbom, &config);
1600
1601 let children = graph.get_children(&a_id);
1602 assert!(
1603 children.contains(&b_id),
1604 "DependsOn edge should be included"
1605 );
1606 assert!(
1607 !children.contains(&c_id),
1608 "DevDependsOn edge should be excluded by filter"
1609 );
1610 }
1611}