use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use super::ValueExt;
#[derive(Debug, thiserror::Error)]
pub enum MergeError {
#[error("cannot merge CycloneDX and SPDX SBOMs — both must be the same format")]
FormatMismatch,
#[error("cannot merge SPDX 3.0 and SPDX 2.x SBOMs")]
SpdxVersionMismatch,
#[error(transparent)]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeConfig {
pub dedup_strategy: DeduplicationStrategy,
}
impl Default for MergeConfig {
fn default() -> Self {
Self {
dedup_strategy: DeduplicationStrategy::Name,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum DeduplicationStrategy {
#[default]
Name,
Purl,
None,
}
pub fn merge_sbom_json(
primary_json: &str,
secondary_json: &str,
config: &MergeConfig,
) -> Result<String, MergeError> {
let mut primary: Value = serde_json::from_str(primary_json)?;
let secondary: Value = serde_json::from_str(secondary_json)?;
let primary_is_cdx = primary.get("bomFormat").is_some();
let secondary_is_cdx = secondary.get("bomFormat").is_some();
let primary_is_spdx3 = primary.get("@context").is_some();
let secondary_is_spdx3 = secondary.get("@context").is_some();
if primary_is_cdx != secondary_is_cdx {
return Err(MergeError::FormatMismatch);
}
if primary_is_spdx3 != secondary_is_spdx3 {
return Err(MergeError::SpdxVersionMismatch);
}
if primary_is_cdx {
merge_cyclonedx(&mut primary, &secondary, config)?;
} else if primary_is_spdx3 {
merge_spdx3(&mut primary, &secondary, config)?;
} else {
merge_spdx2(&mut primary, &secondary, config)?;
}
Ok(serde_json::to_string_pretty(&primary)?)
}
fn merge_cyclonedx(
primary: &mut Value,
secondary: &Value,
config: &MergeConfig,
) -> Result<(), MergeError> {
if let Some(s_comps) = secondary.get("components").and_then(Value::as_array)
&& let Some(p_comps) = ensure_array(primary, "components")
{
if config.dedup_strategy == DeduplicationStrategy::None {
for comp in s_comps {
p_comps.push(comp.clone());
}
} else {
let mut seen = build_seen_set(p_comps, config);
for comp in s_comps {
let key = component_key(comp, config);
if seen.insert(key) {
p_comps.push(comp.clone());
}
}
}
}
if let Some(s_deps) = secondary.get("dependencies").and_then(Value::as_array)
&& let Some(p_deps) = ensure_array(primary, "dependencies")
{
let existing_refs: HashSet<String> = p_deps
.iter()
.filter_map(|d| d.get("ref").and_then(Value::as_str).map(String::from))
.collect();
for dep in s_deps {
let dep_ref = dep.str_field("ref");
if !existing_refs.contains(dep_ref) {
p_deps.push(dep.clone());
}
}
}
merge_vulnerabilities(primary, secondary);
Ok(())
}
fn ensure_array<'a>(primary: &'a mut Value, field: &str) -> Option<&'a mut Vec<Value>> {
primary.as_object_mut().and_then(|o| {
o.entry(field)
.or_insert_with(|| Value::Array(Vec::new()))
.as_array_mut()
})
}
fn merge_vulnerabilities(primary: &mut Value, secondary: &Value) {
let Some(s_vulns) = secondary.get("vulnerabilities").and_then(Value::as_array) else {
return;
};
if s_vulns.is_empty() {
return;
}
let Some(p_vulns) = ensure_array(primary, "vulnerabilities") else {
return;
};
let mut index_by_id: HashMap<String, usize> = p_vulns
.iter()
.enumerate()
.filter_map(|(i, v)| {
v.get("id")
.and_then(Value::as_str)
.map(|id| (id.to_string(), i))
})
.collect();
for vuln in s_vulns {
let id = vuln.str_field("id");
if id.is_empty() {
p_vulns.push(vuln.clone());
continue;
}
if let Some(&i) = index_by_id.get(id) {
let Some(s_affects) = vuln.get("affects").and_then(Value::as_array) else {
continue;
};
let Some(existing) = p_vulns[i].as_object_mut() else {
continue;
};
let existing_refs: HashSet<String> = existing
.get("affects")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|a| a.get("ref").and_then(Value::as_str))
.map(String::from)
.collect()
})
.unwrap_or_default();
let to_add: Vec<Value> = s_affects
.iter()
.filter(|a| {
a.get("ref")
.and_then(Value::as_str)
.is_none_or(|r| !existing_refs.contains(r))
})
.cloned()
.collect();
if !to_add.is_empty()
&& let Some(affects) = existing
.entry("affects")
.or_insert_with(|| Value::Array(Vec::new()))
.as_array_mut()
{
affects.extend(to_add);
}
} else {
index_by_id.insert(id.to_string(), p_vulns.len());
p_vulns.push(vuln.clone());
}
}
}
fn merge_relationships(primary: &mut Value, secondary: &Value) {
let Some(s_rels) = secondary.get("relationships").and_then(Value::as_array) else {
return;
};
if s_rels.is_empty() {
return;
}
let Some(p_rels) = ensure_array(primary, "relationships") else {
return;
};
fn rel_key(rel: &Value) -> (String, String, String) {
(
rel.str_field("relationshipType").to_string(),
rel.str_field("spdxElementId").to_string(),
rel.str_field("relatedSpdxElement").to_string(),
)
}
let mut seen: HashSet<(String, String, String)> = p_rels.iter().map(rel_key).collect();
for rel in s_rels {
if seen.insert(rel_key(rel)) {
p_rels.push(rel.clone());
}
}
}
fn merge_spdx3(
primary: &mut Value,
secondary: &Value,
config: &MergeConfig,
) -> Result<(), MergeError> {
let primary_key = if primary.get("element").is_some() {
"element"
} else {
"@graph"
};
let secondary_key = if secondary.get("element").is_some() {
"element"
} else {
"@graph"
};
let secondary_elements = secondary.get(secondary_key).and_then(Value::as_array);
if let Some(s_elems) = secondary_elements
&& let Some(p_elems) = ensure_array(primary, primary_key)
{
let mut seen: HashSet<String> = p_elems
.iter()
.filter_map(|e| e.get("spdxId").and_then(Value::as_str).map(String::from))
.collect();
for elem in s_elems {
let spdx_id = elem.str_field("spdxId");
let elem_type = elem.str_field("type");
if elem_type.contains("Package") || elem_type.contains("package") {
let key = component_key(elem, config);
if !seen.insert(key) {
continue;
}
} else if !seen.insert(spdx_id.to_string()) {
continue;
}
p_elems.push(elem.clone());
}
}
Ok(())
}
fn merge_spdx2(
primary: &mut Value,
secondary: &Value,
config: &MergeConfig,
) -> Result<(), MergeError> {
if let Some(s_pkgs) = secondary.get("packages").and_then(Value::as_array)
&& let Some(p_pkgs) = ensure_array(primary, "packages")
{
if config.dedup_strategy == DeduplicationStrategy::None {
for pkg in s_pkgs {
p_pkgs.push(pkg.clone());
}
} else {
let mut seen = build_seen_set(p_pkgs, config);
for pkg in s_pkgs {
let key = component_key(pkg, config);
if seen.insert(key) {
p_pkgs.push(pkg.clone());
}
}
}
}
merge_relationships(primary, secondary);
Ok(())
}
fn build_seen_set(components: &[Value], config: &MergeConfig) -> HashSet<String> {
components
.iter()
.map(|c| component_key(c, config))
.collect()
}
fn component_key(comp: &Value, config: &MergeConfig) -> String {
match config.dedup_strategy {
DeduplicationStrategy::Purl => {
if let Some(purl) = comp.get("purl").and_then(Value::as_str) {
return purl.to_string();
}
if let Some(refs) = comp.get("externalReferences").and_then(Value::as_array) {
for r in refs {
if r.get("type").and_then(Value::as_str) == Some("purl")
&& let Some(url) = r.get("url").and_then(Value::as_str)
{
return url.to_string();
}
}
}
name_version_key(comp)
}
DeduplicationStrategy::Name | DeduplicationStrategy::None => name_version_key(comp),
}
}
fn name_version_key(comp: &Value) -> String {
if let Some(cp) = comp.get("cryptoProperties")
&& let Some(oid) = cp.get("oid").and_then(Value::as_str)
{
let asset_type = cp
.get("assetType")
.and_then(Value::as_str)
.unwrap_or("unknown");
return format!("crypto:{asset_type}:{oid}");
}
let name = comp.str_field("name");
let version = comp
.get("version")
.or_else(|| comp.get("versionInfo"))
.and_then(Value::as_str)
.unwrap_or("");
format!("{name}@{version}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_cyclonedx_dedup() {
let primary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
{"name":"foo","version":"1.0"},
{"name":"bar","version":"2.0"}
]}"#;
let secondary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
{"name":"foo","version":"1.0"},
{"name":"baz","version":"3.0"}
]}"#;
let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
let doc: Value = serde_json::from_str(&result).unwrap();
let components = doc["components"].as_array().unwrap();
assert_eq!(components.len(), 3); }
#[test]
fn merge_different_formats_fails() {
let cdx = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#;
let spdx = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT","packages":[]}"#;
let result = merge_sbom_json(cdx, spdx, &MergeConfig::default());
assert!(result.is_err());
}
#[test]
fn merge_no_dedup() {
let a = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
{"name":"foo","version":"1.0"}
]}"#;
let b = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
{"name":"foo","version":"1.0"}
]}"#;
let config = MergeConfig {
dedup_strategy: DeduplicationStrategy::None,
};
let result = merge_sbom_json(a, b, &config).unwrap();
let doc: Value = serde_json::from_str(&result).unwrap();
let components = doc["components"].as_array().unwrap();
assert_eq!(components.len(), 2);
}
#[test]
fn merge_creates_components_when_primary_lacks_array() {
let primary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5",
"metadata":{"component":{"type":"application","name":"shell"}}}"#;
let secondary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
{"name":"foo","version":"1.0"},
{"name":"bar","version":"2.0"}
]}"#;
let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
let doc: Value = serde_json::from_str(&result).unwrap();
let components = doc["components"].as_array().unwrap();
assert_eq!(
components.len(),
2,
"secondary components must not be dropped"
);
}
#[test]
fn merge_spdx2_primary_spdx3_secondary_errors() {
let spdx2 = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT","packages":[]}"#;
let spdx3 = r#"{"@context":"https://spdx.org/rdf/3.0.1/spdx-context.jsonld","@graph":[]}"#;
let result = merge_sbom_json(spdx2, spdx3, &MergeConfig::default());
assert!(matches!(result, Err(MergeError::SpdxVersionMismatch)));
let result = merge_sbom_json(spdx3, spdx2, &MergeConfig::default());
assert!(matches!(result, Err(MergeError::SpdxVersionMismatch)));
}
#[test]
fn merge_dedups_vulnerabilities_by_id_and_unions_affects() {
let primary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5",
"components":[{"bom-ref":"a","name":"a","version":"1.0"}],
"vulnerabilities":[{"id":"CVE-1","description":"keep me","affects":[{"ref":"a"}]}]}"#;
let secondary = r#"{"bomFormat":"CycloneDX","specVersion":"1.5",
"components":[{"bom-ref":"b","name":"b","version":"2.0"}],
"vulnerabilities":[
{"id":"CVE-1","description":"secondary copy","affects":[{"ref":"a"},{"ref":"b"}]},
{"id":"CVE-2","affects":[{"ref":"b"}]}
]}"#;
let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
let doc: Value = serde_json::from_str(&result).unwrap();
let vulns = doc["vulnerabilities"].as_array().unwrap();
assert_eq!(vulns.len(), 2, "CVE-1 must not be duplicated");
let cve1 = vulns.iter().find(|v| v["id"] == "CVE-1").unwrap();
assert_eq!(cve1["description"], "keep me", "primary entry wins");
let refs: Vec<&str> = cve1["affects"]
.as_array()
.unwrap()
.iter()
.filter_map(|a| a["ref"].as_str())
.collect();
assert_eq!(refs, vec!["a", "b"], "affects refs unioned without dupes");
}
#[test]
fn merge_dedups_relationships_by_triple() {
let primary = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT",
"packages":[{"SPDXID":"SPDXRef-a","name":"a"}],
"relationships":[
{"spdxElementId":"SPDXRef-DOCUMENT","relationshipType":"DESCRIBES","relatedSpdxElement":"SPDXRef-a"}
]}"#;
let secondary = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT",
"packages":[{"SPDXID":"SPDXRef-b","name":"b"}],
"relationships":[
{"spdxElementId":"SPDXRef-DOCUMENT","relationshipType":"DESCRIBES","relatedSpdxElement":"SPDXRef-a"},
{"spdxElementId":"SPDXRef-a","relationshipType":"DEPENDS_ON","relatedSpdxElement":"SPDXRef-b"}
]}"#;
let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
let doc: Value = serde_json::from_str(&result).unwrap();
let rels = doc["relationships"].as_array().unwrap();
assert_eq!(rels.len(), 2, "duplicate DESCRIBES must be dropped");
}
#[test]
fn merge_spdx2_creates_packages_when_primary_lacks_array() {
let primary = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT"}"#;
let secondary = r#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT",
"packages":[{"SPDXID":"SPDXRef-a","name":"a"}]}"#;
let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
let doc: Value = serde_json::from_str(&result).unwrap();
assert_eq!(doc["packages"].as_array().unwrap().len(), 1);
}
#[test]
fn merge_crypto_oid_dedup() {
let primary = r#"{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
{"name":"AES-256-GCM","type":"cryptographic-asset","cryptoProperties":{"assetType":"algorithm","oid":"2.16.840.1.101.3.4.1.46"}}
]}"#;
let secondary = r#"{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
{"name":"AES-256-GCM-v2","type":"cryptographic-asset","cryptoProperties":{"assetType":"algorithm","oid":"2.16.840.1.101.3.4.1.46"}},
{"name":"SHA-384","type":"cryptographic-asset","cryptoProperties":{"assetType":"algorithm","oid":"2.16.840.1.101.3.4.2.2"}}
]}"#;
let result = merge_sbom_json(primary, secondary, &MergeConfig::default()).unwrap();
let doc: Value = serde_json::from_str(&result).unwrap();
let components = doc["components"].as_array().unwrap();
assert_eq!(components.len(), 2);
}
}