1use crate::diff::traits::{ChangeComputer, ComponentChangeSet, ComponentMatches};
4use crate::diff::{ComponentChange, CostModel, FieldChange};
5use crate::model::{
6 Component, CryptoAssetType, CryptoProperties, DatasetInfo, DatasetRef, MlModelInfo,
7 NormalizedSbom,
8};
9use std::collections::HashSet;
10
11pub struct ComponentChangeComputer {
13 cost_model: CostModel,
14 include_unchanged: bool,
15}
16
17impl ComponentChangeComputer {
18 #[must_use]
20 pub const fn new(cost_model: CostModel) -> Self {
21 Self {
22 cost_model,
23 include_unchanged: false,
24 }
25 }
26
27 #[must_use]
30 pub const fn with_include_unchanged(mut self, include_unchanged: bool) -> Self {
31 self.include_unchanged = include_unchanged;
32 self
33 }
34
35 fn compute_field_changes(&self, old: &Component, new: &Component) -> (Vec<FieldChange>, u32) {
37 let mut changes = Vec::new();
38 let mut total_cost = 0u32;
39
40 if old.name != new.name {
45 changes.push(FieldChange {
46 field: "name".to_string(),
47 old_value: Some(old.name.clone()),
48 new_value: Some(new.name.clone()),
49 });
50 total_cost += self.cost_model.supplier_changed;
51 }
52
53 if old.ecosystem != new.ecosystem {
56 changes.push(FieldChange {
57 field: "ecosystem".to_string(),
58 old_value: old.ecosystem.as_ref().map(std::string::ToString::to_string),
59 new_value: new.ecosystem.as_ref().map(std::string::ToString::to_string),
60 });
61 total_cost += self.cost_model.supplier_changed;
62 }
63
64 if old.version != new.version {
66 changes.push(FieldChange {
67 field: "version".to_string(),
68 old_value: old.version.clone(),
69 new_value: new.version.clone(),
70 });
71 total_cost += self
72 .cost_model
73 .version_change_cost(&old.semver, &new.semver);
74 }
75
76 let old_licenses: HashSet<_> = old
78 .licenses
79 .declared
80 .iter()
81 .map(|l| &l.expression)
82 .collect();
83 let new_licenses: HashSet<_> = new
84 .licenses
85 .declared
86 .iter()
87 .map(|l| &l.expression)
88 .collect();
89 if old_licenses != new_licenses {
90 changes.push(FieldChange {
91 field: "licenses".to_string(),
92 old_value: Some(
93 old.licenses
94 .declared
95 .iter()
96 .map(|l| l.expression.clone())
97 .collect::<Vec<_>>()
98 .join(", "),
99 ),
100 new_value: Some(
101 new.licenses
102 .declared
103 .iter()
104 .map(|l| l.expression.clone())
105 .collect::<Vec<_>>()
106 .join(", "),
107 ),
108 });
109 total_cost += self.cost_model.license_changed;
110 }
111
112 if old.supplier != new.supplier {
114 changes.push(FieldChange {
115 field: "supplier".to_string(),
116 old_value: old.supplier.as_ref().map(|s| s.name.clone()),
117 new_value: new.supplier.as_ref().map(|s| s.name.clone()),
118 });
119 total_cost += self.cost_model.supplier_changed;
120 }
121
122 if old.version == new.version && !old.hashes.is_empty() && !new.hashes.is_empty() {
124 let old_hashes: HashSet<_> = old.hashes.iter().map(|h| &h.value).collect();
125 let new_hashes: HashSet<_> = new.hashes.iter().map(|h| &h.value).collect();
126 if old_hashes.is_disjoint(&new_hashes) {
127 changes.push(FieldChange {
128 field: "hashes".to_string(),
129 old_value: Some(
130 old.hashes
131 .first()
132 .map(|h| h.value.clone())
133 .unwrap_or_default(),
134 ),
135 new_value: Some(
136 new.hashes
137 .first()
138 .map(|h| h.value.clone())
139 .unwrap_or_default(),
140 ),
141 });
142 total_cost += self.cost_model.hash_mismatch;
143 }
144 }
145
146 if old.ml_model != new.ml_model {
148 total_cost += Self::compute_ml_changes(&self.cost_model, old, new, &mut changes);
149 }
150
151 if old.dataset != new.dataset {
153 total_cost += Self::compute_dataset_changes(&self.cost_model, old, new, &mut changes);
154 }
155
156 if old.crypto_properties != new.crypto_properties {
158 total_cost += Self::compute_crypto_changes(&self.cost_model, old, new, &mut changes);
159 }
160
161 (changes, total_cost)
162 }
163
164 fn push_scalar_change(
166 changes: &mut Vec<FieldChange>,
167 field: &str,
168 old: &Option<String>,
169 new: &Option<String>,
170 cost: &mut u32,
171 field_cost: u32,
172 ) {
173 if old != new {
174 changes.push(FieldChange {
175 field: field.to_string(),
176 old_value: old.clone(),
177 new_value: new.clone(),
178 });
179 *cost += field_cost;
180 }
181 }
182
183 fn dataset_ref_key(reference: &DatasetRef) -> Option<&str> {
187 reference
188 .reference
189 .as_deref()
190 .or(reference.name.as_deref())
191 .or(reference.purl.as_deref())
192 }
193
194 fn compute_ml_changes(
201 cost_model: &CostModel,
202 old: &Component,
203 new: &Component,
204 changes: &mut Vec<FieldChange>,
205 ) -> u32 {
206 let mut cost = 0u32;
207
208 match (&old.ml_model, &new.ml_model) {
209 (Some(old_ml), Some(new_ml)) => {
210 cost += Self::compute_ml_sub_changes(cost_model, old_ml, new_ml, changes);
211 }
212 (None, Some(_)) | (Some(_), None) => {
213 changes.push(FieldChange {
215 field: "ml_model".to_string(),
216 old_value: old.ml_model.as_ref().map(|_| "present".to_string()),
217 new_value: new.ml_model.as_ref().map(|_| "present".to_string()),
218 });
219 cost += cost_model.ml_model_changed;
220 }
221 (None, None) => {}
222 }
223
224 cost
225 }
226
227 fn compute_ml_sub_changes(
228 cost_model: &CostModel,
229 old: &MlModelInfo,
230 new: &MlModelInfo,
231 changes: &mut Vec<FieldChange>,
232 ) -> u32 {
233 let mut cost = 0u32;
234
235 Self::push_scalar_change(
236 changes,
237 "ml_approach",
238 &old.approach,
239 &new.approach,
240 &mut cost,
241 cost_model.ml_approach_changed,
242 );
243
244 let old_arch = Self::join_architecture(old);
247 let new_arch = Self::join_architecture(new);
248 Self::push_scalar_change(
249 changes,
250 "ml_architecture",
251 &old_arch,
252 &new_arch,
253 &mut cost,
254 cost_model.ml_architecture_changed,
255 );
256
257 Self::push_scalar_change(
258 changes,
259 "ml_task",
260 &old.task,
261 &new.task,
262 &mut cost,
263 cost_model.ml_task_changed,
264 );
265 Self::push_scalar_change(
266 changes,
267 "ml_quantization",
268 &old.quantization,
269 &new.quantization,
270 &mut cost,
271 cost_model.ml_quantization_changed,
272 );
273 Self::push_scalar_change(
274 changes,
275 "ml_model_card",
276 &old.model_card_url,
277 &new.model_card_url,
278 &mut cost,
279 cost_model.ml_model_card_changed,
280 );
281
282 cost += Self::compute_training_dataset_changes(cost_model, old, new, changes);
283 cost += Self::compute_performance_metric_changes(old, new, changes);
284
285 cost
286 }
287
288 fn compute_performance_metric_changes(
289 old: &MlModelInfo,
290 new: &MlModelInfo,
291 changes: &mut Vec<FieldChange>,
292 ) -> u32 {
293 let key = |metric: &crate::model::MetricEntry| {
294 metric.metric_type.as_ref().map(|kind| {
295 format!(
296 "{}{}",
297 kind.to_ascii_lowercase(),
298 metric
299 .slice
300 .as_ref()
301 .map(|slice| format!("@{slice}"))
302 .unwrap_or_default()
303 )
304 })
305 };
306 let old_metrics: std::collections::HashMap<_, _> = old
307 .performance_metrics
308 .iter()
309 .filter_map(|metric| key(metric).map(|key| (key, metric.value.clone())))
310 .collect();
311 let new_metrics: std::collections::HashMap<_, _> = new
312 .performance_metrics
313 .iter()
314 .filter_map(|metric| key(metric).map(|key| (key, metric.value.clone())))
315 .collect();
316
317 let mut keys: Vec<_> = old_metrics
318 .keys()
319 .filter(|key| new_metrics.contains_key(*key))
320 .cloned()
321 .collect();
322 keys.sort();
323 let mut changed = 0;
324 for key in keys {
325 if old_metrics[&key] != new_metrics[&key] {
326 changes.push(FieldChange {
327 field: format!("ml_metric:{key}"),
328 old_value: old_metrics[&key].clone(),
329 new_value: new_metrics[&key].clone(),
330 });
331 changed += 1;
332 }
333 }
334 changed
335 }
336
337 fn join_architecture(ml: &MlModelInfo) -> Option<String> {
339 match (&ml.architecture_family, &ml.architecture_name) {
340 (Some(family), Some(name)) => Some(format!("{family}/{name}")),
341 (Some(value), None) | (None, Some(value)) => Some(value.clone()),
342 (None, None) => None,
343 }
344 }
345
346 fn compute_training_dataset_changes(
350 cost_model: &CostModel,
351 old: &MlModelInfo,
352 new: &MlModelInfo,
353 changes: &mut Vec<FieldChange>,
354 ) -> u32 {
355 let mut cost = 0u32;
356
357 let old_keys: HashSet<&str> = old
358 .training_datasets
359 .iter()
360 .filter_map(Self::dataset_ref_key)
361 .collect();
362 let new_keys: HashSet<&str> = new
363 .training_datasets
364 .iter()
365 .filter_map(Self::dataset_ref_key)
366 .collect();
367
368 let mut removed: Vec<&str> = old_keys.difference(&new_keys).copied().collect();
371 removed.sort_unstable();
372 for key in removed {
373 changes.push(FieldChange {
374 field: "ml_training_dataset".to_string(),
375 old_value: Some(key.to_string()),
376 new_value: None,
377 });
378 cost += cost_model.ml_training_dataset_removed;
379 }
380
381 let mut added: Vec<&str> = new_keys.difference(&old_keys).copied().collect();
383 added.sort_unstable();
384 for key in added {
385 changes.push(FieldChange {
386 field: "ml_training_dataset".to_string(),
387 old_value: None,
388 new_value: Some(key.to_string()),
389 });
390 cost += cost_model.ml_training_dataset_added;
391 }
392
393 cost
394 }
395
396 fn compute_dataset_changes(
403 cost_model: &CostModel,
404 old: &Component,
405 new: &Component,
406 changes: &mut Vec<FieldChange>,
407 ) -> u32 {
408 let mut cost = 0u32;
409
410 match (&old.dataset, &new.dataset) {
411 (Some(old_ds), Some(new_ds)) => {
412 cost += Self::compute_dataset_sub_changes(cost_model, old_ds, new_ds, changes);
413 }
414 (None, Some(_)) | (Some(_), None) => {
415 changes.push(FieldChange {
416 field: "dataset".to_string(),
417 old_value: old.dataset.as_ref().map(|_| "present".to_string()),
418 new_value: new.dataset.as_ref().map(|_| "present".to_string()),
419 });
420 cost += cost_model.dataset_changed;
421 }
422 (None, None) => {}
423 }
424
425 cost
426 }
427
428 fn compute_dataset_sub_changes(
429 cost_model: &CostModel,
430 old: &DatasetInfo,
431 new: &DatasetInfo,
432 changes: &mut Vec<FieldChange>,
433 ) -> u32 {
434 let mut cost = 0u32;
435
436 Self::push_scalar_change(
437 changes,
438 "dataset_type",
439 &old.dataset_type,
440 &new.dataset_type,
441 &mut cost,
442 cost_model.dataset_type_changed,
443 );
444
445 let old_sens: HashSet<&str> = old
448 .sensitivity_classifications
449 .iter()
450 .map(String::as_str)
451 .collect();
452 let new_sens: HashSet<&str> = new
453 .sensitivity_classifications
454 .iter()
455 .map(String::as_str)
456 .collect();
457
458 let mut added: Vec<&str> = new_sens.difference(&old_sens).copied().collect();
459 added.sort_unstable();
460 for class in added {
461 changes.push(FieldChange {
462 field: "dataset_sensitivity".to_string(),
463 old_value: None,
464 new_value: Some(class.to_string()),
465 });
466 cost += cost_model.dataset_sensitivity_added;
467 }
468
469 let mut removed: Vec<&str> = old_sens.difference(&new_sens).copied().collect();
470 removed.sort_unstable();
471 for class in removed {
472 changes.push(FieldChange {
473 field: "dataset_sensitivity".to_string(),
474 old_value: Some(class.to_string()),
475 new_value: None,
476 });
477 cost += cost_model.dataset_sensitivity_removed;
478 }
479
480 let old_gov: HashSet<&str> = old.governance_owners.iter().map(String::as_str).collect();
482 let new_gov: HashSet<&str> = new.governance_owners.iter().map(String::as_str).collect();
483 if old_gov != new_gov {
484 changes.push(FieldChange {
485 field: "dataset_governance".to_string(),
486 old_value: Self::join_sorted(&old.governance_owners),
487 new_value: Self::join_sorted(&new.governance_owners),
488 });
489 cost += cost_model.dataset_governance_changed;
490 }
491
492 cost
493 }
494
495 fn join_sorted(values: &[String]) -> Option<String> {
498 if values.is_empty() {
499 return None;
500 }
501 let mut sorted: Vec<&str> = values.iter().map(String::as_str).collect();
502 sorted.sort_unstable();
503 Some(sorted.join(", "))
504 }
505
506 fn compute_crypto_changes(
508 cost_model: &CostModel,
509 old: &Component,
510 new: &Component,
511 changes: &mut Vec<FieldChange>,
512 ) -> u32 {
513 let mut cost = 0u32;
514
515 match (&old.crypto_properties, &new.crypto_properties) {
516 (Some(old_cp), Some(new_cp)) => {
517 cost += Self::compute_crypto_sub_changes(cost_model, old_cp, new_cp, changes);
518 }
519 (None, Some(new_cp)) => {
520 changes.push(FieldChange {
521 field: "crypto_properties".to_string(),
522 old_value: None,
523 new_value: Some(new_cp.asset_type.to_string()),
524 });
525 cost += cost_model.crypto_algorithm_changed;
526 }
527 (Some(old_cp), None) => {
528 changes.push(FieldChange {
529 field: "crypto_properties".to_string(),
530 old_value: Some(old_cp.asset_type.to_string()),
531 new_value: None,
532 });
533 cost += cost_model.crypto_algorithm_changed;
534 }
535 (None, None) => {}
536 }
537
538 cost
539 }
540
541 fn compute_crypto_sub_changes(
542 cost_model: &CostModel,
543 old: &CryptoProperties,
544 new: &CryptoProperties,
545 changes: &mut Vec<FieldChange>,
546 ) -> u32 {
547 let mut cost = 0u32;
548
549 if let (Some(old_algo), Some(new_algo)) =
551 (&old.algorithm_properties, &new.algorithm_properties)
552 {
553 if old_algo.algorithm_family != new_algo.algorithm_family {
555 changes.push(FieldChange {
556 field: "crypto_algorithm".to_string(),
557 old_value: old_algo.algorithm_family.clone(),
558 new_value: new_algo.algorithm_family.clone(),
559 });
560 cost += cost_model.crypto_algorithm_changed;
561 }
562
563 if old_algo.nist_quantum_security_level != new_algo.nist_quantum_security_level {
565 changes.push(FieldChange {
566 field: "crypto_quantum_level".to_string(),
567 old_value: old_algo.nist_quantum_security_level.map(|l| l.to_string()),
568 new_value: new_algo.nist_quantum_security_level.map(|l| l.to_string()),
569 });
570 cost += cost_model.crypto_quantum_level_changed;
571 }
572
573 if let (Some(old_bits), Some(new_bits)) = (
575 old_algo.classical_security_level,
576 new_algo.classical_security_level,
577 ) && new_bits < old_bits
578 {
579 changes.push(FieldChange {
580 field: "crypto_downgrade".to_string(),
581 old_value: Some(format!("{old_bits} bits")),
582 new_value: Some(format!("{new_bits} bits")),
583 });
584 cost += cost_model.crypto_downgrade;
585 }
586 }
587
588 if let (Some(old_mat), Some(new_mat)) = (
590 &old.related_crypto_material_properties,
591 &new.related_crypto_material_properties,
592 ) && old_mat.state != new_mat.state
593 {
594 changes.push(FieldChange {
595 field: "crypto_key_state".to_string(),
596 old_value: old_mat.state.as_ref().map(|s| s.to_string()),
597 new_value: new_mat.state.as_ref().map(|s| s.to_string()),
598 });
599 cost += cost_model.crypto_key_rotated;
600 }
601
602 if let (Some(old_cert), Some(new_cert)) =
604 (&old.certificate_properties, &new.certificate_properties)
605 && old_cert.not_valid_after != new_cert.not_valid_after
606 {
607 changes.push(FieldChange {
608 field: "crypto_cert_expiry".to_string(),
609 old_value: old_cert.not_valid_after.map(|d| d.to_rfc3339()),
610 new_value: new_cert.not_valid_after.map(|d| d.to_rfc3339()),
611 });
612 cost += cost_model.crypto_cert_expiry_changed;
613 }
614
615 if let (Some(old_proto), Some(new_proto)) =
617 (&old.protocol_properties, &new.protocol_properties)
618 && old_proto.version != new_proto.version
619 {
620 changes.push(FieldChange {
621 field: "crypto_protocol_version".to_string(),
622 old_value: old_proto.version.clone(),
623 new_value: new_proto.version.clone(),
624 });
625 cost += cost_model.crypto_protocol_changed;
626 }
627
628 if old.asset_type != new.asset_type
630 && old.asset_type != CryptoAssetType::Other("unknown".to_string())
631 {
632 changes.push(FieldChange {
633 field: "crypto_asset_type".to_string(),
634 old_value: Some(old.asset_type.to_string()),
635 new_value: Some(new.asset_type.to_string()),
636 });
637 cost += cost_model.crypto_algorithm_changed;
638 }
639
640 cost
641 }
642}
643
644impl Default for ComponentChangeComputer {
645 fn default() -> Self {
646 Self::new(CostModel::default())
647 }
648}
649
650impl ChangeComputer for ComponentChangeComputer {
651 type ChangeSet = ComponentChangeSet;
652
653 fn compute(
654 &self,
655 old: &NormalizedSbom,
656 new: &NormalizedSbom,
657 matches: &ComponentMatches,
658 ) -> ComponentChangeSet {
659 let mut result = ComponentChangeSet::new();
660 let matched_new_ids: HashSet<_> = matches
661 .values()
662 .filter_map(std::clone::Clone::clone)
663 .collect();
664
665 for (old_id, new_id_opt) in matches {
667 if new_id_opt.is_none()
668 && let Some(old_comp) = old.components.get(old_id)
669 {
670 result.removed.push(ComponentChange::removed(
671 old_comp,
672 self.cost_model.component_removed,
673 ));
674 }
675 }
676
677 for new_id in new.components.keys() {
679 if !matched_new_ids.contains(new_id)
680 && let Some(new_comp) = new.components.get(new_id)
681 {
682 result.added.push(ComponentChange::added(
683 new_comp,
684 self.cost_model.component_added,
685 ));
686 }
687 }
688
689 for (old_id, new_id_opt) in matches {
691 if let Some(new_id) = new_id_opt
692 && let (Some(old_comp), Some(new_comp)) =
693 (old.components.get(old_id), new.components.get(new_id))
694 {
695 if old_comp.content_hash != new_comp.content_hash {
697 let (field_changes, cost) = self.compute_field_changes(old_comp, new_comp);
698 if !field_changes.is_empty() {
699 result.modified.push(ComponentChange::modified(
700 old_comp,
701 new_comp,
702 field_changes,
703 cost,
704 ));
705 } else if self.include_unchanged {
706 result
709 .modified
710 .push(ComponentChange::unchanged(old_comp, new_comp));
711 }
712 } else if self.include_unchanged {
713 result
714 .modified
715 .push(ComponentChange::unchanged(old_comp, new_comp));
716 }
717 }
718 }
719
720 result.removed.sort_by(|a, b| a.id.cmp(&b.id));
723 result.modified.sort_by(|a, b| a.id.cmp(&b.id));
724
725 result
726 }
727
728 fn name(&self) -> &'static str {
729 "ComponentChangeComputer"
730 }
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
738
739 #[test]
740 fn test_component_change_computer_default() {
741 let computer = ComponentChangeComputer::default();
742 assert_eq!(computer.name(), "ComponentChangeComputer");
743 }
744
745 #[test]
746 fn test_empty_sboms() {
747 let computer = ComponentChangeComputer::default();
748 let old = NormalizedSbom::default();
749 let new = NormalizedSbom::default();
750 let matches = ComponentMatches::new();
751
752 let result = computer.compute(&old, &new, &matches);
753 assert!(result.is_empty());
754 }
755
756 fn find_change<'a>(changes: &'a [FieldChange], field: &str) -> &'a FieldChange {
758 changes
759 .iter()
760 .find(|c| c.field == field)
761 .unwrap_or_else(|| panic!("expected a `{field}` field change, got {changes:?}"))
762 }
763
764 #[test]
768 fn renames_and_ecosystem_migrations_produce_field_changes() {
769 let computer = ComponentChangeComputer::default();
770
771 let mut old = Component::new("foo-utils".to_string(), "old-ref".to_string());
772 old.version = Some("1.0.0".to_string());
773 old.ecosystem = Some(crate::model::Ecosystem::Npm);
774 old.calculate_content_hash();
775
776 let mut renamed = Component::new("foo-util".to_string(), "new-ref".to_string());
777 renamed.version = Some("1.0.0".to_string());
778 renamed.ecosystem = Some(crate::model::Ecosystem::Npm);
779 renamed.calculate_content_hash();
780
781 let (changes, cost) = computer.compute_field_changes(&old, &renamed);
782 let change = find_change(&changes, "name");
783 assert_eq!(change.old_value.as_deref(), Some("foo-utils"));
784 assert_eq!(change.new_value.as_deref(), Some("foo-util"));
785 assert!(cost > 0, "a rename must carry a nonzero cost");
786
787 let mut migrated = Component::new("foo-utils".to_string(), "new-ref-2".to_string());
788 migrated.version = Some("1.0.0".to_string());
789 migrated.ecosystem = Some(crate::model::Ecosystem::PyPi);
790 migrated.calculate_content_hash();
791
792 let (changes, _) = computer.compute_field_changes(&old, &migrated);
793 let change = find_change(&changes, "ecosystem");
794 assert_eq!(change.old_value.as_deref(), Some("npm"));
795 assert_eq!(change.new_value.as_deref(), Some("pypi"));
796
797 let mut old_sbom = NormalizedSbom::default();
800 let mut new_sbom = NormalizedSbom::default();
801 let old_id = old.canonical_id.clone();
802 let new_id = renamed.canonical_id.clone();
803 old_sbom.add_component(old);
804 new_sbom.add_component(renamed);
805 let mut matches = ComponentMatches::new();
806 matches.insert(old_id, Some(new_id));
807
808 let result = computer.compute(&old_sbom, &new_sbom, &matches);
809 assert_eq!(
810 result.modified.len(),
811 1,
812 "matched rename must be reported as modified"
813 );
814 find_change(&result.modified[0].field_changes, "name");
815 }
816
817 #[test]
818 fn test_ml_quantization_change_is_granular() {
819 let computer = ComponentChangeComputer::default();
820 let mut old = Component::new("model".to_string(), "model@1".to_string());
821 let mut new = old.clone();
822
823 old.ml_model = Some(MlModelInfo {
824 quantization: Some("fp32".to_string()),
825 ..MlModelInfo::default()
826 });
827 new.ml_model = Some(MlModelInfo {
828 quantization: Some("int4".to_string()),
829 ..MlModelInfo::default()
830 });
831
832 let (changes, total_cost) = computer.compute_field_changes(&old, &new);
833
834 assert!(changes.iter().all(|c| c.field != "ml_model"));
836 let change = find_change(&changes, "ml_quantization");
837 assert_eq!(change.old_value.as_deref(), Some("fp32"));
838 assert_eq!(change.new_value.as_deref(), Some("int4"));
839 assert_eq!(total_cost, CostModel::default().ml_quantization_changed);
840 }
841
842 #[test]
843 fn test_ml_architecture_and_task_changes_are_granular() {
844 let computer = ComponentChangeComputer::default();
845 let mut old = Component::new("model".to_string(), "model@1".to_string());
846 let mut new = old.clone();
847
848 old.ml_model = Some(MlModelInfo {
849 architecture_family: Some("cnn".to_string()),
850 architecture_name: Some("resnet".to_string()),
851 task: Some("computer-vision".to_string()),
852 ..MlModelInfo::default()
853 });
854 new.ml_model = Some(MlModelInfo {
855 architecture_family: Some("transformer".to_string()),
856 architecture_name: Some("bert".to_string()),
857 task: Some("nlp".to_string()),
858 ..MlModelInfo::default()
859 });
860
861 let (changes, _) = computer.compute_field_changes(&old, &new);
862
863 let arch = find_change(&changes, "ml_architecture");
864 assert_eq!(arch.old_value.as_deref(), Some("cnn/resnet"));
865 assert_eq!(arch.new_value.as_deref(), Some("transformer/bert"));
866 let task = find_change(&changes, "ml_task");
867 assert_eq!(task.old_value.as_deref(), Some("computer-vision"));
868 assert_eq!(task.new_value.as_deref(), Some("nlp"));
869 }
870
871 #[test]
872 fn test_ml_training_dataset_removed_has_high_cost() {
873 let computer = ComponentChangeComputer::default();
874 let mut old = Component::new("model".to_string(), "model@1".to_string());
875 let mut new = old.clone();
876
877 old.ml_model = Some(MlModelInfo {
878 training_datasets: vec![
879 DatasetRef {
880 reference: Some("ds-imagenet".to_string()),
881 name: Some("imagenet".to_string()),
882 purl: None,
883 },
884 DatasetRef {
885 reference: Some("ds-coco".to_string()),
886 name: Some("coco".to_string()),
887 purl: None,
888 },
889 ],
890 ..MlModelInfo::default()
891 });
892 new.ml_model = Some(MlModelInfo {
893 training_datasets: vec![DatasetRef {
894 reference: Some("ds-imagenet".to_string()),
895 name: Some("imagenet".to_string()),
896 purl: None,
897 }],
898 ..MlModelInfo::default()
899 });
900
901 let (changes, total_cost) = computer.compute_field_changes(&old, &new);
902
903 let removed = find_change(&changes, "ml_training_dataset");
904 assert_eq!(removed.old_value.as_deref(), Some("ds-coco"));
905 assert_eq!(removed.new_value, None);
906 assert_eq!(total_cost, CostModel::default().ml_training_dataset_removed);
907 }
908
909 #[test]
910 fn test_dataset_sensitivity_escalation_has_high_cost() {
911 let computer = ComponentChangeComputer::default();
912 let mut old = Component::new("dataset".to_string(), "dataset@1".to_string());
913 let mut new = old.clone();
914
915 old.dataset = Some(DatasetInfo {
916 dataset_type: Some("training".to_string()),
917 sensitivity_classifications: vec!["public".to_string()],
918 ..DatasetInfo::default()
919 });
920 new.dataset = Some(DatasetInfo {
921 dataset_type: Some("training".to_string()),
922 sensitivity_classifications: vec!["public".to_string(), "pii".to_string()],
923 ..DatasetInfo::default()
924 });
925
926 let (changes, total_cost) = computer.compute_field_changes(&old, &new);
927
928 assert!(changes.iter().all(|c| c.field != "dataset"));
930 let escalation = find_change(&changes, "dataset_sensitivity");
931 assert_eq!(escalation.old_value, None);
932 assert_eq!(escalation.new_value.as_deref(), Some("pii"));
933 assert_eq!(total_cost, CostModel::default().dataset_sensitivity_added);
934 }
935
936 #[test]
937 fn test_dataset_type_and_governance_changes_are_granular() {
938 let computer = ComponentChangeComputer::default();
939 let mut old = Component::new("dataset".to_string(), "dataset@1".to_string());
940 let mut new = old.clone();
941
942 old.dataset = Some(DatasetInfo {
943 dataset_type: Some("training".to_string()),
944 governance_owners: vec!["alice".to_string()],
945 ..DatasetInfo::default()
946 });
947 new.dataset = Some(DatasetInfo {
948 dataset_type: Some("validation".to_string()),
949 governance_owners: vec!["bob".to_string()],
950 ..DatasetInfo::default()
951 });
952
953 let (changes, _) = computer.compute_field_changes(&old, &new);
954
955 let ty = find_change(&changes, "dataset_type");
956 assert_eq!(ty.old_value.as_deref(), Some("training"));
957 assert_eq!(ty.new_value.as_deref(), Some("validation"));
958 let gov = find_change(&changes, "dataset_governance");
959 assert_eq!(gov.old_value.as_deref(), Some("alice"));
960 assert_eq!(gov.new_value.as_deref(), Some("bob"));
961 }
962
963 #[test]
964 fn test_security_focused_escalates_ml_and_dataset_costs() {
965 let secure = ComponentChangeComputer::new(CostModel::security_focused());
966 let default = ComponentChangeComputer::default();
967
968 let mut old = Component::new("dataset".to_string(), "dataset@1".to_string());
969 let mut new = old.clone();
970 old.dataset = Some(DatasetInfo {
971 sensitivity_classifications: vec![],
972 ..DatasetInfo::default()
973 });
974 new.dataset = Some(DatasetInfo {
975 sensitivity_classifications: vec!["pii".to_string()],
976 ..DatasetInfo::default()
977 });
978
979 let (_, secure_cost) = secure.compute_field_changes(&old, &new);
980 let (_, default_cost) = default.compute_field_changes(&old, &new);
981 assert!(
982 secure_cost > default_cost,
983 "security profile should weight PII escalation higher (secure={secure_cost}, default={default_cost})"
984 );
985 }
986
987 #[test]
993 fn component_type_is_resolved_on_change_entries() {
994 use crate::model::{
995 ComponentType, CryptoMaterialType, Ecosystem, RelatedCryptoMaterialProperties,
996 };
997
998 fn crypto_component(name: &str, asset_type: CryptoAssetType) -> Component {
999 let mut c = Component::new(name.to_string(), format!("crypto/{name}"));
1000 c.component_type = ComponentType::Cryptographic;
1001 c.crypto_properties = Some(CryptoProperties::new(asset_type));
1002 c.calculate_content_hash();
1003 c
1004 }
1005
1006 let cert = crypto_component("web-cert", CryptoAssetType::Certificate);
1008 let mut key = crypto_component("tls-key", CryptoAssetType::RelatedCryptoMaterial);
1009 key.crypto_properties = Some(
1010 CryptoProperties::new(CryptoAssetType::RelatedCryptoMaterial)
1011 .with_related_crypto_material_properties(RelatedCryptoMaterialProperties::new(
1012 CryptoMaterialType::PrivateKey,
1013 )),
1014 );
1015 key.calculate_content_hash();
1016 let mut lib_old = Component::new("lodash".to_string(), "pkg:npm/lodash@1".to_string());
1017 lib_old.version = Some("1.0.0".to_string());
1018 lib_old.ecosystem = Some(Ecosystem::Npm);
1019 lib_old.calculate_content_hash();
1020
1021 let algo = crypto_component("AES-128-GCM", CryptoAssetType::Algorithm);
1023 let mut lib_new = Component::new("lodash".to_string(), "pkg:npm/lodash@2".to_string());
1024 lib_new.version = Some("2.0.0".to_string());
1025 lib_new.ecosystem = Some(Ecosystem::Npm);
1026 lib_new.calculate_content_hash();
1027
1028 let mut old_sbom = NormalizedSbom::default();
1029 let mut new_sbom = NormalizedSbom::default();
1030 let mut matches = ComponentMatches::new();
1031 matches.insert(cert.canonical_id.clone(), None);
1032 matches.insert(key.canonical_id.clone(), None);
1033 matches.insert(
1034 lib_old.canonical_id.clone(),
1035 Some(lib_new.canonical_id.clone()),
1036 );
1037 old_sbom.add_component(cert);
1038 old_sbom.add_component(key);
1039 old_sbom.add_component(lib_old);
1040 new_sbom.add_component(algo);
1041 new_sbom.add_component(lib_new);
1042
1043 let result = ComponentChangeComputer::default().compute(&old_sbom, &new_sbom, &matches);
1044
1045 assert_eq!(result.added.len(), 1);
1046 assert_eq!(
1047 result.added[0].component_type.as_deref(),
1048 Some("algorithm"),
1049 "added CBOM algorithm must carry its assetType"
1050 );
1051
1052 let type_of = |name: &str| {
1053 result
1054 .removed
1055 .iter()
1056 .find(|c| c.name == name)
1057 .unwrap_or_else(|| panic!("expected a removed `{name}` entry"))
1058 .component_type
1059 .clone()
1060 };
1061 assert_eq!(
1062 type_of("web-cert").as_deref(),
1063 Some("certificate"),
1064 "removed CBOM certificate must carry its assetType"
1065 );
1066 assert_eq!(
1067 type_of("tls-key").as_deref(),
1068 Some("private-key"),
1069 "related-crypto-material must narrow to its material type"
1070 );
1071
1072 assert_eq!(result.modified.len(), 1);
1073 assert_eq!(
1074 result.modified[0].component_type.as_deref(),
1075 Some("library"),
1076 "plain SBOM components keep their CycloneDX type"
1077 );
1078 }
1079}