sbom_tools/diff/changes/
licenses.rs1use crate::diff::traits::{ChangeComputer, ComponentMatches, LicenseChangeSet};
4use crate::diff::{ComponentLicenseChange, LicenseChange};
5use crate::model::NormalizedSbom;
6use std::collections::{BTreeSet, HashMap};
7
8pub struct LicenseChangeComputer;
10
11impl LicenseChangeComputer {
12 #[must_use]
14 pub const fn new() -> Self {
15 Self
16 }
17}
18
19impl Default for LicenseChangeComputer {
20 fn default() -> Self {
21 Self::new()
22 }
23}
24
25impl ChangeComputer for LicenseChangeComputer {
26 type ChangeSet = LicenseChangeSet;
27
28 fn compute(
35 &self,
36 old: &NormalizedSbom,
37 new: &NormalizedSbom,
38 matches: &ComponentMatches,
39 ) -> LicenseChangeSet {
40 let mut result = LicenseChangeSet::new();
41
42 let mut old_licenses: HashMap<String, Vec<String>> = HashMap::new();
44 for (_id, comp) in &old.components {
45 for lic in &comp.licenses.declared {
46 old_licenses
47 .entry(lic.expression.clone())
48 .or_default()
49 .push(comp.name.clone());
50 }
51 }
52
53 let mut new_licenses: HashMap<String, Vec<String>> = HashMap::new();
55 for (_id, comp) in &new.components {
56 for lic in &comp.licenses.declared {
57 new_licenses
58 .entry(lic.expression.clone())
59 .or_default()
60 .push(comp.name.clone());
61 }
62 }
63
64 for (license, components) in &new_licenses {
66 if !old_licenses.contains_key(license) {
67 result.new_licenses.push(LicenseChange {
68 license: license.clone(),
69 components: components.clone(),
70 family: "Unknown".to_string(), });
72 }
73 }
74
75 for (license, components) in &old_licenses {
77 if !new_licenses.contains_key(license) {
78 result.removed_licenses.push(LicenseChange {
79 license: license.clone(),
80 components: components.clone(),
81 family: "Unknown".to_string(),
82 });
83 }
84 }
85
86 result
89 .new_licenses
90 .sort_by(|a, b| a.license.cmp(&b.license));
91 result
92 .removed_licenses
93 .sort_by(|a, b| a.license.cmp(&b.license));
94
95 for (old_id, new_id_opt) in matches {
97 if let Some(new_id) = new_id_opt
98 && let (Some(old_comp), Some(new_comp)) =
99 (old.components.get(old_id), new.components.get(new_id))
100 {
101 if old_comp.content_hash == new_comp.content_hash {
103 continue;
104 }
105
106 let old_set: BTreeSet<&str> = old_comp
107 .licenses
108 .declared
109 .iter()
110 .map(|lic| lic.expression.as_str())
111 .collect();
112 let new_set: BTreeSet<&str> = new_comp
113 .licenses
114 .declared
115 .iter()
116 .map(|lic| lic.expression.as_str())
117 .collect();
118
119 if old_set != new_set {
120 result.component_changes.push(ComponentLicenseChange {
121 component_id: new_id.to_string(),
122 component_name: new_comp.name.clone(),
123 old_licenses: old_set.into_iter().map(String::from).collect(),
124 new_licenses: new_set.into_iter().map(String::from).collect(),
125 });
126 }
127 }
128 }
129
130 result.component_changes.sort_by(|a, b| {
131 (&a.component_name, &a.component_id).cmp(&(&b.component_name, &b.component_id))
132 });
133
134 result
135 }
136
137 fn name(&self) -> &'static str {
138 "LicenseChangeComputer"
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::model::{Component, LicenseExpression};
146
147 fn component_with_licenses(name: &str, format_id: &str, licenses: &[&str]) -> Component {
148 let mut comp = Component::new(name.to_string(), format_id.to_string());
149 for lic in licenses {
150 comp.licenses
151 .add_declared(LicenseExpression::new((*lic).to_string()));
152 }
153 comp.calculate_content_hash();
155 comp
156 }
157
158 fn sbom_with(components: Vec<Component>) -> NormalizedSbom {
159 let mut sbom = NormalizedSbom::default();
160 for comp in components {
161 sbom.add_component(comp);
162 }
163 sbom
164 }
165
166 #[test]
167 fn test_license_change_computer_default() {
168 let computer = LicenseChangeComputer;
169 assert_eq!(computer.name(), "LicenseChangeComputer");
170 }
171
172 #[test]
173 fn test_empty_sboms() {
174 let computer = LicenseChangeComputer;
175 let old = NormalizedSbom::default();
176 let new = NormalizedSbom::default();
177 let matches = ComponentMatches::new();
178
179 let result = computer.compute(&old, &new, &matches);
180 assert!(result.is_empty());
181 }
182
183 #[test]
184 fn matched_pair_license_transition() {
185 let old_comp = component_with_licenses("a", "a@1.0", &["MIT"]);
186 let new_comp = component_with_licenses("a", "a@1.0", &["GPL-3.0-only"]);
187 let mut matches = ComponentMatches::new();
188 matches.insert(
189 old_comp.canonical_id.clone(),
190 Some(new_comp.canonical_id.clone()),
191 );
192 let old = sbom_with(vec![old_comp]);
193 let new = sbom_with(vec![new_comp]);
194
195 let result = LicenseChangeComputer::new().compute(&old, &new, &matches);
196
197 assert_eq!(result.component_changes.len(), 1);
198 let change = &result.component_changes[0];
199 assert_eq!(change.component_name, "a");
200 assert_eq!(change.old_licenses, vec!["MIT".to_string()]);
201 assert_eq!(change.new_licenses, vec!["GPL-3.0-only".to_string()]);
202 assert_eq!(result.new_licenses.len(), 1);
203 assert_eq!(result.new_licenses[0].license, "GPL-3.0-only");
204 assert_eq!(result.removed_licenses.len(), 1);
205 assert_eq!(result.removed_licenses[0].license, "MIT");
206 }
207
208 #[test]
209 fn renamed_but_matched_component_included() {
210 let old_comp = component_with_licenses("a-old", "a-old@1.0", &["MIT"]);
211 let new_comp = component_with_licenses("a-new", "a-new@1.0", &["Apache-2.0"]);
212 let mut matches = ComponentMatches::new();
213 matches.insert(
214 old_comp.canonical_id.clone(),
215 Some(new_comp.canonical_id.clone()),
216 );
217 let new_id = new_comp.canonical_id.clone();
218 let old = sbom_with(vec![old_comp]);
219 let new = sbom_with(vec![new_comp]);
220
221 let result = LicenseChangeComputer::new().compute(&old, &new, &matches);
222
223 assert_eq!(result.component_changes.len(), 1);
224 assert_eq!(result.component_changes[0].component_name, "a-new");
225 assert_eq!(result.component_changes[0].component_id, new_id.to_string());
226 }
227
228 #[test]
229 fn unmatched_add_remove_not_in_component_changes() {
230 let old_comp = component_with_licenses("a", "a@1.0", &["MIT"]);
231 let new_comp = component_with_licenses("b", "b@1.0", &["Apache-2.0"]);
232 let mut matches = ComponentMatches::new();
233 matches.insert(old_comp.canonical_id.clone(), None);
234 let old = sbom_with(vec![old_comp]);
235 let new = sbom_with(vec![new_comp]);
236
237 let result = LicenseChangeComputer::new().compute(&old, &new, &matches);
238
239 assert!(result.component_changes.is_empty());
240 assert_eq!(result.new_licenses.len(), 1);
241 assert_eq!(result.removed_licenses.len(), 1);
242 }
243
244 #[test]
245 fn identical_license_sets_not_reported() {
246 let old_comp = component_with_licenses("a", "a@1.0", &["MIT"]);
247 let mut new_comp = component_with_licenses("a", "a@1.0", &["MIT"]);
248 new_comp.version = Some("2.0.0".to_string());
249 new_comp.calculate_content_hash();
250 let mut matches = ComponentMatches::new();
251 matches.insert(
252 old_comp.canonical_id.clone(),
253 Some(new_comp.canonical_id.clone()),
254 );
255 let old = sbom_with(vec![old_comp]);
256 let new = sbom_with(vec![new_comp]);
257
258 let result = LicenseChangeComputer::new().compute(&old, &new, &matches);
259
260 assert!(result.component_changes.is_empty());
261 }
262}