Skip to main content

sbom_diff/
lib.rs

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/// The result of comparing two SBOMs.
10///
11/// Contains lists of added, removed, and changed components.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Diff {
14    /// Components present in the new SBOM but not the old.
15    pub added: Vec<Component>,
16    /// Components present in the old SBOM but not the new.
17    pub removed: Vec<Component>,
18    /// Components present in both with field-level changes.
19    pub changed: Vec<ComponentChange>,
20    /// Whether document metadata differs (usually ignored).
21    pub metadata_changed: bool,
22}
23
24/// A component that exists in both SBOMs with detected changes.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ComponentChange {
27    /// The component identifier (from the new SBOM).
28    pub id: ComponentId,
29    /// The component as it appeared in the old SBOM.
30    pub old: Component,
31    /// The component as it appears in the new SBOM.
32    pub new: Component,
33    /// List of specific field changes detected.
34    pub changes: Vec<FieldChange>,
35}
36
37/// A specific field that changed between two versions of a component.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub enum FieldChange {
40    /// Version changed: (old, new).
41    Version(String, String),
42    /// Licenses changed: (old, new).
43    License(BTreeSet<String>, BTreeSet<String>),
44    /// Supplier changed: (old, new).
45    Supplier(Option<String>, Option<String>),
46    /// Package URL changed: (old, new).
47    Purl(Option<String>, Option<String>),
48    /// Hashes changed (details not tracked).
49    Hashes,
50}
51
52/// Fields that can be compared and filtered.
53///
54/// Use with [`Differ::diff`] to limit comparison to specific fields.
55#[derive(Debug, Copy, Clone, PartialEq, Eq)]
56pub enum Field {
57    /// Package version.
58    Version,
59    /// License identifiers.
60    License,
61    /// Supplier/publisher.
62    Supplier,
63    /// Package URL.
64    Purl,
65    /// Checksums.
66    Hashes,
67}
68
69/// SBOM comparison engine.
70///
71/// Compares two SBOMs and produces a [`Diff`] describing the changes.
72/// Components are matched first by ID (purl), then by identity (name + ecosystem).
73pub struct Differ;
74
75impl Differ {
76    /// Compares two SBOMs and returns the differences.
77    ///
78    /// Both SBOMs are normalized before comparison to ignore irrelevant differences
79    /// like ordering or metadata timestamps.
80    ///
81    /// # Arguments
82    ///
83    /// * `old` - The baseline SBOM
84    /// * `new` - The SBOM to compare against the baseline
85    /// * `only` - Optional filter to limit comparison to specific fields
86    ///
87    /// # Example
88    ///
89    /// ```
90    /// use sbom_diff::{Differ, Field};
91    /// use sbom_model::Sbom;
92    ///
93    /// let old = Sbom::default();
94    /// let new = Sbom::default();
95    ///
96    /// // Compare all fields
97    /// let diff = Differ::diff(&old, &new, None);
98    ///
99    /// // Compare only version and license changes
100    /// let diff = Differ::diff(&old, &new, Some(&[Field::Version, Field::License]));
101    /// ```
102    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        // 1. Match by ID
117        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        // 2. Reconciliation: Match by "Identity" (Name + Ecosystem)
129        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}