Skip to main content

sbom_tools/diff/changes/
metadata.rs

1//! Document-metadata change computer.
2//!
3//! Compares the `DocumentMetadata` of two SBOMs and emits one
4//! [`MetadataChange`] per differing field. Component / dependency /
5//! vulnerability diffing is blind to document-level signals (author churn, tool
6//! upgrades, timestamp updates, spec-version bumps, lifecycle transitions,
7//! signature changes, primary-component version bumps); this pass restores them.
8//!
9//! Output is deterministic: scalar fields are emitted in a fixed order, and
10//! creator add/remove/change entries are sorted by their stable key.
11
12use crate::diff::MetadataChange;
13use crate::model::{Creator, CreatorType, NormalizedSbom, SignatureInfo};
14use std::collections::BTreeMap;
15
16/// Compute document-level metadata changes between two SBOMs.
17///
18/// Returns a deterministic, ordered list of [`MetadataChange`] entries covering
19/// the document name, format/spec version, creation timestamp, creators
20/// (authors and tools), lifecycle phase, signature, and document- /
21/// primary-component version. An empty vec means the metadata is unchanged.
22#[must_use]
23pub fn compute_metadata_changes(old: &NormalizedSbom, new: &NormalizedSbom) -> Vec<MetadataChange> {
24    let old_doc = &old.document;
25    let new_doc = &new.document;
26    let mut changes = Vec::new();
27
28    // ── Scalar document fields (fixed emission order) ───────────────────────
29    push(
30        &mut changes,
31        "name",
32        old_doc.name.clone(),
33        new_doc.name.clone(),
34    );
35
36    // Format + spec version. The format label and spec version together capture
37    // a "CycloneDX 1.5 -> 1.7" upgrade or a cross-format conversion.
38    push(
39        &mut changes,
40        "format",
41        Some(old_doc.format.to_string()),
42        Some(new_doc.format.to_string()),
43    );
44    push(
45        &mut changes,
46        "spec_version",
47        non_empty(&old_doc.spec_version),
48        non_empty(&new_doc.spec_version),
49    );
50
51    // Creation timestamp (RFC 3339 so it round-trips and redacts cleanly).
52    push(
53        &mut changes,
54        "created",
55        Some(old_doc.created.to_rfc3339()),
56        Some(new_doc.created.to_rfc3339()),
57    );
58
59    // Lifecycle phase (e.g. pre-build -> build -> operations).
60    push(
61        &mut changes,
62        "lifecycle_phase",
63        old_doc.lifecycle_phase.clone(),
64        new_doc.lifecycle_phase.clone(),
65    );
66
67    // ── Signature (presence + algorithm) ────────────────────────────────────
68    push(
69        &mut changes,
70        "signature.algorithm",
71        signature_label(old_doc.signature.as_ref()),
72        signature_label(new_doc.signature.as_ref()),
73    );
74
75    // ── Document- and primary-component version ─────────────────────────────
76    // Three distinct version-ish signals: the document revision counter
77    // (CycloneDX top-level `version`, bumped on each BOM revision of the same
78    // serial number), the document identity (serial number / namespace), and
79    // the primary component's version — the product version this SBOM
80    // describes (e.g. the "1.0.0 -> 2.0.0" release bump that is otherwise only
81    // visible as a per-component modification).
82    push(
83        &mut changes,
84        "doc_version",
85        old_doc.doc_version.map(|v| v.to_string()),
86        new_doc.doc_version.map(|v| v.to_string()),
87    );
88    push(
89        &mut changes,
90        "serial_number",
91        old_doc.serial_number.clone(),
92        new_doc.serial_number.clone(),
93    );
94    push(
95        &mut changes,
96        "primary_component_version",
97        old.primary_component().and_then(|c| c.version.clone()),
98        new.primary_component().and_then(|c| c.version.clone()),
99    );
100
101    // ── Creators: authors and tools (add / remove / change) ─────────────────
102    push_creator_changes(&mut changes, &old_doc.creators, &new_doc.creators);
103
104    changes
105}
106
107/// Append a [`MetadataChange`] for `field` when `old` and `new` differ.
108fn push(changes: &mut Vec<MetadataChange>, field: &str, old: Option<String>, new: Option<String>) {
109    if let Some(change) = MetadataChange::from_values(field, old, new) {
110        changes.push(change);
111    }
112}
113
114/// Treat an empty string the same as an absent value, so a blank `spec_version`
115/// doesn't masquerade as a present-but-empty field.
116fn non_empty(s: &str) -> Option<String> {
117    if s.is_empty() {
118        None
119    } else {
120        Some(s.to_string())
121    }
122}
123
124/// Render a signature as `"<algorithm>"` (or `"<algorithm> (unsigned)"` when the
125/// algorithm is declared but no value is attached). Absent signature -> `None`,
126/// so a newly-signed SBOM reads as an `added` change.
127fn signature_label(sig: Option<&SignatureInfo>) -> Option<String> {
128    sig.map(|s| {
129        if s.has_value {
130            s.algorithm.clone()
131        } else {
132            format!("{} (unsigned)", s.algorithm)
133        }
134    })
135}
136
137/// The prefix used for a creator's metadata field key, keyed by creator type so
138/// authors and tools are reported under distinct field names.
139const fn creator_field(kind: &CreatorType) -> &'static str {
140    match kind {
141        CreatorType::Tool => "creator.tool",
142        CreatorType::Organization => "creator.organization",
143        CreatorType::Person => "creator.author",
144    }
145}
146
147/// Stable, human-readable label for a creator: `"name <email>"` when an email is
148/// present, otherwise just the name. Used both as the change value and (with the
149/// field prefix) as the dedup key.
150fn creator_label(c: &Creator) -> String {
151    match &c.email {
152        Some(email) if !email.is_empty() => format!("{} <{email}>", c.name),
153        _ => c.name.clone(),
154    }
155}
156
157/// Emit creator add/remove/change entries.
158///
159/// Tools and organizations are keyed by `(field, name)` so a version bump on the
160/// same tool (e.g. `syft 0.9 -> syft 1.0`, both named `syft`) surfaces as a
161/// single `modified` entry rather than an unrelated add + remove. Persons are
162/// keyed by their full label since people don't carry versions.
163fn push_creator_changes(changes: &mut Vec<MetadataChange>, old: &[Creator], new: &[Creator]) {
164    // Keyed maps preserve a deterministic (BTree-sorted) iteration order.
165    let old_map = index_creators(old);
166    let new_map = index_creators(new);
167
168    // Modified or removed: walk old keys.
169    for (key, (field, old_label)) in &old_map {
170        match new_map.get(key) {
171            Some((_, new_label)) if new_label != old_label => push(
172                changes,
173                field,
174                Some(old_label.clone()),
175                Some(new_label.clone()),
176            ),
177            Some(_) => {} // unchanged
178            None => push(changes, field, Some(old_label.clone()), None),
179        }
180    }
181
182    // Added: keys present only in new.
183    for (key, (field, new_label)) in &new_map {
184        if !old_map.contains_key(key) {
185            push(changes, field, None, Some(new_label.clone()));
186        }
187    }
188}
189
190/// Build a stable key -> `(field, label)` map for a creator list.
191///
192/// The key is `(field, identity)` where `identity` is the creator's name for
193/// versioned creators (tools/organizations) and the full label for persons,
194/// so equal-named tools collapse to one entry and re-version as `modified`.
195fn index_creators(creators: &[Creator]) -> BTreeMap<(String, String), (&'static str, String)> {
196    let mut map = BTreeMap::new();
197    for c in creators {
198        let field = creator_field(&c.creator_type);
199        let label = creator_label(c);
200        let identity = match c.creator_type {
201            CreatorType::Tool | CreatorType::Organization => c.name.clone(),
202            CreatorType::Person => label.clone(),
203        };
204        map.insert((field.to_string(), identity), (field, label));
205    }
206    map
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::diff::MetadataChangeKind;
213    use crate::model::{DocumentMetadata, SbomFormat};
214    use chrono::{TimeZone, Utc};
215
216    fn sbom_with(doc: DocumentMetadata) -> NormalizedSbom {
217        NormalizedSbom::new(doc)
218    }
219
220    fn find<'a>(changes: &'a [MetadataChange], field: &str) -> &'a MetadataChange {
221        changes
222            .iter()
223            .find(|c| c.field == field)
224            .unwrap_or_else(|| panic!("expected a `{field}` change, got {changes:?}"))
225    }
226
227    #[test]
228    fn identical_metadata_yields_no_changes() {
229        let doc = DocumentMetadata::default();
230        let old = sbom_with(doc.clone());
231        let new = sbom_with(doc);
232        assert!(compute_metadata_changes(&old, &new).is_empty());
233    }
234
235    #[test]
236    fn name_and_spec_version_changes_are_emitted() {
237        let mut old_doc = DocumentMetadata::default();
238        old_doc.name = Some("old".to_string());
239        old_doc.spec_version = "1.5".to_string();
240        old_doc.created = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
241        let mut new_doc = old_doc.clone();
242        new_doc.name = Some("new".to_string());
243        new_doc.spec_version = "1.7".to_string();
244
245        let changes = compute_metadata_changes(&sbom_with(old_doc), &sbom_with(new_doc));
246
247        let name = find(&changes, "name");
248        assert_eq!(name.old_value.as_deref(), Some("old"));
249        assert_eq!(name.new_value.as_deref(), Some("new"));
250        assert_eq!(name.kind, MetadataChangeKind::Modified);
251
252        let spec = find(&changes, "spec_version");
253        assert_eq!(spec.old_value.as_deref(), Some("1.5"));
254        assert_eq!(spec.new_value.as_deref(), Some("1.7"));
255    }
256
257    #[test]
258    fn format_change_is_emitted() {
259        let mut old_doc = DocumentMetadata::default();
260        old_doc.format = SbomFormat::CycloneDx;
261        let mut new_doc = old_doc.clone();
262        new_doc.format = SbomFormat::Spdx;
263
264        let changes = compute_metadata_changes(&sbom_with(old_doc), &sbom_with(new_doc));
265        let fmt = find(&changes, "format");
266        assert_eq!(fmt.old_value.as_deref(), Some("CycloneDX"));
267        assert_eq!(fmt.new_value.as_deref(), Some("SPDX"));
268    }
269
270    #[test]
271    fn tool_version_bump_is_a_single_modified_change() {
272        let mut old_doc = DocumentMetadata::default();
273        old_doc.creators = vec![Creator {
274            creator_type: CreatorType::Tool,
275            name: "syft".to_string(),
276            email: None,
277        }];
278        let mut new_doc = old_doc.clone();
279        // Same tool name, but represented with an email-style version marker to
280        // force a label difference (a real bump would change the label too).
281        new_doc.creators = vec![Creator {
282            creator_type: CreatorType::Tool,
283            name: "syft".to_string(),
284            email: Some("v1.0".to_string()),
285        }];
286
287        let changes = compute_metadata_changes(&sbom_with(old_doc), &sbom_with(new_doc));
288        let tool = find(&changes, "creator.tool");
289        assert_eq!(tool.kind, MetadataChangeKind::Modified);
290        assert_eq!(tool.old_value.as_deref(), Some("syft"));
291        assert_eq!(tool.new_value.as_deref(), Some("syft <v1.0>"));
292    }
293
294    #[test]
295    fn author_add_and_remove_are_emitted() {
296        let mut old_doc = DocumentMetadata::default();
297        old_doc.creators = vec![Creator {
298            creator_type: CreatorType::Person,
299            name: "alice".to_string(),
300            email: None,
301        }];
302        let mut new_doc = DocumentMetadata::default();
303        new_doc.creators = vec![Creator {
304            creator_type: CreatorType::Person,
305            name: "bob".to_string(),
306            email: None,
307        }];
308
309        let changes = compute_metadata_changes(&sbom_with(old_doc), &sbom_with(new_doc));
310        let authors: Vec<&MetadataChange> = changes
311            .iter()
312            .filter(|c| c.field == "creator.author")
313            .collect();
314        assert_eq!(authors.len(), 2, "expected one removed + one added author");
315        assert!(
316            authors.iter().any(|c| c.kind == MetadataChangeKind::Removed
317                && c.old_value.as_deref() == Some("alice"))
318        );
319        assert!(
320            authors
321                .iter()
322                .any(|c| c.kind == MetadataChangeKind::Added
323                    && c.new_value.as_deref() == Some("bob"))
324        );
325    }
326
327    #[test]
328    fn doc_version_bump_is_a_modified_change() {
329        let mut old_doc = DocumentMetadata::default();
330        old_doc.doc_version = Some(1);
331        let mut new_doc = old_doc.clone();
332        new_doc.doc_version = Some(2);
333
334        let changes = compute_metadata_changes(&sbom_with(old_doc), &sbom_with(new_doc));
335        let dv = find(&changes, "doc_version");
336        assert_eq!(dv.kind, MetadataChangeKind::Modified);
337        assert_eq!(dv.old_value.as_deref(), Some("1"));
338        assert_eq!(dv.new_value.as_deref(), Some("2"));
339    }
340
341    #[test]
342    fn equal_or_absent_doc_version_is_silent() {
343        // Absent on both sides (SPDX) — no change.
344        let doc = DocumentMetadata::default();
345        assert!(compute_metadata_changes(&sbom_with(doc.clone()), &sbom_with(doc)).is_empty());
346
347        // Equal counters — no change.
348        let mut doc = DocumentMetadata::default();
349        doc.doc_version = Some(3);
350        assert!(
351            compute_metadata_changes(&sbom_with(doc.clone()), &sbom_with(doc))
352                .iter()
353                .all(|c| c.field != "doc_version")
354        );
355    }
356
357    #[test]
358    fn newly_signed_sbom_reports_added_signature() {
359        let old_doc = DocumentMetadata::default();
360        let mut new_doc = DocumentMetadata::default();
361        new_doc.signature = Some(SignatureInfo {
362            algorithm: "Ed25519".to_string(),
363            has_value: true,
364        });
365
366        let changes = compute_metadata_changes(&sbom_with(old_doc), &sbom_with(new_doc));
367        let sig = find(&changes, "signature.algorithm");
368        assert_eq!(sig.kind, MetadataChangeKind::Added);
369        assert_eq!(sig.new_value.as_deref(), Some("Ed25519"));
370    }
371}