1use crate::diff::MetadataChange;
13use crate::model::{Creator, CreatorType, NormalizedSbom, SignatureInfo};
14use std::collections::BTreeMap;
15
16#[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 push(
30 &mut changes,
31 "name",
32 old_doc.name.clone(),
33 new_doc.name.clone(),
34 );
35
36 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 push(
53 &mut changes,
54 "created",
55 Some(old_doc.created.to_rfc3339()),
56 Some(new_doc.created.to_rfc3339()),
57 );
58
59 push(
61 &mut changes,
62 "lifecycle_phase",
63 old_doc.lifecycle_phase.clone(),
64 new_doc.lifecycle_phase.clone(),
65 );
66
67 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 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 push_creator_changes(&mut changes, &old_doc.creators, &new_doc.creators);
103
104 changes
105}
106
107fn 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
114fn non_empty(s: &str) -> Option<String> {
117 if s.is_empty() {
118 None
119 } else {
120 Some(s.to_string())
121 }
122}
123
124fn 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
137const 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
147fn 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
157fn push_creator_changes(changes: &mut Vec<MetadataChange>, old: &[Creator], new: &[Creator]) {
164 let old_map = index_creators(old);
166 let new_map = index_creators(new);
167
168 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(_) => {} None => push(changes, field, Some(old_label.clone()), None),
179 }
180 }
181
182 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
190fn 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 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 let doc = DocumentMetadata::default();
345 assert!(compute_metadata_changes(&sbom_with(doc.clone()), &sbom_with(doc)).is_empty());
346
347 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}