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 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 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 if let Some(&existing_depth) = depths.get(&id) {
121 if depth >= existing_depth {
122 continue; }
124 }
125
126 depths.insert(id.clone(), depth);
128
129 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 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
183pub 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 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 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 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 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 if config.detect_depth_changes {
239 detect_depth_changes(&old_graph, &new_graph, component_matches, &mut changes);
240 }
241
242 if config.detect_reparenting {
244 detect_reparenting(&old_graph, &new_graph, component_matches, &mut changes);
245 }
246
247 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
262fn 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
277fn 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 GraphChangeImpact::High
296 } else if nd == 1 && od > 1 {
297 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
318fn 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 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 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, };
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 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}