Skip to main content

sbom_tools/diff/
incremental.rs

1//! Incremental diffing with result caching.
2//!
3//! This module provides caching and incremental computation for SBOM diffs,
4//! dramatically improving performance when comparing related SBOMs (e.g.,
5//! successive builds where only a few components change).
6//!
7//! # How It Works
8//!
9//! 1. **Content Hashing**: Each SBOM section (components, dependencies, licenses,
10//!    vulnerabilities) has a separate content hash.
11//! 2. **Change Detection**: Before recomputing, we check if each section changed.
12//! 3. **Partial Recomputation**: Only sections that changed are recomputed.
13//! 4. **Result Caching**: Full results are cached for exact SBOM pair matches.
14//!
15//! # Performance Gains
16//!
17//! - Exact cache hit: O(1) lookup
18//! - Partial change: Only recompute changed sections (typically 10-50% of work)
19//! - Cold start: Same as regular diff
20
21use crate::diff::{DiffEngine, DiffResult};
22use crate::error::SbomDiffError;
23use crate::model::NormalizedSbom;
24use std::collections::HashMap;
25use std::hash::{Hash, Hasher};
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::{Arc, RwLock};
28use std::time::{Duration, Instant};
29
30// ============================================================================
31// Cache Key Types
32// ============================================================================
33
34/// Key for full diff cache lookup.
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct DiffCacheKey {
37    /// Hash of the old SBOM
38    pub old_hash: u64,
39    /// Hash of the new SBOM
40    pub new_hash: u64,
41}
42
43impl DiffCacheKey {
44    /// Create a cache key from two SBOMs.
45    #[must_use]
46    pub const fn from_sboms(old: &NormalizedSbom, new: &NormalizedSbom) -> Self {
47        Self {
48            old_hash: old.content_hash,
49            new_hash: new.content_hash,
50        }
51    }
52}
53
54/// Section-level hashes for incremental change detection.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct SectionHashes {
57    /// Hash of all components
58    pub components: u64,
59    /// Hash of all dependency edges
60    pub dependencies: u64,
61    /// Hash of all licenses
62    pub licenses: u64,
63    /// Hash of all vulnerabilities
64    pub vulnerabilities: u64,
65}
66
67impl SectionHashes {
68    /// Compute section hashes for an SBOM.
69    ///
70    /// CORRECTNESS CONTRACT: each section hash must cover EVERY input its
71    /// section computer reads — `diff_sections` splices the previous pair's
72    /// section verbatim whenever a hash is unchanged, so an uncovered field
73    /// silently serves a stale diff (a vulnerability moving between
74    /// components and an edge scope flip both did exactly that). A hash that
75    /// covers too much only costs a spurious recompute; one that covers too
76    /// little is a correctness bug.
77    ///
78    /// Hashes are order-sensitive by design: reordering only ever produces a
79    /// false "changed", which is safe.
80    #[must_use]
81    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
82        use std::collections::hash_map::DefaultHasher;
83
84        // Component hash
85        let mut hasher = DefaultHasher::new();
86        for (id, comp) in &sbom.components {
87            id.hash(&mut hasher);
88            comp.name.hash(&mut hasher);
89            comp.version.hash(&mut hasher);
90            comp.content_hash.hash(&mut hasher);
91        }
92        let components = hasher.finish();
93
94        // Dependencies hash: every field of the DependencyChangeComputer's
95        // edge key, including scope. Enums hash by discriminant (plus the
96        // payload for `Other`) to keep the per-edge loop allocation-free.
97        let mut hasher = DefaultHasher::new();
98        for edge in &sbom.edges {
99            edge.from.hash(&mut hasher);
100            edge.to.hash(&mut hasher);
101            std::mem::discriminant(&edge.relationship).hash(&mut hasher);
102            if let crate::model::DependencyType::Other(other) = &edge.relationship {
103                other.hash(&mut hasher);
104            }
105            edge.scope
106                .as_ref()
107                .map(std::mem::discriminant)
108                .hash(&mut hasher);
109        }
110        let dependencies = hasher.finish();
111
112        // Licenses hash: the LicenseChangeComputer attributes declared
113        // licenses to their owning component, so the owner is part of the
114        // section content — without it, a license moving between components
115        // leaves the hash unchanged.
116        let mut hasher = DefaultHasher::new();
117        for (id, comp) in &sbom.components {
118            for lic in &comp.licenses.declared {
119                id.hash(&mut hasher);
120                lic.expression.hash(&mut hasher);
121            }
122        }
123        let licenses = hasher.finish();
124
125        // Vulnerabilities hash: covers the fields VulnerabilityDetail and
126        // VexStatusChange are built from — owning component, severity, CVSS,
127        // KEV/EPSS, and the effective VEX source (per-vuln falling back to
128        // per-component, mirroring VulnerabilityDetail::from_ref).
129        //
130        // Two inputs are deliberately covered elsewhere: component_depth
131        // derives from edges + the component set, so the engine reruns the
132        // vulnerability computer whenever the dependencies or components
133        // sections are dirty; and the remaining textual detail fields
134        // (source, CWEs, description, remediation, published, KEV dates) are
135        // part of Component::content_hash, which feeds the components hash
136        // above — the same rerun rule catches them.
137        //
138        // Enum fields hash by discriminant to keep this loop allocation-free.
139        let mut hasher = DefaultHasher::new();
140        for (id, comp) in &sbom.components {
141            for vuln in &comp.vulnerabilities {
142                id.hash(&mut hasher);
143                vuln.id.hash(&mut hasher);
144                vuln.severity
145                    .as_ref()
146                    .map(std::mem::discriminant)
147                    .hash(&mut hasher);
148                vuln.max_cvss_score().map(f32::to_bits).hash(&mut hasher);
149                vuln.is_kev.hash(&mut hasher);
150                vuln.epss_score.map(f64::to_bits).hash(&mut hasher);
151                let vex = vuln.vex_status.as_ref().or(comp.vex_status.as_ref());
152                vex.map(|v| std::mem::discriminant(&v.status))
153                    .hash(&mut hasher);
154                vex.and_then(|v| v.justification.as_ref().map(std::mem::discriminant))
155                    .hash(&mut hasher);
156                vex.and_then(|v| v.impact_statement.as_deref())
157                    .hash(&mut hasher);
158            }
159        }
160        let vulnerabilities = hasher.finish();
161
162        Self {
163            components,
164            dependencies,
165            licenses,
166            vulnerabilities,
167        }
168    }
169
170    /// Check which sections differ between two hash sets.
171    #[must_use]
172    pub const fn changed_sections(&self, other: &Self) -> ChangedSections {
173        ChangedSections {
174            components: self.components != other.components,
175            dependencies: self.dependencies != other.dependencies,
176            licenses: self.licenses != other.licenses,
177            vulnerabilities: self.vulnerabilities != other.vulnerabilities,
178        }
179    }
180}
181
182/// Indicates which sections changed between two SBOMs.
183#[derive(Debug, Clone, Default)]
184pub struct ChangedSections {
185    pub components: bool,
186    pub dependencies: bool,
187    pub licenses: bool,
188    pub vulnerabilities: bool,
189}
190
191impl ChangedSections {
192    /// Create a `ChangedSections` with all sections marked as changed.
193    #[must_use]
194    pub const fn all_changed() -> Self {
195        Self {
196            components: true,
197            dependencies: true,
198            licenses: true,
199            vulnerabilities: true,
200        }
201    }
202
203    /// Check if any section changed.
204    #[must_use]
205    pub const fn any(&self) -> bool {
206        self.components || self.dependencies || self.licenses || self.vulnerabilities
207    }
208
209    /// Check if all sections changed.
210    #[must_use]
211    pub const fn all(&self) -> bool {
212        self.components && self.dependencies && self.licenses && self.vulnerabilities
213    }
214
215    /// Count how many sections changed.
216    #[must_use]
217    pub fn count(&self) -> usize {
218        [
219            self.components,
220            self.dependencies,
221            self.licenses,
222            self.vulnerabilities,
223        ]
224        .iter()
225        .filter(|&&b| b)
226        .count()
227    }
228}
229
230// ============================================================================
231// Cached Entry
232// ============================================================================
233
234/// A cached diff result with metadata.
235#[derive(Debug, Clone)]
236pub struct CachedDiffResult {
237    /// The diff result
238    pub result: Arc<DiffResult>,
239    /// When this was computed
240    pub computed_at: Instant,
241    /// Section hashes from old SBOM
242    pub old_hashes: SectionHashes,
243    /// Section hashes from new SBOM
244    pub new_hashes: SectionHashes,
245}
246
247impl CachedDiffResult {
248    /// Create a new cached result.
249    #[must_use]
250    pub fn new(
251        result: Arc<DiffResult>,
252        old_hashes: SectionHashes,
253        new_hashes: SectionHashes,
254    ) -> Self {
255        Self {
256            result,
257            computed_at: Instant::now(),
258            old_hashes,
259            new_hashes,
260        }
261    }
262
263    /// Check if this entry is still valid (not expired).
264    #[must_use]
265    pub fn is_valid(&self, ttl: Duration) -> bool {
266        self.computed_at.elapsed() < ttl
267    }
268
269    /// Get age of this cache entry.
270    #[must_use]
271    pub fn age(&self) -> Duration {
272        self.computed_at.elapsed()
273    }
274}
275
276// ============================================================================
277// Diff Cache
278// ============================================================================
279
280/// Configuration for the diff cache.
281#[derive(Debug, Clone)]
282pub struct DiffCacheConfig {
283    /// Maximum number of entries to cache
284    pub max_entries: usize,
285    /// Time-to-live for cache entries
286    pub ttl: Duration,
287}
288
289impl Default for DiffCacheConfig {
290    fn default() -> Self {
291        Self {
292            max_entries: 100,
293            ttl: Duration::from_secs(3600), // 1 hour
294        }
295    }
296}
297
298/// Thread-safe cache for diff results.
299///
300/// Supports both full result caching and incremental computation
301/// when only some sections change.
302pub struct DiffCache {
303    /// Full result cache (keyed by SBOM pair hashes)
304    cache: RwLock<HashMap<DiffCacheKey, CachedDiffResult>>,
305    /// Configuration
306    config: DiffCacheConfig,
307    /// Statistics (atomics so lookups never take a write lock)
308    stats: AtomicCacheStats,
309}
310
311/// Lock-free statistics storage; `get()` previously took the map's WRITE
312/// lock (to bump a never-read per-entry hit counter) plus a stats write
313/// lock, serializing all readers.
314#[derive(Debug, Default)]
315struct AtomicCacheStats {
316    lookups: AtomicU64,
317    hits: AtomicU64,
318    misses: AtomicU64,
319    incremental_hits: AtomicU64,
320    evictions: AtomicU64,
321    time_saved_ms: AtomicU64,
322}
323
324impl AtomicCacheStats {
325    fn snapshot(&self) -> CacheStats {
326        CacheStats {
327            lookups: self.lookups.load(Ordering::Relaxed),
328            hits: self.hits.load(Ordering::Relaxed),
329            misses: self.misses.load(Ordering::Relaxed),
330            incremental_hits: self.incremental_hits.load(Ordering::Relaxed),
331            evictions: self.evictions.load(Ordering::Relaxed),
332            time_saved_ms: self.time_saved_ms.load(Ordering::Relaxed),
333        }
334    }
335}
336
337/// Statistics for cache performance.
338#[derive(Debug, Clone, Default)]
339pub struct CacheStats {
340    /// Total cache lookups
341    pub lookups: u64,
342    /// Exact cache hits
343    pub hits: u64,
344    /// Cache misses
345    pub misses: u64,
346    /// Incremental computations (partial cache hit)
347    pub incremental_hits: u64,
348    /// Entries evicted (capacity evictions plus bulk purges of expired
349    /// entries at capacity, so a single put can add more than one)
350    pub evictions: u64,
351    /// Total computation time saved (estimated)
352    pub time_saved_ms: u64,
353}
354
355impl CacheStats {
356    /// Get the cache hit rate.
357    #[must_use]
358    pub fn hit_rate(&self) -> f64 {
359        if self.lookups == 0 {
360            0.0
361        } else {
362            (self.hits + self.incremental_hits) as f64 / self.lookups as f64
363        }
364    }
365}
366
367impl DiffCache {
368    /// Create a new diff cache with default configuration.
369    #[must_use]
370    pub fn new() -> Self {
371        Self::with_config(DiffCacheConfig::default())
372    }
373
374    /// Create a new diff cache with custom configuration.
375    #[must_use]
376    pub fn with_config(config: DiffCacheConfig) -> Self {
377        Self {
378            cache: RwLock::new(HashMap::new()),
379            config,
380            stats: AtomicCacheStats::default(),
381        }
382    }
383
384    /// Look up a cached result.
385    ///
386    /// Returns `Some` if an exact match is found and still valid.
387    pub fn get(&self, key: &DiffCacheKey) -> Option<Arc<DiffResult>> {
388        let result = {
389            let cache = self.cache.read().expect("cache lock poisoned");
390            cache.get(key).and_then(|entry| {
391                entry
392                    .is_valid(self.config.ttl)
393                    .then(|| Arc::clone(&entry.result))
394            })
395        };
396
397        self.stats.lookups.fetch_add(1, Ordering::Relaxed);
398        if let Some(ref result) = result {
399            self.stats.hits.fetch_add(1, Ordering::Relaxed);
400            self.stats
401                .time_saved_ms
402                .fetch_add(Self::estimate_computation_time(result), Ordering::Relaxed);
403        } else {
404            self.stats.misses.fetch_add(1, Ordering::Relaxed);
405        }
406        result
407    }
408
409    /// Store a result in the cache.
410    pub fn put(
411        &self,
412        key: DiffCacheKey,
413        result: Arc<DiffResult>,
414        old_hashes: SectionHashes,
415        new_hashes: SectionHashes,
416    ) {
417        let mut cache = self.cache.write().expect("cache lock poisoned");
418
419        // Overwriting an existing key needs no capacity; previously it still
420        // evicted an unrelated oldest entry first.
421        if !cache.contains_key(&key) && cache.len() >= self.config.max_entries {
422            // Expired entries occupy capacity but serve no one — drop them
423            // before evicting live entries.
424            let before = cache.len();
425            cache.retain(|_, entry| entry.is_valid(self.config.ttl));
426            let expired = before - cache.len();
427            if expired > 0 {
428                self.stats
429                    .evictions
430                    .fetch_add(expired as u64, Ordering::Relaxed);
431            }
432
433            while cache.len() >= self.config.max_entries {
434                if let Some(oldest_key) = Self::find_oldest_entry(&cache) {
435                    cache.remove(&oldest_key);
436                    self.stats.evictions.fetch_add(1, Ordering::Relaxed);
437                } else {
438                    break;
439                }
440            }
441        }
442
443        cache.insert(key, CachedDiffResult::new(result, old_hashes, new_hashes));
444    }
445
446    /// Find the oldest cache entry.
447    fn find_oldest_entry(cache: &HashMap<DiffCacheKey, CachedDiffResult>) -> Option<DiffCacheKey> {
448        cache
449            .iter()
450            .max_by_key(|(_, entry)| entry.age())
451            .map(|(key, _)| key.clone())
452    }
453
454    /// Estimate computation time based on result size.
455    fn estimate_computation_time(result: &DiffResult) -> u64 {
456        // Rough estimate: 1ms per 10 components
457        let component_count = result.components.added.len()
458            + result.components.removed.len()
459            + result.components.modified.len();
460        (component_count / 10).max(1) as u64
461    }
462
463    /// Get cache statistics.
464    pub fn stats(&self) -> CacheStats {
465        self.stats.snapshot()
466    }
467
468    /// Clear all cached entries.
469    pub fn clear(&self) {
470        let mut cache = self.cache.write().expect("cache lock poisoned");
471        cache.clear();
472    }
473
474    /// Get the number of cached entries.
475    pub fn len(&self) -> usize {
476        self.cache.read().expect("cache lock poisoned").len()
477    }
478
479    /// Check if the cache is empty.
480    pub fn is_empty(&self) -> bool {
481        self.cache.read().expect("cache lock poisoned").is_empty()
482    }
483}
484
485impl Default for DiffCache {
486    fn default() -> Self {
487        Self::new()
488    }
489}
490
491// ============================================================================
492// Incremental Diff Engine
493// ============================================================================
494
495/// Metadata about the last diffed pair, used for incremental updates.
496struct LastDiffMeta {
497    /// Cache key of the last diffed pair
498    key: DiffCacheKey,
499    /// Section hashes from the last pair's old SBOM
500    old_hashes: SectionHashes,
501    /// Section hashes from the last pair's new SBOM
502    new_hashes: SectionHashes,
503}
504
505/// A diff engine wrapper that supports incremental computation and caching.
506///
507/// Wraps the standard `DiffEngine` and adds:
508/// - Result caching for repeated comparisons
509/// - Section-level change detection
510/// - Incremental recomputation for partial changes
511pub struct IncrementalDiffEngine {
512    /// The underlying diff engine
513    engine: DiffEngine,
514    /// Result cache
515    cache: DiffCache,
516    /// Track previous computation for incremental updates
517    last_diff: RwLock<Option<LastDiffMeta>>,
518}
519
520impl IncrementalDiffEngine {
521    /// Create a new incremental diff engine.
522    #[must_use]
523    pub fn new(engine: DiffEngine) -> Self {
524        Self {
525            engine,
526            cache: DiffCache::new(),
527            last_diff: RwLock::new(None),
528        }
529    }
530
531    /// Create with custom cache configuration.
532    #[must_use]
533    pub fn with_cache_config(engine: DiffEngine, config: DiffCacheConfig) -> Self {
534        Self {
535            engine,
536            cache: DiffCache::with_config(config),
537            last_diff: RwLock::new(None),
538        }
539    }
540
541    /// Perform a diff, using cache when possible.
542    ///
543    /// Returns the diff result and metadata about cache usage.
544    ///
545    /// # Errors
546    ///
547    /// Returns an error if the underlying diff computation fails.
548    pub fn diff(
549        &self,
550        old: &NormalizedSbom,
551        new: &NormalizedSbom,
552    ) -> Result<IncrementalDiffResult, SbomDiffError> {
553        let start = Instant::now();
554
555        // Hand-built SBOMs carry content_hash == 0 (the documented "unset"
556        // state the engine's identical-SBOM short-circuit also respects).
557        // Every such pair would collide on the (0,0) cache key and be served
558        // each other's cached results, and the last-pair splice base would be
559        // unidentifiable — bypass the incremental machinery entirely.
560        if old.content_hash == 0 || new.content_hash == 0 {
561            let result = self.engine.diff(old, new)?;
562            return Ok(IncrementalDiffResult {
563                result: Arc::new(result),
564                cache_hit: CacheHitType::Miss,
565                sections_recomputed: ChangedSections::all_changed(),
566                computation_time: start.elapsed(),
567            });
568        }
569
570        let cache_key = DiffCacheKey::from_sboms(old, new);
571
572        // Check for exact cache hit — shared, not deep-cloned back out.
573        if let Some(mut cached) = self.cache.get(&cache_key) {
574            // Day-count fields embed the day they were computed; a hit
575            // across midnight would serve yesterday's counts. The clone
576            // only happens when a count actually changed (rare).
577            if cached.day_counts_stale() {
578                Arc::make_mut(&mut cached).refresh_derived_day_counts();
579            }
580            return Ok(IncrementalDiffResult {
581                result: cached,
582                cache_hit: CacheHitType::Full,
583                sections_recomputed: ChangedSections::default(),
584                computation_time: start.elapsed(),
585            });
586        }
587
588        // Compute section hashes
589        let old_hashes = SectionHashes::from_sbom(old);
590        let new_hashes = SectionHashes::from_sbom(new);
591
592        // Check for incremental opportunity against the last diffed pair
593        let (changed, prev_key) = {
594            let last = self.last_diff.read().expect("last_diff lock poisoned");
595            match &*last {
596                Some(meta) => {
597                    let old_changed = old_hashes != meta.old_hashes;
598                    let new_changed = new_hashes != meta.new_hashes;
599
600                    if !old_changed && !new_changed {
601                        // Nothing changed, but we don't have the result cached
602                        // This shouldn't normally happen, but fall through to full compute
603                        (None, None)
604                    } else {
605                        (
606                            Some(
607                                meta.old_hashes
608                                    .changed_sections(&old_hashes)
609                                    .or(&meta.new_hashes.changed_sections(&new_hashes)),
610                            ),
611                            Some(meta.key.clone()),
612                        )
613                    }
614                }
615                None => (None, None),
616            }
617        };
618
619        // Section-selective or full computation
620        let (result, cache_hit, sections_recomputed) = if let Some(ref changed) = changed
621            && let Some(ref prev_key) = prev_key
622            && !changed.all()
623            && changed.any()
624        {
625            // Change detection is relative to the last diffed pair, so only
626            // that exact pair's cached result is a valid splice base
627            if let Some(prev_result) = self.find_previous_result(prev_key) {
628                match self.engine.diff_sections(old, new, changed, &prev_result) {
629                    Ok(result) => (result, CacheHitType::Partial, changed.clone()),
630                    Err(_) => {
631                        // Fall back to full computation on diff_sections errors
632                        let result = self.engine.diff(old, new)?;
633                        (result, CacheHitType::Miss, ChangedSections::all_changed())
634                    }
635                }
636            } else {
637                // Last pair's entry was evicted or expired — full computation
638                let result = self.engine.diff(old, new)?;
639                (result, CacheHitType::Miss, ChangedSections::all_changed())
640            }
641        } else {
642            // Either no change detection possible, or all sections changed — full computation
643            let result = self.engine.diff(old, new)?;
644            let sections = changed.unwrap_or_else(ChangedSections::all_changed);
645            (result, CacheHitType::Miss, sections)
646        };
647
648        // Track incremental hits in cache stats
649        if cache_hit == CacheHitType::Partial {
650            self.cache
651                .stats
652                .incremental_hits
653                .fetch_add(1, Ordering::Relaxed);
654        }
655
656        // Partial splices reuse cached vulnerability sections, which carry
657        // day counts from when the splice base was computed.
658        let mut result = result;
659        if cache_hit == CacheHitType::Partial {
660            result.refresh_derived_day_counts();
661        }
662
663        // Cache the result: Arc hand-off, no deep clone of the DiffResult.
664        let result = Arc::new(result);
665        self.cache.put(
666            cache_key.clone(),
667            Arc::clone(&result),
668            old_hashes.clone(),
669            new_hashes.clone(),
670        );
671
672        // Update last diff metadata
673        *self.last_diff.write().expect("last_diff lock poisoned") = Some(LastDiffMeta {
674            key: cache_key,
675            old_hashes,
676            new_hashes,
677        });
678
679        Ok(IncrementalDiffResult {
680            result,
681            cache_hit,
682            sections_recomputed,
683            computation_time: start.elapsed(),
684        })
685    }
686
687    /// Find the last diffed pair's cached result to use as a base for
688    /// incremental recomputation.
689    ///
690    /// Section change detection is computed against the last diffed pair, so
691    /// only that exact pair's cached entry is a valid splice base.
692    fn find_previous_result(&self, key: &DiffCacheKey) -> Option<Arc<DiffResult>> {
693        let cache = self.cache.cache.read().ok()?;
694        cache
695            .get(key)
696            .filter(|e| e.is_valid(self.cache.config.ttl))
697            .map(|e| Arc::clone(&e.result))
698    }
699
700    /// Get the underlying engine.
701    pub const fn engine(&self) -> &DiffEngine {
702        &self.engine
703    }
704
705    /// Get cache statistics.
706    pub fn cache_stats(&self) -> CacheStats {
707        self.cache.stats()
708    }
709
710    /// Clear the cache.
711    pub fn clear_cache(&self) {
712        self.cache.clear();
713    }
714}
715
716impl ChangedSections {
717    /// Combine two `ChangedSections` with OR logic.
718    const fn or(&self, other: &Self) -> Self {
719        Self {
720            components: self.components || other.components,
721            dependencies: self.dependencies || other.dependencies,
722            licenses: self.licenses || other.licenses,
723            vulnerabilities: self.vulnerabilities || other.vulnerabilities,
724        }
725    }
726}
727
728/// Type of cache hit achieved.
729#[derive(Debug, Clone, Copy, PartialEq, Eq)]
730pub enum CacheHitType {
731    /// Full result was in cache
732    Full,
733    /// Partial cache hit, some sections reused
734    Partial,
735    /// No cache hit, full computation required
736    Miss,
737}
738
739/// Result of an incremental diff operation.
740#[derive(Debug)]
741pub struct IncrementalDiffResult {
742    /// The diff result, shared with the cache entry (no deep clone on
743    /// either the miss or the full-hit path)
744    pub result: Arc<DiffResult>,
745    /// Type of cache hit
746    pub cache_hit: CacheHitType,
747    /// Which sections were recomputed (false = reused from cache)
748    pub sections_recomputed: ChangedSections,
749    /// Time taken for this operation
750    pub computation_time: Duration,
751}
752
753impl IncrementalDiffResult {
754    /// Get the diff result.
755    pub fn into_result(self) -> DiffResult {
756        // The cache usually holds the other reference; clone only then.
757        Arc::try_unwrap(self.result).unwrap_or_else(|shared| (*shared).clone())
758    }
759
760    /// Check if this was a cache hit.
761    #[must_use]
762    pub fn was_cached(&self) -> bool {
763        self.cache_hit == CacheHitType::Full
764    }
765}
766
767// ============================================================================
768// Tests
769// ============================================================================
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774    use crate::model::DocumentMetadata;
775
776    fn make_sbom(name: &str, components: &[&str]) -> NormalizedSbom {
777        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
778        for comp_name in components {
779            let comp = crate::model::Component::new(
780                comp_name.to_string(),
781                format!("{}-{}", name, comp_name),
782            );
783            sbom.add_component(comp);
784        }
785        // Ensure unique content hash
786        sbom.content_hash = {
787            use std::collections::hash_map::DefaultHasher;
788            let mut hasher = DefaultHasher::new();
789            name.hash(&mut hasher);
790            for c in components {
791                c.hash(&mut hasher);
792            }
793            hasher.finish()
794        };
795        sbom
796    }
797
798    #[test]
799    fn test_section_hashes() {
800        let sbom1 = make_sbom("test1", &["a", "b", "c"]);
801        let sbom2 = make_sbom("test2", &["a", "b", "c"]);
802        let sbom3 = make_sbom("test3", &["a", "b", "d"]);
803
804        let hash1 = SectionHashes::from_sbom(&sbom1);
805        let hash2 = SectionHashes::from_sbom(&sbom2);
806        let hash3 = SectionHashes::from_sbom(&sbom3);
807
808        // Different SBOMs with same components should have different component hashes
809        // (because canonical IDs differ)
810        assert_ne!(hash1.components, hash2.components);
811
812        // Different components should definitely differ
813        assert_ne!(hash1.components, hash3.components);
814    }
815
816    #[test]
817    fn test_changed_sections() {
818        let hash1 = SectionHashes {
819            components: 100,
820            dependencies: 200,
821            licenses: 300,
822            vulnerabilities: 400,
823        };
824
825        let hash2 = SectionHashes {
826            components: 100,
827            dependencies: 200,
828            licenses: 999, // Changed
829            vulnerabilities: 400,
830        };
831
832        let changed = hash1.changed_sections(&hash2);
833        assert!(!changed.components);
834        assert!(!changed.dependencies);
835        assert!(changed.licenses);
836        assert!(!changed.vulnerabilities);
837        assert_eq!(changed.count(), 1);
838    }
839
840    #[test]
841    fn test_diff_cache_basic() {
842        let cache = DiffCache::new();
843        let key = DiffCacheKey {
844            old_hash: 123,
845            new_hash: 456,
846        };
847
848        // Initially empty
849        assert!(cache.get(&key).is_none());
850        assert!(cache.is_empty());
851
852        // Add a result
853        let result = DiffResult::new();
854        let hashes = SectionHashes {
855            components: 0,
856            dependencies: 0,
857            licenses: 0,
858            vulnerabilities: 0,
859        };
860        cache.put(
861            key.clone(),
862            Arc::new(result),
863            hashes.clone(),
864            hashes.clone(),
865        );
866
867        // Should be retrievable
868        assert!(cache.get(&key).is_some());
869        assert_eq!(cache.len(), 1);
870
871        // Stats should show 1 hit, 1 miss
872        let stats = cache.stats();
873        assert_eq!(stats.hits, 1);
874        assert_eq!(stats.misses, 1);
875    }
876
877    #[test]
878    fn test_diff_cache_eviction() {
879        let config = DiffCacheConfig {
880            max_entries: 3,
881            ttl: Duration::from_secs(3600),
882        };
883        let cache = DiffCache::with_config(config);
884
885        let hashes = SectionHashes {
886            components: 0,
887            dependencies: 0,
888            licenses: 0,
889            vulnerabilities: 0,
890        };
891
892        // Add 5 entries, should only keep 3
893        for i in 0..5 {
894            let key = DiffCacheKey {
895                old_hash: i,
896                new_hash: i + 100,
897            };
898            cache.put(
899                key,
900                Arc::new(DiffResult::new()),
901                hashes.clone(),
902                hashes.clone(),
903            );
904        }
905
906        assert_eq!(cache.len(), 3);
907    }
908
909    #[test]
910    fn test_cache_hit_type() {
911        assert_eq!(CacheHitType::Full, CacheHitType::Full);
912        assert_ne!(CacheHitType::Full, CacheHitType::Miss);
913    }
914
915    #[test]
916    fn test_incremental_diff_engine() {
917        let engine = DiffEngine::new();
918        let incremental = IncrementalDiffEngine::new(engine);
919
920        let old = make_sbom("old", &["a", "b", "c"]);
921        let new = make_sbom("new", &["a", "b", "d"]);
922
923        // First diff should be a miss
924        let result1 = incremental.diff(&old, &new).expect("diff should succeed");
925        assert_eq!(result1.cache_hit, CacheHitType::Miss);
926
927        // Same diff should be a hit
928        let result2 = incremental.diff(&old, &new).expect("diff should succeed");
929        assert_eq!(result2.cache_hit, CacheHitType::Full);
930
931        // Stats should reflect this
932        let stats = incremental.cache_stats();
933        assert_eq!(stats.hits, 1);
934        assert_eq!(stats.misses, 1);
935    }
936
937    #[test]
938    fn test_changed_sections_all_changed() {
939        let all = ChangedSections::all_changed();
940        assert!(all.components);
941        assert!(all.dependencies);
942        assert!(all.licenses);
943        assert!(all.vulnerabilities);
944        assert!(all.all());
945        assert!(all.any());
946        assert_eq!(all.count(), 4);
947    }
948
949    #[test]
950    fn test_changed_sections_or_combine() {
951        let a = ChangedSections {
952            components: true,
953            dependencies: false,
954            licenses: false,
955            vulnerabilities: false,
956        };
957        let b = ChangedSections {
958            components: false,
959            dependencies: false,
960            licenses: true,
961            vulnerabilities: false,
962        };
963        let combined = a.or(&b);
964        assert!(combined.components);
965        assert!(!combined.dependencies);
966        assert!(combined.licenses);
967        assert!(!combined.vulnerabilities);
968        assert_eq!(combined.count(), 2);
969    }
970
971    #[test]
972    fn test_diff_sections_selective_recomputation() {
973        // Test that diff_sections on DiffEngine produces a valid result
974        // when only a subset of sections are marked as changed
975        let engine = DiffEngine::new();
976        let old = make_sbom("old", &["a", "b", "c"]);
977        let new = make_sbom("new", &["a", "b", "d"]);
978
979        // Full diff first to get a baseline
980        let full_result = engine.diff(&old, &new).expect("diff should succeed");
981
982        // Now do a section-selective diff recomputing only components
983        let sections = ChangedSections {
984            components: true,
985            dependencies: false,
986            licenses: false,
987            vulnerabilities: false,
988        };
989        let selective_result = engine
990            .diff_sections(&old, &new, &sections, &full_result)
991            .expect("diff_sections should succeed");
992
993        // Components should be freshly computed — same as full diff
994        assert_eq!(
995            selective_result.components.added.len(),
996            full_result.components.added.len()
997        );
998        assert_eq!(
999            selective_result.components.removed.len(),
1000            full_result.components.removed.len()
1001        );
1002        assert_eq!(
1003            selective_result.components.modified.len(),
1004            full_result.components.modified.len()
1005        );
1006
1007        // Dependencies were not recomputed — should be preserved from cached
1008        assert_eq!(
1009            selective_result.dependencies.added.len(),
1010            full_result.dependencies.added.len()
1011        );
1012        assert_eq!(
1013            selective_result.dependencies.removed.len(),
1014            full_result.dependencies.removed.len()
1015        );
1016    }
1017
1018    #[test]
1019    fn test_diff_sections_all_changed_matches_full_diff() {
1020        // When all sections are marked as changed, diff_sections should produce
1021        // the same result as a full diff
1022        let engine = DiffEngine::new();
1023        let old = make_sbom("old", &["a", "b", "c"]);
1024        let new = make_sbom("new", &["a", "b", "d"]);
1025
1026        let full_result = engine.diff(&old, &new).expect("diff should succeed");
1027        let sections = ChangedSections::all_changed();
1028        let selective_result = engine
1029            .diff_sections(&old, &new, &sections, &DiffResult::new())
1030            .expect("diff_sections should succeed");
1031
1032        assert_eq!(
1033            selective_result.components.added.len(),
1034            full_result.components.added.len()
1035        );
1036        assert_eq!(
1037            selective_result.components.removed.len(),
1038            full_result.components.removed.len()
1039        );
1040        assert_eq!(
1041            selective_result.vulnerabilities.introduced.len(),
1042            full_result.vulnerabilities.introduced.len()
1043        );
1044    }
1045
1046    #[test]
1047    fn test_incremental_partial_change_detection() {
1048        // Simulate the incremental path: diff two SBOMs, then diff again
1049        // with a slightly different new SBOM that shares the same old SBOM.
1050        // This tests that the engine detects partial changes and attempts
1051        // section-selective diff.
1052        let engine = DiffEngine::new();
1053        let incremental = IncrementalDiffEngine::new(engine);
1054
1055        let old = make_sbom("old", &["a", "b", "c"]);
1056        let new1 = make_sbom("new1", &["a", "b", "d"]);
1057
1058        // First diff populates the last-diff metadata
1059        let result1 = incremental.diff(&old, &new1).expect("diff should succeed");
1060        assert_eq!(result1.cache_hit, CacheHitType::Miss);
1061
1062        // Second diff with different SBOMs (different content hashes = no exact cache hit)
1063        // but the last-diff metadata is now set, so change detection runs
1064        let new2 = make_sbom("new2", &["a", "b", "e"]);
1065        let result2 = incremental.diff(&old, &new2).expect("diff should succeed");
1066
1067        // This should either be a Partial hit (if section-selective kicked in)
1068        // or a Miss (if all sections changed). Either way, it should produce a valid result.
1069        assert!(
1070            result2.cache_hit == CacheHitType::Partial || result2.cache_hit == CacheHitType::Miss
1071        );
1072        // The result should have been computed successfully regardless
1073        assert!(result2.sections_recomputed.any());
1074    }
1075
1076    #[test]
1077    fn test_find_previous_result_empty_cache() {
1078        let engine = DiffEngine::new();
1079        let incremental = IncrementalDiffEngine::new(engine);
1080        let key = DiffCacheKey {
1081            old_hash: 1,
1082            new_hash: 2,
1083        };
1084        // With an empty cache, find_previous_result should return None
1085        assert!(incremental.find_previous_result(&key).is_none());
1086    }
1087
1088    #[test]
1089    fn test_find_previous_result_after_diff() {
1090        let engine = DiffEngine::new();
1091        let incremental = IncrementalDiffEngine::new(engine);
1092
1093        let old = make_sbom("old", &["a", "b"]);
1094        let new = make_sbom("new", &["a", "c"]);
1095
1096        // Populate the cache
1097        let _ = incremental.diff(&old, &new).expect("diff should succeed");
1098
1099        // The diffed pair's key should be retrievable; an unrelated key should not
1100        let key = DiffCacheKey::from_sboms(&old, &new);
1101        assert!(incremental.find_previous_result(&key).is_some());
1102        let other_key = DiffCacheKey {
1103            old_hash: key.old_hash.wrapping_add(1),
1104            new_hash: key.new_hash,
1105        };
1106        assert!(incremental.find_previous_result(&other_key).is_none());
1107    }
1108
1109    fn assert_sections_match(actual: &DiffResult, expected: &DiffResult) {
1110        assert_eq!(
1111            actual.components.added.len(),
1112            expected.components.added.len()
1113        );
1114        assert_eq!(
1115            actual.components.removed.len(),
1116            expected.components.removed.len()
1117        );
1118        assert_eq!(
1119            actual.components.modified.len(),
1120            expected.components.modified.len()
1121        );
1122        assert_eq!(
1123            actual.licenses.new_licenses.len(),
1124            expected.licenses.new_licenses.len()
1125        );
1126        assert_eq!(
1127            actual.licenses.removed_licenses.len(),
1128            expected.licenses.removed_licenses.len()
1129        );
1130        assert_eq!(
1131            actual.vulnerabilities.introduced.len(),
1132            expected.vulnerabilities.introduced.len()
1133        );
1134        assert_eq!(
1135            actual.vulnerabilities.resolved.len(),
1136            expected.vulnerabilities.resolved.len()
1137        );
1138        assert_eq!(
1139            actual.dependencies.added.len(),
1140            expected.dependencies.added.len()
1141        );
1142        assert_eq!(
1143            actual.dependencies.removed.len(),
1144            expected.dependencies.removed.len()
1145        );
1146    }
1147
1148    #[test]
1149    fn test_no_cross_pair_section_splice() {
1150        // Note: DiffEngine::diff has no constructible Err path today, so the
1151        // Result-propagation change is covered at the type level only.
1152        let incremental = IncrementalDiffEngine::new(DiffEngine::new());
1153
1154        let a_old = make_sbom("a-old", &["a", "b", "c"]);
1155        let a_new = make_sbom("a-new", &["a", "b", "d"]);
1156        let b_old = make_sbom("b-old", &["x", "y"]);
1157        let b_new = make_sbom("b-new", &["x", "z", "w"]);
1158
1159        // Pair A first, then pair B through the same engine
1160        let _ = incremental
1161            .diff(&a_old, &a_new)
1162            .expect("diff should succeed");
1163        let b_result = incremental
1164            .diff(&b_old, &b_new)
1165            .expect("diff should succeed");
1166
1167        // Every section of B's result must match a from-scratch full diff —
1168        // no sections spliced in from pair A's cached result
1169        let fresh = DiffEngine::new()
1170            .diff(&b_old, &b_new)
1171            .expect("diff should succeed");
1172        assert_sections_match(&b_result.result, &fresh);
1173    }
1174
1175    #[test]
1176    fn test_partial_splice_uses_last_pair_base() {
1177        let incremental = IncrementalDiffEngine::new(DiffEngine::new());
1178
1179        let s0 = make_sbom("s0", &["a", "b", "c"]);
1180        let s1 = make_sbom("s1", &["a", "b", "d"]);
1181        let s2 = make_sbom("s2", &["a", "b", "e"]);
1182
1183        // s0->s1 first so that s0->s2 only differs in the components section
1184        let _ = incremental.diff(&s0, &s1).expect("diff should succeed");
1185        let result = incremental.diff(&s0, &s2).expect("diff should succeed");
1186        assert_eq!(result.cache_hit, CacheHitType::Partial);
1187
1188        // The spliced result must match a from-scratch full diff of s0->s2
1189        let fresh = DiffEngine::new()
1190            .diff(&s0, &s2)
1191            .expect("diff should succeed");
1192        assert_sections_match(&result.result, &fresh);
1193    }
1194
1195    // ------------------------------------------------------------------
1196    // Stale-splice regressions: every scenario below previously produced a
1197    // Partial hit whose section hash missed the change, splicing a stale
1198    // section from the previous pair's result. Each test primes the engine
1199    // with (base, v1), diffs (base, v2), and requires the result to be
1200    // byte-equivalent (via serde) to a from-scratch full diff.
1201    // ------------------------------------------------------------------
1202
1203    use crate::model::{
1204        Component, DependencyEdge, DependencyScope, DependencyType, LicenseExpression,
1205        Organization, Severity, VexState, VexStatus, VulnerabilityRef, VulnerabilitySource,
1206    };
1207
1208    fn rich_comp(name: &str, version: &str) -> Component {
1209        let mut c = Component::new(name.to_string(), format!("pkg:npm/{name}@{version}"));
1210        c.version = Some(version.to_string());
1211        c
1212    }
1213
1214    fn with_vuln(mut c: Component, id: &str, severity: Severity) -> Component {
1215        let mut v = VulnerabilityRef::new(id.to_string(), VulnerabilitySource::Osv);
1216        v.severity = Some(severity);
1217        c.vulnerabilities.push(v);
1218        c
1219    }
1220
1221    fn with_license(mut c: Component, expr: &str) -> Component {
1222        c.licenses
1223            .add_declared(LicenseExpression::new(expr.to_string()));
1224        c
1225    }
1226
1227    fn rich_sbom(comps: Vec<Component>, edges: Vec<DependencyEdge>) -> NormalizedSbom {
1228        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1229        for mut c in comps {
1230            c.calculate_content_hash();
1231            sbom.add_component(c);
1232        }
1233        for e in edges {
1234            sbom.add_edge(e);
1235        }
1236        sbom.calculate_content_hash();
1237        sbom
1238    }
1239
1240    fn edge(from: &Component, to: &Component, scope: Option<DependencyScope>) -> DependencyEdge {
1241        let mut e = DependencyEdge::new(
1242            from.canonical_id.clone(),
1243            to.canonical_id.clone(),
1244            DependencyType::DependsOn,
1245        );
1246        e.scope = scope;
1247        e
1248    }
1249
1250    /// Prime with (base, v1), diff (base, v2), and require equivalence with
1251    /// a from-scratch full diff of (base, v2).
1252    fn assert_incremental_matches_full<F>(
1253        engine: F,
1254        base: &NormalizedSbom,
1255        v1: &NormalizedSbom,
1256        v2: &NormalizedSbom,
1257    ) where
1258        F: Fn() -> DiffEngine,
1259    {
1260        let incremental = IncrementalDiffEngine::new(engine());
1261        let _ = incremental.diff(base, v1).expect("prime diff");
1262        let got = incremental.diff(base, v2).expect("target diff");
1263        assert_eq!(
1264            got.cache_hit,
1265            CacheHitType::Partial,
1266            "test fixture must exercise the partial-splice path \
1267             (recomputed: {:?})",
1268            got.sections_recomputed
1269        );
1270
1271        let fresh = engine().diff(base, v2).expect("full diff");
1272        assert_eq!(
1273            serde_json::to_value(got.result.as_ref()).expect("serialize incremental"),
1274            serde_json::to_value(&fresh).expect("serialize full"),
1275            "incremental result diverged from a from-scratch full diff"
1276        );
1277    }
1278
1279    #[test]
1280    fn vulnerability_moving_between_components_is_not_spliced_stale() {
1281        let base = rich_sbom(
1282            vec![
1283                with_vuln(rich_comp("liba", "1.0.0"), "CVE-2024-0001", Severity::High),
1284                rich_comp("libb", "1.0.0"),
1285                rich_comp("app", "1.0.0"),
1286            ],
1287            vec![],
1288        );
1289        let v1 = rich_sbom(
1290            vec![
1291                with_vuln(rich_comp("liba", "1.0.0"), "CVE-2024-0001", Severity::High),
1292                rich_comp("libb", "1.0.0"),
1293                rich_comp("app", "2.0.0"),
1294            ],
1295            vec![],
1296        );
1297        // Same vuln id, different owning component: the old id-only section
1298        // hash was identical to v1's, splicing v1's stale attribution.
1299        let v2 = rich_sbom(
1300            vec![
1301                rich_comp("liba", "1.0.0"),
1302                with_vuln(rich_comp("libb", "1.0.0"), "CVE-2024-0001", Severity::High),
1303                rich_comp("app", "2.0.0"),
1304            ],
1305            vec![],
1306        );
1307        assert_incremental_matches_full(DiffEngine::new, &base, &v1, &v2);
1308    }
1309
1310    #[test]
1311    fn vulnerability_severity_change_is_not_spliced_stale() {
1312        let base = rich_sbom(
1313            vec![
1314                with_vuln(rich_comp("liba", "1.0.0"), "CVE-2024-0002", Severity::Low),
1315                rich_comp("app", "1.0.0"),
1316            ],
1317            vec![],
1318        );
1319        let v1 = rich_sbom(
1320            vec![
1321                with_vuln(rich_comp("liba", "1.0.0"), "CVE-2024-0002", Severity::Low),
1322                rich_comp("app", "2.0.0"),
1323            ],
1324            vec![],
1325        );
1326        let v2 = rich_sbom(
1327            vec![
1328                with_vuln(
1329                    rich_comp("liba", "1.0.0"),
1330                    "CVE-2024-0002",
1331                    Severity::Critical,
1332                ),
1333                rich_comp("app", "2.0.0"),
1334            ],
1335            vec![],
1336        );
1337        assert_incremental_matches_full(DiffEngine::new, &base, &v1, &v2);
1338    }
1339
1340    #[test]
1341    fn vex_status_change_is_not_spliced_stale() {
1342        let vex_comp = |state: Option<VexState>| {
1343            let mut c = rich_comp("liba", "1.0.0");
1344            let mut v =
1345                VulnerabilityRef::new("CVE-2024-0003".to_string(), VulnerabilitySource::Osv);
1346            v.severity = Some(Severity::High);
1347            v.vex_status = state.map(VexStatus::new);
1348            c.vulnerabilities.push(v);
1349            c
1350        };
1351        let base = rich_sbom(vec![vex_comp(None), rich_comp("app", "1.0.0")], vec![]);
1352        let v1 = rich_sbom(vec![vex_comp(None), rich_comp("app", "2.0.0")], vec![]);
1353        let v2 = rich_sbom(
1354            vec![
1355                vex_comp(Some(VexState::NotAffected)),
1356                rich_comp("app", "2.0.0"),
1357            ],
1358            vec![],
1359        );
1360        assert_incremental_matches_full(DiffEngine::new, &base, &v1, &v2);
1361    }
1362
1363    #[test]
1364    fn license_moving_between_components_is_not_spliced_stale() {
1365        let base = rich_sbom(
1366            vec![
1367                with_license(rich_comp("liba", "1.0.0"), "MIT"),
1368                rich_comp("libb", "1.0.0"),
1369                rich_comp("app", "1.0.0"),
1370            ],
1371            vec![],
1372        );
1373        let v1 = rich_sbom(
1374            vec![
1375                with_license(rich_comp("liba", "1.0.0"), "MIT"),
1376                rich_comp("libb", "1.0.0"),
1377                rich_comp("app", "2.0.0"),
1378            ],
1379            vec![],
1380        );
1381        // Same flattened expression sequence, different owner.
1382        let v2 = rich_sbom(
1383            vec![
1384                rich_comp("liba", "1.0.0"),
1385                with_license(rich_comp("libb", "1.0.0"), "MIT"),
1386                rich_comp("app", "2.0.0"),
1387            ],
1388            vec![],
1389        );
1390        assert_incremental_matches_full(DiffEngine::new, &base, &v1, &v2);
1391    }
1392
1393    #[test]
1394    fn edge_scope_change_is_not_spliced_stale() {
1395        let a = rich_comp("a", "1.0.0");
1396        let b = rich_comp("b", "1.0.0");
1397        let base = rich_sbom(
1398            vec![a.clone(), b.clone()],
1399            vec![edge(&a, &b, Some(DependencyScope::Required))],
1400        );
1401        let a2 = rich_comp("a", "2.0.0");
1402        let v1 = rich_sbom(
1403            vec![a2.clone(), b.clone()],
1404            vec![edge(&a2, &b, Some(DependencyScope::Required))],
1405        );
1406        // Only the edge scope flips between v1 and v2: the old section hash
1407        // (from/to/relationship only) saw no change and spliced v1's empty
1408        // dependency diff.
1409        let v2 = rich_sbom(
1410            vec![a2.clone(), b.clone()],
1411            vec![edge(&a2, &b, Some(DependencyScope::Optional))],
1412        );
1413        assert_incremental_matches_full(DiffEngine::new, &base, &v1, &v2);
1414    }
1415
1416    #[test]
1417    fn graph_changes_and_match_metrics_refresh_on_partial_hit() {
1418        let engine = || DiffEngine::new().with_graph_diff(crate::diff::GraphDiffConfig::default());
1419        let a = rich_comp("a", "1.0.0");
1420        let b = rich_comp("b", "1.0.0");
1421        let c = rich_comp("c", "1.0.0");
1422        let d = rich_comp("d", "1.0.0");
1423        let base = rich_sbom(vec![a.clone(), b.clone()], vec![edge(&a, &b, None)]);
1424        let v1 = rich_sbom(
1425            vec![a.clone(), b.clone(), c.clone()],
1426            vec![edge(&a, &b, None), edge(&a, &c, None)],
1427        );
1428        // v2 differs from v1 in components and edges: the graph diff and the
1429        // match metrics must be recomputed, not carried from (base, v1).
1430        let v2 = rich_sbom(
1431            vec![a.clone(), b.clone(), c.clone(), d.clone()],
1432            vec![edge(&a, &b, None), edge(&a, &c, None), edge(&b, &d, None)],
1433        );
1434        assert_incremental_matches_full(engine, &base, &v1, &v2);
1435    }
1436
1437    /// Audit regression: every section computer consumes the component
1438    /// MATCHES, which derive from component content. A component-only change
1439    /// (rename with unchanged canonical id, so the edges — and the
1440    /// dependencies/licenses section hashes — are untouched) can flip a
1441    /// fuzzy match and with it the dependency and license diffs; those
1442    /// sections must rerun rather than splice stale.
1443    #[test]
1444    fn component_only_change_refreshes_match_dependent_sections() {
1445        let app = rich_comp("app", "1.0.0");
1446        // Fuzzy-matched counterpart of base's "libfoo": different purl (so
1447        // no exact-id match), identical name.
1448        let make_lib = |name: &str| {
1449            let mut c = Component::new(name.to_string(), "pkg:npm/libfoo-fork@1.0.0".to_string());
1450            c.version = Some("1.0.0".to_string());
1451            c.licenses
1452                .add_declared(LicenseExpression::new("MIT".to_string()));
1453            c
1454        };
1455        let base_lib = with_license(rich_comp("libfoo", "1.0.0"), "MIT");
1456
1457        let base = rich_sbom(
1458            vec![app.clone(), base_lib.clone()],
1459            vec![edge(&app, &base_lib, None)],
1460        );
1461        let v1_lib = make_lib("libfoo");
1462        let v1 = rich_sbom(
1463            vec![app.clone(), v1_lib.clone()],
1464            vec![edge(&app, &v1_lib, None)],
1465        );
1466        // v2: same canonical id (same purl-derived ref), renamed — the match
1467        // against base's "libfoo" breaks, so the edge and license diffs
1468        // change while the dependencies/licenses section hashes stay clean.
1469        let v2_lib = make_lib("totally-unrelated");
1470        let v2 = rich_sbom(
1471            vec![app.clone(), v2_lib.clone()],
1472            vec![edge(&app, &v2_lib, None)],
1473        );
1474        assert_incremental_matches_full(DiffEngine::new, &base, &v1, &v2);
1475    }
1476
1477    /// Audit regression: VulnerabilityDetail carries component_depth,
1478    /// derived from edges — an EDGE-ONLY change (component set and
1479    /// vulnerability content untouched) must still rerun the vulnerability
1480    /// computer, or spliced details carry stale depths.
1481    #[test]
1482    fn edge_only_change_refreshes_vulnerability_depths() {
1483        let app = rich_comp("app", "1.0.0");
1484        let mid = rich_comp("mid", "1.0.0");
1485        let vulnerable = with_vuln(rich_comp("leaf", "1.0.0"), "CVE-2024-0009", Severity::High);
1486        let app2 = rich_comp("app", "2.0.0");
1487
1488        // base/v1: leaf is a direct dependency (depth 1); mid is isolated.
1489        let base = rich_sbom(
1490            vec![app.clone(), mid.clone(), vulnerable.clone()],
1491            vec![edge(&app, &vulnerable, None)],
1492        );
1493        let v1 = rich_sbom(
1494            vec![app2.clone(), mid.clone(), vulnerable.clone()],
1495            vec![edge(&app2, &vulnerable, None)],
1496        );
1497        // v2: identical components, but leaf now sits behind mid (depth 2).
1498        let v2 = rich_sbom(
1499            vec![app2.clone(), mid.clone(), vulnerable.clone()],
1500            vec![edge(&app2, &mid, None), edge(&mid, &vulnerable, None)],
1501        );
1502        assert_incremental_matches_full(DiffEngine::new, &base, &v1, &v2);
1503    }
1504
1505    #[test]
1506    fn zero_content_hash_sboms_bypass_the_cache() {
1507        // Hand-built SBOMs without calculate_content_hash() all carry hash 0
1508        // and previously collided on the (0,0) cache key: the second pair got
1509        // the first pair's result as a Full hit.
1510        let hand_built = |names: &[&str]| {
1511            let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1512            for name in names {
1513                sbom.add_component(Component::new((*name).to_string(), format!("ref-{name}")));
1514            }
1515            sbom
1516        };
1517        let incremental = IncrementalDiffEngine::new(DiffEngine::new());
1518
1519        let first = incremental
1520            .diff(&hand_built(&["a", "b"]), &hand_built(&["a", "b", "c"]))
1521            .expect("first diff");
1522        assert_eq!(first.cache_hit, CacheHitType::Miss);
1523
1524        let second = incremental
1525            .diff(&hand_built(&["x"]), &hand_built(&["x", "y", "z", "w"]))
1526            .expect("second diff");
1527        assert_eq!(
1528            second.cache_hit,
1529            CacheHitType::Miss,
1530            "zero-hash pairs must never be served from cache"
1531        );
1532        assert_eq!(second.result.summary.components_added, 3);
1533    }
1534
1535    #[test]
1536    fn section_hashes_cover_all_section_computer_inputs() {
1537        // Direct sensitivity checks: each mutation must flip its section hash.
1538        let a = rich_comp("liba", "1.0.0");
1539        let b = rich_comp("libb", "1.0.0");
1540
1541        // Edge scope
1542        let s1 = rich_sbom(
1543            vec![a.clone(), b.clone()],
1544            vec![edge(&a, &b, Some(DependencyScope::Required))],
1545        );
1546        let s2 = rich_sbom(
1547            vec![a.clone(), b.clone()],
1548            vec![edge(&a, &b, Some(DependencyScope::Optional))],
1549        );
1550        assert_ne!(
1551            SectionHashes::from_sbom(&s1).dependencies,
1552            SectionHashes::from_sbom(&s2).dependencies,
1553            "edge scope must be part of the dependencies hash"
1554        );
1555
1556        // Vulnerability attribution
1557        let v1 = rich_sbom(
1558            vec![with_vuln(a.clone(), "CVE-1", Severity::High), b.clone()],
1559            vec![],
1560        );
1561        let v2 = rich_sbom(
1562            vec![a.clone(), with_vuln(b.clone(), "CVE-1", Severity::High)],
1563            vec![],
1564        );
1565        assert_ne!(
1566            SectionHashes::from_sbom(&v1).vulnerabilities,
1567            SectionHashes::from_sbom(&v2).vulnerabilities,
1568            "the owning component must be part of the vulnerabilities hash"
1569        );
1570
1571        // Severity
1572        let sev1 = rich_sbom(vec![with_vuln(a.clone(), "CVE-1", Severity::Low)], vec![]);
1573        let sev2 = rich_sbom(
1574            vec![with_vuln(a.clone(), "CVE-1", Severity::Critical)],
1575            vec![],
1576        );
1577        assert_ne!(
1578            SectionHashes::from_sbom(&sev1).vulnerabilities,
1579            SectionHashes::from_sbom(&sev2).vulnerabilities,
1580            "severity must be part of the vulnerabilities hash"
1581        );
1582
1583        // License attribution
1584        let l1 = rich_sbom(vec![with_license(a.clone(), "MIT"), b.clone()], vec![]);
1585        let l2 = rich_sbom(vec![a.clone(), with_license(b.clone(), "MIT")], vec![]);
1586        assert_ne!(
1587            SectionHashes::from_sbom(&l1).licenses,
1588            SectionHashes::from_sbom(&l2).licenses,
1589            "the owning component must be part of the licenses hash"
1590        );
1591    }
1592
1593    /// Cached results embed the day their day-count fields were computed;
1594    /// a cache hit across midnight must refresh them from the stored dates.
1595    #[test]
1596    fn day_count_refresh_corrects_stale_counts() {
1597        let mut detail = crate::diff::VulnerabilityDetail::from_ref(
1598            &VulnerabilityRef::new("CVE-2024-1111".to_string(), VulnerabilitySource::Osv),
1599            &rich_comp("liba", "1.0.0"),
1600        );
1601        detail.published_date = Some("2020-01-01".to_string());
1602        detail.days_since_published = Some(1); // stale: computed "long ago"
1603        detail.kev_due_date = Some("2030-01-01".to_string());
1604        detail.days_until_due = Some(9999); // stale
1605
1606        let today = chrono::Utc::now().date_naive();
1607        assert!(detail.refresh_day_counts(today), "stale counts must change");
1608        let expected_since =
1609            (today - chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()).num_days();
1610        let expected_due =
1611            (chrono::NaiveDate::from_ymd_opt(2030, 1, 1).unwrap() - today).num_days();
1612        assert_eq!(detail.days_since_published, Some(expected_since));
1613        assert_eq!(detail.days_until_due, Some(expected_due));
1614
1615        // Idempotent: a second refresh on the same day changes nothing.
1616        assert!(!detail.refresh_day_counts(today));
1617
1618        // Producer/refresher parity: a FRESHLY computed KEV-bearing detail
1619        // must not read as stale (from_ref previously stored a
1620        // DateTime-truncated due-day count, one lower than the refresher's
1621        // date-granular value for midnight-UTC due dates — so every KEV
1622        // cache hit deep-cloned and hit/miss disagreed).
1623        let mut kev_vuln =
1624            VulnerabilityRef::new("CVE-2024-2222".to_string(), VulnerabilitySource::Osv);
1625        kev_vuln.is_kev = true;
1626        kev_vuln.kev_info = Some(crate::model::KevInfo::new(
1627            chrono::Utc::now(),
1628            chrono::Utc::now() + chrono::Duration::days(30),
1629            "patch".to_string(),
1630        ));
1631        let mut fresh =
1632            crate::diff::VulnerabilityDetail::from_ref(&kev_vuln, &rich_comp("libb", "1.0.0"));
1633        assert!(
1634            !fresh.refresh_day_counts(today),
1635            "fresh KEV day counts must already be date-granular consistent"
1636        );
1637    }
1638
1639    /// `from_ref` must carry the KEV ransomware-campaign flag across the
1640    /// diff boundary (it was previously dropped, leaving the diff-mode
1641    /// RANSOMWARE badge dead code).
1642    #[test]
1643    fn from_ref_copies_ransomware_flag() {
1644        let mut vuln = VulnerabilityRef::new("CVE-2024-3333".to_string(), VulnerabilitySource::Osv);
1645        vuln.is_kev = true;
1646        let mut kev = crate::model::KevInfo::new(
1647            chrono::Utc::now(),
1648            chrono::Utc::now() + chrono::Duration::days(30),
1649            "patch".to_string(),
1650        );
1651        kev.known_ransomware_use = true;
1652        vuln.kev_info = Some(kev);
1653
1654        let detail = crate::diff::VulnerabilityDetail::from_ref(&vuln, &rich_comp("liba", "1.0.0"));
1655        assert!(
1656            detail.is_ransomware,
1657            "ransomware flag must survive from_ref"
1658        );
1659
1660        // Negative case: KEV entry present but NOT flagged for ransomware use.
1661        let mut kev_only =
1662            VulnerabilityRef::new("CVE-2024-4444".to_string(), VulnerabilitySource::Osv);
1663        kev_only.is_kev = true;
1664        kev_only.kev_info = Some(crate::model::KevInfo::new(
1665            chrono::Utc::now(),
1666            chrono::Utc::now() + chrono::Duration::days(30),
1667            "patch".to_string(),
1668        ));
1669        let plain =
1670            crate::diff::VulnerabilityDetail::from_ref(&kev_only, &rich_comp("libb", "1.0.0"));
1671        assert!(
1672            !plain.is_ransomware,
1673            "KEV without known ransomware use must not set the flag"
1674        );
1675    }
1676
1677    #[test]
1678    fn component_hash_distinguishes_field_boundaries() {
1679        // Dropped license "MIT" + gained supplier "MIT" used to collide.
1680        let mut with_mit_license = rich_comp("x", "1.0.0");
1681        with_mit_license
1682            .licenses
1683            .add_declared(LicenseExpression::new("MIT".to_string()));
1684        with_mit_license.calculate_content_hash();
1685
1686        let mut with_mit_supplier = rich_comp("x", "1.0.0");
1687        with_mit_supplier.supplier = Some(Organization::new("MIT".to_string()));
1688        with_mit_supplier.calculate_content_hash();
1689
1690        assert_ne!(
1691            with_mit_license.content_hash, with_mit_supplier.content_hash,
1692            "field boundaries must be unambiguous in the content hash"
1693        );
1694
1695        // Severity and VEX changes must be hash-visible.
1696        let low = {
1697            let mut c = with_vuln(rich_comp("y", "1.0.0"), "CVE-9", Severity::Low);
1698            c.calculate_content_hash();
1699            c
1700        };
1701        let critical = {
1702            let mut c = with_vuln(rich_comp("y", "1.0.0"), "CVE-9", Severity::Critical);
1703            c.calculate_content_hash();
1704            c
1705        };
1706        assert_ne!(low.content_hash, critical.content_hash);
1707
1708        let vexed = {
1709            let mut c = with_vuln(rich_comp("y", "1.0.0"), "CVE-9", Severity::Low);
1710            c.vulnerabilities[0].vex_status = Some(VexStatus::new(VexState::NotAffected));
1711            c.calculate_content_hash();
1712            c
1713        };
1714        assert_ne!(low.content_hash, vexed.content_hash);
1715
1716        // CVSS-only changes must be hash-visible: same id, same severity,
1717        // different base score.
1718        let cvss = |score: f32| {
1719            let mut c = with_vuln(rich_comp("z", "1.0.0"), "CVE-9", Severity::High);
1720            c.vulnerabilities[0].cvss.push(crate::model::CvssScore {
1721                version: crate::model::CvssVersion::V31,
1722                base_score: score,
1723                vector: None,
1724                exploitability_score: None,
1725                impact_score: None,
1726            });
1727            c.calculate_content_hash();
1728            c
1729        };
1730        assert_ne!(
1731            cvss(7.5).content_hash,
1732            cvss(8.0).content_hash,
1733            "CVSS base score must be part of the content hash"
1734        );
1735
1736        // ML list framing: a training dataset and a performance metric with
1737        // the same payload used to collide byte-for-byte.
1738        use crate::model::{DatasetRef, MetricEntry, MlModelInfo};
1739        let with_training = {
1740            let mut c = rich_comp("m", "1.0.0");
1741            c.ml_model = Some(MlModelInfo {
1742                training_datasets: vec![DatasetRef {
1743                    reference: Some("a".to_string()),
1744                    name: None,
1745                    purl: None,
1746                }],
1747                ..MlModelInfo::default()
1748            });
1749            c.calculate_content_hash();
1750            c
1751        };
1752        let with_metric = {
1753            let mut c = rich_comp("m", "1.0.0");
1754            c.ml_model = Some(MlModelInfo {
1755                performance_metrics: vec![MetricEntry {
1756                    metric_type: Some("a".to_string()),
1757                    value: None,
1758                    slice: None,
1759                }],
1760                ..MlModelInfo::default()
1761            });
1762            c.calculate_content_hash();
1763            c
1764        };
1765        assert_ne!(
1766            with_training.content_hash, with_metric.content_hash,
1767            "ml list boundaries must be unambiguous in the content hash"
1768        );
1769    }
1770}