1#![doc = include_str!("../readme.md")]
2
3use sbom_model::{Component, ComponentId, Sbom};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet, HashSet};
6
7pub mod renderer;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Diff {
14 pub added: Vec<Component>,
16 pub removed: Vec<Component>,
18 pub changed: Vec<ComponentChange>,
20 pub metadata_changed: bool,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ComponentChange {
27 pub id: ComponentId,
29 pub old: Component,
31 pub new: Component,
33 pub changes: Vec<FieldChange>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub enum FieldChange {
40 Version(String, String),
42 License(BTreeSet<String>, BTreeSet<String>),
44 Supplier(Option<String>, Option<String>),
46 Purl(Option<String>, Option<String>),
48 Hashes,
50}
51
52#[derive(Debug, Copy, Clone, PartialEq, Eq)]
56pub enum Field {
57 Version,
59 License,
61 Supplier,
63 Purl,
65 Hashes,
67}
68
69pub struct Differ;
74
75impl Differ {
76 pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
103 let mut old = old.clone();
104 let mut new = new.clone();
105
106 old.normalize();
107 new.normalize();
108
109 let mut added = Vec::new();
110 let mut removed = Vec::new();
111 let mut changed = Vec::new();
112
113 let mut processed_old = HashSet::new();
114 let mut processed_new = HashSet::new();
115
116 for (id, new_comp) in &new.components {
118 if let Some(old_comp) = old.components.get(id) {
119 processed_old.insert(id.clone());
120 processed_new.insert(id.clone());
121
122 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
123 changed.push(change);
124 }
125 }
126 }
127
128 let mut old_identity_map = BTreeMap::new();
130 for (id, comp) in &old.components {
131 if !processed_old.contains(id) {
132 let identity = (comp.ecosystem.clone(), comp.name.clone());
133 old_identity_map
134 .entry(identity)
135 .or_insert_with(Vec::new)
136 .push(id.clone());
137 }
138 }
139
140 for (id, new_comp) in &new.components {
141 if processed_new.contains(id) {
142 continue;
143 }
144
145 let identity = (new_comp.ecosystem.clone(), new_comp.name.clone());
146 if let Some(old_ids) = old_identity_map.get_mut(&identity) {
147 if let Some(old_id) = old_ids.pop() {
148 if let Some(old_comp) = old.components.get(&old_id) {
149 processed_old.insert(old_id.clone());
150 processed_new.insert(id.clone());
151
152 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
153 changed.push(change);
154 }
155 continue;
156 }
157 }
158 }
159
160 added.push(new_comp.clone());
161 processed_new.insert(id.clone());
162 }
163
164 for (id, old_comp) in &old.components {
165 if !processed_old.contains(id) {
166 removed.push(old_comp.clone());
167 }
168 }
169
170 Diff {
171 added,
172 removed,
173 changed,
174 metadata_changed: old.metadata != new.metadata,
175 }
176 }
177
178 fn compute_change(
179 old: &Component,
180 new: &Component,
181 only: Option<&[Field]>,
182 ) -> Option<ComponentChange> {
183 let mut changes = Vec::new();
184
185 let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
186
187 if should_include(Field::Version) && old.version != new.version {
188 changes.push(FieldChange::Version(
189 old.version.clone().unwrap_or_default(),
190 new.version.clone().unwrap_or_default(),
191 ));
192 }
193
194 if should_include(Field::License) && old.licenses != new.licenses {
195 changes.push(FieldChange::License(
196 old.licenses.clone(),
197 new.licenses.clone(),
198 ));
199 }
200
201 if should_include(Field::Supplier) && old.supplier != new.supplier {
202 changes.push(FieldChange::Supplier(
203 old.supplier.clone(),
204 new.supplier.clone(),
205 ));
206 }
207
208 if should_include(Field::Purl) && old.purl != new.purl {
209 changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
210 }
211
212 if should_include(Field::Hashes) && old.hashes != new.hashes {
213 changes.push(FieldChange::Hashes);
214 }
215
216 if changes.is_empty() {
217 None
218 } else {
219 Some(ComponentChange {
220 id: new.id.clone(),
221 old: old.clone(),
222 new: new.clone(),
223 changes,
224 })
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn test_diff_added_removed() {
235 let mut old = Sbom::default();
236 let mut new = Sbom::default();
237
238 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
239 let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
240
241 old.components.insert(c1.id.clone(), c1);
242 new.components.insert(c2.id.clone(), c2);
243
244 let diff = Differ::diff(&old, &new, None);
245 assert_eq!(diff.added.len(), 1);
246 assert_eq!(diff.removed.len(), 1);
247 assert_eq!(diff.changed.len(), 0);
248 }
249
250 #[test]
251 fn test_diff_changed() {
252 let mut old = Sbom::default();
253 let mut new = Sbom::default();
254
255 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
256 let mut c2 = c1.clone();
257 c2.version = Some("1.1".to_string());
258
259 old.components.insert(c1.id.clone(), c1);
260 new.components.insert(c2.id.clone(), c2);
261
262 let diff = Differ::diff(&old, &new, None);
263 assert_eq!(diff.added.len(), 0);
264 assert_eq!(diff.removed.len(), 0);
265 assert_eq!(diff.changed.len(), 1);
266 assert!(matches!(
267 diff.changed[0].changes[0],
268 FieldChange::Version(_, _)
269 ));
270 }
271
272 #[test]
273 fn test_diff_identity_reconciliation() {
274 let mut old = Sbom::default();
275 let mut new = Sbom::default();
276
277 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
278 let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
279
280 old.components.insert(c1.id.clone(), c1);
281 new.components.insert(c2.id.clone(), c2);
282
283 let diff = Differ::diff(&old, &new, None);
284 assert_eq!(diff.changed.len(), 1);
285 assert_eq!(diff.added.len(), 0);
286 }
287
288 #[test]
289 fn test_diff_filtering() {
290 let mut old = Sbom::default();
291 let mut new = Sbom::default();
292
293 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
294 c1.licenses.insert("MIT".into());
295
296 let mut c2 = c1.clone();
297 c2.version = Some("1.1".to_string());
298 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
299
300 old.components.insert(c1.id.clone(), c1);
301 new.components.insert(c2.id.clone(), c2);
302
303 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
304 assert_eq!(diff.changed.len(), 1);
305 assert_eq!(diff.changed[0].changes.len(), 1);
306 assert!(matches!(
307 diff.changed[0].changes[0],
308 FieldChange::Version(_, _)
309 ));
310 }
311}