Skip to main content

sbom_tools/diff/
engine.rs

1//! Semantic diff engine implementation.
2
3use 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/// Semantic diff engine for comparing SBOMs.
22#[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    /// Create a new diff engine with default settings
35    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    /// Create a diff engine with a custom cost model
48    pub const fn with_cost_model(mut self, cost_model: CostModel) -> Self {
49        self.cost_model = cost_model;
50        self
51    }
52
53    /// Set fuzzy matching configuration
54    pub const fn with_fuzzy_config(mut self, config: FuzzyMatchConfig) -> Self {
55        self.fuzzy_config = config;
56        self
57    }
58
59    /// Include unchanged components in the result
60    pub const fn include_unchanged(mut self, include: bool) -> Self {
61        self.include_unchanged = include;
62        self
63    }
64
65    /// Enable graph-aware diffing with the given configuration
66    pub fn with_graph_diff(mut self, config: GraphDiffConfig) -> Self {
67        self.graph_diff_config = Some(config);
68        self
69    }
70
71    /// Set custom matching rules from a configuration
72    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    /// Set custom matching rules engine directly
78    pub fn with_rule_engine(mut self, engine: RuleEngine) -> Self {
79        self.rule_engine = Some(engine);
80        self
81    }
82
83    /// Set a custom component matcher.
84    pub fn with_matcher(mut self, matcher: Box<dyn ComponentMatcher>) -> Self {
85        self.custom_matcher = Some(matcher);
86        self
87    }
88
89    /// Configure large SBOM optimization settings.
90    pub const fn with_large_sbom_config(mut self, config: LargeSbomConfig) -> Self {
91        self.large_sbom_config = config;
92        self
93    }
94
95    /// Get the large SBOM configuration.
96    #[must_use]
97    pub const fn large_sbom_config(&self) -> &LargeSbomConfig {
98        &self.large_sbom_config
99    }
100
101    /// Check if a custom matcher is configured
102    #[must_use]
103    pub fn has_custom_matcher(&self) -> bool {
104        self.custom_matcher.is_some()
105    }
106
107    /// Check if graph diffing is enabled
108    #[must_use]
109    pub const fn graph_diff_enabled(&self) -> bool {
110        self.graph_diff_config.is_some()
111    }
112
113    /// Check if custom matching rules are configured
114    #[must_use]
115    pub const fn has_matching_rules(&self) -> bool {
116        self.rule_engine.is_some()
117    }
118
119    /// Compare two SBOMs and return the diff result
120    #[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        // Quick check: if content hashes match, SBOMs are identical
136        if old.content_hash == new.content_hash && old.content_hash != 0 {
137            result.semantic_score = PERCENT_MAX;
138            return Ok(result);
139        }
140
141        // Apply custom matching rules if configured
142        // Use Cow to avoid cloning SBOMs when no rules are applied
143        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        // Build component mappings using the configured matcher
156        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        // Apply canonical mappings from rule engine
171        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        // Compute match metrics for observability
177        {
178            // Sorted so float accumulation order (and thus the serialized
179            // average) is identical across runs
180            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        // Compute changes using the modular change computers
206        self.compute_all_changes(
207            &old_filtered,
208            &new_filtered,
209            &component_matches,
210            matcher,
211            &mut result,
212        );
213
214        // Perform graph-aware diffing if enabled
215        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        // Calculate semantic score
227        result.semantic_score = self.compute_semantic_score(&result, old, new);
228
229        result.calculate_summary();
230        Ok(result)
231    }
232
233    /// Compute all changes using the modular change computers.
234    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        // Component changes
243        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                // Add match explanation for modified components
252                // Use stored canonical IDs directly instead of reconstructing from name+version
253                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                    // Use the actual score from the matching phase if available
262                    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        // Dependency changes
274        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        // License changes
280        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        // Vulnerability changes
287        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        // Document-level metadata changes (author/tool/timestamp/spec-version/etc.)
295        result.metadata_changes = compute_metadata_changes(old, new);
296    }
297
298    /// Diff only the specified sections, reusing cached results for unchanged sections.
299    ///
300    /// This enables true incremental diffing: when only some SBOM sections changed,
301    /// we skip recomputing the unchanged sections and reuse them from the cached result.
302    /// Component matching is always recomputed since it's needed by all section computers.
303    ///
304    /// Falls back to a full diff if no cached result is provided.
305    pub(crate) fn diff_sections(
306        &self,
307        old: &NormalizedSbom,
308        new: &NormalizedSbom,
309        sections: &ChangedSections,
310        cached: &DiffResult,
311    ) -> Result<DiffResult, SbomDiffError> {
312        // Start with the cached result so unchanged sections are preserved
313        let mut result = cached.clone();
314
315        // Apply custom matching rules if configured
316        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        // Always recompute matching — it's needed for any section computer
329        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        // Apply canonical mappings from rule engine
344        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        // Selectively recompute only the changed sections
350        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        // Document-metadata changes are cheap and not tracked by `ChangedSections`,
410        // so always recompute them rather than risk serving a stale cached vec
411        // when only the document header changed.
412        result.metadata_changes = compute_metadata_changes(&old_filtered, &new_filtered);
413
414        // Always recompute summary and semantic score since they depend on all sections
415        result.semantic_score = self.compute_semantic_score(&result, &old_filtered, &new_filtered);
416        result.calculate_summary();
417        Ok(result)
418    }
419
420    /// Compute the semantic score from a `DiffResult`.
421    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    /// Normalize `raw_cost` to a 0–100 similarity percentage against an
442    /// SBOM-derived upper-bound budget.
443    ///
444    /// For each cost axis we compute the worst-case contribution given the
445    /// inputs (e.g. every component on both sides being added or removed) and
446    /// sum them to form `total_budget`. The score is then
447    /// `PERCENT_MAX * (1 - raw_cost / total_budget)`, clamped to `[0, PERCENT_MAX]`
448    /// to defend against future cost-model changes where the bound might no
449    /// longer dominate. When both SBOMs are empty the budget collapses to ~0
450    /// and the function returns `PERCENT_MAX` (an empty diff is fully similar).
451    ///
452    /// Note: `modification_budget` uses the actual `modified.len()` rather
453    /// than `min(old, new)`. The actual count is still a valid upper bound on
454    /// `raw_modification_cost` (each modification contributes at most
455    /// `max(version_major, license_changed)`), and using it keeps the per-axis
456    /// scoring sensitive to the modifications that actually occurred.
457    /// `vulnerability_resolved` is intentionally excluded from the budget: it
458    /// is a reward (negative cost in `CostModel`), so it can only reduce
459    /// `raw_cost`, never push it above the bound.
460    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
500/// Upper bound of the 0–100 similarity percentage emitted by
501/// [`DiffEngine::normalize_semantic_score`].
502const 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}