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(
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 push_creator_changes(&mut changes, &old_doc.creators, &new_doc.creators);
95
96 changes
97}
98
99fn 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
106fn non_empty(s: &str) -> Option<String> {
109 if s.is_empty() {
110 None
111 } else {
112 Some(s.to_string())
113 }
114}
115
116fn 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
129const 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
139fn 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
149fn push_creator_changes(changes: &mut Vec<MetadataChange>, old: &[Creator], new: &[Creator]) {
156 let old_map = index_creators(old);
158 let new_map = index_creators(new);
159
160 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(_) => {} None => push(changes, field, Some(old_label.clone()), None),
171 }
172 }
173
174 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
182fn 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 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}