Skip to main content

sbom_tools/diff/changes/
components.rs

1//! Component change computer implementation.
2
3use 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
11/// Computes component-level changes between SBOMs.
12pub struct ComponentChangeComputer {
13    cost_model: CostModel,
14    include_unchanged: bool,
15}
16
17impl ComponentChangeComputer {
18    /// Create a new component change computer with the given cost model.
19    #[must_use]
20    pub const fn new(cost_model: CostModel) -> Self {
21        Self {
22            cost_model,
23            include_unchanged: false,
24        }
25    }
26
27    /// Also emit `ChangeType::Unchanged` entries for matched, content-equal
28    /// pairs (drives `--include-unchanged`).
29    #[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    /// Compute individual field changes between two components.
36    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        // Name change (pair was matched via fuzzy/alias/cross-ecosystem, so
41        // differing names mean a rename/migration). Without this a matched
42        // pair whose only differences are name/ecosystem produced an empty
43        // field-change list and vanished from the modified section entirely.
44        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        // Ecosystem migration (e.g. a curated cross-ecosystem match) — a
54        // supply-chain-relevant change that must be visible in reports.
55        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        // Version change
65        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        // License change
77        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        // Supplier change
113        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        // Hash change (same version but different hash = integrity concern)
123        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        // ML model metadata changes (granular, prefixed per-field)
147        if old.ml_model != new.ml_model {
148            total_cost += Self::compute_ml_changes(&self.cost_model, old, new, &mut changes);
149        }
150
151        // Dataset metadata changes (granular, prefixed per-field)
152        if old.dataset != new.dataset {
153            total_cost += Self::compute_dataset_changes(&self.cost_model, old, new, &mut changes);
154        }
155
156        // Cryptographic property changes
157        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    /// Push a scalar `Option<String>` field change when the values differ.
165    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    /// Stable identity key for a training-dataset reference: prefer the BOM-ref,
184    /// then the name, then the PURL. Used to detect added/removed datasets across
185    /// two model revisions.
186    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    /// Compute ML-model-specific field changes between two components.
195    ///
196    /// Emits granular, prefixed `ml_*` field changes (approach, architecture, task,
197    /// quantization, model card) plus per-dataset `ml_training_dataset` add/remove
198    /// entries, rather than one opaque serialized blob. This surfaces model-swap
199    /// signals such as `fp32 -> int4` re-quantization or training-data provenance loss.
200    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                // Model metadata appeared or disappeared wholesale.
214                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        // Architecture family and name are reported under a single prefixed field
245        // so a "resnet -> bert" or "cnn -> transformer" swap reads as one signal.
246        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    /// Combine architecture family and name into a single display value.
338    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    /// Emit per-dataset `ml_training_dataset` add/remove changes keyed by
347    /// `DatasetRef.reference`-or-`name`. Training-dataset removal is treated as a
348    /// provenance-loss signal and carries a high cost.
349    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        // Removed training datasets (present in old, absent in new). Sorted for
369        // deterministic output.
370        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        // Added training datasets (absent in old, present in new).
382        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    /// Compute dataset-component-specific field changes between two components.
397    ///
398    /// Emits granular, prefixed `dataset_*` field changes: type, per-classification
399    /// sensitivity add/remove, and governance. Gaining a sensitivity classification
400    /// (e.g. a dataset newly tagged `pii`) is a data-governance signal and carries
401    /// a high cost.
402    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        // Sensitivity classifications: emit per-classification add/remove so a
446        // dataset newly gaining "pii" is visible and costly.
447        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        // Governance owners: report a single change when the owner set differs.
481        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    /// Join a list of strings into a deterministic, comma-separated display value,
496    /// or `None` when empty.
497    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    /// Compute crypto-specific field changes between two components.
507    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        // Algorithm property changes
550        if let (Some(old_algo), Some(new_algo)) =
551            (&old.algorithm_properties, &new.algorithm_properties)
552        {
553            // Algorithm family change
554            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            // Quantum security level change
564            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            // Security downgrade detection: classical security level decreased
574            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        // Key material state changes
589        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        // Certificate expiry changes
603        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        // Protocol version changes
616        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        // Asset type change (e.g., algorithm → protocol)
629        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        // Find removed components
666        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        // Find added components
678        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        // Find modified components
690        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                // Check if component was actually modified
696                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                        // Hash differs only in untracked detail; no reportable
707                        // field change — an unchanged entry for inventory view.
708                        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        // Removed/modified are collected from hash-map iteration; sort by ID
721        // for deterministic output ordering
722        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    // `DatasetInfo`, `DatasetRef`, and `MlModelInfo` are re-exported via the
736    // parent module's `use crate::model::{...}`.
737    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    /// Locate the single field change with the given field name, asserting it exists.
757    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    /// A matched pair differing only in name (rename) or ecosystem
765    /// (migration) previously produced an empty field-change list and was
766    /// silently dropped from the modified section — reported NOWHERE.
767    #[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        // End-to-end through compute(): the rename pair must appear in the
798        // modified list, not vanish.
799        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        // The opaque "ml_model" blob is gone; a prefixed ml_quantization change appears.
835        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        // No opaque "dataset" blob; a prefixed dataset_sensitivity add appears.
929        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    /// The serialized `component_type` on every change entry resolves CBOM
988    /// crypto assets to their cryptoProperties assetType — an added
989    /// "algorithm" and a removed "certificate" must be distinguishable in
990    /// diff JSON — with related-crypto-material narrowed to its declared
991    /// material type. Plain components keep their CycloneDX type.
992    #[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        // Old side: a certificate, a private key, and a library.
1007        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        // New side: an algorithm appears, the library gets a version bump.
1022        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}