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    // The document version proper is the serial number / namespace; the primary
77    // component's version is the product version this SBOM describes (e.g. the
78    // "1.0.0 -> 2.0.0" release bump that is otherwise only visible as a
79    // per-component modification).
80    push(
81        &mut changes,
82        "serial_number",
83        old_doc.serial_number.clone(),
84        new_doc.serial_number.clone(),
85    );
86    push(
87        &mut changes,
88        "primary_component_version",
89        old.primary_component().and_then(|c| c.version.clone()),
90        new.primary_component().and_then(|c| c.version.clone()),
91    );
92
93    // ── Creators: authors and tools (add / remove / change) ─────────────────
94    push_creator_changes(&mut changes, &old_doc.creators, &new_doc.creators);
95
96    changes
97}
98
99/// Append a [`MetadataChange`] for `field` when `old` and `new` differ.
100fn push(changes: &mut Vec<MetadataChange>, field: &str, old: Option<String>, new: Option<String>) {
101    if let Some(change) = MetadataChange::from_values(field, old, new) {
102        changes.push(change);
103    }
104}
105
106/// Treat an empty string the same as an absent value, so a blank `spec_version`
107/// doesn't masquerade as a present-but-empty field.
108fn non_empty(s: &str) -> Option<String> {
109    if s.is_empty() {
110        None
111    } else {
112        Some(s.to_string())
113    }
114}
115
116/// Render a signature as `"<algorithm>"` (or `"<algorithm> (unsigned)"` when the
117/// algorithm is declared but no value is attached). Absent signature -> `None`,
118/// so a newly-signed SBOM reads as an `added` change.
119fn signature_label(sig: Option<&SignatureInfo>) -> Option<String> {
120    sig.map(|s| {
121        if s.has_value {
122            s.algorithm.clone()
123        } else {
124            format!("{} (unsigned)", s.algorithm)
125        }
126    })
127}
128
129/// The prefix used for a creator's metadata field key, keyed by creator type so
130/// authors and tools are reported under distinct field names.
131const fn creator_field(kind: &CreatorType) -> &'static str {
132    match kind {
133        CreatorType::Tool => "creator.tool",
134        CreatorType::Organization => "creator.organization",
135        CreatorType::Person => "creator.author",
136    }
137}
138
139/// Stable, human-readable label for a creator: `"name <email>"` when an email is
140/// present, otherwise just the name. Used both as the change value and (with the
141/// field prefix) as the dedup key.
142fn creator_label(c: &Creator) -> String {
143    match &c.email {
144        Some(email) if !email.is_empty() => format!("{} <{email}>", c.name),
145        _ => c.name.clone(),
146    }
147}
148
149/// Emit creator add/remove/change entries.
150///
151/// Tools and organizations are keyed by `(field, name)` so a version bump on the
152/// same tool (e.g. `syft 0.9 -> syft 1.0`, both named `syft`) surfaces as a
153/// single `modified` entry rather than an unrelated add + remove. Persons are
154/// keyed by their full label since people don't carry versions.
155fn push_creator_changes(changes: &mut Vec<MetadataChange>, old: &[Creator], new: &[Creator]) {
156    // Keyed maps preserve a deterministic (BTree-sorted) iteration order.
157    let old_map = index_creators(old);
158    let new_map = index_creators(new);
159
160    // Modified or removed: walk old keys.
161    for (key, (field, old_label)) in &old_map {
162        match new_map.get(key) {
163            Some((_, new_label)) if new_label != old_label => push(
164                changes,
165                field,
166                Some(old_label.clone()),
167                Some(new_label.clone()),
168            ),
169            Some(_) => {} // unchanged
170            None => push(changes, field, Some(old_label.clone()), None),
171        }
172    }
173
174    // Added: keys present only in new.
175    for (key, (field, new_label)) in &new_map {
176        if !old_map.contains_key(key) {
177            push(changes, field, None, Some(new_label.clone()));
178        }
179    }
180}
181
182/// Build a stable key -> `(field, label)` map for a creator list.
183///
184/// The key is `(field, identity)` where `identity` is the creator's name for
185/// versioned creators (tools/organizations) and the full label for persons,
186/// so equal-named tools collapse to one entry and re-version as `modified`.
187fn index_creators(creators: &[Creator]) -> BTreeMap<(String, String), (&'static str, String)> {
188    let mut map = BTreeMap::new();
189    for c in creators {
190        let field = creator_field(&c.creator_type);
191        let label = creator_label(c);
192        let identity = match c.creator_type {
193            CreatorType::Tool | CreatorType::Organization => c.name.clone(),
194            CreatorType::Person => label.clone(),
195        };
196        map.insert((field.to_string(), identity), (field, label));
197    }
198    map
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::diff::MetadataChangeKind;
205    use crate::model::{DocumentMetadata, SbomFormat};
206    use chrono::{TimeZone, Utc};
207
208    fn sbom_with(doc: DocumentMetadata) -> NormalizedSbom {
209        NormalizedSbom::new(doc)
210    }
211
212    fn find<'a>(changes: &'a [MetadataChange], field: &str) -> &'a MetadataChange {
213        changes
214            .iter()
215            .find(|c| c.field == field)
216            .unwrap_or_else(|| panic!("expected a `{field}` change, got {changes:?}"))
217    }
218
219    #[test]
220    fn identical_metadata_yields_no_changes() {
221        let doc = DocumentMetadata::default();
222        let old = sbom_with(doc.clone());
223        let new = sbom_with(doc);
224        assert!(compute_metadata_changes(&old, &new).is_empty());
225    }
226
227    #[test]
228    fn name_and_spec_version_changes_are_emitted() {
229        let mut old_doc = DocumentMetadata::default();
230        old_doc.name = Some("old".to_string());
231        old_doc.spec_version = "1.5".to_string();
232        old_doc.created = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
233        let mut new_doc = old_doc.clone();
234        new_doc.name = Some("new".to_string());
235        new_doc.spec_version = "1.7".to_string();
236
237        let changes = compute_metadata_changes(&sbom_with(old_doc), &sbom_with(new_doc));
238
239        let name = find(&changes, "name");
240        assert_eq!(name.old_value.as_deref(), Some("old"));
241        assert_eq!(name.new_value.as_deref(), Some("new"));
242        assert_eq!(name.kind, MetadataChangeKind::Modified);
243
244        let spec = find(&changes, "spec_version");
245        assert_eq!(spec.old_value.as_deref(), Some("1.5"));
246        assert_eq!(spec.new_value.as_deref(), Some("1.7"));
247    }
248
249    #[test]
250    fn format_change_is_emitted() {
251        let mut old_doc = DocumentMetadata::default();
252        old_doc.format = SbomFormat::CycloneDx;
253        let mut new_doc = old_doc.clone();
254        new_doc.format = SbomFormat::Spdx;
255
256        let changes = compute_metadata_changes(&sbom_with(old_doc), &sbom_with(new_doc));
257        let fmt = find(&changes, "format");
258        assert_eq!(fmt.old_value.as_deref(), Some("CycloneDX"));
259        assert_eq!(fmt.new_value.as_deref(), Some("SPDX"));
260    }
261
262    #[test]
263    fn tool_version_bump_is_a_single_modified_change() {
264        let mut old_doc = DocumentMetadata::default();
265        old_doc.creators = vec![Creator {
266            creator_type: CreatorType::Tool,
267            name: "syft".to_string(),
268            email: None,
269        }];
270        let mut new_doc = old_doc.clone();
271        // Same tool name, but represented with an email-style version marker to
272        // force a label difference (a real bump would change the label too).
273        new_doc.creators = vec![Creator {
274            creator_type: CreatorType::Tool,
275            name: "syft".to_string(),
276            email: Some("v1.0".to_string()),
277        }];
278
279        let changes = compute_metadata_changes(&sbom_with(old_doc), &sbom_with(new_doc));
280        let tool = find(&changes, "creator.tool");
281        assert_eq!(tool.kind, MetadataChangeKind::Modified);
282        assert_eq!(tool.old_value.as_deref(), Some("syft"));
283        assert_eq!(tool.new_value.as_deref(), Some("syft <v1.0>"));
284    }
285
286    #[test]
287    fn author_add_and_remove_are_emitted() {
288        let mut old_doc = DocumentMetadata::default();
289        old_doc.creators = vec![Creator {
290            creator_type: CreatorType::Person,
291            name: "alice".to_string(),
292            email: None,
293        }];
294        let mut new_doc = DocumentMetadata::default();
295        new_doc.creators = vec![Creator {
296            creator_type: CreatorType::Person,
297            name: "bob".to_string(),
298            email: None,
299        }];
300
301        let changes = compute_metadata_changes(&sbom_with(old_doc), &sbom_with(new_doc));
302        let authors: Vec<&MetadataChange> = changes
303            .iter()
304            .filter(|c| c.field == "creator.author")
305            .collect();
306        assert_eq!(authors.len(), 2, "expected one removed + one added author");
307        assert!(
308            authors.iter().any(|c| c.kind == MetadataChangeKind::Removed
309                && c.old_value.as_deref() == Some("alice"))
310        );
311        assert!(
312            authors
313                .iter()
314                .any(|c| c.kind == MetadataChangeKind::Added
315                    && c.new_value.as_deref() == Some("bob"))
316        );
317    }
318
319    #[test]
320    fn newly_signed_sbom_reports_added_signature() {
321        let old_doc = DocumentMetadata::default();
322        let mut new_doc = DocumentMetadata::default();
323        new_doc.signature = Some(SignatureInfo {
324            algorithm: "Ed25519".to_string(),
325            has_value: true,
326        });
327
328        let changes = compute_metadata_changes(&sbom_with(old_doc), &sbom_with(new_doc));
329        let sig = find(&changes, "signature.algorithm");
330        assert_eq!(sig.kind, MetadataChangeKind::Added);
331        assert_eq!(sig.new_value.as_deref(), Some("Ed25519"));
332    }
333}