1use super::changes::{
4 ComponentChangeComputer, DependencyChangeComputer, LicenseChangeComputer,
5 VulnerabilityChangeComputer, compute_metadata_changes,
6};
7pub use super::engine_config::LargeSbomConfig;
8use super::engine_matching::{ComponentMatchResult, match_components};
9use super::engine_rules::{apply_rules, remap_match_result};
10use super::incremental::ChangedSections;
11use super::result::MatchMetrics;
12use super::traits::ChangeComputer;
13use super::{CostModel, DiffResult, GraphDiffConfig, MatchInfo, diff_dependency_graph};
14use crate::error::SbomDiffError;
15use crate::matching::{
16 ComponentMatcher, FuzzyMatchConfig, FuzzyMatcher, MatchingRulesConfig, RuleEngine,
17};
18use crate::model::NormalizedSbom;
19use std::borrow::Cow;
20
21#[must_use]
23pub struct DiffEngine {
24 cost_model: CostModel,
25 fuzzy_config: FuzzyMatchConfig,
26 include_unchanged: bool,
27 graph_diff_config: Option<GraphDiffConfig>,
28 rule_engine: Option<RuleEngine>,
29 custom_matcher: Option<Box<dyn ComponentMatcher>>,
30 large_sbom_config: LargeSbomConfig,
31}
32
33impl DiffEngine {
34 pub fn new() -> Self {
36 Self {
37 cost_model: CostModel::default(),
38 fuzzy_config: FuzzyMatchConfig::balanced(),
39 include_unchanged: false,
40 graph_diff_config: None,
41 rule_engine: None,
42 custom_matcher: None,
43 large_sbom_config: LargeSbomConfig::default(),
44 }
45 }
46
47 pub const fn with_cost_model(mut self, cost_model: CostModel) -> Self {
49 self.cost_model = cost_model;
50 self
51 }
52
53 pub const fn with_fuzzy_config(mut self, config: FuzzyMatchConfig) -> Self {
55 self.fuzzy_config = config;
56 self
57 }
58
59 pub const fn include_unchanged(mut self, include: bool) -> Self {
61 self.include_unchanged = include;
62 self
63 }
64
65 pub fn with_graph_diff(mut self, config: GraphDiffConfig) -> Self {
67 self.graph_diff_config = Some(config);
68 self
69 }
70
71 pub fn with_matching_rules(mut self, config: MatchingRulesConfig) -> Result<Self, String> {
73 self.rule_engine = Some(RuleEngine::new(config)?);
74 Ok(self)
75 }
76
77 pub fn with_rule_engine(mut self, engine: RuleEngine) -> Self {
79 self.rule_engine = Some(engine);
80 self
81 }
82
83 pub fn with_matcher(mut self, matcher: Box<dyn ComponentMatcher>) -> Self {
85 self.custom_matcher = Some(matcher);
86 self
87 }
88
89 pub const fn with_large_sbom_config(mut self, config: LargeSbomConfig) -> Self {
91 self.large_sbom_config = config;
92 self
93 }
94
95 #[must_use]
97 pub const fn large_sbom_config(&self) -> &LargeSbomConfig {
98 &self.large_sbom_config
99 }
100
101 #[must_use]
103 pub fn has_custom_matcher(&self) -> bool {
104 self.custom_matcher.is_some()
105 }
106
107 #[must_use]
109 pub const fn graph_diff_enabled(&self) -> bool {
110 self.graph_diff_config.is_some()
111 }
112
113 #[must_use]
115 pub const fn has_matching_rules(&self) -> bool {
116 self.rule_engine.is_some()
117 }
118
119 #[must_use = "diff result contains all changes and should not be discarded"]
121 pub fn diff(
122 &self,
123 old: &NormalizedSbom,
124 new: &NormalizedSbom,
125 ) -> Result<DiffResult, SbomDiffError> {
126 let _span = tracing::info_span!(
127 "diff_engine::diff",
128 old_components = old.component_count(),
129 new_components = new.component_count(),
130 )
131 .entered();
132
133 let mut result = DiffResult::new();
134
135 if old.content_hash == new.content_hash && old.content_hash != 0 {
137 result.semantic_score = PERCENT_MAX;
138 return Ok(result);
139 }
140
141 let (old_filtered, new_filtered, canonical_maps) =
144 if let Some(rule_result) = apply_rules(self.rule_engine.as_ref(), old, new) {
145 result.rules_applied = rule_result.rules_count;
146 (
147 Cow::Owned(rule_result.old_filtered),
148 Cow::Owned(rule_result.new_filtered),
149 Some((rule_result.old_canonical, rule_result.new_canonical)),
150 )
151 } else {
152 (Cow::Borrowed(old), Cow::Borrowed(new), None)
153 };
154
155 let default_matcher = FuzzyMatcher::new(self.fuzzy_config.clone());
157 let matcher: &dyn ComponentMatcher = self
158 .custom_matcher
159 .as_ref()
160 .map_or(&default_matcher as &dyn ComponentMatcher, |m| m.as_ref());
161
162 let mut component_matches = match_components(
163 &old_filtered,
164 &new_filtered,
165 matcher,
166 &self.fuzzy_config,
167 &self.large_sbom_config,
168 );
169
170 if let Some((old_canonical, new_canonical)) = &canonical_maps {
172 component_matches =
173 remap_match_result(&component_matches, old_canonical, new_canonical);
174 }
175
176 {
178 let mut scores: Vec<f64> = component_matches.pairs.values().copied().collect();
181 scores.sort_unstable_by(f64::total_cmp);
182 let exact = scores.iter().filter(|&&s| s >= 0.99).count();
183 let fuzzy = scores.len() - exact;
184 let matched_count = scores.len();
185 let unmatched_old = old_filtered.component_count().saturating_sub(matched_count);
186 let unmatched_new = new_filtered.component_count().saturating_sub(matched_count);
187 let avg = if scores.is_empty() {
188 0.0
189 } else {
190 scores.iter().sum::<f64>() / scores.len() as f64
191 };
192 let min = scores.iter().copied().fold(f64::INFINITY, f64::min);
193
194 result.match_metrics = Some(MatchMetrics {
195 exact_matches: exact,
196 fuzzy_matches: fuzzy,
197 rule_matches: result.rules_applied,
198 unmatched_old,
199 unmatched_new,
200 avg_match_score: avg,
201 min_match_score: if min.is_infinite() { 0.0 } else { min },
202 });
203 }
204
205 self.compute_all_changes(
207 &old_filtered,
208 &new_filtered,
209 &component_matches,
210 matcher,
211 &mut result,
212 );
213
214 if let Some(ref graph_config) = self.graph_diff_config {
216 let (graph_changes, graph_summary) = diff_dependency_graph(
217 &old_filtered,
218 &new_filtered,
219 &component_matches.matches,
220 graph_config,
221 );
222 result.graph_changes = graph_changes;
223 result.graph_summary = Some(graph_summary);
224 }
225
226 result.semantic_score = self.compute_semantic_score(&result, old, new);
228
229 result.calculate_summary();
230 Ok(result)
231 }
232
233 fn compute_all_changes(
235 &self,
236 old: &NormalizedSbom,
237 new: &NormalizedSbom,
238 match_result: &ComponentMatchResult,
239 matcher: &dyn ComponentMatcher,
240 result: &mut DiffResult,
241 ) {
242 let comp_computer = ComponentChangeComputer::new(self.cost_model.clone());
244 let comp_changes = comp_computer.compute(old, new, &match_result.matches);
245 result.components.added = comp_changes.added;
246 result.components.removed = comp_changes.removed;
247 result.components.modified = comp_changes
248 .modified
249 .into_iter()
250 .map(|mut change| {
251 if let (Some(old_id), Some(new_id)) =
254 (&change.old_canonical_id, &change.canonical_id)
255 && let (Some(old_comp), Some(new_comp)) =
256 (old.components.get(old_id), new.components.get(new_id))
257 {
258 let explanation = matcher.explain_match(old_comp, new_comp);
259 let mut match_info = MatchInfo::from_explanation(&explanation);
260
261 if let Some(&score) = match_result.pairs.get(&(old_id.clone(), new_id.clone()))
263 {
264 match_info.score = score;
265 }
266
267 change = change.with_match_info(match_info);
268 }
269 change
270 })
271 .collect();
272
273 let dep_computer = DependencyChangeComputer::new();
275 let dep_changes = dep_computer.compute(old, new, &match_result.matches);
276 result.dependencies.added = dep_changes.added;
277 result.dependencies.removed = dep_changes.removed;
278
279 let lic_computer = LicenseChangeComputer::new();
281 let lic_changes = lic_computer.compute(old, new, &match_result.matches);
282 result.licenses.new_licenses = lic_changes.new_licenses;
283 result.licenses.removed_licenses = lic_changes.removed_licenses;
284 result.licenses.component_changes = lic_changes.component_changes;
285
286 let vuln_computer = VulnerabilityChangeComputer::new();
288 let vuln_changes = vuln_computer.compute(old, new, &match_result.matches);
289 result.vulnerabilities.introduced = vuln_changes.introduced;
290 result.vulnerabilities.resolved = vuln_changes.resolved;
291 result.vulnerabilities.persistent = vuln_changes.persistent;
292 result.vulnerabilities.vex_changes = vuln_changes.vex_changes;
293
294 result.metadata_changes = compute_metadata_changes(old, new);
296 }
297
298 pub(crate) fn diff_sections(
306 &self,
307 old: &NormalizedSbom,
308 new: &NormalizedSbom,
309 sections: &ChangedSections,
310 cached: &DiffResult,
311 ) -> Result<DiffResult, SbomDiffError> {
312 let mut result = cached.clone();
314
315 let (old_filtered, new_filtered, canonical_maps) =
317 if let Some(rule_result) = apply_rules(self.rule_engine.as_ref(), old, new) {
318 result.rules_applied = rule_result.rules_count;
319 (
320 Cow::Owned(rule_result.old_filtered),
321 Cow::Owned(rule_result.new_filtered),
322 Some((rule_result.old_canonical, rule_result.new_canonical)),
323 )
324 } else {
325 (Cow::Borrowed(old), Cow::Borrowed(new), None)
326 };
327
328 let default_matcher = FuzzyMatcher::new(self.fuzzy_config.clone());
330 let matcher: &dyn ComponentMatcher = self
331 .custom_matcher
332 .as_ref()
333 .map_or(&default_matcher as &dyn ComponentMatcher, |m| m.as_ref());
334
335 let mut component_matches = match_components(
336 &old_filtered,
337 &new_filtered,
338 matcher,
339 &self.fuzzy_config,
340 &self.large_sbom_config,
341 );
342
343 if let Some((old_canonical, new_canonical)) = &canonical_maps {
345 component_matches =
346 remap_match_result(&component_matches, old_canonical, new_canonical);
347 }
348
349 if sections.components {
351 let comp_computer = ComponentChangeComputer::new(self.cost_model.clone());
352 let comp_changes =
353 comp_computer.compute(&old_filtered, &new_filtered, &component_matches.matches);
354 result.components.added = comp_changes.added;
355 result.components.removed = comp_changes.removed;
356 result.components.modified = comp_changes
357 .modified
358 .into_iter()
359 .map(|mut change| {
360 if let (Some(old_id), Some(new_id)) =
361 (&change.old_canonical_id, &change.canonical_id)
362 && let (Some(old_comp), Some(new_comp)) = (
363 old_filtered.components.get(old_id),
364 new_filtered.components.get(new_id),
365 )
366 {
367 let explanation = matcher.explain_match(old_comp, new_comp);
368 let mut match_info = MatchInfo::from_explanation(&explanation);
369 if let Some(&score) = component_matches
370 .pairs
371 .get(&(old_id.clone(), new_id.clone()))
372 {
373 match_info.score = score;
374 }
375 change = change.with_match_info(match_info);
376 }
377 change
378 })
379 .collect();
380 }
381
382 if sections.dependencies {
383 let dep_computer = DependencyChangeComputer::new();
384 let dep_changes =
385 dep_computer.compute(&old_filtered, &new_filtered, &component_matches.matches);
386 result.dependencies.added = dep_changes.added;
387 result.dependencies.removed = dep_changes.removed;
388 }
389
390 if sections.licenses {
391 let lic_computer = LicenseChangeComputer::new();
392 let lic_changes =
393 lic_computer.compute(&old_filtered, &new_filtered, &component_matches.matches);
394 result.licenses.new_licenses = lic_changes.new_licenses;
395 result.licenses.removed_licenses = lic_changes.removed_licenses;
396 result.licenses.component_changes = lic_changes.component_changes;
397 }
398
399 if sections.vulnerabilities {
400 let vuln_computer = VulnerabilityChangeComputer::new();
401 let vuln_changes =
402 vuln_computer.compute(&old_filtered, &new_filtered, &component_matches.matches);
403 result.vulnerabilities.introduced = vuln_changes.introduced;
404 result.vulnerabilities.resolved = vuln_changes.resolved;
405 result.vulnerabilities.persistent = vuln_changes.persistent;
406 result.vulnerabilities.vex_changes = vuln_changes.vex_changes;
407 }
408
409 result.metadata_changes = compute_metadata_changes(&old_filtered, &new_filtered);
413
414 result.semantic_score = self.compute_semantic_score(&result, &old_filtered, &new_filtered);
416 result.calculate_summary();
417 Ok(result)
418 }
419
420 fn compute_semantic_score(
422 &self,
423 result: &DiffResult,
424 old_sbom: &NormalizedSbom,
425 new_sbom: &NormalizedSbom,
426 ) -> f64 {
427 let raw_cost = self.cost_model.calculate_semantic_score(
428 result.components.added.len(),
429 result.components.removed.len(),
430 result.components.modified.len(),
431 result.licenses.component_changes.len(),
432 result.vulnerabilities.introduced.len(),
433 result.vulnerabilities.resolved.len(),
434 result.dependencies.added.len(),
435 result.dependencies.removed.len(),
436 );
437
438 self.normalize_semantic_score(raw_cost, result, old_sbom, new_sbom)
439 }
440
441 fn normalize_semantic_score(
461 &self,
462 raw_cost: f64,
463 result: &DiffResult,
464 old_sbom: &NormalizedSbom,
465 new_sbom: &NormalizedSbom,
466 ) -> f64 {
467 let component_budget = (old_sbom.component_count() + new_sbom.component_count()) as f64
468 * f64::from(
469 self.cost_model
470 .component_added
471 .max(self.cost_model.component_removed),
472 );
473 let dependency_budget = (old_sbom.edges.len() + new_sbom.edges.len()) as f64
474 * f64::from(
475 self.cost_model
476 .dependency_added
477 .max(self.cost_model.dependency_removed),
478 );
479 let vulnerability_budget = (old_sbom.vulnerability_counts().total()
480 + new_sbom.vulnerability_counts().total()) as f64
481 * f64::from(self.cost_model.vulnerability_introduced);
482 let modification_budget = result.components.modified.len() as f64
483 * f64::from(
484 self.cost_model
485 .version_major
486 .max(self.cost_model.license_changed),
487 );
488
489 let total_budget =
490 component_budget + dependency_budget + vulnerability_budget + modification_budget;
491
492 if total_budget <= f64::EPSILON {
493 return PERCENT_MAX;
494 }
495
496 (PERCENT_MAX * (1.0 - (raw_cost / total_budget))).clamp(0.0, PERCENT_MAX)
497 }
498}
499
500const PERCENT_MAX: f64 = 100.0;
503
504impl Default for DiffEngine {
505 fn default() -> Self {
506 Self::new()
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn test_empty_diff() {
516 let engine = DiffEngine::new();
517 let sbom = NormalizedSbom::default();
518 let result = engine.diff(&sbom, &sbom).expect("diff should succeed");
519 assert!(!result.has_changes());
520 }
521}