use std::collections::{HashMap, HashSet, VecDeque};
use crate::model::{CanonicalId, DependencyScope, DependencyType, NormalizedSbom};
use super::result::{
DependencyChangeType, DependencyGraphChange, GraphChangeImpact, GraphChangeSummary,
};
const CYCLIC_SENTINEL_DEPTH: u32 = u32::MAX;
#[derive(Debug, Clone)]
pub struct GraphDiffConfig {
pub detect_reparenting: bool,
pub detect_depth_changes: bool,
pub max_depth: u32,
pub relation_filter: Vec<String>,
}
impl Default for GraphDiffConfig {
fn default() -> Self {
Self {
detect_reparenting: true,
detect_depth_changes: true,
max_depth: 0,
relation_filter: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct EdgeAttrs {
relationship: DependencyType,
scope: Option<DependencyScope>,
}
struct DependencyGraph<'a> {
sbom: &'a NormalizedSbom,
edges: HashMap<CanonicalId, Vec<CanonicalId>>,
reverse_edges: HashMap<CanonicalId, Vec<CanonicalId>>,
edge_attrs: HashMap<(CanonicalId, CanonicalId), EdgeAttrs>,
depths: HashMap<CanonicalId, u32>,
vulnerable_components: HashSet<CanonicalId>,
}
impl<'a> DependencyGraph<'a> {
fn from_sbom(sbom: &'a NormalizedSbom, config: &GraphDiffConfig) -> Self {
let mut edges: HashMap<CanonicalId, Vec<CanonicalId>> = HashMap::new();
let mut reverse_edges: HashMap<CanonicalId, Vec<CanonicalId>> = HashMap::new();
let mut edge_attrs: HashMap<(CanonicalId, CanonicalId), EdgeAttrs> = HashMap::new();
let mut vulnerable_components = HashSet::new();
for edge in &sbom.edges {
if !config.relation_filter.is_empty()
&& !config
.relation_filter
.iter()
.any(|f| f.eq_ignore_ascii_case(&edge.relationship.to_string()))
{
continue; }
edges
.entry(edge.from.clone())
.or_default()
.push(edge.to.clone());
reverse_edges
.entry(edge.to.clone())
.or_default()
.push(edge.from.clone());
edge_attrs.insert(
(edge.from.clone(), edge.to.clone()),
EdgeAttrs {
relationship: edge.relationship.clone(),
scope: edge.scope.clone(),
},
);
}
for (id, comp) in &sbom.components {
if !comp.vulnerabilities.is_empty() {
vulnerable_components.insert(id.clone());
}
}
let all_components: HashSet<_> = sbom.components.keys().cloned().collect();
let depths =
Self::calculate_depths(&edges, &reverse_edges, &all_components, config.max_depth);
Self {
sbom,
edges,
reverse_edges,
edge_attrs,
depths,
vulnerable_components,
}
}
fn calculate_depths(
edges: &HashMap<CanonicalId, Vec<CanonicalId>>,
reverse_edges: &HashMap<CanonicalId, Vec<CanonicalId>>,
all_components: &HashSet<CanonicalId>,
max_depth: u32,
) -> HashMap<CanonicalId, u32> {
let mut depths = HashMap::new();
let mut queue: VecDeque<(CanonicalId, u32)> = all_components
.iter()
.filter(|id| reverse_edges.get(*id).is_none_or(std::vec::Vec::is_empty))
.cloned()
.map(|id| (id, 1))
.collect();
while let Some((id, depth)) = queue.pop_front() {
if let Some(&existing_depth) = depths.get(&id)
&& depth >= existing_depth
{
continue; }
depths.insert(id.clone(), depth);
if max_depth > 0 && depth >= max_depth {
continue;
}
if let Some(children) = edges.get(&id) {
for child_id in children {
let child_depth = depth + 1;
let dominated = depths.get(child_id).is_some_and(|&d| d <= child_depth);
if !dominated {
queue.push_back((child_id.clone(), child_depth));
}
}
}
}
for id in all_components {
depths.entry(id.clone()).or_insert(CYCLIC_SENTINEL_DEPTH);
}
depths
}
fn get_parents(&self, component_id: &CanonicalId) -> Vec<CanonicalId> {
self.reverse_edges
.get(component_id)
.cloned()
.unwrap_or_default()
}
fn get_children(&self, component_id: &CanonicalId) -> Vec<CanonicalId> {
self.edges.get(component_id).cloned().unwrap_or_default()
}
fn get_edge_attrs(&self, from: &CanonicalId, to: &CanonicalId) -> Option<&EdgeAttrs> {
self.edge_attrs.get(&(from.clone(), to.clone()))
}
fn get_depth(&self, component_id: &CanonicalId) -> Option<u32> {
self.depths.get(component_id).copied()
}
fn is_vulnerable(&self, component_id: &CanonicalId) -> bool {
self.vulnerable_components.contains(component_id)
}
fn get_component_name(&self, component_id: &CanonicalId) -> String {
self.sbom.components.get(component_id).map_or_else(
|| component_id.to_string(),
|c| {
c.version
.as_ref()
.map_or_else(|| c.name.clone(), |v| format!("{}@{}", c.name, v))
},
)
}
}
#[allow(clippy::implicit_hasher)]
#[must_use]
pub fn diff_dependency_graph(
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
component_matches: &HashMap<CanonicalId, Option<CanonicalId>>,
config: &GraphDiffConfig,
) -> (Vec<DependencyGraphChange>, GraphChangeSummary) {
let old_graph = DependencyGraph::from_sbom(old_sbom, config);
let new_graph = DependencyGraph::from_sbom(new_sbom, config);
let mut changes = Vec::new();
for (old_id, new_id_option) in component_matches {
if let Some(new_id) = new_id_option {
let component_name = new_graph.get_component_name(new_id);
let old_children_mapped: HashSet<CanonicalId> = old_graph
.get_children(old_id)
.into_iter()
.filter_map(|old_child| {
component_matches
.get(&old_child)
.and_then(|opt| opt.clone())
})
.collect();
let new_children: HashSet<_> = new_graph.get_children(new_id).into_iter().collect();
let old_child_to_new: HashMap<CanonicalId, CanonicalId> = old_graph
.get_children(old_id)
.into_iter()
.filter_map(|old_child| {
component_matches
.get(&old_child)
.and_then(|opt| opt.clone())
.map(|new_child_id| (new_child_id, old_child))
})
.collect();
for child_id in new_children.difference(&old_children_mapped) {
let dep_name = new_graph.get_component_name(child_id);
let impact = assess_impact_added(&new_graph, child_id);
changes.push(DependencyGraphChange {
component_id: new_id.clone(),
component_name: component_name.clone(),
change: DependencyChangeType::DependencyAdded {
dependency_id: child_id.clone(),
dependency_name: dep_name,
},
impact,
});
}
for child_id in old_children_mapped.difference(&new_children) {
let dep_name = new_graph.get_component_name(child_id);
changes.push(DependencyGraphChange {
component_id: new_id.clone(),
component_name: component_name.clone(),
change: DependencyChangeType::DependencyRemoved {
dependency_id: child_id.clone(),
dependency_name: dep_name,
},
impact: GraphChangeImpact::Low,
});
}
for child_id in old_children_mapped.intersection(&new_children) {
let old_attrs = old_child_to_new
.get(child_id)
.and_then(|old_child_id| old_graph.get_edge_attrs(old_id, old_child_id));
let new_attrs = new_graph.get_edge_attrs(new_id, child_id);
if let (Some(old_a), Some(new_a)) = (old_attrs, new_attrs)
&& old_a != new_a
{
let dep_name = new_graph.get_component_name(child_id);
changes.push(DependencyGraphChange {
component_id: new_id.clone(),
component_name: component_name.clone(),
change: DependencyChangeType::RelationshipChanged {
dependency_id: child_id.clone(),
dependency_name: dep_name,
old_relationship: old_a.relationship.to_string(),
new_relationship: new_a.relationship.to_string(),
old_scope: old_a.scope.as_ref().map(ToString::to_string),
new_scope: new_a.scope.as_ref().map(ToString::to_string),
},
impact: GraphChangeImpact::Medium,
});
}
}
}
}
if config.detect_depth_changes {
detect_depth_changes(&old_graph, &new_graph, component_matches, &mut changes);
}
if config.detect_reparenting {
detect_reparenting(&old_graph, &new_graph, component_matches, &mut changes);
}
changes.sort_by(|a, b| {
let impact_order = |i: &GraphChangeImpact| match i {
GraphChangeImpact::Critical => 0,
GraphChangeImpact::High => 1,
GraphChangeImpact::Medium => 2,
GraphChangeImpact::Low => 3,
};
impact_order(&a.impact).cmp(&impact_order(&b.impact))
});
let summary = GraphChangeSummary::from_changes(&changes);
(changes, summary)
}
fn assess_impact_added(graph: &DependencyGraph, component_id: &CanonicalId) -> GraphChangeImpact {
let depth = graph
.get_depth(component_id)
.unwrap_or(CYCLIC_SENTINEL_DEPTH);
let is_direct = depth > 0 && depth <= 2 && depth != CYCLIC_SENTINEL_DEPTH;
if graph.is_vulnerable(component_id) {
if is_direct {
GraphChangeImpact::Critical
} else {
GraphChangeImpact::High
}
} else if is_direct {
GraphChangeImpact::Medium
} else {
GraphChangeImpact::Low
}
}
fn detect_depth_changes(
old_graph: &DependencyGraph,
new_graph: &DependencyGraph,
matches: &HashMap<CanonicalId, Option<CanonicalId>>,
changes: &mut Vec<DependencyGraphChange>,
) {
for (old_id, new_id_opt) in matches {
if let Some(new_id) = new_id_opt {
let old_depth = old_graph.get_depth(old_id);
let new_depth = new_graph.get_depth(new_id);
if let (Some(od), Some(nd)) = (old_depth, new_depth)
&& od != nd
{
if od == CYCLIC_SENTINEL_DEPTH && nd == CYCLIC_SENTINEL_DEPTH {
continue;
}
let component_name = new_graph.get_component_name(new_id);
let impact =
if nd < od && nd != CYCLIC_SENTINEL_DEPTH && new_graph.is_vulnerable(new_id) {
GraphChangeImpact::High
} else if nd <= 2 && (od > 2 || od == CYCLIC_SENTINEL_DEPTH) {
GraphChangeImpact::Medium
} else {
GraphChangeImpact::Low
};
changes.push(DependencyGraphChange {
component_id: new_id.clone(),
component_name,
change: DependencyChangeType::DepthChanged {
old_depth: od,
new_depth: nd,
},
impact,
});
}
}
}
}
fn detect_reparenting(
old_graph: &DependencyGraph,
new_graph: &DependencyGraph,
matches: &HashMap<CanonicalId, Option<CanonicalId>>,
changes: &mut Vec<DependencyGraphChange>,
) {
for (old_id, new_id_opt) in matches {
if let Some(new_id) = new_id_opt {
let old_parents = old_graph.get_parents(old_id);
let new_parents = new_graph.get_parents(new_id);
if old_parents.is_empty() && new_parents.is_empty() {
continue;
}
let old_parents_mapped: HashSet<CanonicalId> = old_parents
.iter()
.filter_map(|old_parent| matches.get(old_parent).and_then(|opt| opt.clone()))
.collect();
let new_parents_set: HashSet<CanonicalId> = new_parents.into_iter().collect();
if old_parents_mapped == new_parents_set {
continue;
}
let removed_parents: Vec<_> = old_parents_mapped.difference(&new_parents_set).collect();
let added_parents: Vec<_> = new_parents_set.difference(&old_parents_mapped).collect();
if removed_parents.is_empty() || added_parents.is_empty() {
continue;
}
let old_parent = removed_parents[0];
let new_parent = added_parents[0];
let component_name = new_graph.get_component_name(new_id);
let old_parent_name = new_graph.get_component_name(old_parent);
let new_parent_name = new_graph.get_component_name(new_parent);
changes.retain(|c| match &c.change {
DependencyChangeType::DependencyAdded { dependency_id, .. } => {
!(dependency_id == new_id && c.component_id == *new_parent)
}
DependencyChangeType::DependencyRemoved { dependency_id, .. } => {
!(dependency_id == new_id && c.component_id == *old_parent)
}
_ => true,
});
changes.push(DependencyGraphChange {
component_id: new_id.clone(),
component_name,
change: DependencyChangeType::Reparented {
dependency_id: new_id.clone(),
dependency_name: new_graph.get_component_name(new_id),
old_parent_id: old_parent.clone(),
old_parent_name,
new_parent_id: new_parent.clone(),
new_parent_name,
},
impact: GraphChangeImpact::Medium,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
Component, DependencyEdge, DependencyType, NormalizedSbom, VulnerabilityRef,
VulnerabilitySource,
};
fn make_component(name: &str) -> Component {
Component::new(name.to_string(), name.to_string())
}
fn make_component_v(name: &str, version: &str) -> Component {
Component::new(name.to_string(), format!("{name}@{version}"))
.with_version(version.to_string())
}
fn make_sbom(
components: Vec<Component>,
edges: Vec<(CanonicalId, CanonicalId)>,
) -> NormalizedSbom {
let mut sbom = NormalizedSbom::default();
for comp in components {
sbom.add_component(comp);
}
for (from, to) in edges {
sbom.add_edge(DependencyEdge::new(from, to, DependencyType::DependsOn));
}
sbom
}
fn make_sbom_with_rel(
components: Vec<Component>,
edges: Vec<(CanonicalId, CanonicalId, DependencyType)>,
) -> NormalizedSbom {
let mut sbom = NormalizedSbom::default();
for comp in components {
sbom.add_component(comp);
}
for (from, to, rel) in edges {
sbom.add_edge(DependencyEdge::new(from, to, rel));
}
sbom
}
#[test]
fn test_graph_diff_config_default() {
let config = GraphDiffConfig::default();
assert!(config.detect_reparenting);
assert!(config.detect_depth_changes);
assert_eq!(config.max_depth, 0);
}
#[test]
fn test_graph_change_impact_display() {
assert_eq!(GraphChangeImpact::Critical.as_str(), "critical");
assert_eq!(GraphChangeImpact::High.as_str(), "high");
assert_eq!(GraphChangeImpact::Medium.as_str(), "medium");
assert_eq!(GraphChangeImpact::Low.as_str(), "low");
}
#[test]
fn test_children_mapped_through_component_matches() {
let a_old = make_component("a-old");
let b_old = make_component("b-old");
let a_old_id = a_old.canonical_id.clone();
let b_old_id = b_old.canonical_id.clone();
let old_sbom = make_sbom(
vec![a_old, b_old],
vec![(a_old_id.clone(), b_old_id.clone())],
);
let a_new = make_component("a-new");
let b_new = make_component("b-new");
let a_new_id = a_new.canonical_id.clone();
let b_new_id = b_new.canonical_id.clone();
let new_sbom = make_sbom(
vec![a_new, b_new],
vec![(a_new_id.clone(), b_new_id.clone())],
);
let mut matches = HashMap::new();
matches.insert(a_old_id, Some(a_new_id));
matches.insert(b_old_id, Some(b_new_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert_eq!(summary.dependencies_added, 0, "No false add: {changes:?}");
assert_eq!(
summary.dependencies_removed, 0,
"No false remove: {changes:?}"
);
}
#[test]
fn test_depth_linear_chain() {
let a = make_component("a");
let b = make_component("b");
let c = make_component("c");
let d = make_component("d");
let ids: Vec<_> = [&a, &b, &c, &d]
.iter()
.map(|c| c.canonical_id.clone())
.collect();
let sbom = make_sbom(
vec![a, b, c, d],
vec![
(ids[0].clone(), ids[1].clone()),
(ids[1].clone(), ids[2].clone()),
(ids[2].clone(), ids[3].clone()),
],
);
let config = GraphDiffConfig::default();
let graph = DependencyGraph::from_sbom(&sbom, &config);
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));
}
#[test]
fn test_depth_diamond_dependency() {
let a = make_component("a");
let b = make_component("b");
let c = make_component("c");
let d = make_component("d");
let ids: Vec<_> = [&a, &b, &c, &d]
.iter()
.map(|c| c.canonical_id.clone())
.collect();
let sbom = make_sbom(
vec![a, b, c, d],
vec![
(ids[0].clone(), ids[1].clone()),
(ids[0].clone(), ids[2].clone()),
(ids[1].clone(), ids[3].clone()),
(ids[2].clone(), ids[3].clone()),
],
);
let config = GraphDiffConfig::default();
let graph = DependencyGraph::from_sbom(&sbom, &config);
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(2));
assert_eq!(graph.get_depth(&ids[3]), Some(3)); }
#[test]
fn test_depth_rootless_cycle() {
let a = make_component("a");
let b = make_component("b");
let c = make_component("c");
let ids: Vec<_> = [&a, &b, &c]
.iter()
.map(|c| c.canonical_id.clone())
.collect();
let sbom = make_sbom(
vec![a, b, c],
vec![
(ids[0].clone(), ids[1].clone()),
(ids[1].clone(), ids[2].clone()),
(ids[2].clone(), ids[0].clone()),
],
);
let config = GraphDiffConfig::default();
let graph = DependencyGraph::from_sbom(&sbom, &config);
for (i, id) in ids.iter().enumerate() {
let depth = graph.get_depth(id);
assert!(depth.is_some(), "Node {i} should have depth");
assert_eq!(
depth.unwrap(),
CYCLIC_SENTINEL_DEPTH,
"Cyclic node {i} should get sentinel depth, not 0"
);
}
}
#[test]
fn test_depth_cycle_reachable_from_root() {
let root = make_component("root");
let a = make_component("a");
let b = make_component("b");
let c = make_component("c");
let ids: Vec<_> = [&root, &a, &b, &c]
.iter()
.map(|comp| comp.canonical_id.clone())
.collect();
let sbom = make_sbom(
vec![root, a, b, c],
vec![
(ids[0].clone(), ids[1].clone()), (ids[1].clone(), ids[2].clone()), (ids[2].clone(), ids[3].clone()), (ids[3].clone(), ids[2].clone()), ],
);
let config = GraphDiffConfig::default();
let graph = DependencyGraph::from_sbom(&sbom, &config);
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)); }
#[test]
fn test_depth_disconnected_subgraphs() {
let r1 = make_component("r1");
let a = make_component("a");
let r2 = make_component("r2");
let b = make_component("b");
let c = make_component("c");
let ids: Vec<_> = [&r1, &a, &r2, &b, &c]
.iter()
.map(|comp| comp.canonical_id.clone())
.collect();
let sbom = make_sbom(
vec![r1, a, r2, b, c],
vec![
(ids[0].clone(), ids[1].clone()), (ids[2].clone(), ids[3].clone()), (ids[3].clone(), ids[4].clone()), ],
);
let config = GraphDiffConfig::default();
let graph = DependencyGraph::from_sbom(&sbom, &config);
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)); }
#[test]
fn test_self_referencing_edge_no_infinite_loop() {
let a = make_component("a");
let a_id = a.canonical_id.clone();
let sbom = make_sbom(vec![a], vec![(a_id.clone(), a_id.clone())]);
let config = GraphDiffConfig::default();
let graph = DependencyGraph::from_sbom(&sbom, &config);
let depth = graph.get_depth(&a_id);
assert!(depth.is_some(), "A should have a depth");
assert_eq!(
depth.unwrap(),
CYCLIC_SENTINEL_DEPTH,
"Self-referencing node should get sentinel depth"
);
}
#[test]
fn test_depth_max_depth_limit() {
let a = make_component("a");
let b = make_component("b");
let c = make_component("c");
let d = make_component("d");
let ids: Vec<_> = [&a, &b, &c, &d]
.iter()
.map(|c| c.canonical_id.clone())
.collect();
let sbom = make_sbom(
vec![a, b, c, d],
vec![
(ids[0].clone(), ids[1].clone()),
(ids[1].clone(), ids[2].clone()),
(ids[2].clone(), ids[3].clone()),
],
);
let config = GraphDiffConfig {
max_depth: 2,
..Default::default()
};
let graph = DependencyGraph::from_sbom(&sbom, &config);
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(CYCLIC_SENTINEL_DEPTH));
assert_eq!(graph.get_depth(&ids[3]), Some(CYCLIC_SENTINEL_DEPTH));
}
#[test]
fn test_reparenting_single_parent() {
let p1 = make_component("p1");
let p2 = make_component("p2");
let child = make_component("child");
let p1_id = p1.canonical_id.clone();
let p2_id = p2.canonical_id.clone();
let child_id = child.canonical_id.clone();
let old_sbom = make_sbom(
vec![p1.clone(), p2.clone(), child.clone()],
vec![(p1_id.clone(), child_id.clone())],
);
let new_sbom = make_sbom(
vec![p1.clone(), p2.clone(), child.clone()],
vec![(p2_id.clone(), child_id.clone())],
);
let mut matches = HashMap::new();
matches.insert(p1_id.clone(), Some(p1_id));
matches.insert(p2_id.clone(), Some(p2_id));
matches.insert(child_id.clone(), Some(child_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert!(
summary.reparented > 0,
"Should detect reparenting: {changes:?}"
);
}
#[test]
fn test_renamed_parent_is_not_reparenting() {
let p1 = make_component("p1");
let p2 = make_component("p2");
let child = make_component("child");
let p1_id = p1.canonical_id.clone();
let p2_id = p2.canonical_id.clone();
let child_id = child.canonical_id.clone();
let old_sbom = make_sbom(
vec![p1, child.clone()],
vec![(p1_id.clone(), child_id.clone())],
);
let new_sbom = make_sbom(
vec![p2, child.clone()],
vec![(p2_id.clone(), child_id.clone())],
);
let mut matches = HashMap::new();
matches.insert(p1_id, Some(p2_id));
matches.insert(child_id.clone(), Some(child_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert_eq!(
summary.reparented, 0,
"Renamed parent should not be reparenting: {changes:?}"
);
}
#[test]
fn test_reparenting_multi_parent() {
let p1 = make_component("p1");
let p2 = make_component("p2");
let p3 = make_component("p3");
let child = make_component("child");
let p1_id = p1.canonical_id.clone();
let p2_id = p2.canonical_id.clone();
let p3_id = p3.canonical_id.clone();
let child_id = child.canonical_id.clone();
let old_sbom = make_sbom(
vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
vec![
(p1_id.clone(), child_id.clone()),
(p2_id.clone(), child_id.clone()),
],
);
let new_sbom = make_sbom(
vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
vec![
(p1_id.clone(), child_id.clone()),
(p3_id.clone(), child_id.clone()),
],
);
let mut matches = HashMap::new();
matches.insert(p1_id.clone(), Some(p1_id));
matches.insert(p2_id.clone(), Some(p2_id));
matches.insert(p3_id.clone(), Some(p3_id));
matches.insert(child_id.clone(), Some(child_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert!(
summary.reparented > 0,
"Should detect multi-parent reparenting: {changes:?}"
);
}
#[test]
fn test_vulnerable_direct_dep_is_critical() {
let a = make_component("root");
let mut vuln_comp = make_component("vuln-lib");
vuln_comp.vulnerabilities.push(VulnerabilityRef {
id: "CVE-2024-0001".to_string(),
source: VulnerabilitySource::Osv,
severity: None,
cvss: vec![],
affected_versions: vec![],
remediation: None,
description: None,
cwes: vec![],
published: None,
modified: None,
is_kev: false,
kev_info: None,
vex_status: None,
});
let a_id = a.canonical_id.clone();
let v_id = vuln_comp.canonical_id.clone();
let old_sbom = make_sbom(vec![a.clone()], vec![]);
let new_sbom = make_sbom(
vec![a.clone(), vuln_comp],
vec![(a_id.clone(), v_id.clone())],
);
let mut matches = HashMap::new();
matches.insert(a_id.clone(), Some(a_id));
let config = GraphDiffConfig::default();
let (changes, _) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
let critical = changes
.iter()
.any(|c| c.impact == GraphChangeImpact::Critical);
assert!(
critical,
"Vulnerable direct dep should be critical impact: {changes:?}"
);
}
#[test]
fn test_empty_sboms_no_changes() {
let old_sbom = NormalizedSbom::default();
let new_sbom = NormalizedSbom::default();
let matches = HashMap::new();
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert!(changes.is_empty());
assert_eq!(summary.total_changes, 0);
}
#[test]
fn test_identical_graphs_no_changes() {
let a = make_component("a");
let b = make_component("b");
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
let sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
let mut matches = HashMap::new();
matches.insert(a_id.clone(), Some(a_id));
matches.insert(b_id.clone(), Some(b_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&sbom, &sbom, &matches, &config);
assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
assert_eq!(
summary.dependencies_removed, 0,
"No false removes: {changes:?}"
);
}
#[test]
fn test_removed_child_not_false_positive() {
let a = make_component("a");
let b_v1 = make_component_v("b", "1.0");
let b_v2 = make_component_v("b", "2.0");
let a_id = a.canonical_id.clone();
let b_v1_id = b_v1.canonical_id.clone();
let b_v2_id = b_v2.canonical_id.clone();
let old_sbom = make_sbom(vec![a.clone(), b_v1], vec![(a_id.clone(), b_v1_id.clone())]);
let new_sbom = make_sbom(vec![a.clone(), b_v2], vec![(a_id.clone(), b_v2_id.clone())]);
let mut matches = HashMap::new();
matches.insert(a_id.clone(), Some(a_id));
matches.insert(b_v1_id, Some(b_v2_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert_eq!(
summary.dependencies_added, 0,
"Version bump should not be false add: {changes:?}"
);
assert_eq!(
summary.dependencies_removed, 0,
"Version bump should not be false remove: {changes:?}"
);
}
#[test]
fn test_unmatched_old_child_excluded_from_comparison() {
let a = make_component("a");
let b = make_component("b");
let c = make_component("c");
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
let c_id = c.canonical_id.clone();
let old_sbom = make_sbom(
vec![a.clone(), b.clone(), c],
vec![(a_id.clone(), b_id.clone()), (a_id.clone(), c_id.clone())],
);
let new_sbom = make_sbom(
vec![a.clone(), b.clone()],
vec![(a_id.clone(), b_id.clone())],
);
let mut matches = HashMap::new();
matches.insert(a_id.clone(), Some(a_id));
matches.insert(b_id.clone(), Some(b_id));
matches.insert(c_id, None);
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
}
#[test]
fn test_reparenting_with_removed_parent() {
let p1 = make_component("p1");
let p2 = make_component("p2");
let child = make_component("child");
let p1_id = p1.canonical_id.clone();
let p2_id = p2.canonical_id.clone();
let child_id = child.canonical_id.clone();
let old_sbom = make_sbom(
vec![p1.clone(), p2, child.clone()],
vec![
(p1_id.clone(), child_id.clone()),
(p2_id.clone(), child_id.clone()),
],
);
let new_sbom = make_sbom(
vec![p1.clone(), child.clone()],
vec![(p1_id.clone(), child_id.clone())],
);
let mut matches = HashMap::new();
matches.insert(p1_id.clone(), Some(p1_id));
matches.insert(p2_id, None); matches.insert(child_id.clone(), Some(child_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert_eq!(
summary.reparented, 0,
"Removed parent should not trigger reparenting: {changes:?}"
);
}
#[test]
fn test_relationship_change_detected() {
let a = make_component("a");
let b = make_component("b");
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
let old_sbom = make_sbom_with_rel(
vec![a.clone(), b.clone()],
vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
);
let new_sbom = make_sbom_with_rel(
vec![a, b],
vec![(a_id.clone(), b_id.clone(), DependencyType::DevDependsOn)],
);
let mut matches = HashMap::new();
matches.insert(a_id.clone(), Some(a_id));
matches.insert(b_id.clone(), Some(b_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert!(
summary.relationship_changed > 0,
"Should detect relationship change: {changes:?}"
);
assert_eq!(
summary.dependencies_added, 0,
"Relationship change is not an add: {changes:?}"
);
assert_eq!(
summary.dependencies_removed, 0,
"Relationship change is not a remove: {changes:?}"
);
}
#[test]
fn test_scope_change_detected() {
use crate::model::DependencyScope;
let a = make_component("a");
let b = make_component("b");
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
let mut old_sbom = NormalizedSbom::default();
old_sbom.add_component(a.clone());
old_sbom.add_component(b.clone());
old_sbom.add_edge(
DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
.with_scope(DependencyScope::Required),
);
let mut new_sbom = NormalizedSbom::default();
new_sbom.add_component(a);
new_sbom.add_component(b);
new_sbom.add_edge(
DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
.with_scope(DependencyScope::Optional),
);
let mut matches = HashMap::new();
matches.insert(a_id.clone(), Some(a_id));
matches.insert(b_id.clone(), Some(b_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert!(
summary.relationship_changed > 0,
"Should detect scope change: {changes:?}"
);
}
#[test]
fn test_reparenting_does_not_suppress_unrelated_add() {
let p1 = make_component("p1");
let p2 = make_component("p2");
let p3 = make_component("p3");
let child = make_component("child");
let p1_id = p1.canonical_id.clone();
let p2_id = p2.canonical_id.clone();
let p3_id = p3.canonical_id.clone();
let child_id = child.canonical_id.clone();
let old_sbom = make_sbom(
vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
vec![(p1_id.clone(), child_id.clone())],
);
let new_sbom = make_sbom(
vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
vec![
(p2_id.clone(), child_id.clone()),
(p3_id.clone(), child_id.clone()),
],
);
let mut matches = HashMap::new();
matches.insert(p1_id.clone(), Some(p1_id));
matches.insert(p2_id.clone(), Some(p2_id.clone()));
matches.insert(p3_id.clone(), Some(p3_id.clone()));
matches.insert(child_id.clone(), Some(child_id.clone()));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert!(
summary.reparented > 0,
"Should detect reparenting: {changes:?}"
);
let reparent = changes
.iter()
.find(|c| matches!(&c.change, DependencyChangeType::Reparented { .. }))
.expect("Should have a reparent entry");
let reparent_new_parent = match &reparent.change {
DependencyChangeType::Reparented { new_parent_id, .. } => new_parent_id.clone(),
_ => unreachable!(),
};
let other_parent = if reparent_new_parent == p2_id {
&p3_id
} else {
&p2_id
};
let other_added = changes.iter().any(|c| {
c.component_id == *other_parent
&& matches!(
&c.change,
DependencyChangeType::DependencyAdded { dependency_id, .. }
if *dependency_id == child_id
)
});
assert!(
other_added,
"The non-reparented parent's add should not be suppressed: {changes:?}"
);
}
#[test]
fn test_root_promotion_not_skipped() {
let p1 = make_component("p1");
let child = make_component("child");
let p1_id = p1.canonical_id.clone();
let child_id = child.canonical_id.clone();
let old_sbom = make_sbom(
vec![p1.clone(), child.clone()],
vec![(p1_id.clone(), child_id.clone())],
);
let new_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
let mut matches = HashMap::new();
matches.insert(p1_id.clone(), Some(p1_id.clone()));
matches.insert(child_id.clone(), Some(child_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert!(
summary.dependencies_removed > 0,
"Root promotion: dependency removal should be detected: {changes:?}"
);
assert_eq!(
summary.reparented, 0,
"Root promotion is not reparenting: {changes:?}"
);
}
#[test]
fn test_root_demotion_not_skipped() {
let p1 = make_component("p1");
let child = make_component("child");
let p1_id = p1.canonical_id.clone();
let child_id = child.canonical_id.clone();
let old_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
let new_sbom = make_sbom(
vec![p1.clone(), child.clone()],
vec![(p1_id.clone(), child_id.clone())],
);
let mut matches = HashMap::new();
matches.insert(p1_id.clone(), Some(p1_id.clone()));
matches.insert(child_id.clone(), Some(child_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert!(
summary.dependencies_added > 0,
"Root demotion: dependency addition should be detected: {changes:?}"
);
assert_eq!(
summary.reparented, 0,
"Root demotion is not reparenting: {changes:?}"
);
}
#[test]
fn test_parent_added_multi_parent_not_reparenting() {
let p1 = make_component("p1");
let p2 = make_component("p2");
let child = make_component("child");
let p1_id = p1.canonical_id.clone();
let p2_id = p2.canonical_id.clone();
let child_id = child.canonical_id.clone();
let old_sbom = make_sbom(
vec![p1.clone(), p2.clone(), child.clone()],
vec![(p1_id.clone(), child_id.clone())],
);
let new_sbom = make_sbom(
vec![p1.clone(), p2.clone(), child.clone()],
vec![
(p1_id.clone(), child_id.clone()),
(p2_id.clone(), child_id.clone()),
],
);
let mut matches = HashMap::new();
matches.insert(p1_id.clone(), Some(p1_id));
matches.insert(p2_id.clone(), Some(p2_id));
matches.insert(child_id.clone(), Some(child_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert_eq!(
summary.reparented, 0,
"Adding a new parent while keeping old is not reparenting: {changes:?}"
);
assert!(
summary.dependencies_added > 0,
"P2→C should be detected as added: {changes:?}"
);
}
#[test]
fn test_same_relationship_no_change() {
let a = make_component("a");
let b = make_component("b");
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
let old_sbom = make_sbom_with_rel(
vec![a.clone(), b.clone()],
vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
);
let new_sbom = make_sbom_with_rel(
vec![a, b],
vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
);
let mut matches = HashMap::new();
matches.insert(a_id.clone(), Some(a_id));
matches.insert(b_id.clone(), Some(b_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert_eq!(
summary.relationship_changed, 0,
"Same relationship should not be a change: {changes:?}"
);
}
#[test]
fn test_duplicate_edges_different_types() {
let a = make_component("a");
let b = make_component("b");
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
let mut sbom = NormalizedSbom::default();
sbom.add_component(a);
sbom.add_component(b);
sbom.add_edge(DependencyEdge::new(
a_id.clone(),
b_id.clone(),
DependencyType::DependsOn,
));
sbom.add_edge(DependencyEdge::new(
a_id.clone(),
b_id.clone(),
DependencyType::DevDependsOn,
));
let config = GraphDiffConfig::default();
let graph = DependencyGraph::from_sbom(&sbom, &config);
let children = graph.get_children(&a_id);
assert!(children.contains(&b_id), "B should be a child of A");
let attrs = graph.get_edge_attrs(&a_id, &b_id);
assert!(attrs.is_some(), "Should have edge attrs for A→B");
}
#[test]
fn test_large_graph_completes() {
let mut components = Vec::new();
let mut edges = Vec::new();
let mut ids = Vec::new();
for i in 0..500 {
let comp = make_component(&format!("node-{i}"));
ids.push(comp.canonical_id.clone());
components.push(comp);
}
for i in 0..499 {
edges.push((ids[i].clone(), ids[i + 1].clone()));
}
let sbom = make_sbom(components, edges);
let config = GraphDiffConfig::default();
let graph = DependencyGraph::from_sbom(&sbom, &config);
assert_eq!(graph.get_depth(&ids[0]), Some(1));
assert_eq!(graph.get_depth(&ids[499]), Some(500));
}
#[test]
fn test_empty_vs_nonempty_graph() {
let a = make_component("a");
let b = make_component("b");
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
let old_sbom = make_sbom(vec![a.clone(), b.clone()], vec![]);
let new_sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
let mut matches = HashMap::new();
matches.insert(a_id.clone(), Some(a_id));
matches.insert(b_id.clone(), Some(b_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert!(
summary.dependencies_added > 0,
"Should detect added dependency: {changes:?}"
);
assert_eq!(
summary.dependencies_removed, 0,
"No false removes: {changes:?}"
);
}
#[test]
fn test_nonempty_vs_empty_graph() {
let a = make_component("a");
let b = make_component("b");
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
let old_sbom = make_sbom(
vec![a.clone(), b.clone()],
vec![(a_id.clone(), b_id.clone())],
);
let new_sbom = make_sbom(vec![a, b], vec![]);
let mut matches = HashMap::new();
matches.insert(a_id.clone(), Some(a_id));
matches.insert(b_id.clone(), Some(b_id));
let config = GraphDiffConfig::default();
let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
assert!(
summary.dependencies_removed > 0,
"Should detect removed dependency: {changes:?}"
);
assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
}
#[test]
fn test_relation_filter() {
let a = make_component("a");
let b = make_component("b");
let c = make_component("c");
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
let c_id = c.canonical_id.clone();
let mut sbom = NormalizedSbom::default();
sbom.add_component(a);
sbom.add_component(b);
sbom.add_component(c);
sbom.add_edge(DependencyEdge::new(
a_id.clone(),
b_id.clone(),
DependencyType::DependsOn,
));
sbom.add_edge(DependencyEdge::new(
a_id.clone(),
c_id.clone(),
DependencyType::DevDependsOn,
));
let config = GraphDiffConfig {
relation_filter: vec!["depends-on".to_string()],
..Default::default()
};
let graph = DependencyGraph::from_sbom(&sbom, &config);
let children = graph.get_children(&a_id);
assert!(
children.contains(&b_id),
"DependsOn edge should be included"
);
assert!(
!children.contains(&c_id),
"DevDependsOn edge should be excluded by filter"
);
}
}