1use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::{HashMap, HashSet};
9
10use super::ValueExt;
11
12#[derive(Debug, thiserror::Error)]
14pub enum MergeError {
15 #[error("cannot merge CycloneDX and SPDX SBOMs — both must be the same format")]
17 FormatMismatch,
18 #[error("cannot merge SPDX 3.0 and SPDX 2.x SBOMs")]
20 SpdxVersionMismatch,
21 #[error(transparent)]
23 Json(#[from] serde_json::Error),
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct MergeConfig {
29 pub dedup_strategy: DeduplicationStrategy,
31}
32
33impl Default for MergeConfig {
34 fn default() -> Self {
35 Self {
36 dedup_strategy: DeduplicationStrategy::Name,
37 }
38 }
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
43#[serde(rename_all = "kebab-case")]
44pub enum DeduplicationStrategy {
45 #[default]
47 Name,
48 Purl,
50 None,
52}
53
54pub fn merge_sbom_json(
65 primary_json: &str,
66 secondary_json: &str,
67 config: &MergeConfig,
68) -> Result<String, MergeError> {
69 let mut primary: Value = serde_json::from_str(primary_json)?;
70 let secondary: Value = serde_json::from_str(secondary_json)?;
71
72 let primary_is_cdx = primary.get("bomFormat").is_some();
73 let secondary_is_cdx = secondary.get("bomFormat").is_some();
74 let primary_is_spdx3 = primary.get("@context").is_some();
75 let secondary_is_spdx3 = secondary.get("@context").is_some();
76
77 if primary_is_cdx != secondary_is_cdx {
79 return Err(MergeError::FormatMismatch);
80 }
81
82 if primary_is_spdx3 != secondary_is_spdx3 {
86 return Err(MergeError::SpdxVersionMismatch);
87 }
88
89 if primary_is_cdx {
90 merge_cyclonedx(&mut primary, &secondary, config)?;
91 } else if primary_is_spdx3 {
92 merge_spdx3(&mut primary, &secondary, config)?;
93 } else {
94 merge_spdx2(&mut primary, &secondary, config)?;
95 }
96
97 Ok(serde_json::to_string_pretty(&primary)?)
98}
99
100fn merge_cyclonedx(
101 primary: &mut Value,
102 secondary: &Value,
103 config: &MergeConfig,
104) -> Result<(), MergeError> {
105 if let Some(s_comps) = secondary.get("components").and_then(Value::as_array)
109 && let Some(p_comps) = ensure_array(primary, "components")
110 {
111 if config.dedup_strategy == DeduplicationStrategy::None {
112 for comp in s_comps {
114 p_comps.push(comp.clone());
115 }
116 } else {
117 let mut seen = build_seen_set(p_comps, config);
119
120 for comp in s_comps {
122 let key = component_key(comp, config);
123 if seen.insert(key) {
124 p_comps.push(comp.clone());
125 }
126 }
127 }
128 }
129
130 if let Some(s_deps) = secondary.get("dependencies").and_then(Value::as_array)
132 && let Some(p_deps) = ensure_array(primary, "dependencies")
133 {
134 let existing_refs: HashSet<String> = p_deps
135 .iter()
136 .filter_map(|d| d.get("ref").and_then(Value::as_str).map(String::from))
137 .collect();
138
139 for dep in s_deps {
140 let dep_ref = dep.str_field("ref");
141 if !existing_refs.contains(dep_ref) {
142 p_deps.push(dep.clone());
143 }
144 }
145 }
146
147 merge_vulnerabilities(primary, secondary);
149
150 Ok(())
151}
152
153fn ensure_array<'a>(primary: &'a mut Value, field: &str) -> Option<&'a mut Vec<Value>> {
157 primary.as_object_mut().and_then(|o| {
158 o.entry(field)
159 .or_insert_with(|| Value::Array(Vec::new()))
160 .as_array_mut()
161 })
162}
163
164fn merge_vulnerabilities(primary: &mut Value, secondary: &Value) {
169 let Some(s_vulns) = secondary.get("vulnerabilities").and_then(Value::as_array) else {
170 return;
171 };
172 if s_vulns.is_empty() {
173 return;
174 }
175 let Some(p_vulns) = ensure_array(primary, "vulnerabilities") else {
176 return;
177 };
178
179 let mut index_by_id: HashMap<String, usize> = p_vulns
180 .iter()
181 .enumerate()
182 .filter_map(|(i, v)| {
183 v.get("id")
184 .and_then(Value::as_str)
185 .map(|id| (id.to_string(), i))
186 })
187 .collect();
188
189 for vuln in s_vulns {
190 let id = vuln.str_field("id");
191 if id.is_empty() {
192 p_vulns.push(vuln.clone());
193 continue;
194 }
195 if let Some(&i) = index_by_id.get(id) {
196 let Some(s_affects) = vuln.get("affects").and_then(Value::as_array) else {
198 continue;
199 };
200 let Some(existing) = p_vulns[i].as_object_mut() else {
201 continue;
202 };
203 let existing_refs: HashSet<String> = existing
204 .get("affects")
205 .and_then(Value::as_array)
206 .map(|arr| {
207 arr.iter()
208 .filter_map(|a| a.get("ref").and_then(Value::as_str))
209 .map(String::from)
210 .collect()
211 })
212 .unwrap_or_default();
213 let to_add: Vec<Value> = s_affects
214 .iter()
215 .filter(|a| {
216 a.get("ref")
217 .and_then(Value::as_str)
218 .is_none_or(|r| !existing_refs.contains(r))
219 })
220 .cloned()
221 .collect();
222 if !to_add.is_empty()
223 && let Some(affects) = existing
224 .entry("affects")
225 .or_insert_with(|| Value::Array(Vec::new()))
226 .as_array_mut()
227 {
228 affects.extend(to_add);
229 }
230 } else {
231 index_by_id.insert(id.to_string(), p_vulns.len());
232 p_vulns.push(vuln.clone());
233 }
234 }
235}
236
237fn merge_relationships(primary: &mut Value, secondary: &Value) {
240 let Some(s_rels) = secondary.get("relationships").and_then(Value::as_array) else {
241 return;
242 };
243 if s_rels.is_empty() {
244 return;
245 }
246 let Some(p_rels) = ensure_array(primary, "relationships") else {
247 return;
248 };
249
250 fn rel_key(rel: &Value) -> (String, String, String) {
251 (
252 rel.str_field("relationshipType").to_string(),
253 rel.str_field("spdxElementId").to_string(),
254 rel.str_field("relatedSpdxElement").to_string(),
255 )
256 }
257
258 let mut seen: HashSet<(String, String, String)> = p_rels.iter().map(rel_key).collect();
259 for rel in s_rels {
260 if seen.insert(rel_key(rel)) {
261 p_rels.push(rel.clone());
262 }
263 }
264}
265
266fn merge_spdx3(
267 primary: &mut Value,
268 secondary: &Value,
269 config: &MergeConfig,
270) -> Result<(), MergeError> {
271 let primary_key = if primary.get("element").is_some() {
272 "element"
273 } else {
274 "@graph"
275 };
276
277 let secondary_key = if secondary.get("element").is_some() {
278 "element"
279 } else {
280 "@graph"
281 };
282 let secondary_elements = secondary.get(secondary_key).and_then(Value::as_array);
283
284 if let Some(s_elems) = secondary_elements
285 && let Some(p_elems) = ensure_array(primary, primary_key)
286 {
287 let mut seen: HashSet<String> = p_elems
288 .iter()
289 .filter_map(|e| e.get("spdxId").and_then(Value::as_str).map(String::from))
290 .collect();
291
292 for elem in s_elems {
293 let spdx_id = elem.str_field("spdxId");
294
295 let elem_type = elem.str_field("type");
297 if elem_type.contains("Package") || elem_type.contains("package") {
298 let key = component_key(elem, config);
299 if !seen.insert(key) {
300 continue;
301 }
302 } else if !seen.insert(spdx_id.to_string()) {
303 continue;
304 }
305
306 p_elems.push(elem.clone());
307 }
308 }
309
310 Ok(())
311}
312
313fn merge_spdx2(
314 primary: &mut Value,
315 secondary: &Value,
316 config: &MergeConfig,
317) -> Result<(), MergeError> {
318 if let Some(s_pkgs) = secondary.get("packages").and_then(Value::as_array)
320 && let Some(p_pkgs) = ensure_array(primary, "packages")
321 {
322 if config.dedup_strategy == DeduplicationStrategy::None {
323 for pkg in s_pkgs {
324 p_pkgs.push(pkg.clone());
325 }
326 } else {
327 let mut seen = build_seen_set(p_pkgs, config);
328 for pkg in s_pkgs {
329 let key = component_key(pkg, config);
330 if seen.insert(key) {
331 p_pkgs.push(pkg.clone());
332 }
333 }
334 }
335 }
336
337 merge_relationships(primary, secondary);
339
340 Ok(())
341}
342
343fn build_seen_set(components: &[Value], config: &MergeConfig) -> HashSet<String> {
345 components
346 .iter()
347 .map(|c| component_key(c, config))
348 .collect()
349}
350
351fn component_key(comp: &Value, config: &MergeConfig) -> String {
353 match config.dedup_strategy {
354 DeduplicationStrategy::Purl => {
355 if let Some(purl) = comp.get("purl").and_then(Value::as_str) {
357 return purl.to_string();
358 }
359 if let Some(refs) = comp.get("externalReferences").and_then(Value::as_array) {
361 for r in refs {
362 if r.get("type").and_then(Value::as_str) == Some("purl")
363 && let Some(url) = r.get("url").and_then(Value::as_str)
364 {
365 return url.to_string();
366 }
367 }
368 }
369 name_version_key(comp)
371 }
372 DeduplicationStrategy::Name | DeduplicationStrategy::None => name_version_key(comp),
373 }
374}
375
376fn name_version_key(comp: &Value) -> String {
377 if let Some(cp) = comp.get("cryptoProperties")
379 && let Some(oid) = cp.get("oid").and_then(Value::as_str)
380 {
381 let asset_type = cp
382 .get("assetType")
383 .and_then(Value::as_str)
384 .unwrap_or("unknown");
385 return format!("crypto:{asset_type}:{oid}");
386 }
387 let name = comp.str_field("name");
388 let version = comp
389 .get("version")
390 .or_else(|| comp.get("versionInfo"))
391 .and_then(Value::as_str)
392 .unwrap_or("");
393 format!("{name}@{version}")
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn merge_cyclonedx_dedup() {
402 let primary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
403 {"name":"foo","version":"1.0"},
404 {"name":"bar","version":"2.0"}
405 ]}"#;
406
407 let secondary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
408 {"name":"foo","version":"1.0"},
409 {"name":"baz","version":"3.0"}
410 ]}"#;
411
412 let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
413 let doc: Value = serde_json::from_str(&result).unwrap();
414 let components = doc["components"].as_array().unwrap();
415 assert_eq!(components.len(), 3); }
417
418 #[test]
419 fn merge_different_formats_fails() {
420 let cdx = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#;
421 let spdx = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT","packages":[]}"#;
422
423 let result = merge_sbom_json(cdx, spdx, &MergeConfig::default());
424 assert!(result.is_err());
425 }
426
427 #[test]
428 fn merge_no_dedup() {
429 let a = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
430 {"name":"foo","version":"1.0"}
431 ]}"#;
432 let b = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
433 {"name":"foo","version":"1.0"}
434 ]}"#;
435
436 let config = MergeConfig {
437 dedup_strategy: DeduplicationStrategy::None,
438 };
439 let result = merge_sbom_json(a, b, &config).unwrap();
440 let doc: Value = serde_json::from_str(&result).unwrap();
441 let components = doc["components"].as_array().unwrap();
442 assert_eq!(components.len(), 2);
444 }
445
446 #[test]
449 fn merge_creates_components_when_primary_lacks_array() {
450 let primary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5",
451 "metadata":{"component":{"type":"application","name":"shell"}}}"#;
452 let secondary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
453 {"name":"foo","version":"1.0"},
454 {"name":"bar","version":"2.0"}
455 ]}"#;
456
457 let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
458 let doc: Value = serde_json::from_str(&result).unwrap();
459 let components = doc["components"].as_array().unwrap();
460 assert_eq!(
461 components.len(),
462 2,
463 "secondary components must not be dropped"
464 );
465 }
466
467 #[test]
470 fn merge_spdx2_primary_spdx3_secondary_errors() {
471 let spdx2 = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT","packages":[]}"#;
472 let spdx3 = r#"{"@context":"https://spdx.org/rdf/3.0.1/spdx-context.jsonld","@graph":[]}"#;
473
474 let result = merge_sbom_json(spdx2, spdx3, &MergeConfig::default());
475 assert!(matches!(result, Err(MergeError::SpdxVersionMismatch)));
476
477 let result = merge_sbom_json(spdx3, spdx2, &MergeConfig::default());
479 assert!(matches!(result, Err(MergeError::SpdxVersionMismatch)));
480 }
481
482 #[test]
485 fn merge_dedups_vulnerabilities_by_id_and_unions_affects() {
486 let primary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5",
487 "components":[{"bom-ref":"a","name":"a","version":"1.0"}],
488 "vulnerabilities":[{"id":"CVE-1","description":"keep me","affects":[{"ref":"a"}]}]}"#;
489 let secondary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5",
490 "components":[{"bom-ref":"b","name":"b","version":"2.0"}],
491 "vulnerabilities":[
492 {"id":"CVE-1","description":"secondary copy","affects":[{"ref":"a"},{"ref":"b"}]},
493 {"id":"CVE-2","affects":[{"ref":"b"}]}
494 ]}"#;
495
496 let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
497 let doc: Value = serde_json::from_str(&result).unwrap();
498 let vulns = doc["vulnerabilities"].as_array().unwrap();
499 assert_eq!(vulns.len(), 2, "CVE-1 must not be duplicated");
500
501 let cve1 = vulns.iter().find(|v| v["id"] == "CVE-1").unwrap();
502 assert_eq!(cve1["description"], "keep me", "primary entry wins");
503 let refs: Vec<&str> = cve1["affects"]
504 .as_array()
505 .unwrap()
506 .iter()
507 .filter_map(|a| a["ref"].as_str())
508 .collect();
509 assert_eq!(refs, vec!["a", "b"], "affects refs unioned without dupes");
510 }
511
512 #[test]
515 fn merge_dedups_relationships_by_triple() {
516 let primary = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT",
517 "packages":[{"SPDXID":"SPDXRef-a","name":"a"}],
518 "relationships":[
519 {"spdxElementId":"SPDXRef-DOCUMENT","relationshipType":"DESCRIBES","relatedSpdxElement":"SPDXRef-a"}
520 ]}"#;
521 let secondary = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT",
522 "packages":[{"SPDXID":"SPDXRef-b","name":"b"}],
523 "relationships":[
524 {"spdxElementId":"SPDXRef-DOCUMENT","relationshipType":"DESCRIBES","relatedSpdxElement":"SPDXRef-a"},
525 {"spdxElementId":"SPDXRef-a","relationshipType":"DEPENDS_ON","relatedSpdxElement":"SPDXRef-b"}
526 ]}"#;
527
528 let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
529 let doc: Value = serde_json::from_str(&result).unwrap();
530 let rels = doc["relationships"].as_array().unwrap();
531 assert_eq!(rels.len(), 2, "duplicate DESCRIBES must be dropped");
532 }
533
534 #[test]
536 fn merge_spdx2_creates_packages_when_primary_lacks_array() {
537 let primary = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT"}"#;
538 let secondary = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT",
539 "packages":[{"SPDXID":"SPDXRef-a","name":"a"}]}"#;
540
541 let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
542 let doc: Value = serde_json::from_str(&result).unwrap();
543 assert_eq!(doc["packages"].as_array().unwrap().len(), 1);
544 }
545
546 #[test]
547 fn merge_crypto_oid_dedup() {
548 let primary = r#"{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
549 {"name":"AES-256-GCM","type":"cryptographic-asset","cryptoProperties":{"assetType":"algorithm","oid":"2.16.840.1.101.3.4.1.46"}}
550 ]}"#;
551
552 let secondary = r#"{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
553 {"name":"AES-256-GCM-v2","type":"cryptographic-asset","cryptoProperties":{"assetType":"algorithm","oid":"2.16.840.1.101.3.4.1.46"}},
554 {"name":"SHA-384","type":"cryptographic-asset","cryptoProperties":{"assetType":"algorithm","oid":"2.16.840.1.101.3.4.2.2"}}
555 ]}"#;
556
557 let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
558 let doc: Value = serde_json::from_str(&result).unwrap();
559 let components = doc["components"].as_array().unwrap();
560 assert_eq!(components.len(), 2);
562 }
563}