1use crate::diff::traits::{ChangeComputer, ComponentChangeSet, ComponentMatches};
4use crate::diff::{ComponentChange, CostModel, FieldChange};
5use crate::model::{Component, CryptoAssetType, CryptoProperties, NormalizedSbom};
6use std::collections::HashSet;
7
8pub struct ComponentChangeComputer {
10 cost_model: CostModel,
11}
12
13impl ComponentChangeComputer {
14 #[must_use]
16 pub const fn new(cost_model: CostModel) -> Self {
17 Self { cost_model }
18 }
19
20 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 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 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 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 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 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 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 if let (Some(old_algo), Some(new_algo)) =
160 (&old.algorithm_properties, &new.algorithm_properties)
161 {
162 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 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 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 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 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 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 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 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 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 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 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}