Skip to main content

sbom_tools/diff/
cost.rs

1//! Cost model for diff operations.
2
3use serde::{Deserialize, Serialize};
4
5/// Cost model configuration for semantic diff operations.
6///
7/// Costs are used to determine the minimum-cost alignment between two SBOMs.
8/// Higher costs indicate more significant changes.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CostModel {
11    /// Cost for adding a new component
12    pub component_added: u32,
13    /// Cost for removing a component
14    pub component_removed: u32,
15    /// Cost for patch version change
16    pub version_patch: u32,
17    /// Cost for minor version change
18    pub version_minor: u32,
19    /// Cost for major version change
20    pub version_major: u32,
21    /// Cost for license change
22    pub license_changed: u32,
23    /// Cost for supplier change
24    pub supplier_changed: u32,
25    /// Cost for introducing a vulnerability
26    pub vulnerability_introduced: u32,
27    /// Reward (negative cost) for resolving a vulnerability
28    pub vulnerability_resolved: i32,
29    /// Cost for adding a dependency
30    pub dependency_added: u32,
31    /// Cost for removing a dependency
32    pub dependency_removed: u32,
33    /// Cost for hash mismatch (integrity concern)
34    pub hash_mismatch: u32,
35    /// Cost for crypto algorithm family change
36    pub crypto_algorithm_changed: u32,
37    /// Cost for crypto security downgrade (weaker algorithm or lower security level)
38    pub crypto_downgrade: u32,
39    /// Cost for crypto key rotation
40    pub crypto_key_rotated: u32,
41    /// Cost for certificate expiry date change
42    pub crypto_cert_expiry_changed: u32,
43    /// Cost for protocol version change
44    pub crypto_protocol_changed: u32,
45    /// Cost for quantum security level change
46    pub crypto_quantum_level_changed: u32,
47}
48
49impl Default for CostModel {
50    fn default() -> Self {
51        Self {
52            component_added: 10,
53            component_removed: 10,
54            version_patch: 2,
55            version_minor: 4,
56            version_major: 7,
57            license_changed: 6,
58            supplier_changed: 4,
59            vulnerability_introduced: 15,
60            vulnerability_resolved: -3,
61            dependency_added: 5,
62            dependency_removed: 5,
63            hash_mismatch: 8,
64            crypto_algorithm_changed: 8,
65            crypto_downgrade: 20,
66            crypto_key_rotated: 3,
67            crypto_cert_expiry_changed: 5,
68            crypto_protocol_changed: 6,
69            crypto_quantum_level_changed: 10,
70        }
71    }
72}
73
74impl CostModel {
75    /// Create a security-focused cost model
76    #[must_use]
77    pub fn security_focused() -> Self {
78        Self {
79            vulnerability_introduced: 25,
80            vulnerability_resolved: -5,
81            hash_mismatch: 15,
82            supplier_changed: 8,
83            crypto_downgrade: 30,
84            crypto_quantum_level_changed: 15,
85            crypto_algorithm_changed: 12,
86            ..Default::default()
87        }
88    }
89
90    /// Create a compliance-focused cost model
91    #[must_use]
92    pub fn compliance_focused() -> Self {
93        Self {
94            license_changed: 12,
95            supplier_changed: 8,
96            ..Default::default()
97        }
98    }
99
100    /// Get cost for version change based on semver
101    #[must_use]
102    pub const fn version_change_cost(
103        &self,
104        old: &Option<semver::Version>,
105        new: &Option<semver::Version>,
106    ) -> u32 {
107        match (old, new) {
108            (Some(old_ver), Some(new_ver)) => {
109                if old_ver.major != new_ver.major {
110                    self.version_major
111                } else if old_ver.minor != new_ver.minor {
112                    self.version_minor
113                } else if old_ver.patch != new_ver.patch {
114                    self.version_patch
115                } else {
116                    0
117                }
118            }
119            (None, Some(_)) | (Some(_), None) => self.version_minor,
120            (None, None) => 0,
121        }
122    }
123
124    /// Calculate total semantic score from change counts
125    #[allow(clippy::too_many_arguments)]
126    #[must_use]
127    pub const fn calculate_semantic_score(
128        &self,
129        components_added: usize,
130        components_removed: usize,
131        version_changes: usize,
132        license_changes: usize,
133        vulns_introduced: usize,
134        vulns_resolved: usize,
135        deps_added: usize,
136        deps_removed: usize,
137    ) -> f64 {
138        let score = (components_added as u32 * self.component_added)
139            + (components_removed as u32 * self.component_removed)
140            + (version_changes as u32 * self.version_minor)
141            + (license_changes as u32 * self.license_changed)
142            + (vulns_introduced as u32 * self.vulnerability_introduced)
143            + (deps_added as u32 * self.dependency_added)
144            + (deps_removed as u32 * self.dependency_removed);
145
146        let reward = vulns_resolved as i32 * self.vulnerability_resolved;
147
148        (score as i32 + reward) as f64
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_default_cost_model() {
158        let model = CostModel::default();
159        assert_eq!(model.component_added, 10);
160        assert_eq!(model.component_removed, 10);
161        assert_eq!(model.version_patch, 2);
162        assert_eq!(model.version_minor, 4);
163        assert_eq!(model.version_major, 7);
164        assert_eq!(model.license_changed, 6);
165        assert_eq!(model.vulnerability_introduced, 15);
166        assert_eq!(model.vulnerability_resolved, -3);
167        assert_eq!(model.hash_mismatch, 8);
168        assert_eq!(model.crypto_algorithm_changed, 8);
169        assert_eq!(model.crypto_downgrade, 20);
170        assert_eq!(model.crypto_key_rotated, 3);
171        assert_eq!(model.crypto_cert_expiry_changed, 5);
172        assert_eq!(model.crypto_protocol_changed, 6);
173        assert_eq!(model.crypto_quantum_level_changed, 10);
174    }
175
176    #[test]
177    fn test_security_focused_model() {
178        let model = CostModel::security_focused();
179        let default = CostModel::default();
180        assert!(model.vulnerability_introduced > default.vulnerability_introduced);
181        assert!(model.hash_mismatch > default.hash_mismatch);
182        assert!(model.vulnerability_resolved < default.vulnerability_resolved);
183        assert!(model.crypto_downgrade > default.crypto_downgrade);
184        assert!(model.crypto_quantum_level_changed > default.crypto_quantum_level_changed);
185    }
186
187    #[test]
188    fn test_compliance_focused_model() {
189        let model = CostModel::compliance_focused();
190        let default = CostModel::default();
191        assert!(model.license_changed > default.license_changed);
192        assert!(model.supplier_changed > default.supplier_changed);
193    }
194
195    fn ver(major: u64, minor: u64, patch: u64) -> semver::Version {
196        semver::Version::new(major, minor, patch)
197    }
198
199    #[test]
200    fn test_version_change_cost_major() {
201        let model = CostModel::default();
202        let old = Some(ver(1, 0, 0));
203        let new = Some(ver(2, 0, 0));
204        assert_eq!(model.version_change_cost(&old, &new), model.version_major);
205    }
206
207    #[test]
208    fn test_version_change_cost_minor() {
209        let model = CostModel::default();
210        let old = Some(ver(1, 0, 0));
211        let new = Some(ver(1, 1, 0));
212        assert_eq!(model.version_change_cost(&old, &new), model.version_minor);
213    }
214
215    #[test]
216    fn test_version_change_cost_patch() {
217        let model = CostModel::default();
218        let old = Some(ver(1, 0, 0));
219        let new = Some(ver(1, 0, 1));
220        assert_eq!(model.version_change_cost(&old, &new), model.version_patch);
221    }
222
223    #[test]
224    fn test_version_change_cost_same() {
225        let model = CostModel::default();
226        let old = Some(ver(1, 2, 3));
227        let new = Some(ver(1, 2, 3));
228        assert_eq!(model.version_change_cost(&old, &new), 0);
229    }
230
231    #[test]
232    fn test_version_change_cost_none_to_some() {
233        let model = CostModel::default();
234        let new = Some(ver(1, 0, 0));
235        assert_eq!(model.version_change_cost(&None, &new), model.version_minor);
236    }
237
238    #[test]
239    fn test_version_change_cost_some_to_none() {
240        let model = CostModel::default();
241        let old = Some(ver(1, 0, 0));
242        assert_eq!(model.version_change_cost(&old, &None), model.version_minor);
243    }
244
245    #[test]
246    fn test_version_change_cost_none_none() {
247        let model = CostModel::default();
248        assert_eq!(model.version_change_cost(&None, &None), 0);
249    }
250
251    #[test]
252    fn test_semantic_score_zero() {
253        let model = CostModel::default();
254        let score = model.calculate_semantic_score(0, 0, 0, 0, 0, 0, 0, 0);
255        assert_eq!(score, 0.0);
256    }
257
258    #[test]
259    fn test_semantic_score_basic() {
260        let model = CostModel::default();
261        // 2 added (10 each) + 1 removed (10) + 1 version change (4) = 34
262        let score = model.calculate_semantic_score(2, 1, 1, 0, 0, 0, 0, 0);
263        assert_eq!(score, 34.0);
264    }
265
266    #[test]
267    fn test_semantic_score_with_resolved() {
268        let model = CostModel::default();
269        // 1 vuln introduced (15) + 2 resolved (2 * -3 = -6) = 9
270        let score = model.calculate_semantic_score(0, 0, 0, 0, 1, 2, 0, 0);
271        assert_eq!(score, 9.0);
272    }
273}