Skip to main content

sbom_tools/serialization/
merger.rs

1//! SBOM merging.
2//!
3//! Combines multiple SBOMs into a single document, deduplicating
4//! components based on configurable strategies.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::{HashMap, HashSet};
9
10use super::ValueExt;
11
12/// Errors that can occur during SBOM merging
13#[derive(Debug, thiserror::Error)]
14pub enum MergeError {
15    /// The two SBOMs are different formats (e.g., CycloneDX and SPDX)
16    #[error("cannot merge CycloneDX and SPDX SBOMs — both must be the same format")]
17    FormatMismatch,
18    /// The two SBOMs are incompatible SPDX versions
19    #[error("cannot merge SPDX 3.0 and SPDX 2.x SBOMs")]
20    SpdxVersionMismatch,
21    /// JSON serialization/deserialization error
22    #[error(transparent)]
23    Json(#[from] serde_json::Error),
24}
25
26/// Configuration for SBOM merging
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct MergeConfig {
29    /// Deduplication strategy
30    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/// Strategy for deduplicating components during merge
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
43#[serde(rename_all = "kebab-case")]
44pub enum DeduplicationStrategy {
45    /// Deduplicate by package name + version
46    #[default]
47    Name,
48    /// Deduplicate by PURL (exact match)
49    Purl,
50    /// Keep all components (no dedup)
51    None,
52}
53
54/// Merge two SBOM JSON documents into one.
55///
56/// The primary SBOM provides the document metadata; components from both
57/// are merged with deduplication.
58///
59/// Both SBOMs must be the same format (CycloneDX or SPDX).
60///
61/// # Errors
62///
63/// Returns error if the SBOMs are different formats or JSON parsing fails.
64pub 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    // Verify same format family
78    if primary_is_cdx != secondary_is_cdx {
79        return Err(MergeError::FormatMismatch);
80    }
81
82    // SPDX 2.x and SPDX 3.0 are incompatible in BOTH directions. The old
83    // check only caught SPDX3-primary + SPDX2-secondary; the reverse silently
84    // emitted the primary unchanged.
85    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    // The primary may legitimately lack a `components` array (metadata-only
106    // shell document): create it from the secondary rather than silently
107    // dropping every secondary component.
108    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            // No deduplication — keep all components
113            for comp in s_comps {
114                p_comps.push(comp.clone());
115            }
116        } else {
117            // Build dedup set from primary
118            let mut seen = build_seen_set(p_comps, config);
119
120            // Add non-duplicate components from secondary
121            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    // Merge dependencies
131    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, deduplicating by id (affects refs are unioned)
148    merge_vulnerabilities(primary, secondary);
149
150    Ok(())
151}
152
153/// Get a mutable reference to `primary[field]` as an array, creating an empty
154/// array when the field is absent. Returns `None` only when `primary` is not
155/// an object or the existing field is not an array.
156fn 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
164/// Merge the secondary's `vulnerabilities` into the primary, deduplicating by
165/// vulnerability id. When both documents carry the same id, the entries are
166/// merged by unioning `affects` refs (the primary's entry wins for every
167/// other field). Entries without an id cannot be identified and are appended.
168fn 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            // Union affects refs into the primary's entry.
197            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
237/// Merge the secondary's SPDX `relationships` into the primary, deduplicating
238/// by the `(relationshipType, spdxElementId, relatedSpdxElement)` triple.
239fn 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            // For packages, apply dedup logic
296            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    // Merge packages (creating the array when the primary lacks one)
319    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, deduplicating by (type, source, target)
338    merge_relationships(primary, secondary);
339
340    Ok(())
341}
342
343/// Build a set of dedup keys from existing components
344fn build_seen_set(components: &[Value], config: &MergeConfig) -> HashSet<String> {
345    components
346        .iter()
347        .map(|c| component_key(c, config))
348        .collect()
349}
350
351/// Generate a deduplication key for a component
352fn component_key(comp: &Value, config: &MergeConfig) -> String {
353    match config.dedup_strategy {
354        DeduplicationStrategy::Purl => {
355            // Try purl field directly
356            if let Some(purl) = comp.get("purl").and_then(Value::as_str) {
357                return purl.to_string();
358            }
359            // Try externalReferences for PURL
360            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            // Fall back to name-version
370            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    // For cryptographic components, use OID as the dedup key if available
378    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); // foo, bar, baz (foo deduped)
416    }
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        // None strategy keeps all components, including duplicates
443        assert_eq!(components.len(), 2);
444    }
445
446    /// A metadata-only primary (no `components` array) must still receive
447    /// every secondary component.
448    #[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    /// SPDX 2.x primary + SPDX 3.0 secondary must error, exactly like the
468    /// reverse direction (it previously emitted the primary unchanged).
469    #[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        // And the reverse direction still errors too.
478        let result = merge_sbom_json(spdx3, spdx2, &MergeConfig::default());
479        assert!(matches!(result, Err(MergeError::SpdxVersionMismatch)));
480    }
481
482    /// Overlapping vulnerabilities are deduplicated by id, with affects refs
483    /// unioned into the primary's entry.
484    #[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    /// Overlapping SPDX relationships are deduplicated by
513    /// (type, source, target).
514    #[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    /// SPDX2 primary without a packages array still receives secondary packages.
535    #[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        // AES-256-GCM-v2 deduped by OID, SHA-384 added → 2 total
561        assert_eq!(components.len(), 2);
562    }
563}