Skip to main content

sbom_tools/diff/
graph.rs

1//! Graph-aware dependency diffing module.
2//!
3//! This module provides functionality to detect structural changes in the
4//! dependency graph between two SBOMs, going beyond simple component-level
5//! comparisons to identify:
6//! - Dependencies being added or removed
7//! - Dependencies being reparented (moved from one parent to another)
8//! - Depth changes (transitive becoming direct or vice versa)
9
10use std::collections::{HashMap, HashSet, VecDeque};
11
12use crate::model::{CanonicalId, NormalizedSbom};
13
14use super::result::{
15    DependencyChangeType, DependencyGraphChange, GraphChangeImpact, GraphChangeSummary,
16};
17
18/// Configuration for graph-aware diffing
19#[derive(Debug, Clone)]
20pub struct GraphDiffConfig {
21    /// Whether to detect reparenting (computationally more expensive)
22    pub detect_reparenting: bool,
23    /// Whether to track depth changes
24    pub detect_depth_changes: bool,
25    /// Maximum depth to analyze (0 = unlimited)
26    pub max_depth: u32,
27}
28
29impl Default for GraphDiffConfig {
30    fn default() -> Self {
31        Self {
32            detect_reparenting: true,
33            detect_depth_changes: true,
34            max_depth: 0,
35        }
36    }
37}
38
39/// Internal representation of dependency graph for diffing
40struct DependencyGraph<'a> {
41    /// Reference to the SBOM
42    sbom: &'a NormalizedSbom,
43    /// parent_id -> Vec<child_id>
44    edges: HashMap<CanonicalId, Vec<CanonicalId>>,
45    /// child_id -> Vec<parent_id> (reverse index)
46    reverse_edges: HashMap<CanonicalId, Vec<CanonicalId>>,
47    /// component_id -> minimum depth from root (1 = direct)
48    /// Uses minimum depth when multiple paths exist (diamond dependencies)
49    depths: HashMap<CanonicalId, u32>,
50    /// component_id -> has vulnerabilities
51    vulnerable_components: HashSet<CanonicalId>,
52}
53
54impl<'a> DependencyGraph<'a> {
55    fn from_sbom(sbom: &'a NormalizedSbom, config: &GraphDiffConfig) -> Self {
56        let mut edges: HashMap<CanonicalId, Vec<CanonicalId>> = HashMap::new();
57        let mut reverse_edges: HashMap<CanonicalId, Vec<CanonicalId>> = HashMap::new();
58        let mut vulnerable_components = HashSet::new();
59
60        // Build edge maps from SBOM edges
61        for edge in &sbom.edges {
62            edges
63                .entry(edge.from.clone())
64                .or_default()
65                .push(edge.to.clone());
66
67            reverse_edges
68                .entry(edge.to.clone())
69                .or_default()
70                .push(edge.from.clone());
71        }
72
73        // Identify vulnerable components
74        for (id, comp) in &sbom.components {
75            if !comp.vulnerabilities.is_empty() {
76                vulnerable_components.insert(id.clone());
77            }
78        }
79
80        // Calculate depths via BFS from roots (respecting max_depth limit)
81        let all_components: HashSet<_> = sbom.components.keys().cloned().collect();
82        let depths =
83            Self::calculate_depths(&edges, &reverse_edges, &all_components, config.max_depth);
84
85        Self {
86            sbom,
87            edges,
88            reverse_edges,
89            depths,
90            vulnerable_components,
91        }
92    }
93
94    /// Calculate minimum depths from roots via BFS.
95    ///
96    /// Uses minimum depth when multiple paths exist (diamond dependencies),
97    /// which gives the most accurate "direct vs transitive" classification.
98    /// Respects max_depth limit (0 = unlimited).
99    fn calculate_depths(
100        edges: &HashMap<CanonicalId, Vec<CanonicalId>>,
101        reverse_edges: &HashMap<CanonicalId, Vec<CanonicalId>>,
102        all_components: &HashSet<CanonicalId>,
103        max_depth: u32,
104    ) -> HashMap<CanonicalId, u32> {
105        let mut depths = HashMap::new();
106
107        // Find roots (components with no parents)
108        let roots: Vec<_> = all_components
109            .iter()
110            .filter(|id| reverse_edges.get(*id).map(|p| p.is_empty()).unwrap_or(true))
111            .cloned()
112            .collect();
113
114        // BFS to calculate minimum depths
115        // We use a queue that may revisit nodes if a shorter path is found
116        let mut queue: VecDeque<(CanonicalId, u32)> = roots.into_iter().map(|id| (id, 1)).collect();
117
118        while let Some((id, depth)) = queue.pop_front() {
119            // Check if we've found a shorter path to this node
120            if let Some(&existing_depth) = depths.get(&id) {
121                if depth >= existing_depth {
122                    continue; // Already have a shorter or equal path
123                }
124            }
125
126            // Record this depth (it's either new or shorter than existing)
127            depths.insert(id.clone(), depth);
128
129            // Stop traversing if we've reached max_depth (0 = unlimited)
130            if max_depth > 0 && depth >= max_depth {
131                continue;
132            }
133
134            if let Some(children) = edges.get(&id) {
135                for child_id in children {
136                    let child_depth = depth + 1;
137                    // Only queue if this might be a shorter path
138                    let dominated = depths.get(child_id).is_some_and(|&d| d <= child_depth);
139                    if !dominated {
140                        queue.push_back((child_id.clone(), child_depth));
141                    }
142                }
143            }
144        }
145
146        depths
147    }
148
149    fn get_parents(&self, component_id: &CanonicalId) -> Vec<CanonicalId> {
150        self.reverse_edges
151            .get(component_id)
152            .cloned()
153            .unwrap_or_default()
154    }
155
156    fn get_children(&self, component_id: &CanonicalId) -> Vec<CanonicalId> {
157        self.edges.get(component_id).cloned().unwrap_or_default()
158    }
159
160    fn get_depth(&self, component_id: &CanonicalId) -> Option<u32> {
161        self.depths.get(component_id).copied()
162    }
163
164    fn is_vulnerable(&self, component_id: &CanonicalId) -> bool {
165        self.vulnerable_components.contains(component_id)
166    }
167
168    fn get_component_name(&self, component_id: &CanonicalId) -> String {
169        self.sbom
170            .components
171            .get(component_id)
172            .map(|c| {
173                if let Some(v) = &c.version {
174                    format!("{}@{}", c.name, v)
175                } else {
176                    c.name.clone()
177                }
178            })
179            .unwrap_or_else(|| component_id.to_string())
180    }
181}
182
183/// Perform graph-aware diff between two SBOMs
184pub fn diff_dependency_graph(
185    old_sbom: &NormalizedSbom,
186    new_sbom: &NormalizedSbom,
187    component_matches: &HashMap<CanonicalId, Option<CanonicalId>>,
188    config: &GraphDiffConfig,
189) -> (Vec<DependencyGraphChange>, GraphChangeSummary) {
190    let old_graph = DependencyGraph::from_sbom(old_sbom, config);
191    let new_graph = DependencyGraph::from_sbom(new_sbom, config);
192
193    let mut changes = Vec::new();
194
195    // Iterate through matched components to find dependency changes
196    for (old_id, new_id_option) in component_matches.iter() {
197        if let Some(new_id) = new_id_option {
198            let component_name = new_graph.get_component_name(new_id);
199
200            // Get children in both graphs
201            let old_children: HashSet<_> = old_graph.get_children(old_id).into_iter().collect();
202            let new_children: HashSet<_> = new_graph.get_children(new_id).into_iter().collect();
203
204            // Detect added dependencies
205            for child_id in new_children.difference(&old_children) {
206                let dep_name = new_graph.get_component_name(child_id);
207                let impact = assess_impact_added(&new_graph, child_id);
208
209                changes.push(DependencyGraphChange {
210                    component_id: new_id.clone(),
211                    component_name: component_name.clone(),
212                    change: DependencyChangeType::DependencyAdded {
213                        dependency_id: child_id.clone(),
214                        dependency_name: dep_name,
215                    },
216                    impact,
217                });
218            }
219
220            // Detect removed dependencies
221            for child_id in old_children.difference(&new_children) {
222                let dep_name = old_graph.get_component_name(child_id);
223
224                changes.push(DependencyGraphChange {
225                    component_id: new_id.clone(),
226                    component_name: component_name.clone(),
227                    change: DependencyChangeType::DependencyRemoved {
228                        dependency_id: child_id.clone(),
229                        dependency_name: dep_name,
230                    },
231                    impact: GraphChangeImpact::Low,
232                });
233            }
234        }
235    }
236
237    // Detect depth changes
238    if config.detect_depth_changes {
239        detect_depth_changes(&old_graph, &new_graph, component_matches, &mut changes);
240    }
241
242    // Detect reparenting (post-process to find moved dependencies)
243    if config.detect_reparenting {
244        detect_reparenting(&old_graph, &new_graph, component_matches, &mut changes);
245    }
246
247    // Sort changes by impact (critical first)
248    changes.sort_by(|a, b| {
249        let impact_order = |i: &GraphChangeImpact| match i {
250            GraphChangeImpact::Critical => 0,
251            GraphChangeImpact::High => 1,
252            GraphChangeImpact::Medium => 2,
253            GraphChangeImpact::Low => 3,
254        };
255        impact_order(&a.impact).cmp(&impact_order(&b.impact))
256    });
257
258    let summary = GraphChangeSummary::from_changes(&changes);
259    (changes, summary)
260}
261
262/// Assess the impact of adding a dependency
263fn assess_impact_added(graph: &DependencyGraph, component_id: &CanonicalId) -> GraphChangeImpact {
264    if graph.is_vulnerable(component_id) {
265        if graph.get_depth(component_id) == Some(1) {
266            GraphChangeImpact::Critical
267        } else {
268            GraphChangeImpact::High
269        }
270    } else if graph.get_depth(component_id) == Some(1) {
271        GraphChangeImpact::Medium
272    } else {
273        GraphChangeImpact::Low
274    }
275}
276
277/// Detect depth changes between matched components
278fn detect_depth_changes(
279    old_graph: &DependencyGraph,
280    new_graph: &DependencyGraph,
281    matches: &HashMap<CanonicalId, Option<CanonicalId>>,
282    changes: &mut Vec<DependencyGraphChange>,
283) {
284    for (old_id, new_id_opt) in matches {
285        if let Some(new_id) = new_id_opt {
286            let old_depth = old_graph.get_depth(old_id);
287            let new_depth = new_graph.get_depth(new_id);
288
289            if let (Some(od), Some(nd)) = (old_depth, new_depth) {
290                if od != nd {
291                    let component_name = new_graph.get_component_name(new_id);
292
293                    let impact = if nd < od && new_graph.is_vulnerable(new_id) {
294                        // Vulnerable component moved closer to root
295                        GraphChangeImpact::High
296                    } else if nd == 1 && od > 1 {
297                        // Became direct dependency
298                        GraphChangeImpact::Medium
299                    } else {
300                        GraphChangeImpact::Low
301                    };
302
303                    changes.push(DependencyGraphChange {
304                        component_id: new_id.clone(),
305                        component_name,
306                        change: DependencyChangeType::DepthChanged {
307                            old_depth: od,
308                            new_depth: nd,
309                        },
310                        impact,
311                    });
312                }
313            }
314        }
315    }
316}
317
318/// Detect reparented components (moved from one parent to another)
319fn detect_reparenting(
320    old_graph: &DependencyGraph,
321    new_graph: &DependencyGraph,
322    matches: &HashMap<CanonicalId, Option<CanonicalId>>,
323    changes: &mut Vec<DependencyGraphChange>,
324) {
325    for (old_id, new_id_opt) in matches {
326        if let Some(new_id) = new_id_opt {
327            let old_parents = old_graph.get_parents(old_id);
328            let new_parents = new_graph.get_parents(new_id);
329
330            // Only consider reparenting if exactly one parent in both
331            if old_parents.len() == 1 && new_parents.len() == 1 {
332                let old_parent = &old_parents[0];
333                let new_parent = &new_parents[0];
334
335                // Check if the parents are different (accounting for component matching)
336                let old_parent_matched = matches.get(old_parent).and_then(|opt| opt.as_ref());
337
338                let is_reparented = match old_parent_matched {
339                    Some(old_parent_in_new) => old_parent_in_new != new_parent,
340                    None => true, // Old parent doesn't exist in new SBOM
341                };
342
343                if is_reparented {
344                    let component_name = new_graph.get_component_name(new_id);
345                    let old_parent_name = old_graph.get_component_name(old_parent);
346                    let new_parent_name = new_graph.get_component_name(new_parent);
347
348                    // Remove any corresponding Added/Removed entries for this component
349                    // as they will be replaced by the Reparented entry
350                    changes.retain(|c| match &c.change {
351                        DependencyChangeType::DependencyAdded { dependency_id, .. }
352                        | DependencyChangeType::DependencyRemoved { dependency_id, .. } => {
353                            dependency_id != new_id
354                        }
355                        _ => true,
356                    });
357
358                    changes.push(DependencyGraphChange {
359                        component_id: new_id.clone(),
360                        component_name,
361                        change: DependencyChangeType::Reparented {
362                            dependency_id: new_id.clone(),
363                            dependency_name: new_graph.get_component_name(new_id),
364                            old_parent_id: old_parent.clone(),
365                            old_parent_name,
366                            new_parent_id: new_parent.clone(),
367                            new_parent_name,
368                        },
369                        impact: GraphChangeImpact::Medium,
370                    });
371                }
372            }
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_graph_diff_config_default() {
383        let config = GraphDiffConfig::default();
384        assert!(config.detect_reparenting);
385        assert!(config.detect_depth_changes);
386        assert_eq!(config.max_depth, 0);
387    }
388
389    #[test]
390    fn test_graph_change_impact_display() {
391        assert_eq!(GraphChangeImpact::Critical.as_str(), "critical");
392        assert_eq!(GraphChangeImpact::High.as_str(), "high");
393        assert_eq!(GraphChangeImpact::Medium.as_str(), "medium");
394        assert_eq!(GraphChangeImpact::Low.as_str(), "low");
395    }
396}