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::{Component, CryptoAssetType, CryptoProperties, NormalizedSbom};
6use std::collections::HashSet;
7
8/// Computes component-level changes between SBOMs.
9pub struct ComponentChangeComputer {
10    cost_model: CostModel,
11}
12
13impl ComponentChangeComputer {
14    /// Create a new component change computer with the given cost model.
15    #[must_use]
16    pub const fn new(cost_model: CostModel) -> Self {
17        Self { cost_model }
18    }
19
20    /// Compute individual field changes between two components.
21    fn compute_field_changes(&self, old: &Component, new: &Component) -> (Vec<FieldChange>, u32) {
22        let mut changes = Vec::new();
23        let mut total_cost = 0u32;
24
25        // Version change
26        if old.version != new.version {
27            changes.push(FieldChange {
28                field: "version".to_string(),
29                old_value: old.version.clone(),
30                new_value: new.version.clone(),
31            });
32            total_cost += self
33                .cost_model
34                .version_change_cost(&old.semver, &new.semver);
35        }
36
37        // License change
38        let old_licenses: HashSet<_> = old
39            .licenses
40            .declared
41            .iter()
42            .map(|l| &l.expression)
43            .collect();
44        let new_licenses: HashSet<_> = new
45            .licenses
46            .declared
47            .iter()
48            .map(|l| &l.expression)
49            .collect();
50        if old_licenses != new_licenses {
51            changes.push(FieldChange {
52                field: "licenses".to_string(),
53                old_value: Some(
54                    old.licenses
55                        .declared
56                        .iter()
57                        .map(|l| l.expression.clone())
58                        .collect::<Vec<_>>()
59                        .join(", "),
60                ),
61                new_value: Some(
62                    new.licenses
63                        .declared
64                        .iter()
65                        .map(|l| l.expression.clone())
66                        .collect::<Vec<_>>()
67                        .join(", "),
68                ),
69            });
70            total_cost += self.cost_model.license_changed;
71        }
72
73        // Supplier change
74        if old.supplier != new.supplier {
75            changes.push(FieldChange {
76                field: "supplier".to_string(),
77                old_value: old.supplier.as_ref().map(|s| s.name.clone()),
78                new_value: new.supplier.as_ref().map(|s| s.name.clone()),
79            });
80            total_cost += self.cost_model.supplier_changed;
81        }
82
83        // Hash change (same version but different hash = integrity concern)
84        if old.version == new.version && !old.hashes.is_empty() && !new.hashes.is_empty() {
85            let old_hashes: HashSet<_> = old.hashes.iter().map(|h| &h.value).collect();
86            let new_hashes: HashSet<_> = new.hashes.iter().map(|h| &h.value).collect();
87            if old_hashes.is_disjoint(&new_hashes) {
88                changes.push(FieldChange {
89                    field: "hashes".to_string(),
90                    old_value: Some(
91                        old.hashes
92                            .first()
93                            .map(|h| h.value.clone())
94                            .unwrap_or_default(),
95                    ),
96                    new_value: Some(
97                        new.hashes
98                            .first()
99                            .map(|h| h.value.clone())
100                            .unwrap_or_default(),
101                    ),
102                });
103                total_cost += self.cost_model.hash_mismatch;
104            }
105        }
106
107        // Cryptographic property changes
108        if old.crypto_properties != new.crypto_properties {
109            total_cost += Self::compute_crypto_changes(&self.cost_model, old, new, &mut changes);
110        }
111
112        (changes, total_cost)
113    }
114
115    /// Compute crypto-specific field changes between two components.
116    fn compute_crypto_changes(
117        cost_model: &CostModel,
118        old: &Component,
119        new: &Component,
120        changes: &mut Vec<FieldChange>,
121    ) -> u32 {
122        let mut cost = 0u32;
123
124        match (&old.crypto_properties, &new.crypto_properties) {
125            (Some(old_cp), Some(new_cp)) => {
126                cost += Self::compute_crypto_sub_changes(cost_model, old_cp, new_cp, changes);
127            }
128            (None, Some(new_cp)) => {
129                changes.push(FieldChange {
130                    field: "crypto_properties".to_string(),
131                    old_value: None,
132                    new_value: Some(new_cp.asset_type.to_string()),
133                });
134                cost += cost_model.crypto_algorithm_changed;
135            }
136            (Some(old_cp), None) => {
137                changes.push(FieldChange {
138                    field: "crypto_properties".to_string(),
139                    old_value: Some(old_cp.asset_type.to_string()),
140                    new_value: None,
141                });
142                cost += cost_model.crypto_algorithm_changed;
143            }
144            (None, None) => {}
145        }
146
147        cost
148    }
149
150    fn compute_crypto_sub_changes(
151        cost_model: &CostModel,
152        old: &CryptoProperties,
153        new: &CryptoProperties,
154        changes: &mut Vec<FieldChange>,
155    ) -> u32 {
156        let mut cost = 0u32;
157
158        // Algorithm property changes
159        if let (Some(old_algo), Some(new_algo)) =
160            (&old.algorithm_properties, &new.algorithm_properties)
161        {
162            // Algorithm family change
163            if old_algo.algorithm_family != new_algo.algorithm_family {
164                changes.push(FieldChange {
165                    field: "crypto_algorithm".to_string(),
166                    old_value: old_algo.algorithm_family.clone(),
167                    new_value: new_algo.algorithm_family.clone(),
168                });
169                cost += cost_model.crypto_algorithm_changed;
170            }
171
172            // Quantum security level change
173            if old_algo.nist_quantum_security_level != new_algo.nist_quantum_security_level {
174                changes.push(FieldChange {
175                    field: "crypto_quantum_level".to_string(),
176                    old_value: old_algo.nist_quantum_security_level.map(|l| l.to_string()),
177                    new_value: new_algo.nist_quantum_security_level.map(|l| l.to_string()),
178                });
179                cost += cost_model.crypto_quantum_level_changed;
180            }
181
182            // Security downgrade detection: classical security level decreased
183            if let (Some(old_bits), Some(new_bits)) = (
184                old_algo.classical_security_level,
185                new_algo.classical_security_level,
186            ) && new_bits < old_bits
187            {
188                changes.push(FieldChange {
189                    field: "crypto_downgrade".to_string(),
190                    old_value: Some(format!("{old_bits} bits")),
191                    new_value: Some(format!("{new_bits} bits")),
192                });
193                cost += cost_model.crypto_downgrade;
194            }
195        }
196
197        // Key material state changes
198        if let (Some(old_mat), Some(new_mat)) = (
199            &old.related_crypto_material_properties,
200            &new.related_crypto_material_properties,
201        ) && old_mat.state != new_mat.state
202        {
203            changes.push(FieldChange {
204                field: "crypto_key_state".to_string(),
205                old_value: old_mat.state.as_ref().map(|s| s.to_string()),
206                new_value: new_mat.state.as_ref().map(|s| s.to_string()),
207            });
208            cost += cost_model.crypto_key_rotated;
209        }
210
211        // Certificate expiry changes
212        if let (Some(old_cert), Some(new_cert)) =
213            (&old.certificate_properties, &new.certificate_properties)
214            && old_cert.not_valid_after != new_cert.not_valid_after
215        {
216            changes.push(FieldChange {
217                field: "crypto_cert_expiry".to_string(),
218                old_value: old_cert.not_valid_after.map(|d| d.to_rfc3339()),
219                new_value: new_cert.not_valid_after.map(|d| d.to_rfc3339()),
220            });
221            cost += cost_model.crypto_cert_expiry_changed;
222        }
223
224        // Protocol version changes
225        if let (Some(old_proto), Some(new_proto)) =
226            (&old.protocol_properties, &new.protocol_properties)
227            && old_proto.version != new_proto.version
228        {
229            changes.push(FieldChange {
230                field: "crypto_protocol_version".to_string(),
231                old_value: old_proto.version.clone(),
232                new_value: new_proto.version.clone(),
233            });
234            cost += cost_model.crypto_protocol_changed;
235        }
236
237        // Asset type change (e.g., algorithm → protocol)
238        if old.asset_type != new.asset_type
239            && old.asset_type != CryptoAssetType::Other("unknown".to_string())
240        {
241            changes.push(FieldChange {
242                field: "crypto_asset_type".to_string(),
243                old_value: Some(old.asset_type.to_string()),
244                new_value: Some(new.asset_type.to_string()),
245            });
246            cost += cost_model.crypto_algorithm_changed;
247        }
248
249        cost
250    }
251}
252
253impl Default for ComponentChangeComputer {
254    fn default() -> Self {
255        Self::new(CostModel::default())
256    }
257}
258
259impl ChangeComputer for ComponentChangeComputer {
260    type ChangeSet = ComponentChangeSet;
261
262    fn compute(
263        &self,
264        old: &NormalizedSbom,
265        new: &NormalizedSbom,
266        matches: &ComponentMatches,
267    ) -> ComponentChangeSet {
268        let mut result = ComponentChangeSet::new();
269        let matched_new_ids: HashSet<_> = matches
270            .values()
271            .filter_map(std::clone::Clone::clone)
272            .collect();
273
274        // Find removed components
275        for (old_id, new_id_opt) in matches {
276            if new_id_opt.is_none()
277                && let Some(old_comp) = old.components.get(old_id)
278            {
279                result.removed.push(ComponentChange::removed(
280                    old_comp,
281                    self.cost_model.component_removed,
282                ));
283            }
284        }
285
286        // Find added components
287        for new_id in new.components.keys() {
288            if !matched_new_ids.contains(new_id)
289                && let Some(new_comp) = new.components.get(new_id)
290            {
291                result.added.push(ComponentChange::added(
292                    new_comp,
293                    self.cost_model.component_added,
294                ));
295            }
296        }
297
298        // Find modified components
299        for (old_id, new_id_opt) in matches {
300            if let Some(new_id) = new_id_opt
301                && let (Some(old_comp), Some(new_comp)) =
302                    (old.components.get(old_id), new.components.get(new_id))
303            {
304                // Check if component was actually modified
305                if old_comp.content_hash != new_comp.content_hash {
306                    let (field_changes, cost) = self.compute_field_changes(old_comp, new_comp);
307                    if !field_changes.is_empty() {
308                        result.modified.push(ComponentChange::modified(
309                            old_comp,
310                            new_comp,
311                            field_changes,
312                            cost,
313                        ));
314                    }
315                }
316            }
317        }
318
319        result
320    }
321
322    fn name(&self) -> &'static str {
323        "ComponentChangeComputer"
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_component_change_computer_default() {
333        let computer = ComponentChangeComputer::default();
334        assert_eq!(computer.name(), "ComponentChangeComputer");
335    }
336
337    #[test]
338    fn test_empty_sboms() {
339        let computer = ComponentChangeComputer::default();
340        let old = NormalizedSbom::default();
341        let new = NormalizedSbom::default();
342        let matches = ComponentMatches::new();
343
344        let result = computer.compute(&old, &new, &matches);
345        assert!(result.is_empty());
346    }
347}