Skip to main content

sbom_diff/
lib.rs

1use sbom_model::{Component, ComponentId, Sbom};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, HashSet};
4
5pub mod renderer;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Diff {
9    pub added: Vec<Component>,
10    pub removed: Vec<Component>,
11    pub changed: Vec<ComponentChange>,
12    pub metadata_changed: bool,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ComponentChange {
17    pub id: ComponentId,
18    pub old: Component,
19    pub new: Component,
20    pub changes: Vec<FieldChange>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub enum FieldChange {
25    Version(String, String),
26    License(Vec<String>, Vec<String>),
27    Supplier(Option<String>, Option<String>),
28    Purl(Option<String>, Option<String>),
29    Hashes,
30}
31
32#[derive(Debug, Copy, Clone, PartialEq, Eq)]
33pub enum Field {
34    Version,
35    License,
36    Supplier,
37    Purl,
38    Hashes,
39}
40
41pub struct Differ;
42
43impl Differ {
44    pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
45        let mut old = old.clone();
46        let mut new = new.clone();
47
48        old.normalize();
49        new.normalize();
50
51        let mut added = Vec::new();
52        let mut removed = Vec::new();
53        let mut changed = Vec::new();
54
55        let mut processed_old = HashSet::new();
56        let mut processed_new = HashSet::new();
57
58        // 1. Match by ID
59        for (id, new_comp) in &new.components {
60            if let Some(old_comp) = old.components.get(id) {
61                processed_old.insert(id.clone());
62                processed_new.insert(id.clone());
63
64                if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
65                    changed.push(change);
66                }
67            }
68        }
69
70        // 2. Reconciliation: Match by "Identity" (Name + Ecosystem)
71        let mut old_identity_map = BTreeMap::new();
72        for (id, comp) in &old.components {
73            if !processed_old.contains(id) {
74                let identity = (comp.ecosystem.clone(), comp.name.clone());
75                old_identity_map
76                    .entry(identity)
77                    .or_insert_with(Vec::new)
78                    .push(id.clone());
79            }
80        }
81
82        for (id, new_comp) in &new.components {
83            if processed_new.contains(id) {
84                continue;
85            }
86
87            let identity = (new_comp.ecosystem.clone(), new_comp.name.clone());
88            if let Some(old_ids) = old_identity_map.get_mut(&identity) {
89                if let Some(old_id) = old_ids.pop() {
90                    if let Some(old_comp) = old.components.get(&old_id) {
91                        processed_old.insert(old_id.clone());
92                        processed_new.insert(id.clone());
93
94                        if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
95                            changed.push(change);
96                        }
97                        continue;
98                    }
99                }
100            }
101
102            added.push(new_comp.clone());
103            processed_new.insert(id.clone());
104        }
105
106        for (id, old_comp) in &old.components {
107            if !processed_old.contains(id) {
108                removed.push(old_comp.clone());
109            }
110        }
111
112        Diff {
113            added,
114            removed,
115            changed,
116            metadata_changed: old.metadata != new.metadata,
117        }
118    }
119
120    fn compute_change(
121        old: &Component,
122        new: &Component,
123        only: Option<&[Field]>,
124    ) -> Option<ComponentChange> {
125        let mut changes = Vec::new();
126
127        let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
128
129        if should_include(Field::Version) && old.version != new.version {
130            changes.push(FieldChange::Version(
131                old.version.clone().unwrap_or_default(),
132                new.version.clone().unwrap_or_default(),
133            ));
134        }
135
136        if should_include(Field::License) && old.licenses != new.licenses {
137            changes.push(FieldChange::License(
138                old.licenses.clone(),
139                new.licenses.clone(),
140            ));
141        }
142
143        if should_include(Field::Supplier) && old.supplier != new.supplier {
144            changes.push(FieldChange::Supplier(
145                old.supplier.clone(),
146                new.supplier.clone(),
147            ));
148        }
149
150        if should_include(Field::Purl) && old.purl != new.purl {
151            changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
152        }
153
154        if should_include(Field::Hashes) && old.hashes != new.hashes {
155            changes.push(FieldChange::Hashes);
156        }
157
158        if changes.is_empty() {
159            None
160        } else {
161            Some(ComponentChange {
162                id: new.id.clone(),
163                old: old.clone(),
164                new: new.clone(),
165                changes,
166            })
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_diff_added_removed() {
177        let mut old = Sbom::default();
178        let mut new = Sbom::default();
179
180        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
181        let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
182
183        old.components.insert(c1.id.clone(), c1);
184        new.components.insert(c2.id.clone(), c2);
185
186        let diff = Differ::diff(&old, &new, None);
187        assert_eq!(diff.added.len(), 1);
188        assert_eq!(diff.removed.len(), 1);
189        assert_eq!(diff.changed.len(), 0);
190    }
191
192    #[test]
193    fn test_diff_changed() {
194        let mut old = Sbom::default();
195        let mut new = Sbom::default();
196
197        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
198        let mut c2 = c1.clone();
199        c2.version = Some("1.1".to_string());
200
201        old.components.insert(c1.id.clone(), c1);
202        new.components.insert(c2.id.clone(), c2);
203
204        let diff = Differ::diff(&old, &new, None);
205        assert_eq!(diff.added.len(), 0);
206        assert_eq!(diff.removed.len(), 0);
207        assert_eq!(diff.changed.len(), 1);
208        assert!(matches!(
209            diff.changed[0].changes[0],
210            FieldChange::Version(_, _)
211        ));
212    }
213
214    #[test]
215    fn test_diff_identity_reconciliation() {
216        let mut old = Sbom::default();
217        let mut new = Sbom::default();
218
219        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
220        let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
221
222        old.components.insert(c1.id.clone(), c1);
223        new.components.insert(c2.id.clone(), c2);
224
225        let diff = Differ::diff(&old, &new, None);
226        assert_eq!(diff.changed.len(), 1);
227        assert_eq!(diff.added.len(), 0);
228    }
229
230    #[test]
231    fn test_diff_filtering() {
232        let mut old = Sbom::default();
233        let mut new = Sbom::default();
234
235        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
236        c1.licenses.push("MIT".into());
237
238        let mut c2 = c1.clone();
239        c2.version = Some("1.1".to_string());
240        c2.licenses = vec!["Apache-2.0".into()];
241
242        old.components.insert(c1.id.clone(), c1);
243        new.components.insert(c2.id.clone(), c2);
244
245        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
246        assert_eq!(diff.changed.len(), 1);
247        assert_eq!(diff.changed[0].changes.len(), 1);
248        assert!(matches!(
249            diff.changed[0].changes[0],
250            FieldChange::Version(_, _)
251        ));
252    }
253}