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;
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::{ComponentMatcher, FuzzyMatchConfig, FuzzyMatcher, RuleEngine};
16use crate::model::NormalizedSbom;
17use std::borrow::Cow;
18
19/// Semantic diff engine for comparing SBOMs.
20#[must_use]
21pub struct DiffEngine {
22    cost_model: CostModel,
23    fuzzy_config: FuzzyMatchConfig,
24    include_unchanged: bool,
25    graph_diff_config: Option<GraphDiffConfig>,
26    rule_engine: Option<RuleEngine>,
27    custom_matcher: Option<Box<dyn ComponentMatcher>>,
28    large_sbom_config: LargeSbomConfig,
29}
30
31impl DiffEngine {
32    /// Create a new diff engine with default settings
33    pub fn new() -> Self {
34        Self {
35            cost_model: CostModel::default(),
36            fuzzy_config: FuzzyMatchConfig::balanced(),
37            include_unchanged: false,
38            graph_diff_config: None,
39            rule_engine: None,
40            custom_matcher: None,
41            large_sbom_config: LargeSbomConfig::default(),
42        }
43    }
44
45    /// Create a diff engine with a custom cost model
46    pub const fn with_cost_model(mut self, cost_model: CostModel) -> Self {
47        self.cost_model = cost_model;
48        self
49    }
50
51    /// Set fuzzy matching configuration
52    pub const fn with_fuzzy_config(mut self, config: FuzzyMatchConfig) -> Self {
53        self.fuzzy_config = config;
54        self
55    }
56
57    /// Include unchanged components in the result
58    pub const fn include_unchanged(mut self, include: bool) -> Self {
59        self.include_unchanged = include;
60        self
61    }
62
63    /// Enable graph-aware diffing with the given configuration
64    pub fn with_graph_diff(mut self, config: GraphDiffConfig) -> Self {
65        self.graph_diff_config = Some(config);
66        self
67    }
68
69    /// Set custom matching rules engine directly
70    pub fn with_rule_engine(mut self, engine: RuleEngine) -> Self {
71        self.rule_engine = Some(engine);
72        self
73    }
74
75    /// Set a custom component matcher.
76    pub fn with_matcher(mut self, matcher: Box<dyn ComponentMatcher>) -> Self {
77        self.custom_matcher = Some(matcher);
78        self
79    }
80
81    /// Configure large SBOM optimization settings.
82    pub const fn with_large_sbom_config(mut self, config: LargeSbomConfig) -> Self {
83        self.large_sbom_config = config;
84        self
85    }
86
87    /// Get the large SBOM configuration.
88    #[must_use]
89    pub const fn large_sbom_config(&self) -> &LargeSbomConfig {
90        &self.large_sbom_config
91    }
92
93    /// Check if a custom matcher is configured
94    #[must_use]
95    pub fn has_custom_matcher(&self) -> bool {
96        self.custom_matcher.is_some()
97    }
98
99    /// Check if graph diffing is enabled
100    #[must_use]
101    pub const fn graph_diff_enabled(&self) -> bool {
102        self.graph_diff_config.is_some()
103    }
104
105    /// Check if custom matching rules are configured
106    #[must_use]
107    pub const fn has_matching_rules(&self) -> bool {
108        self.rule_engine.is_some()
109    }
110
111    /// Compare two SBOMs and return the diff result
112    #[must_use = "diff result contains all changes and should not be discarded"]
113    pub fn diff(
114        &self,
115        old: &NormalizedSbom,
116        new: &NormalizedSbom,
117    ) -> Result<DiffResult, SbomDiffError> {
118        let _span = tracing::info_span!(
119            "diff_engine::diff",
120            old_components = old.component_count(),
121            new_components = new.component_count(),
122        )
123        .entered();
124
125        let mut result = DiffResult::new();
126
127        // Quick check: if content hashes match, SBOMs are identical. With
128        // --include-unchanged the caller asked for full inventory output, so
129        // the shortcut would contradict the flag (identical SBOMs would be
130        // the one case with NO inventory) — fall through instead.
131        if old.content_hash == new.content_hash && old.content_hash != 0 && !self.include_unchanged
132        {
133            result.semantic_score = PERCENT_MAX;
134            return Ok(result);
135        }
136
137        // Apply custom matching rules if configured
138        // Use Cow to avoid cloning SBOMs when no rules are applied
139        let (old_filtered, new_filtered, canonical_maps) =
140            if let Some(rule_result) = apply_rules(self.rule_engine.as_ref(), old, new) {
141                result.rules_applied = rule_result.rules_count;
142                (
143                    Cow::Owned(rule_result.old_filtered),
144                    Cow::Owned(rule_result.new_filtered),
145                    Some((rule_result.old_canonical, rule_result.new_canonical)),
146                )
147            } else {
148                (Cow::Borrowed(old), Cow::Borrowed(new), None)
149            };
150
151        // Build component mappings using the configured matcher; the matcher
152        // carries the cross-ecosystem policy so every candidate source scores
153        // uniformly.
154        let default_matcher = FuzzyMatcher::new(self.fuzzy_config.clone())
155            .with_cross_ecosystem(self.large_sbom_config.cross_ecosystem.clone());
156        let matcher: &dyn ComponentMatcher = self
157            .custom_matcher
158            .as_ref()
159            .map_or(&default_matcher as &dyn ComponentMatcher, |m| m.as_ref());
160
161        // Equivalence canonical maps act as identity bridges INSIDE matching;
162        // the result always carries real component IDs.
163        let component_matches = match_components(
164            &old_filtered,
165            &new_filtered,
166            matcher,
167            &self.large_sbom_config,
168            canonical_maps
169                .as_ref()
170                .map(|(old_map, new_map)| (old_map, new_map)),
171        );
172
173        // Compute all sections, then the derived outputs (metrics, graph
174        // diff, score, summary) via the same routines the incremental path
175        // uses — one implementation, so the two paths cannot drift.
176        self.compute_sections(
177            &old_filtered,
178            &new_filtered,
179            &component_matches,
180            matcher,
181            &ChangedSections::all_changed(),
182            &mut result,
183        );
184        self.finalize_result(
185            &old_filtered,
186            &new_filtered,
187            &component_matches,
188            true,
189            &mut result,
190        );
191        Ok(result)
192    }
193
194    /// Compute the selected change sections into `result`.
195    ///
196    /// The SINGLE implementation behind both the full path (`diff`, all
197    /// sections) and the incremental path (`diff_sections`, dirty sections
198    /// only) — hand-duplicating this orchestration is how the two paths
199    /// previously drifted (the incremental path forgot match metrics and the
200    /// graph diff).
201    fn compute_sections(
202        &self,
203        old: &NormalizedSbom,
204        new: &NormalizedSbom,
205        match_result: &ComponentMatchResult,
206        matcher: &dyn ComponentMatcher,
207        sections: &ChangedSections,
208        result: &mut DiffResult,
209    ) {
210        if sections.components {
211            let comp_computer = ComponentChangeComputer::new(self.cost_model.clone())
212                .with_include_unchanged(self.include_unchanged);
213            let comp_changes = comp_computer.compute(old, new, &match_result.matches);
214            result.components.added = comp_changes.added;
215            result.components.removed = comp_changes.removed;
216            result.components.modified = comp_changes
217                .modified
218                .into_iter()
219                .map(|mut change| {
220                    // Add match explanation for modified components. Use
221                    // stored canonical IDs directly instead of reconstructing
222                    // from name+version. Unchanged inventory entries skip the
223                    // enrichment — explaining a self-match is wasted work.
224                    if change.change_type != crate::diff::ChangeType::Unchanged
225                        && let (Some(old_id), Some(new_id)) =
226                            (&change.old_canonical_id, &change.canonical_id)
227                        && let (Some(old_comp), Some(new_comp)) =
228                            (old.components.get(old_id), new.components.get(new_id))
229                    {
230                        let pair = (old_id.clone(), new_id.clone());
231                        let match_info = if match_result.rule_bridged.contains(&pair) {
232                            // Rule provenance: the pair was matched because a
233                            // user equivalence rule declared it identical, not
234                            // because the fuzzy matcher scored it — a fuzzy
235                            // explanation here would mislabel the match.
236                            let score = match_result.pairs.get(&pair).copied().unwrap_or(1.0);
237                            MatchInfo {
238                                score,
239                                method: "EquivalenceRule".to_string(),
240                                reason: "Declared equivalent by a user matching rule".to_string(),
241                                score_breakdown: Vec::new(),
242                                normalizations: vec!["equivalence_rule".to_string()],
243                                confidence_interval: Some(
244                                    super::result::ConfidenceInterval::from_tier(
245                                        score,
246                                        "EquivalenceRule",
247                                    ),
248                                ),
249                            }
250                        } else {
251                            let explanation = matcher.explain_match(old_comp, new_comp);
252                            let mut match_info = MatchInfo::from_explanation(&explanation);
253
254                            // Use the actual score from the matching phase if available
255                            if let Some(&score) = match_result.pairs.get(&pair) {
256                                match_info.score = score;
257                            }
258                            match_info
259                        };
260
261                        change = change.with_match_info(match_info);
262                    }
263                    change
264                })
265                .collect();
266        }
267
268        if sections.dependencies {
269            let dep_computer = DependencyChangeComputer::new();
270            let dep_changes = dep_computer.compute(old, new, &match_result.matches);
271            result.dependencies.added = dep_changes.added;
272            result.dependencies.removed = dep_changes.removed;
273        }
274
275        if sections.licenses {
276            let lic_computer = LicenseChangeComputer::new();
277            let lic_changes = lic_computer.compute(old, new, &match_result.matches);
278            result.licenses.new_licenses = lic_changes.new_licenses;
279            result.licenses.removed_licenses = lic_changes.removed_licenses;
280            result.licenses.component_changes = lic_changes.component_changes;
281        }
282
283        if sections.vulnerabilities {
284            let vuln_computer = VulnerabilityChangeComputer::new();
285            let vuln_changes = vuln_computer.compute(old, new, &match_result.matches);
286            result.vulnerabilities.introduced = vuln_changes.introduced;
287            result.vulnerabilities.resolved = vuln_changes.resolved;
288            result.vulnerabilities.persistent = vuln_changes.persistent;
289            result.vulnerabilities.vex_changes = vuln_changes.vex_changes;
290        }
291
292        // Document-level metadata changes are cheap and not tracked by
293        // `ChangedSections` — always recompute rather than risk serving a
294        // stale cached vec when only the document header changed.
295        result.metadata_changes = compute_metadata_changes(old, new);
296    }
297
298    /// Derived outputs recomputed on EVERY path: match metrics (from the
299    /// always-recomputed matching), the graph diff when `refresh_graph`,
300    /// the semantic score, and the summary.
301    ///
302    /// Score normalization convention: both paths normalize against the
303    /// rule-FILTERED SBOMs (the content that was actually diffed), so a
304    /// cache-warm incremental run scores identically to a cold full run.
305    fn finalize_result(
306        &self,
307        old_filtered: &NormalizedSbom,
308        new_filtered: &NormalizedSbom,
309        match_result: &ComponentMatchResult,
310        refresh_graph: bool,
311        result: &mut DiffResult,
312    ) {
313        result.match_metrics =
314            Some(self.compute_match_metrics(match_result, old_filtered, new_filtered));
315
316        if refresh_graph && let Some(ref graph_config) = self.graph_diff_config {
317            let (graph_changes, graph_summary) = diff_dependency_graph(
318                old_filtered,
319                new_filtered,
320                &match_result.matches,
321                graph_config,
322            );
323            result.graph_changes = graph_changes;
324            result.graph_summary = Some(graph_summary);
325        }
326
327        result.semantic_score = self.compute_semantic_score(result, old_filtered, new_filtered);
328        result.calculate_summary();
329    }
330
331    /// Match metrics for observability.
332    fn compute_match_metrics(
333        &self,
334        match_result: &ComponentMatchResult,
335        old_filtered: &NormalizedSbom,
336        new_filtered: &NormalizedSbom,
337    ) -> MatchMetrics {
338        // Sorted so float accumulation order (and thus the serialized
339        // average) is identical across runs
340        let mut scores: Vec<f64> = match_result.pairs.values().copied().collect();
341        scores.sort_unstable_by(f64::total_cmp);
342        // 0.995 sits strictly between the 0.99 non-identical-name cap and
343        // the 1.0 identity tiers, so a capped near-miss never counts as
344        // an exact match.
345        let exact = scores.iter().filter(|&&s| s >= 0.995).count();
346        let fuzzy = scores.len() - exact;
347        let matched_count = scores.len();
348        let unmatched_old = old_filtered.component_count().saturating_sub(matched_count);
349        let unmatched_new = new_filtered.component_count().saturating_sub(matched_count);
350        let avg = if scores.is_empty() {
351            0.0
352        } else {
353            scores.iter().sum::<f64>() / scores.len() as f64
354        };
355        let min = scores.iter().copied().fold(f64::INFINITY, f64::min);
356
357        MatchMetrics {
358            exact_matches: exact,
359            fuzzy_matches: fuzzy,
360            // Matched pairs bridged by user equivalence rules (formerly the
361            // applied-rule tally, which conflated exclusions and per-side tags)
362            rule_matches: match_result.rule_bridged.len(),
363            unmatched_old,
364            unmatched_new,
365            avg_match_score: avg,
366            min_match_score: if min.is_infinite() { 0.0 } else { min },
367        }
368    }
369
370    /// Diff only the specified sections, reusing cached results for unchanged sections.
371    ///
372    /// This enables true incremental diffing: when only some SBOM sections changed,
373    /// we skip recomputing the unchanged sections and reuse them from the cached result.
374    /// Component matching is always recomputed since it's needed by all section computers.
375    ///
376    /// Falls back to a full diff if no cached result is provided.
377    pub(crate) fn diff_sections(
378        &self,
379        old: &NormalizedSbom,
380        new: &NormalizedSbom,
381        sections: &ChangedSections,
382        cached: &DiffResult,
383    ) -> Result<DiffResult, SbomDiffError> {
384        // Start with the cached result so unchanged sections are preserved
385        let mut result = cached.clone();
386
387        // Apply custom matching rules if configured
388        let (old_filtered, new_filtered, canonical_maps) =
389            if let Some(rule_result) = apply_rules(self.rule_engine.as_ref(), old, new) {
390                result.rules_applied = rule_result.rules_count;
391                (
392                    Cow::Owned(rule_result.old_filtered),
393                    Cow::Owned(rule_result.new_filtered),
394                    Some((rule_result.old_canonical, rule_result.new_canonical)),
395                )
396            } else {
397                (Cow::Borrowed(old), Cow::Borrowed(new), None)
398            };
399
400        // Always recompute matching — it's needed for any section computer
401        let default_matcher = FuzzyMatcher::new(self.fuzzy_config.clone())
402            .with_cross_ecosystem(self.large_sbom_config.cross_ecosystem.clone());
403        let matcher: &dyn ComponentMatcher = self
404            .custom_matcher
405            .as_ref()
406            .map_or(&default_matcher as &dyn ComponentMatcher, |m| m.as_ref());
407
408        // Equivalence canonical maps act as identity bridges INSIDE matching;
409        // the result always carries real component IDs.
410        let component_matches = match_components(
411            &old_filtered,
412            &new_filtered,
413            matcher,
414            &self.large_sbom_config,
415            canonical_maps
416                .as_ref()
417                .map(|(old_map, new_map)| (old_map, new_map)),
418        );
419
420        // Selectively recompute only the changed sections via the shared
421        // implementation, then refresh every derived output.
422        //
423        // Widening rule: every section computer consumes the component
424        // MATCHES, and matches are a function of component content — so any
425        // component change can flip a match and with it the dependency,
426        // license, and vulnerability diffs, even when those sections' own
427        // hashes are clean (a rename spliced a stale empty dependency diff).
428        // The vulnerability computer additionally reads component depths,
429        // derived from the dependency graph, so edge changes rerun it too.
430        // The only splice that survives a components-dirty diff is the
431        // components section itself never being clean in that case — the
432        // savings remain for edge-only changes (components and licenses
433        // splice) and for pure metadata changes.
434        let mut effective_sections = sections.clone();
435        effective_sections.dependencies |= sections.components;
436        effective_sections.licenses |= sections.components;
437        effective_sections.vulnerabilities |= sections.dependencies || sections.components;
438        self.compute_sections(
439            &old_filtered,
440            &new_filtered,
441            &component_matches,
442            matcher,
443            &effective_sections,
444            &mut result,
445        );
446
447        // The graph diff reads only components, edges, and matches — all
448        // pinned by the components/dependencies section hashes — so the
449        // spliced graph output is valid exactly when neither of those
450        // sections changed. Match metrics, score, and summary are refreshed
451        // unconditionally.
452        let refresh_graph = sections.components || sections.dependencies;
453        self.finalize_result(
454            &old_filtered,
455            &new_filtered,
456            &component_matches,
457            refresh_graph,
458            &mut result,
459        );
460        Ok(result)
461    }
462
463    /// Compute the semantic score from a `DiffResult`.
464    fn compute_semantic_score(
465        &self,
466        result: &DiffResult,
467        old_sbom: &NormalizedSbom,
468        new_sbom: &NormalizedSbom,
469    ) -> f64 {
470        // Unchanged inventory entries (--include-unchanged) live in the
471        // modified stream but are not changes; scoring must ignore them or
472        // the flag would alter the semantic score.
473        let modified_count = result
474            .components
475            .modified
476            .iter()
477            .filter(|c| c.change_type != crate::diff::ChangeType::Unchanged)
478            .count();
479        let raw_cost = self.cost_model.calculate_semantic_score(
480            result.components.added.len(),
481            result.components.removed.len(),
482            modified_count,
483            result.licenses.component_changes.len(),
484            result.vulnerabilities.introduced.len(),
485            result.vulnerabilities.resolved.len(),
486            result.dependencies.added.len(),
487            result.dependencies.removed.len(),
488        );
489
490        self.normalize_semantic_score(raw_cost, result, old_sbom, new_sbom)
491    }
492
493    /// Normalize `raw_cost` to a 0–100 similarity percentage against an
494    /// SBOM-derived upper-bound budget.
495    ///
496    /// For each cost axis we compute the worst-case contribution given the
497    /// inputs (e.g. every component on both sides being added or removed) and
498    /// sum them to form `total_budget`. The score is then
499    /// `PERCENT_MAX * (1 - raw_cost / total_budget)`, clamped to `[0, PERCENT_MAX]`
500    /// to defend against future cost-model changes where the bound might no
501    /// longer dominate. When both SBOMs are empty the budget collapses to ~0
502    /// and the function returns `PERCENT_MAX` (an empty diff is fully similar).
503    ///
504    /// Note: `modification_budget` uses the actual `modified.len()` rather
505    /// than `min(old, new)`. The actual count is still a valid upper bound on
506    /// `raw_modification_cost` (each modification contributes at most
507    /// `max(version_major, license_changed)`), and using it keeps the per-axis
508    /// scoring sensitive to the modifications that actually occurred.
509    /// `vulnerability_resolved` is intentionally excluded from the budget: it
510    /// is a reward (negative cost in `CostModel`), so it can only reduce
511    /// `raw_cost`, never push it above the bound.
512    fn normalize_semantic_score(
513        &self,
514        raw_cost: f64,
515        result: &DiffResult,
516        old_sbom: &NormalizedSbom,
517        new_sbom: &NormalizedSbom,
518    ) -> f64 {
519        let component_budget = (old_sbom.component_count() + new_sbom.component_count()) as f64
520            * f64::from(
521                self.cost_model
522                    .component_added
523                    .max(self.cost_model.component_removed),
524            );
525        let dependency_budget = (old_sbom.edges.len() + new_sbom.edges.len()) as f64
526            * f64::from(
527                self.cost_model
528                    .dependency_added
529                    .max(self.cost_model.dependency_removed),
530            );
531        let vulnerability_budget = (old_sbom.vulnerability_counts().total()
532            + new_sbom.vulnerability_counts().total()) as f64
533            * f64::from(self.cost_model.vulnerability_introduced);
534        // Excludes Unchanged inventory entries, mirroring compute_semantic_score.
535        let modification_budget = result
536            .components
537            .modified
538            .iter()
539            .filter(|c| c.change_type != crate::diff::ChangeType::Unchanged)
540            .count() as f64
541            * f64::from(
542                self.cost_model
543                    .version_major
544                    .max(self.cost_model.license_changed),
545            );
546
547        let total_budget =
548            component_budget + dependency_budget + vulnerability_budget + modification_budget;
549
550        if total_budget <= f64::EPSILON {
551            return PERCENT_MAX;
552        }
553
554        (PERCENT_MAX * (1.0 - (raw_cost / total_budget))).clamp(0.0, PERCENT_MAX)
555    }
556}
557
558/// Upper bound of the 0–100 similarity percentage emitted by
559/// [`DiffEngine::normalize_semantic_score`].
560const PERCENT_MAX: f64 = 100.0;
561
562impl Default for DiffEngine {
563    fn default() -> Self {
564        Self::new()
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn test_empty_diff() {
574        let engine = DiffEngine::new();
575        let sbom = NormalizedSbom::default();
576        let result = engine.diff(&sbom, &sbom).expect("diff should succeed");
577        assert!(!result.has_changes());
578    }
579
580    /// `--include-unchanged` was an end-to-end no-op: the flag is now
581    /// honored (Unchanged entries appear) without perturbing summary counts
582    /// or the semantic score.
583    #[test]
584    fn include_unchanged_emits_inventory_entries_without_changing_scores() {
585        use crate::diff::ChangeType;
586        use crate::model::{Component, DocumentMetadata};
587
588        let build = |bump_app: bool| {
589            let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
590            let app_version = if bump_app { "2.0.0" } else { "1.0.0" };
591            for (name, version) in [
592                ("app", app_version),
593                ("stable-lib", "3.1.0"),
594                ("other-lib", "0.9.0"),
595            ] {
596                let mut c = Component::new(name.to_string(), format!("pkg:npm/{name}@{version}"));
597                c.version = Some(version.to_string());
598                c.calculate_content_hash();
599                sbom.add_component(c);
600            }
601            sbom.calculate_content_hash();
602            sbom
603        };
604        let old = build(false);
605        let new = build(true);
606
607        let without = DiffEngine::new().diff(&old, &new).expect("diff");
608        let with = DiffEngine::new()
609            .include_unchanged(true)
610            .diff(&old, &new)
611            .expect("diff");
612
613        let unchanged: Vec<_> = with
614            .components
615            .modified
616            .iter()
617            .filter(|c| c.change_type == ChangeType::Unchanged)
618            .collect();
619        assert_eq!(
620            unchanged.len(),
621            2,
622            "the two stable components must appear as Unchanged inventory entries"
623        );
624        assert!(unchanged.iter().all(|c| c.cost == 0));
625
626        // Counts and scoring must be flag-invariant.
627        assert_eq!(
628            with.summary.components_modified,
629            without.summary.components_modified
630        );
631        assert_eq!(with.summary.total_changes, without.summary.total_changes);
632        assert!(
633            (with.semantic_score - without.semantic_score).abs() < 1e-9,
634            "the flag must not change the semantic score: {} vs {}",
635            with.semantic_score,
636            without.semantic_score
637        );
638
639        // Flag off: no Unchanged entries anywhere (default output unchanged).
640        assert!(
641            without
642                .components
643                .modified
644                .iter()
645                .all(|c| c.change_type != ChangeType::Unchanged)
646        );
647
648        // Inventory entries are not changes: has_changes() is flag-invariant.
649        assert_eq!(with.has_changes(), without.has_changes());
650
651        // Identical SBOMs with the flag on must still produce full inventory
652        // (the identical-hash short-circuit would otherwise make "everything
653        // unchanged" the one case with NO inventory).
654        let identical = DiffEngine::new()
655            .include_unchanged(true)
656            .diff(&old, &old)
657            .expect("diff");
658        assert_eq!(
659            identical
660                .components
661                .modified
662                .iter()
663                .filter(|c| c.change_type == ChangeType::Unchanged)
664                .count(),
665            3,
666            "identical SBOMs must yield one Unchanged entry per component"
667        );
668        assert!(!identical.has_changes());
669        assert!((identical.semantic_score - 100.0).abs() < 1e-9);
670    }
671
672    fn rules_component(name: &str, purl: &str, version: &str, id: &str) -> crate::model::Component {
673        let mut c = crate::model::Component::new(name.to_string(), id.to_string());
674        c.version = Some(version.to_string());
675        c.identifiers.purl = Some(purl.to_string());
676        c.calculate_content_hash();
677        c
678    }
679
680    fn rules_sbom(comps: Vec<crate::model::Component>) -> NormalizedSbom {
681        let mut sbom = NormalizedSbom::new(crate::model::DocumentMetadata::default());
682        for c in comps {
683            sbom.add_component(c);
684        }
685        sbom.calculate_content_hash();
686        sbom
687    }
688
689    fn equivalence_engine() -> DiffEngine {
690        use crate::matching::{AliasPattern, EquivalenceGroup, MatchingRulesConfig};
691        let rules = MatchingRulesConfig {
692            equivalences: vec![EquivalenceGroup {
693                name: Some("foo family".to_string()),
694                canonical: "pkg:npm/foo".to_string(),
695                aliases: vec![
696                    AliasPattern::exact("pkg:npm/foo-fork"),
697                    AliasPattern::exact("pkg:npm/foo-legacy"),
698                ],
699                version_sensitive: false,
700            }],
701            ..Default::default()
702        };
703        DiffEngine::new().with_rule_engine(RuleEngine::new(rules).expect("valid rules"))
704    }
705
706    /// Regression for the equivalence-remap corruption: the old
707    /// remap_match_result rewrote match keys into versionless canonical-PURL
708    /// space, so identical rule-matched components were re-reported as
709    /// Added, removed components vanished, and alias collisions silently
710    /// overwrote matches. Equivalences now bridge REAL ids during matching.
711    #[test]
712    fn equivalence_rules_bridge_matches_without_corrupting_the_diff() {
713        // Case A: identical component matching the rule on both sides —
714        // previously re-reported as Added.
715        let old = rules_sbom(vec![rules_component(
716            "foo",
717            "pkg:npm/foo",
718            "1.0.0",
719            "old-foo",
720        )]);
721        let new = rules_sbom(vec![rules_component(
722            "foo",
723            "pkg:npm/foo",
724            "1.0.0",
725            "new-foo",
726        )]);
727        let result = equivalence_engine().diff(&old, &new).expect("diff");
728        // (Document metadata legitimately differs between hand-built SBOMs;
729        // the corruption under test was component-level.)
730        assert_eq!(
731            result.summary.components_added, 0,
732            "identical rule-matched components were re-reported as Added: {:?}",
733            result.summary
734        );
735        assert_eq!(result.summary.components_removed, 0);
736        assert_eq!(result.summary.components_modified, 0);
737
738        // Case B: alias -> canonical migration bridges into ONE modified
739        // entry (real ids), not added+removed.
740        let old = rules_sbom(vec![rules_component(
741            "foo-fork",
742            "pkg:npm/foo-fork",
743            "1.0.0",
744            "old-fork",
745        )]);
746        let new = rules_sbom(vec![rules_component(
747            "foo",
748            "pkg:npm/foo",
749            "1.0.0",
750            "new-foo",
751        )]);
752        let result = equivalence_engine().diff(&old, &new).expect("diff");
753        assert_eq!(result.components.added.len(), 0, "must not report Added");
754        assert_eq!(
755            result.components.removed.len(),
756            0,
757            "must not report Removed"
758        );
759        assert_eq!(result.components.modified.len(), 1);
760        assert!(
761            result.components.modified[0]
762                .field_changes
763                .iter()
764                .any(|f| f.field == "name"),
765            "the migration must surface as a name change"
766        );
767        let match_info = result.components.modified[0]
768            .match_info
769            .as_ref()
770            .expect("bridged pair carries match info");
771        assert!(
772            (match_info.score - 1.0).abs() < 1e-9,
773            "an equivalence bridge asserts identity: score must be 1.0, got {}",
774            match_info.score
775        );
776        assert_eq!(
777            match_info.method, "EquivalenceRule",
778            "bridged pairs must carry rule provenance, not a fuzzy explanation"
779        );
780        assert_eq!(
781            result
782                .match_metrics
783                .as_ref()
784                .expect("metrics populated")
785                .rule_matches,
786            1,
787            "rule_matches must count bridged pairs"
788        );
789
790        // Case C: removed rule-matched component must still be reported
791        // (previously vanished entirely).
792        let old = rules_sbom(vec![rules_component(
793            "foo-fork",
794            "pkg:npm/foo-fork",
795            "1.0.0",
796            "old-fork",
797        )]);
798        let new = rules_sbom(vec![]);
799        let result = equivalence_engine().diff(&old, &new).expect("diff");
800        assert_eq!(result.components.removed.len(), 1);
801        assert_eq!(result.components.removed[0].name, "foo-fork");
802
803        // Case D: two aliases collapsing onto one canonical compete 1:1 —
804        // one matches, the loser is honestly reported removed (previously a
805        // silent HashMap overwrite).
806        let old = rules_sbom(vec![
807            rules_component("foo-fork", "pkg:npm/foo-fork", "1.0.0", "old-fork"),
808            rules_component("foo-legacy", "pkg:npm/foo-legacy", "1.0.0", "old-legacy"),
809        ]);
810        let new = rules_sbom(vec![rules_component(
811            "foo",
812            "pkg:npm/foo",
813            "1.0.0",
814            "new-foo",
815        )]);
816        let result = equivalence_engine().diff(&old, &new).expect("diff");
817        assert_eq!(result.components.added.len(), 0);
818        assert_eq!(result.components.modified.len(), 1);
819        assert_eq!(
820            result.components.removed.len(),
821            1,
822            "the losing alias must be reported removed, not silently dropped"
823        );
824    }
825
826    /// Exclusion rules must take a component's edges with it — leaving them
827    /// in produced dependency changes for explicitly excluded components.
828    #[test]
829    fn exclusion_rules_drop_edges_with_their_components() {
830        use crate::matching::{ExclusionRule, MatchingRulesConfig};
831        use crate::model::{DependencyEdge, DependencyType};
832
833        let rules = MatchingRulesConfig {
834            exclusions: vec![ExclusionRule::exact("pkg:npm/noise")],
835            ..Default::default()
836        };
837        let engine =
838            DiffEngine::new().with_rule_engine(RuleEngine::new(rules).expect("valid rules"));
839
840        let build = |with_edge: bool| {
841            let app = rules_component("app", "pkg:npm/app", "1.0.0", "app-ref");
842            let noise = rules_component("noise", "pkg:npm/noise", "1.0.0", "noise-ref");
843            let app_id = app.canonical_id.clone();
844            let noise_id = noise.canonical_id.clone();
845            let mut sbom = rules_sbom(vec![app, noise]);
846            if with_edge {
847                sbom.add_edge(DependencyEdge::new(
848                    app_id,
849                    noise_id,
850                    DependencyType::DependsOn,
851                ));
852            }
853            sbom.calculate_content_hash();
854            sbom
855        };
856
857        // The only difference is an edge to the EXCLUDED component.
858        let old = build(true);
859        let new = build(false);
860        let result = engine.diff(&old, &new).expect("diff");
861        assert_eq!(
862            result.dependencies.removed.len(),
863            0,
864            "edges of excluded components must not surface in the diff: {:?}",
865            result.dependencies.removed
866        );
867        assert_eq!(result.dependencies.added.len(), 0);
868    }
869
870    /// version_sensitive equivalences bridge only matching versions — the
871    /// flag was previously accepted from config and silently ignored, which
872    /// became consequential once equivalences started working at all.
873    #[test]
874    fn version_sensitive_equivalences_bridge_only_matching_versions() {
875        use crate::matching::{AliasPattern, EquivalenceGroup, MatchingRulesConfig};
876        let engine = |sensitive: bool| {
877            let rules = MatchingRulesConfig {
878                equivalences: vec![EquivalenceGroup {
879                    name: None,
880                    canonical: "pkg:npm/foo".to_string(),
881                    aliases: vec![AliasPattern::exact("pkg:npm/foo-fork")],
882                    version_sensitive: sensitive,
883                }],
884                ..Default::default()
885            };
886            DiffEngine::new().with_rule_engine(RuleEngine::new(rules).expect("valid rules"))
887        };
888
889        let old = rules_sbom(vec![rules_component(
890            "foo-fork",
891            "pkg:npm/foo-fork",
892            "1.0.0",
893            "old-fork",
894        )]);
895        let new = rules_sbom(vec![rules_component(
896            "foo",
897            "pkg:npm/foo",
898            "2.0.0",
899            "new-foo",
900        )]);
901
902        // Version-insensitive: bridges across the version bump.
903        let result = engine(false).diff(&old, &new).expect("diff");
904        assert_eq!(result.components.modified.len(), 1);
905        assert_eq!(result.components.added.len(), 0);
906
907        // Version-sensitive: 1.0.0 and 2.0.0 do not bridge; names are too
908        // different for fuzzy, so the diff is added + removed.
909        let result = engine(true).diff(&old, &new).expect("diff");
910        assert_eq!(result.components.modified.len(), 0);
911        assert_eq!(result.components.added.len(), 1);
912        assert_eq!(result.components.removed.len(), 1);
913
914        // Version-sensitive with MATCHING versions still bridges.
915        let new_same = rules_sbom(vec![rules_component(
916            "foo",
917            "pkg:npm/foo",
918            "1.0.0",
919            "new-foo-same",
920        )]);
921        let result = engine(true).diff(&old, &new_same).expect("diff");
922        assert_eq!(result.components.modified.len(), 1);
923        assert_eq!(result.components.added.len(), 0);
924    }
925}