1use std::collections::{HashMap, HashSet, VecDeque};
11
12use crate::model::{CanonicalId, NormalizedSbom};
13
14use super::result::{
15 DependencyChangeType, DependencyGraphChange, GraphChangeImpact, GraphChangeSummary,
16};
17
18#[derive(Debug, Clone)]
20pub struct GraphDiffConfig {
21 pub detect_reparenting: bool,
23 pub detect_depth_changes: bool,
25 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
39struct DependencyGraph<'a> {
41 sbom: &'a NormalizedSbom,
43 edges: HashMap<CanonicalId, Vec<CanonicalId>>,
45 reverse_edges: HashMap<CanonicalId, Vec<CanonicalId>>,
47 depths: HashMap<CanonicalId, u32>,
50 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 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 for (id, comp) in &sbom.components {
75 if !comp.vulnerabilities.is_empty() {
76 vulnerable_components.insert(id.clone());
77 }
78 }
79
80 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 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 let mut queue: VecDeque<(CanonicalId, u32)> = all_components
110 .iter()
111 .filter(|id| reverse_edges.get(*id).is_none_or(std::vec::Vec::is_empty))
112 .cloned()
113 .map(|id| (id, 1))
114 .collect();
115
116 while let Some((id, depth)) = queue.pop_front() {
117 if let Some(&existing_depth) = depths.get(&id) {
119 if depth >= existing_depth {
120 continue; }
122 }
123
124 depths.insert(id.clone(), depth);
126
127 if max_depth > 0 && depth >= max_depth {
129 continue;
130 }
131
132 if let Some(children) = edges.get(&id) {
133 for child_id in children {
134 let child_depth = depth + 1;
135 let dominated = depths.get(child_id).is_some_and(|&d| d <= child_depth);
137 if !dominated {
138 queue.push_back((child_id.clone(), child_depth));
139 }
140 }
141 }
142 }
143
144 depths
145 }
146
147 fn get_parents(&self, component_id: &CanonicalId) -> Vec<CanonicalId> {
148 self.reverse_edges
149 .get(component_id)
150 .cloned()
151 .unwrap_or_default()
152 }
153
154 fn get_children(&self, component_id: &CanonicalId) -> Vec<CanonicalId> {
155 self.edges.get(component_id).cloned().unwrap_or_default()
156 }
157
158 fn get_depth(&self, component_id: &CanonicalId) -> Option<u32> {
159 self.depths.get(component_id).copied()
160 }
161
162 fn is_vulnerable(&self, component_id: &CanonicalId) -> bool {
163 self.vulnerable_components.contains(component_id)
164 }
165
166 fn get_component_name(&self, component_id: &CanonicalId) -> String {
167 self.sbom
168 .components
169 .get(component_id)
170 .map_or_else(
171 || component_id.to_string(),
172 |c| c.version.as_ref().map_or_else(|| c.name.clone(), |v| format!("{}@{}", c.name, v)),
173 )
174 }
175}
176
177#[allow(clippy::implicit_hasher)]
179#[must_use]
180pub fn diff_dependency_graph(
181 old_sbom: &NormalizedSbom,
182 new_sbom: &NormalizedSbom,
183 component_matches: &HashMap<CanonicalId, Option<CanonicalId>>,
184 config: &GraphDiffConfig,
185) -> (Vec<DependencyGraphChange>, GraphChangeSummary) {
186 let old_graph = DependencyGraph::from_sbom(old_sbom, config);
187 let new_graph = DependencyGraph::from_sbom(new_sbom, config);
188
189 let mut changes = Vec::new();
190
191 for (old_id, new_id_option) in component_matches {
193 if let Some(new_id) = new_id_option {
194 let component_name = new_graph.get_component_name(new_id);
195
196 let old_children: HashSet<_> = old_graph.get_children(old_id).into_iter().collect();
198 let new_children: HashSet<_> = new_graph.get_children(new_id).into_iter().collect();
199
200 for child_id in new_children.difference(&old_children) {
202 let dep_name = new_graph.get_component_name(child_id);
203 let impact = assess_impact_added(&new_graph, child_id);
204
205 changes.push(DependencyGraphChange {
206 component_id: new_id.clone(),
207 component_name: component_name.clone(),
208 change: DependencyChangeType::DependencyAdded {
209 dependency_id: child_id.clone(),
210 dependency_name: dep_name,
211 },
212 impact,
213 });
214 }
215
216 for child_id in old_children.difference(&new_children) {
218 let dep_name = old_graph.get_component_name(child_id);
219
220 changes.push(DependencyGraphChange {
221 component_id: new_id.clone(),
222 component_name: component_name.clone(),
223 change: DependencyChangeType::DependencyRemoved {
224 dependency_id: child_id.clone(),
225 dependency_name: dep_name,
226 },
227 impact: GraphChangeImpact::Low,
228 });
229 }
230 }
231 }
232
233 if config.detect_depth_changes {
235 detect_depth_changes(&old_graph, &new_graph, component_matches, &mut changes);
236 }
237
238 if config.detect_reparenting {
240 detect_reparenting(&old_graph, &new_graph, component_matches, &mut changes);
241 }
242
243 changes.sort_by(|a, b| {
245 let impact_order = |i: &GraphChangeImpact| match i {
246 GraphChangeImpact::Critical => 0,
247 GraphChangeImpact::High => 1,
248 GraphChangeImpact::Medium => 2,
249 GraphChangeImpact::Low => 3,
250 };
251 impact_order(&a.impact).cmp(&impact_order(&b.impact))
252 });
253
254 let summary = GraphChangeSummary::from_changes(&changes);
255 (changes, summary)
256}
257
258fn assess_impact_added(graph: &DependencyGraph, component_id: &CanonicalId) -> GraphChangeImpact {
260 if graph.is_vulnerable(component_id) {
261 if graph.get_depth(component_id) == Some(1) {
262 GraphChangeImpact::Critical
263 } else {
264 GraphChangeImpact::High
265 }
266 } else if graph.get_depth(component_id) == Some(1) {
267 GraphChangeImpact::Medium
268 } else {
269 GraphChangeImpact::Low
270 }
271}
272
273fn detect_depth_changes(
275 old_graph: &DependencyGraph,
276 new_graph: &DependencyGraph,
277 matches: &HashMap<CanonicalId, Option<CanonicalId>>,
278 changes: &mut Vec<DependencyGraphChange>,
279) {
280 for (old_id, new_id_opt) in matches {
281 if let Some(new_id) = new_id_opt {
282 let old_depth = old_graph.get_depth(old_id);
283 let new_depth = new_graph.get_depth(new_id);
284
285 if let (Some(od), Some(nd)) = (old_depth, new_depth) {
286 if od != nd {
287 let component_name = new_graph.get_component_name(new_id);
288
289 let impact = if nd < od && new_graph.is_vulnerable(new_id) {
290 GraphChangeImpact::High
292 } else if nd == 1 && od > 1 {
293 GraphChangeImpact::Medium
295 } else {
296 GraphChangeImpact::Low
297 };
298
299 changes.push(DependencyGraphChange {
300 component_id: new_id.clone(),
301 component_name,
302 change: DependencyChangeType::DepthChanged {
303 old_depth: od,
304 new_depth: nd,
305 },
306 impact,
307 });
308 }
309 }
310 }
311 }
312}
313
314fn detect_reparenting(
316 old_graph: &DependencyGraph,
317 new_graph: &DependencyGraph,
318 matches: &HashMap<CanonicalId, Option<CanonicalId>>,
319 changes: &mut Vec<DependencyGraphChange>,
320) {
321 for (old_id, new_id_opt) in matches {
322 if let Some(new_id) = new_id_opt {
323 let old_parents = old_graph.get_parents(old_id);
324 let new_parents = new_graph.get_parents(new_id);
325
326 if old_parents.len() == 1 && new_parents.len() == 1 {
328 let old_parent = &old_parents[0];
329 let new_parent = &new_parents[0];
330
331 let old_parent_matched = matches.get(old_parent).and_then(|opt| opt.as_ref());
333
334 let is_reparented = !old_parent_matched.is_some_and(|old_parent_in_new| old_parent_in_new == new_parent);
335
336 if is_reparented {
337 let component_name = new_graph.get_component_name(new_id);
338 let old_parent_name = old_graph.get_component_name(old_parent);
339 let new_parent_name = new_graph.get_component_name(new_parent);
340
341 changes.retain(|c| match &c.change {
344 DependencyChangeType::DependencyAdded { dependency_id, .. }
345 | DependencyChangeType::DependencyRemoved { dependency_id, .. } => {
346 dependency_id != new_id
347 }
348 _ => true,
349 });
350
351 changes.push(DependencyGraphChange {
352 component_id: new_id.clone(),
353 component_name,
354 change: DependencyChangeType::Reparented {
355 dependency_id: new_id.clone(),
356 dependency_name: new_graph.get_component_name(new_id),
357 old_parent_id: old_parent.clone(),
358 old_parent_name,
359 new_parent_id: new_parent.clone(),
360 new_parent_name,
361 },
362 impact: GraphChangeImpact::Medium,
363 });
364 }
365 }
366 }
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn test_graph_diff_config_default() {
376 let config = GraphDiffConfig::default();
377 assert!(config.detect_reparenting);
378 assert!(config.detect_depth_changes);
379 assert_eq!(config.max_depth, 0);
380 }
381
382 #[test]
383 fn test_graph_change_impact_display() {
384 assert_eq!(GraphChangeImpact::Critical.as_str(), "critical");
385 assert_eq!(GraphChangeImpact::High.as_str(), "high");
386 assert_eq!(GraphChangeImpact::Medium.as_str(), "medium");
387 assert_eq!(GraphChangeImpact::Low.as_str(), "low");
388 }
389}