1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct DiffCacheKey {
37 pub old_hash: u64,
39 pub new_hash: u64,
41}
42
43impl DiffCacheKey {
44 #[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#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct SectionHashes {
57 pub components: u64,
59 pub dependencies: u64,
61 pub licenses: u64,
63 pub vulnerabilities: u64,
65}
66
67impl SectionHashes {
68 #[must_use]
81 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
82 use std::collections::hash_map::DefaultHasher;
83
84 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 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 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 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 #[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#[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 #[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 #[must_use]
205 pub const fn any(&self) -> bool {
206 self.components || self.dependencies || self.licenses || self.vulnerabilities
207 }
208
209 #[must_use]
211 pub const fn all(&self) -> bool {
212 self.components && self.dependencies && self.licenses && self.vulnerabilities
213 }
214
215 #[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#[derive(Debug, Clone)]
236pub struct CachedDiffResult {
237 pub result: Arc<DiffResult>,
239 pub computed_at: Instant,
241 pub old_hashes: SectionHashes,
243 pub new_hashes: SectionHashes,
245}
246
247impl CachedDiffResult {
248 #[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 #[must_use]
265 pub fn is_valid(&self, ttl: Duration) -> bool {
266 self.computed_at.elapsed() < ttl
267 }
268
269 #[must_use]
271 pub fn age(&self) -> Duration {
272 self.computed_at.elapsed()
273 }
274}
275
276#[derive(Debug, Clone)]
282pub struct DiffCacheConfig {
283 pub max_entries: usize,
285 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), }
295 }
296}
297
298pub struct DiffCache {
303 cache: RwLock<HashMap<DiffCacheKey, CachedDiffResult>>,
305 config: DiffCacheConfig,
307 stats: AtomicCacheStats,
309}
310
311#[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#[derive(Debug, Clone, Default)]
339pub struct CacheStats {
340 pub lookups: u64,
342 pub hits: u64,
344 pub misses: u64,
346 pub incremental_hits: u64,
348 pub evictions: u64,
351 pub time_saved_ms: u64,
353}
354
355impl CacheStats {
356 #[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 #[must_use]
370 pub fn new() -> Self {
371 Self::with_config(DiffCacheConfig::default())
372 }
373
374 #[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 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 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 if !cache.contains_key(&key) && cache.len() >= self.config.max_entries {
422 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 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 fn estimate_computation_time(result: &DiffResult) -> u64 {
456 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 pub fn stats(&self) -> CacheStats {
465 self.stats.snapshot()
466 }
467
468 pub fn clear(&self) {
470 let mut cache = self.cache.write().expect("cache lock poisoned");
471 cache.clear();
472 }
473
474 pub fn len(&self) -> usize {
476 self.cache.read().expect("cache lock poisoned").len()
477 }
478
479 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
491struct LastDiffMeta {
497 key: DiffCacheKey,
499 old_hashes: SectionHashes,
501 new_hashes: SectionHashes,
503}
504
505pub struct IncrementalDiffEngine {
512 engine: DiffEngine,
514 cache: DiffCache,
516 last_diff: RwLock<Option<LastDiffMeta>>,
518}
519
520impl IncrementalDiffEngine {
521 #[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 #[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 pub fn diff(
549 &self,
550 old: &NormalizedSbom,
551 new: &NormalizedSbom,
552 ) -> Result<IncrementalDiffResult, SbomDiffError> {
553 let start = Instant::now();
554
555 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 if let Some(mut cached) = self.cache.get(&cache_key) {
574 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 let old_hashes = SectionHashes::from_sbom(old);
590 let new_hashes = SectionHashes::from_sbom(new);
591
592 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 (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 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 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 let result = self.engine.diff(old, new)?;
633 (result, CacheHitType::Miss, ChangedSections::all_changed())
634 }
635 }
636 } else {
637 let result = self.engine.diff(old, new)?;
639 (result, CacheHitType::Miss, ChangedSections::all_changed())
640 }
641 } else {
642 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 if cache_hit == CacheHitType::Partial {
650 self.cache
651 .stats
652 .incremental_hits
653 .fetch_add(1, Ordering::Relaxed);
654 }
655
656 let mut result = result;
659 if cache_hit == CacheHitType::Partial {
660 result.refresh_derived_day_counts();
661 }
662
663 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 *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 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 pub const fn engine(&self) -> &DiffEngine {
702 &self.engine
703 }
704
705 pub fn cache_stats(&self) -> CacheStats {
707 self.cache.stats()
708 }
709
710 pub fn clear_cache(&self) {
712 self.cache.clear();
713 }
714}
715
716impl ChangedSections {
717 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
730pub enum CacheHitType {
731 Full,
733 Partial,
735 Miss,
737}
738
739#[derive(Debug)]
741pub struct IncrementalDiffResult {
742 pub result: Arc<DiffResult>,
745 pub cache_hit: CacheHitType,
747 pub sections_recomputed: ChangedSections,
749 pub computation_time: Duration,
751}
752
753impl IncrementalDiffResult {
754 pub fn into_result(self) -> DiffResult {
756 Arc::try_unwrap(self.result).unwrap_or_else(|shared| (*shared).clone())
758 }
759
760 #[must_use]
762 pub fn was_cached(&self) -> bool {
763 self.cache_hit == CacheHitType::Full
764 }
765}
766
767#[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 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 assert_ne!(hash1.components, hash2.components);
811
812 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, 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 assert!(cache.get(&key).is_none());
850 assert!(cache.is_empty());
851
852 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 assert!(cache.get(&key).is_some());
869 assert_eq!(cache.len(), 1);
870
871 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 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 let result1 = incremental.diff(&old, &new).expect("diff should succeed");
925 assert_eq!(result1.cache_hit, CacheHitType::Miss);
926
927 let result2 = incremental.diff(&old, &new).expect("diff should succeed");
929 assert_eq!(result2.cache_hit, CacheHitType::Full);
930
931 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 let engine = DiffEngine::new();
976 let old = make_sbom("old", &["a", "b", "c"]);
977 let new = make_sbom("new", &["a", "b", "d"]);
978
979 let full_result = engine.diff(&old, &new).expect("diff should succeed");
981
982 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, §ions, &full_result)
991 .expect("diff_sections should succeed");
992
993 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 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 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, §ions, &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 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 let result1 = incremental.diff(&old, &new1).expect("diff should succeed");
1060 assert_eq!(result1.cache_hit, CacheHitType::Miss);
1061
1062 let new2 = make_sbom("new2", &["a", "b", "e"]);
1065 let result2 = incremental.diff(&old, &new2).expect("diff should succeed");
1066
1067 assert!(
1070 result2.cache_hit == CacheHitType::Partial || result2.cache_hit == CacheHitType::Miss
1071 );
1072 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 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 let _ = incremental.diff(&old, &new).expect("diff should succeed");
1098
1099 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 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 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 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 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 let fresh = DiffEngine::new()
1190 .diff(&s0, &s2)
1191 .expect("diff should succeed");
1192 assert_sections_match(&result.result, &fresh);
1193 }
1194
1195 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 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 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 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 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 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 #[test]
1444 fn component_only_change_refreshes_match_dependent_sections() {
1445 let app = rich_comp("app", "1.0.0");
1446 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 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 #[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 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 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 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 let a = rich_comp("liba", "1.0.0");
1539 let b = rich_comp("libb", "1.0.0");
1540
1541 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 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 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 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 #[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); detail.kev_due_date = Some("2030-01-01".to_string());
1604 detail.days_until_due = Some(9999); 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 assert!(!detail.refresh_day_counts(today));
1617
1618 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 #[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 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 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 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 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 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}