diff --git a/src/CycloneDX.Core/BomRefWalker.cs b/src/CycloneDX.Core/BomRefWalker.cs
new file mode 100644
index 00000000..66efbbec
--- /dev/null
+++ b/src/CycloneDX.Core/BomRefWalker.cs
@@ -0,0 +1,205 @@
+// This file is part of CycloneDX Library for .NET
+//
+// Licensed under the Apache License, Version 2.0 (the “License”);
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an “AS IS” BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) OWASP Foundation. All Rights Reserved.
+
+#if NET8_0_OR_GREATER
+using System;
+using System.Collections.Generic;
+using CycloneDX.Models;
+using CycloneDX.Models.Vulnerabilities;
+
+namespace CycloneDX
+{
+ ///
+ /// Rewrites every "bom-ref"-shaped value in a document
+ /// -- both identifiers (Component/Service/Vulnerability/Annotation
+ /// BomRef) and back-references to them (Dependency.Ref, Composition's
+ /// Assemblies/Dependencies string lists, Vulnerability.Affects[].Ref,
+ /// Annotation subjects) -- through a caller-supplied function.
+ ///
+ ///
+ /// This generalizes traversal code CycloneDX.Utils.CycloneDXUtils.
+ /// HierarchicalMerge already hand-rolls for bom-ref namespacing (see
+ /// its private NamespaceComponentBomRefs/NamespaceDependencyBomRefs/
+ /// NamespaceCompositions/NamespaceVulnerabilitiesRefs/
+ /// NamespaceAnnotationsBomRefs methods) into one reusable entry point,
+ /// parameterized on an arbitrary rewrite function instead of always
+ /// prefixing a namespace. Namespacing becomes
+ /// RewriteRefs(bom, r => $"{ns}:{r}"); a manual single-ref
+ /// rename (as the CLI's rename-entity command needs) becomes
+ /// RewriteRefs(bom, r => r == oldRef ? newRef : r).
+ ///
+ /// Scope note: covers Metadata.Component, Components, Services,
+ /// Dependencies, Compositions, Vulnerabilities, and Annotations -- it
+ /// does not yet walk the newer (CycloneDX 1.6) Declarations/Definitions
+ /// sections. Extending it there is a mechanical follow-up, not an
+ /// architectural one: add another block below following the same
+ /// pattern.
+ ///
+ public static class BomRefWalker
+ {
+ public static void RewriteRefs(Bom bom, Func rewrite)
+ {
+ if (bom is null || rewrite is null)
+ {
+ return;
+ }
+
+ if (bom.Metadata?.Component != null)
+ {
+ RewriteComponentTree(bom.Metadata.Component, rewrite);
+ }
+ if (bom.Metadata?.Tools?.Components != null)
+ {
+ foreach (var component in bom.Metadata.Tools.Components)
+ {
+ RewriteComponentTree(component, rewrite);
+ }
+ }
+ if (bom.Metadata?.Tools?.Services != null)
+ {
+ foreach (var service in bom.Metadata.Tools.Services)
+ {
+ service.BomRef = rewrite(service.BomRef);
+ }
+ }
+
+ if (bom.Components != null)
+ {
+ foreach (var component in bom.Components)
+ {
+ RewriteComponentTree(component, rewrite);
+ }
+ }
+
+ if (bom.Services != null)
+ {
+ foreach (var service in bom.Services)
+ {
+ service.BomRef = rewrite(service.BomRef);
+ }
+ }
+
+ if (bom.Dependencies != null)
+ {
+ RewriteDependencyTree(bom.Dependencies, rewrite);
+ }
+
+ if (bom.Compositions != null)
+ {
+ foreach (var composition in bom.Compositions)
+ {
+ RewriteStringListInPlace(composition.Assemblies, rewrite);
+ RewriteStringListInPlace(composition.Dependencies, rewrite);
+ }
+ }
+
+ if (bom.Vulnerabilities != null)
+ {
+ foreach (var vulnerability in bom.Vulnerabilities)
+ {
+ vulnerability.BomRef = rewrite(vulnerability.BomRef);
+ if (vulnerability.Affects != null)
+ {
+ foreach (var affect in vulnerability.Affects)
+ {
+ affect.Ref = rewrite(affect.Ref);
+ }
+ }
+ }
+ }
+
+ if (bom.Annotations != null)
+ {
+ foreach (var annotation in bom.Annotations)
+ {
+ annotation.BomRef = rewrite(annotation.BomRef);
+ if (annotation.XmlSubjects != null)
+ {
+ for (var i = 0; i < annotation.XmlSubjects.Count; i++)
+ {
+ annotation.XmlSubjects[i].Ref = rewrite(annotation.XmlSubjects[i].Ref);
+ }
+ }
+ if (annotation.Annotator?.Component != null)
+ {
+ RewriteComponentTree(annotation.Annotator.Component, rewrite);
+ }
+ if (annotation.Annotator?.Individual != null)
+ {
+ annotation.Annotator.Individual.BomRef = rewrite(annotation.Annotator.Individual.BomRef);
+ }
+ if (annotation.Annotator?.Organization != null)
+ {
+ annotation.Annotator.Organization.BomRef = rewrite(annotation.Annotator.Organization.BomRef);
+ }
+ if (annotation.Annotator?.Service != null)
+ {
+ annotation.Annotator.Service.BomRef = rewrite(annotation.Annotator.Service.BomRef);
+ }
+ }
+ }
+ }
+
+ private static void RewriteComponentTree(Component topComponent, Func rewrite)
+ {
+ var pending = new Stack();
+ pending.Push(topComponent);
+ while (pending.Count > 0)
+ {
+ var component = pending.Pop();
+ if (component.Components != null)
+ {
+ foreach (var sub in component.Components)
+ {
+ pending.Push(sub);
+ }
+ }
+ component.BomRef = rewrite(component.BomRef);
+ }
+ }
+
+ private static void RewriteDependencyTree(List dependencies, Func rewrite)
+ {
+ var pending = new Stack(dependencies);
+ while (pending.Count > 0)
+ {
+ var dependency = pending.Pop();
+ if (dependency.Dependencies != null)
+ {
+ foreach (var sub in dependency.Dependencies)
+ {
+ pending.Push(sub);
+ }
+ }
+ dependency.Ref = rewrite(dependency.Ref);
+ }
+ }
+
+ private static void RewriteStringListInPlace(List refs, Func rewrite)
+ {
+ if (refs is null)
+ {
+ return;
+ }
+ for (var i = 0; i < refs.Count; i++)
+ {
+ refs[i] = rewrite(refs[i]);
+ }
+ }
+ }
+}
+#endif
diff --git a/src/CycloneDX.Core/MergeableListHelper.cs b/src/CycloneDX.Core/MergeableListHelper.cs
new file mode 100644
index 00000000..ec756359
--- /dev/null
+++ b/src/CycloneDX.Core/MergeableListHelper.cs
@@ -0,0 +1,127 @@
+// This file is part of CycloneDX Library for .NET
+//
+// Licensed under the Apache License, Version 2.0 (the “License”);
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an “AS IS” BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) OWASP Foundation. All Rights Reserved.
+
+#if NET8_0_OR_GREATER
+using System;
+using System.Collections.Generic;
+using CycloneDX.Models;
+
+namespace CycloneDX
+{
+ ///
+ /// Strategy-aware list merging for any element type implementing
+ /// IEquatable/IEquivalent/IMergeable. One generic method merges
+ /// List<Hash>, List<Component>, List<OrganizationalContact>,
+ /// etc., dispatching through real interface calls rather than a
+ /// separate reflection-driven helper per element type. Lives in
+ /// CycloneDX.Core (rather than alongside CycloneDX.Utils/Merge.cs)
+ /// specifically so model classes like Component can call it directly
+ /// from their own MergeWith implementations.
+ ///
+ public static class MergeableListHelper
+ {
+ public static List Merge(List list1, List list2, MergeStrategy strategy)
+ where T : IEquatable, IEquivalent, IMergeable
+ {
+ if (list1 is null) return list2;
+ if (list2 is null) return list1;
+ if (strategy is null || !strategy.UseEntityMerge)
+ {
+ return ExactMatchMerge(list1, list2);
+ }
+
+ var result = new List(list1);
+ foreach (var incoming in list2)
+ {
+ bool merged = false;
+ for (int i = 0; i < result.Count; i++)
+ {
+ var existing = result[i];
+ if (existing.Equals(incoming) || existing.Equivalent(incoming, strategy))
+ {
+ if (existing.MergeWith(incoming, strategy))
+ {
+ result[i] = existing;
+ merged = true;
+ break;
+ }
+ }
+ }
+ if (!merged)
+ {
+ result.Add(incoming);
+ }
+ }
+ return result;
+ }
+
+ ///
+ /// Cheap fallback: dedupe by exact equality only, no MergeWith
+ /// attempts. Mirrors CycloneDX.Utils.ListMergeHelper<T>'s
+ /// behavior (kept independently here to avoid a Core->Utils
+ /// dependency, which would invert this project's reference graph).
+ ///
+ private static List ExactMatchMerge(List list1, List list2) where T : IEquatable
+ {
+ var result = new List(list1);
+ foreach (var item in list2)
+ {
+ bool found = false;
+ foreach (var existing in result)
+ {
+ if (existing.Equals(item))
+ {
+ found = true;
+ break;
+ }
+ }
+ if (!found)
+ {
+ result.Add(item);
+ }
+ }
+ return result;
+ }
+
+ /// Take whichever of two nullable reference values is non-null, preferring .
+ public static T MergeSingle(T a, T b) where T : class => a ?? b;
+
+ /// Union two string lists, preserving order, dropping duplicates. Null if both are null.
+ public static List MergeStringList(List a, List b)
+ {
+ if (a is null) return b;
+ if (b is null) return a;
+ var result = new List(a);
+ foreach (var s in b)
+ {
+ if (!result.Contains(s))
+ {
+ result.Add(s);
+ }
+ }
+ return result;
+ }
+
+ /// Nullable-bool "either says true" merge: null if both unset, else true if either is true.
+ public static bool? MergeNullableBoolOr(bool? a, bool? b)
+ {
+ if (!a.HasValue && !b.HasValue) return null;
+ return (a ?? false) || (b ?? false);
+ }
+ }
+}
+#endif
diff --git a/src/CycloneDX.Core/Models/Annotation.cs b/src/CycloneDX.Core/Models/Annotation.cs
index 58519055..610bb351 100644
--- a/src/CycloneDX.Core/Models/Annotation.cs
+++ b/src/CycloneDX.Core/Models/Annotation.cs
@@ -26,6 +26,9 @@ namespace CycloneDX.Models
{
[ProtoContract]
public class Annotation : IEquatable
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlType("subject")]
public class XmlAnnotationSubject
diff --git a/src/CycloneDX.Core/Models/Bom.cs b/src/CycloneDX.Core/Models/Bom.cs
index bbc90c50..8a23c39b 100644
--- a/src/CycloneDX.Core/Models/Bom.cs
+++ b/src/CycloneDX.Core/Models/Bom.cs
@@ -205,5 +205,308 @@ public int NonNullableVersion
public XmlElement XmlSignature { get; set; }
[XmlIgnore]
public SignatureChoice Signature { get; set; }
+
+#if NET8_0_OR_GREATER
+ ///
+ /// Rename a "bom-ref" identifier and every back-reference to it
+ /// throughout this document (Dependency.Ref, Composition
+ /// assemblies/dependencies, Vulnerability.Affects[].Ref, annotation
+ /// subjects, ...), via .
+ ///
+ ///
+ /// true if was found and rewritten
+ /// somewhere in the document; false if it was not present
+ /// (a non-fatal no-op) or the arguments were invalid.
+ ///
+ ///
+ /// is already used as a bom-ref identifier
+ /// or back-reference somewhere else in this document. Renaming
+ /// anyway would silently make two different entities share one
+ /// bom-ref (or repoint an existing back-reference at the wrong
+ /// entity) -- refused rather than done.
+ ///
+ public bool RenameRef(string oldRef, string newRef)
+ {
+ if (string.IsNullOrEmpty(oldRef) || string.IsNullOrEmpty(newRef) || oldRef == newRef)
+ {
+ return false;
+ }
+
+ // Read-only pass (rewrite function returns its input unchanged,
+ // just records it) to check for a collision before touching
+ // anything -- reuses the same traversal RewriteRefs uses for
+ // the real rewrite, so "what counts as a ref" can't drift
+ // between the check and the actual rename.
+ var existingRefs = new HashSet();
+ BomRefWalker.RewriteRefs(this, r =>
+ {
+ if (!string.IsNullOrEmpty(r))
+ {
+ existingRefs.Add(r);
+ }
+ return r;
+ });
+
+ if (!existingRefs.Contains(oldRef))
+ {
+ return false;
+ }
+ if (existingRefs.Contains(newRef))
+ {
+ throw new InvalidOperationException($"Cannot rename \"{oldRef}\" to \"{newRef}\": \"{newRef}\" is already used as a bom-ref (or a reference to one) elsewhere in this document.");
+ }
+
+ bool found = false;
+ BomRefWalker.RewriteRefs(this, r =>
+ {
+ if (r == oldRef)
+ {
+ found = true;
+ return newRef;
+ }
+ return r;
+ });
+ return found;
+ }
+
+ ///
+ /// Add a reference to this running build of cyclonedx-dotnet-library
+ /// (and, if different, the entry assembly -- typically a consuming
+ /// tool like cyclonedx-cli) into this document's Metadata/Tools.
+ /// Intended for use after processing that creates or modifies a
+ /// document, so any bugs in the processing are traceable to the
+ /// tool/library versions that produced the result. Avoids adding
+ /// exact-duplicate entries.
+ ///
+ public void BomMetadataReferThisToolkit()
+ {
+#pragma warning disable 618
+ var toolThisLibrary = new Tool
+ {
+ Vendor = "OWASP Foundation",
+ Name = System.Reflection.Assembly.GetExecutingAssembly().GetName().Name,
+ Version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString()
+ };
+#pragma warning restore 618
+
+ if (Metadata is null)
+ {
+ Metadata = new Metadata();
+ }
+
+ if (Metadata.Tools is null || Metadata.Tools.Tools is null)
+ {
+#pragma warning disable 618
+ Metadata.Tools = new ToolChoices
+ {
+ Tools = new List(new[] { toolThisLibrary }),
+ };
+#pragma warning restore 618
+ }
+ else if (!Metadata.Tools.Tools.Contains(toolThisLibrary))
+ {
+ Metadata.Tools.Tools.Add(toolThisLibrary);
+ }
+
+ var entryAssembly = System.Reflection.Assembly.GetEntryAssembly();
+ var toolThisScriptName = entryAssembly?.GetName()?.Name;
+ if (!string.IsNullOrEmpty(toolThisScriptName) && toolThisScriptName != toolThisLibrary.Name)
+ {
+#pragma warning disable 618
+ var toolThisScript = new Tool
+ {
+ Name = toolThisScriptName,
+ Vendor = toolThisScriptName.ToLowerInvariant().StartsWith("cyclonedx", StringComparison.Ordinal) ? "OWASP Foundation" : null,
+ Version = entryAssembly.GetName().Version.ToString()
+ };
+#pragma warning restore 618
+
+ if (!Metadata.Tools.Tools.Contains(toolThisScript))
+ {
+ Metadata.Tools.Tools.Add(toolThisScript);
+ }
+ }
+ }
+
+ ///
+ /// Refresh this document's own identity: Version/SerialNumber and
+ /// Metadata/Timestamp. Typically called after content
+ /// manipulations such as a merge or rename. Callers usually also
+ /// want separately.
+ ///
+ public void BomMetadataUpdate(bool generateNewSerialNumber)
+ {
+ if (Version is null || Version < 1 || string.IsNullOrEmpty(SerialNumber))
+ {
+ generateNewSerialNumber = true;
+ }
+
+ if (generateNewSerialNumber)
+ {
+ Version = 1;
+ SerialNumber = "urn:uuid:" + Guid.NewGuid().ToString();
+ }
+ else
+ {
+ Version++;
+ }
+
+ if (Metadata is null)
+ {
+ Metadata = new Metadata();
+ }
+ Metadata.Timestamp = DateTime.Now;
+ }
+
+ ///
+ /// Find every top-level Component (in Metadata.Component and
+ /// Components) that no dependsOn edge anywhere in the document's
+ /// Dependencies tree ever targets. These are structurally valid
+ /// per the JSON/XML schema, but invisible to any consumer that
+ /// walks the dependency graph from the document's subject rather
+ /// than scanning the flat Components list (e.g. Dependency-Track).
+ /// A flat merge of many independently-generated documents can
+ /// easily leave some of them like this: FlatMerge unions each
+ /// input's own Dependencies, but never guarantees the union stays
+ /// one connected graph -- that depends entirely on dependsOn edges
+ /// the inputs already had.
+ ///
+ /// Buckets whatever is found by Scope (Excluded, Optional,
+ /// Required, unspecified) and gives each non-empty bucket its own
+ /// synthetic "attachment" Component, wired into the dependency
+ /// graph as a child of the existing dependency-list entry named by
+ /// (or this document's own
+ /// subject, Metadata.Component.BomRef, if
+ /// is null or doesn't identify an
+ /// existing entry).
+ ///
+ ///
+ /// The bom-refs of newly-created attachment Components, each
+ /// mapped to the dangling bom-refs attached under it. Empty if
+ /// none were found, or if there was no subject to attach under.
+ ///
+ public Dictionary> AttachDanglingComponents(string attachmentRef = null)
+ {
+ var known = new Dictionary();
+ if (Metadata?.Component != null && !string.IsNullOrEmpty(Metadata.Component.BomRef))
+ {
+ known[Metadata.Component.BomRef] = Metadata.Component;
+ }
+ if (Components != null)
+ {
+ foreach (var c in Components)
+ {
+ if (!string.IsNullOrEmpty(c.BomRef))
+ {
+ known[c.BomRef] = c;
+ }
+ }
+ }
+
+ var referenced = new HashSet();
+ void WalkReferenced(List deps, bool isTopLevel)
+ {
+ if (deps is null)
+ {
+ return;
+ }
+ foreach (var d in deps)
+ {
+ if (!isTopLevel && !string.IsNullOrEmpty(d.Ref))
+ {
+ referenced.Add(d.Ref);
+ }
+ WalkReferenced(d.Dependencies, false);
+ }
+ }
+ WalkReferenced(Dependencies, true);
+
+ var rootRef = Metadata?.Component?.BomRef;
+ var dangling = new Dictionary>();
+ foreach (var kvp in known)
+ {
+ if (kvp.Key == rootRef || referenced.Contains(kvp.Key))
+ {
+ continue;
+ }
+ var scopeLabel = kvp.Value.Scope?.ToString() ?? "Unspecified";
+ if (!dangling.TryGetValue(scopeLabel, out var list))
+ {
+ list = new List();
+ dangling[scopeLabel] = list;
+ }
+ list.Add(kvp.Key);
+ }
+
+ var result = new Dictionary>();
+ if (dangling.Count == 0)
+ {
+ return result;
+ }
+
+ if (Dependencies is null)
+ {
+ Dependencies = new List();
+ }
+
+ var attachmentEntry = string.IsNullOrEmpty(attachmentRef)
+ ? null
+ : Dependencies.FirstOrDefault(d => d.Ref == attachmentRef);
+ if (attachmentEntry is null)
+ {
+ attachmentEntry = string.IsNullOrEmpty(rootRef)
+ ? null
+ : Dependencies.FirstOrDefault(d => d.Ref == rootRef);
+ if (attachmentEntry is null && !string.IsNullOrEmpty(rootRef))
+ {
+ attachmentEntry = new Dependency { Ref = rootRef };
+ Dependencies.Add(attachmentEntry);
+ }
+ }
+ if (attachmentEntry is null)
+ {
+ // No subject at all to attach under -- nothing safe to do.
+ return result;
+ }
+ attachmentEntry.Dependencies ??= new List();
+
+ if (Components is null)
+ {
+ Components = new List();
+ }
+
+ var usedRefs = new HashSet(known.Keys);
+ foreach (var kvp in dangling)
+ {
+ var scopeLabel = kvp.Key;
+ var baseRef = $"unreferenced-components:scope={scopeLabel}";
+ var newRef = baseRef;
+ var suffix = 2;
+ while (usedRefs.Contains(newRef))
+ {
+ newRef = $"{baseRef}:{suffix}";
+ suffix++;
+ }
+ usedRefs.Add(newRef);
+
+ Components.Add(new Component
+ {
+ BomRef = newRef,
+ Type = Component.Classification.Application,
+ Name = $"unreferenced-components (scope={scopeLabel})",
+ });
+ Dependencies.Add(new Dependency
+ {
+ Ref = newRef,
+ Dependencies = kvp.Value.Select(r => new Dependency { Ref = r }).ToList(),
+ });
+ attachmentEntry.Dependencies.Add(new Dependency { Ref = newRef });
+
+ result[newRef] = kvp.Value;
+ }
+
+ return result;
+ }
+#endif
}
}
\ No newline at end of file
diff --git a/src/CycloneDX.Core/Models/Component.cs b/src/CycloneDX.Core/Models/Component.cs
index 0a23ad65..12bd69e7 100644
--- a/src/CycloneDX.Core/Models/Component.cs
+++ b/src/CycloneDX.Core/Models/Component.cs
@@ -33,6 +33,9 @@ namespace CycloneDX.Models
[XmlType("component")]
[ProtoContract]
public class Component: IEquatable, IHasBomRef
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[ProtoContract]
public enum Classification
@@ -328,5 +331,180 @@ public override int GetHashCode()
{
return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode();
}
+
+#if NET8_0_OR_GREATER
+ ///
+ /// Cheap pre-check for "plausibly the same real-world component,
+ /// worth attempting MergeWith on" -- not full equality. By spec,
+ /// "type" and "name" are the two required identifying properties;
+ /// "version"/"group"/"purl" are treated as equal-if-both-present
+ /// (so two components with the identical type/name but only one
+ /// side specifying a version are still considered equivalent,
+ /// rather than assumed to be different versions of the same
+ /// thing). bom-ref is deliberately NOT part of this check -- two
+ /// components can describe the same real-world thing under
+ /// different bom-ref values in different source documents, and
+ /// reconciling bom-ref identity is the merge orchestration's job
+ /// (see BomRefWalker), not this per-field check's.
+ ///
+ public bool Equivalent(Component other, MergeStrategy strategy)
+ {
+ if (other is null)
+ {
+ return false;
+ }
+
+ return Type == other.Type
+ && !(Name is null) && !(other.Name is null) && Name == other.Name
+ && (Version is null || other.Version is null || Version == other.Version)
+ && (Group is null || other.Group is null || Group == other.Group)
+ && (Purl is null || other.Purl is null || Purl == other.Purl);
+ }
+
+ ///
+ /// Attempt to fold 's data into this
+ /// component. Scalar fields prefer this instance's own value and
+ /// fall back to 's only when unset; list
+ /// fields are merged via ; Scope
+ /// is reconciled via , which is the one
+ /// place this can still refuse to merge (an Excluded/Required
+ /// clash is a genuine conflict, not something to silently paper
+ /// over) -- see .
+ ///
+ public bool MergeWith(Component other, MergeStrategy strategy)
+ {
+ if (other is null)
+ {
+ return false;
+ }
+ if (Equals(other))
+ {
+ return true;
+ }
+ if (!Equivalent(other, strategy))
+ {
+ return false;
+ }
+
+ if (strategy.ComponentConflictResolution == ComponentConflictResolution.KeepSeparate)
+ {
+ return false;
+ }
+
+ if (!TryMergeScope(Scope, other.Scope, strategy.ComponentConflictResolution, out var mergedScope))
+ {
+ // Scope reconciliation refused (e.g. Excluded vs. Required) --
+ // these are different enough real-world things to keep separate.
+ return false;
+ }
+
+ Scope = mergedScope;
+ MimeType ??= other.MimeType;
+ Supplier = MergeableListHelper.MergeSingle(Supplier, other.Supplier);
+ Manufacturer = MergeableListHelper.MergeSingle(Manufacturer, other.Manufacturer);
+ Authors = MergeableListHelper.Merge(Authors, other.Authors, strategy);
+#pragma warning disable 618
+ Author ??= other.Author;
+#pragma warning restore 618
+ Publisher ??= other.Publisher;
+ VersionRange ??= other.VersionRange;
+ Description ??= other.Description;
+ Hashes = MergeableListHelper.Merge(Hashes, other.Hashes, strategy);
+ Licenses = MergeableListHelper.Merge(Licenses, other.Licenses, strategy);
+ Copyright ??= other.Copyright;
+ PatentAssertions = MergeableListHelper.Merge(PatentAssertions, other.PatentAssertions, strategy);
+ Cpe ??= other.Cpe;
+ Purl ??= other.Purl;
+ OmniborId = MergeableListHelper.MergeStringList(OmniborId, other.OmniborId);
+ Swhid = MergeableListHelper.MergeStringList(Swhid, other.Swhid);
+ Swid = MergeableListHelper.MergeSingle(Swid, other.Swid);
+ Modified = MergeableListHelper.MergeNullableBoolOr(Modified, other.Modified);
+ Pedigree = MergeableListHelper.MergeSingle(Pedigree, other.Pedigree);
+ ExternalReferences = MergeableListHelper.Merge(ExternalReferences, other.ExternalReferences, strategy);
+ Properties = MergeableListHelper.Merge(Properties, other.Properties, strategy);
+ Components = MergeableListHelper.Merge(Components, other.Components, strategy);
+ Evidence = MergeableListHelper.MergeSingle(Evidence, other.Evidence);
+ ReleaseNotes = MergeableListHelper.MergeSingle(ReleaseNotes, other.ReleaseNotes);
+ ModelCard = MergeableListHelper.MergeSingle(ModelCard, other.ModelCard);
+ Data = MergeableListHelper.MergeSingle(Data, other.Data);
+ CryptoProperties = MergeableListHelper.MergeSingle(CryptoProperties, other.CryptoProperties);
+ IsExternal = MergeableListHelper.MergeNullableBoolOr(IsExternal, other.IsExternal);
+ Tags = MergeableListHelper.MergeStringList(Tags, other.Tags);
+ XmlSignature = MergeableListHelper.MergeSingle(XmlSignature, other.XmlSignature);
+ Signature = MergeableListHelper.MergeSingle(Signature, other.Signature);
+
+ return true;
+ }
+
+ ///
+ /// Reconcile two Scope values for components that are otherwise
+ /// being merged into one (see MergeStrategy.ComponentConflictResolution's
+ /// XML docs for the domain rules). "Both sides already equal" is
+ /// handled uniformly first, including the both-Excluded case: two
+ /// components that are both Excluded-scope but differ in some
+ /// unrelated field should still merge, not be treated as a scope
+ /// conflict just because neither side is Optional.
+ ///
+ ///
+ /// false if the two scopes genuinely conflict (Excluded vs.
+ /// Required/unset) and the caller should not merge these two
+ /// components at all.
+ ///
+ public static bool TryMergeScope(ComponentScope? a, ComponentScope? b, ComponentConflictResolution resolution, out ComponentScope? merged)
+ {
+ if (a == b)
+ {
+ merged = a;
+ return true;
+ }
+
+ if (resolution == ComponentConflictResolution.Squash_RenameByScope)
+ {
+ // Scope partitioning under this strategy is handled by a
+ // dedicated pre-pass (CycloneDXUtils' RenameByScope pass)
+ // that splits differently-scoped components into separate,
+ // suffixed bom-refs *before* the generic per-field merge
+ // ever runs -- so by the time two Components reach here with
+ // different Scope values, they should not be merged at all;
+ // treat it as a refusal rather than silently squashing.
+ merged = null;
+ return false;
+ }
+
+ bool aExcluded = a == ComponentScope.Excluded;
+ bool bExcluded = b == ComponentScope.Excluded;
+
+ if (!aExcluded && !bExcluded)
+ {
+ // Neither side excludes the component. Per the spec, an absent
+ // (null/unset) Scope SHOULD be treated as required -- so unless
+ // both sides agree on "optional", the safe reading is whichever
+ // is more inclusive. Squash_UpgradeScope (the default) always
+ // resolves to Required; Squash_DowngradeScope keeps the
+ // narrower "optional" reading when either side actually said so.
+ merged = resolution == ComponentConflictResolution.Squash_UpgradeScope
+ ? ComponentScope.Required
+ : (a == ComponentScope.Optional || b == ComponentScope.Optional)
+ ? ComponentScope.Optional
+ : (ComponentScope?)null;
+ return true;
+ }
+
+ // Exactly one side is Excluded (a == b above already handled both-Excluded).
+ var other = aExcluded ? b : a;
+ if (other == ComponentScope.Optional || other is null)
+ {
+ // Excluded dominates over a merely-optional/unspecified reading.
+ merged = ComponentScope.Excluded;
+ return true;
+ }
+
+ // Excluded vs. Required is a genuine conflict: these describe
+ // different real-world usages of the same component and should
+ // not be silently squashed into one.
+ merged = null;
+ return false;
+ }
+#endif
}
}
\ No newline at end of file
diff --git a/src/CycloneDX.Core/Models/Composition.cs b/src/CycloneDX.Core/Models/Composition.cs
index fac023ec..304b4da6 100644
--- a/src/CycloneDX.Core/Models/Composition.cs
+++ b/src/CycloneDX.Core/Models/Composition.cs
@@ -27,6 +27,9 @@ namespace CycloneDX.Models
{
[ProtoContract]
public class Composition : IXmlSerializable, IEquatable
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[ProtoContract]
public enum AggregateType
diff --git a/src/CycloneDX.Core/Models/Declarations/Assessor.cs b/src/CycloneDX.Core/Models/Declarations/Assessor.cs
index 00f9e095..a1292426 100644
--- a/src/CycloneDX.Core/Models/Declarations/Assessor.cs
+++ b/src/CycloneDX.Core/Models/Declarations/Assessor.cs
@@ -27,6 +27,9 @@ namespace CycloneDX.Models
{
[ProtoContract]
public class Assessor : IEquatable, IHasBomRef
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlAttribute("bom-ref")]
[JsonPropertyName("bom-ref")]
diff --git a/src/CycloneDX.Core/Models/Declarations/Attestation.cs b/src/CycloneDX.Core/Models/Declarations/Attestation.cs
index d36eb48d..56b9f34e 100644
--- a/src/CycloneDX.Core/Models/Declarations/Attestation.cs
+++ b/src/CycloneDX.Core/Models/Declarations/Attestation.cs
@@ -27,6 +27,9 @@ namespace CycloneDX.Models
{
[ProtoContract]
public class Attestation : IEquatable
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlElement("summary")]
[ProtoMember(1)]
diff --git a/src/CycloneDX.Core/Models/Declarations/Claim.cs b/src/CycloneDX.Core/Models/Declarations/Claim.cs
index 8ec91fe2..f4aa307a 100644
--- a/src/CycloneDX.Core/Models/Declarations/Claim.cs
+++ b/src/CycloneDX.Core/Models/Declarations/Claim.cs
@@ -29,6 +29,9 @@ namespace CycloneDX.Models
{
[ProtoContract]
public class Claim : IEquatable, IHasBomRef
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlAttribute("bom-ref")]
[JsonPropertyName("bom-ref")]
diff --git a/src/CycloneDX.Core/Models/Definitions/Standard.cs b/src/CycloneDX.Core/Models/Definitions/Standard.cs
index e5fdcd54..22e098a4 100644
--- a/src/CycloneDX.Core/Models/Definitions/Standard.cs
+++ b/src/CycloneDX.Core/Models/Definitions/Standard.cs
@@ -26,6 +26,9 @@ namespace CycloneDX.Models
{
[ProtoContract]
public class Standard : IEquatable, IHasBomRef
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlAttribute("bom-ref")]
[JsonPropertyName("bom-ref")]
diff --git a/src/CycloneDX.Core/Models/Dependency.cs b/src/CycloneDX.Core/Models/Dependency.cs
index a11a9940..16eab172 100644
--- a/src/CycloneDX.Core/Models/Dependency.cs
+++ b/src/CycloneDX.Core/Models/Dependency.cs
@@ -30,6 +30,9 @@ namespace CycloneDX.Models
[XmlType("dependency")]
[ProtoContract]
public class Dependency : IEquatable
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlAttribute("ref")]
[ProtoMember(1)]
@@ -87,6 +90,60 @@ public override int GetHashCode()
{
return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode();
}
+
+#if NET8_0_OR_GREATER
+ /// Two Dependency entries describe the same graph node if they share a Ref.
+ public bool Equivalent(Dependency other, MergeStrategy strategy)
+ {
+ return other != null && !string.IsNullOrEmpty(Ref) && Ref == other.Ref;
+ }
+
+ ///
+ /// Without this override, Dependency only merges via the
+ /// IMergeable<T> default (exact equality) -- so two BOMs
+ /// describing the same component with different direct-dependency
+ /// lists (e.g. a module built standalone vs. as part of a larger
+ /// build) would each contribute their own `<dependency ref="X">`
+ /// entry, leaving the merged document with two different entries
+ /// for the same Ref instead of one combined entry. This unions the
+ /// two Dependencies (sub-)lists instead, gated by
+ /// : when that's
+ /// false, a genuine difference in dependsOn contents is treated as
+ /// a conflict (refuse, keep both entries) rather than silently
+ /// combined.
+ ///
+ public bool MergeWith(Dependency other, MergeStrategy strategy)
+ {
+ if (other is null)
+ {
+ return false;
+ }
+ if (Equals(other))
+ {
+ return true;
+ }
+ if (!Equivalent(other, strategy))
+ {
+ return false;
+ }
+
+ if (!strategy.MergeSubsetDependencies && !DependsOnSetsEqual(Dependencies, other.Dependencies))
+ {
+ return false;
+ }
+
+ Dependencies = MergeableListHelper.Merge(Dependencies, other.Dependencies, strategy);
+ Provides = MergeableListHelper.MergeSingle(Provides, other.Provides);
+ return true;
+ }
+
+ private static bool DependsOnSetsEqual(List a, List b)
+ {
+ var aRefs = a?.Select(d => d.Ref).ToHashSet() ?? new HashSet();
+ var bRefs = b?.Select(d => d.Ref).ToHashSet() ?? new HashSet();
+ return aRefs.SetEquals(bRefs);
+ }
+#endif
}
[ProtoContract]
diff --git a/src/CycloneDX.Core/Models/ExternalReference.cs b/src/CycloneDX.Core/Models/ExternalReference.cs
index a83a2239..3a0d72dc 100644
--- a/src/CycloneDX.Core/Models/ExternalReference.cs
+++ b/src/CycloneDX.Core/Models/ExternalReference.cs
@@ -29,6 +29,9 @@ namespace CycloneDX.Models
[SuppressMessage("Microsoft.Naming", "CA1707:IdentifiersShouldNotContainUnderscores")]
[ProtoContract]
public class ExternalReference : IEquatable
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[ProtoContract]
public enum ExternalReferenceType
diff --git a/src/CycloneDX.Core/Models/Hash.cs b/src/CycloneDX.Core/Models/Hash.cs
index ca7b0754..08987fa0 100644
--- a/src/CycloneDX.Core/Models/Hash.cs
+++ b/src/CycloneDX.Core/Models/Hash.cs
@@ -15,6 +15,8 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) OWASP Foundation. All Rights Reserved.
+using System;
+using System.Text.Json;
using System.Xml.Serialization;
using ProtoBuf;
@@ -22,7 +24,10 @@ namespace CycloneDX.Models
{
[XmlType("hash")]
[ProtoContract]
- public class Hash
+ public class Hash : IEquatable
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[ProtoContract]
public enum HashAlgorithm
@@ -66,5 +71,64 @@ public enum HashAlgorithm
[XmlText]
[ProtoMember(2)]
public string Content { get; set; }
+
+ public override bool Equals(object obj)
+ {
+ var other = obj as Hash;
+ if (other == null)
+ {
+ return false;
+ }
+
+ return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(other, Json.Serializer.SerializerOptionsForHash);
+ }
+
+ public bool Equals(Hash obj)
+ {
+ return obj != null && JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(obj, Json.Serializer.SerializerOptionsForHash);
+ }
+
+ public override int GetHashCode()
+ {
+ return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode();
+ }
+
+#if NET8_0_OR_GREATER
+ public bool Equivalent(Hash obj, MergeStrategy strategy)
+ {
+ return obj != null && Alg == obj.Alg;
+ }
+
+ ///
+ /// Two hashes of the same algorithm should carry the same content;
+ /// if one side is missing content (e.g. partially populated by a
+ /// producer), fill it in from the other. A genuine content mismatch
+ /// for the same algorithm is a real conflict, not something this
+ /// merge silently resolves.
+ ///
+ public bool MergeWith(Hash obj, MergeStrategy strategy)
+ {
+ if (obj is null)
+ {
+ return false;
+ }
+ if (Equals(obj))
+ {
+ return true;
+ }
+ if (!Equivalent(obj, strategy))
+ {
+ return false;
+ }
+
+ if (Content is null && !(obj.Content is null))
+ {
+ Content = obj.Content;
+ return true;
+ }
+
+ return Content == obj.Content;
+ }
+#endif
}
}
\ No newline at end of file
diff --git a/src/CycloneDX.Core/Models/Interfaces/IBomEntity.cs b/src/CycloneDX.Core/Models/Interfaces/IBomEntity.cs
new file mode 100644
index 00000000..aaad85b9
--- /dev/null
+++ b/src/CycloneDX.Core/Models/Interfaces/IBomEntity.cs
@@ -0,0 +1,100 @@
+// This file is part of CycloneDX Library for .NET
+//
+// Licensed under the Apache License, Version 2.0 (the “License”);
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an “AS IS” BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) OWASP Foundation. All Rights Reserved.
+
+// Default interface methods require the target to support .NET Standard 2.1 /
+// .NET Core 3.0 or later ABI features. This library also targets netstandard2.0
+// (for older .NET Framework consumers), which cannot support default bodies here,
+// so the whole capability is only compiled in for the modern targets. Consumers
+// on netstandard2.0 keep today's behavior (plain FlatMerge/HierarchicalMerge,
+// no per-entity merge strategy) unchanged.
+#if NET8_0_OR_GREATER
+using System;
+
+namespace CycloneDX.Models
+{
+ ///
+ /// Marker interface for model types that participate in generic,
+ /// strategy-driven BOM-tree operations (equivalence checks, merging).
+ /// Deliberately empty: it exists only so and
+ /// share a common, checkable root, without
+ /// requiring a shared base class.
+ ///
+ public interface IBomEntity
+ {
+ }
+
+ ///
+ /// Declares that two instances of can attempt to
+ /// combine into one during a BOM merge.
+ ///
+ ///
+ /// The default implementation is intentionally trivial: it merges only if
+ /// the two instances are already exactly equal per the type's own
+ /// implementation (the same equality every
+ /// model class in this library already provides). Most model types need
+ /// nothing more than this default and simply declare
+ /// : IMergeable<Foo> with no method body. Types with real
+ /// field-by-field reconciliation rules (for example Component,
+ /// where two BOM documents may describe the same component with
+ /// different scopes, hashes, or external references) override this
+ /// method with actual logic instead of relying on the default.
+ ///
+ /// This is the C# idiom this design deliberately favors over a shared
+ /// base class with reflection-driven dispatch: every implementing type
+ /// gets a uniform, compiler-checked contract for free, and only pays for
+ /// custom behavior where it actually has some.
+ ///
+ public interface IMergeable : IBomEntity
+ {
+ ///
+ /// Attempt to merge into this.
+ ///
+ /// The other instance to merge in.
+ /// Merge configuration/strategy toggles.
+ ///
+ /// true if this now represents the union of both
+ /// instances and can be dropped from the
+ /// containing list; false if the two instances could not be
+ /// merged (they should be kept as separate list entries).
+ ///
+ bool MergeWith(T other, MergeStrategy strategy) =>
+ this is IEquatable equatable && other != null && equatable.Equals(other);
+ }
+
+ ///
+ /// Declares that two instances of can be
+ /// evaluated for "close enough to be worth attempting a merge" -- a
+ /// weaker, type-specific relation than exact equality.
+ ///
+ ///
+ /// The default implementation defers to exact
+ /// equality; see for why this is the
+ /// deliberate default rather than an abstract requirement.
+ ///
+ public interface IEquivalent : IBomEntity
+ {
+ ///
+ /// Cheap pre-check: are these two instances plausibly the same
+ /// real-world entity (and thus worth attempting
+ /// on), even if not exactly
+ /// equal?
+ ///
+ bool Equivalent(T other, MergeStrategy strategy) =>
+ this is IEquatable equatable && other != null && equatable.Equals(other);
+ }
+}
+#endif
diff --git a/src/CycloneDX.Core/Models/LicenseChoice.cs b/src/CycloneDX.Core/Models/LicenseChoice.cs
index 31518f25..7a04f8be 100644
--- a/src/CycloneDX.Core/Models/LicenseChoice.cs
+++ b/src/CycloneDX.Core/Models/LicenseChoice.cs
@@ -18,6 +18,7 @@
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
+using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml.Serialization;
using CycloneDX.Xml;
@@ -28,7 +29,10 @@ namespace CycloneDX.Models
[ProtoContract]
- public class LicenseChoice
+ public class LicenseChoice : IEquatable
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlElement("license")]
[ProtoMember(1)]
@@ -66,6 +70,26 @@ public class LicenseChoice
public List Properties { get; set; }
public bool ShouldSerializeProperties() { return Properties?.Count > 0; }
+ public override bool Equals(object obj)
+ {
+ var other = obj as LicenseChoice;
+ if (other == null)
+ {
+ return false;
+ }
+
+ return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(other, Json.Serializer.SerializerOptionsForHash);
+ }
+
+ public bool Equals(LicenseChoice obj)
+ {
+ return obj != null && JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(obj, Json.Serializer.SerializerOptionsForHash);
+ }
+
+ public override int GetHashCode()
+ {
+ return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode();
+ }
}
// This is a workaround to serialize licenses correctly
diff --git a/src/CycloneDX.Core/Models/MergeStrategy.cs b/src/CycloneDX.Core/Models/MergeStrategy.cs
new file mode 100644
index 00000000..8ed2fffa
--- /dev/null
+++ b/src/CycloneDX.Core/Models/MergeStrategy.cs
@@ -0,0 +1,149 @@
+// This file is part of CycloneDX Library for .NET
+//
+// Licensed under the Apache License, Version 2.0 (the “License”);
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an “AS IS” BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) OWASP Foundation. All Rights Reserved.
+
+#if NET8_0_OR_GREATER
+namespace CycloneDX.Models
+{
+ ///
+ /// Selectable algorithm for resolving two Equivalent-but-not-equal
+ /// Components during a merge. Kept as its own enum (rather than
+ /// boolean toggles) specifically so new algorithms can be added as new
+ /// cases without reshaping or any of the
+ /// merge call sites that read it.
+ ///
+ public enum ComponentConflictResolution
+ {
+ /// Leave both components as separate, unrelated entries.
+ KeepSeparate,
+
+ ///
+ /// Merge reconcilable fields into one entry. If Scope differs,
+ /// the more permissive value wins (e.g. "required" beats "optional")
+ /// -- the spec's own guidance is that an absent/ambiguous Scope
+ /// SHOULD be treated as required, so this errs toward not
+ /// under-reporting a dependency that's required somewhere. This is
+ /// the default: it can produce more false-positive "required"
+ /// warnings downstream than , but
+ /// won't silently downgrade a genuinely required dependency to
+ /// merely optional.
+ ///
+ Squash_UpgradeScope,
+
+ ///
+ /// Same as , but when Scope
+ /// differs, prefer keeping it unset/optional over widening it.
+ ///
+ Squash_DowngradeScope,
+
+ ///
+ /// When two equivalent components differ only by Scope, don't
+ /// squash or discard either -- keep them as distinct entries by
+ /// suffixing one's (or both's) bom-ref with
+ /// :scope=<value> and rewriting back-references
+ /// accordingly, so e.g. "required for production" and "excluded for
+ /// tests" both survive the merge intact. Non-Scope fields still
+ /// squash normally within each scope partition. The original,
+ /// unsuffixed bom-ref is kept for whichever scope was seen first;
+ /// suffixes only appear once an actual conflict shows up, so a
+ /// document where every source agrees on Scope is unaffected.
+ ///
+ Squash_RenameByScope,
+ }
+
+ ///
+ /// Configuration steering how CycloneDXUtils.FlatMerge/
+ /// HierarchicalMerge reconcile two or more BOM documents.
+ ///
+ public class MergeStrategy
+ {
+ ///
+ /// When true, list merging considers calling each item's
+ /// to reconcile
+ /// equivalent-but-not-equal entries; when false, list merging
+ /// only deduplicates exactly-equal entries (fast, but may leave
+ /// near-duplicate entries that a smarter merge could have combined).
+ ///
+ public bool UseEntityMerge { get; set; }
+
+ ///
+ /// When merging BOM documents that include Equivalent
+ /// components with differing values of Scope (required/null
+ /// vs. optional vs. excluded), rename the conflicting components'
+ /// bom-ref (and their back-references) instead of silently
+ /// conflating them under one identity.
+ ///
+ public bool RenameConflictingComponents { get; set; }
+
+ ///
+ /// Selects the algorithm used when two Equivalent components
+ /// are not fully equal. See .
+ ///
+ public ComponentConflictResolution ComponentConflictResolution { get; set; }
+
+ ///
+ /// CycloneDX spec says dependency graphs "MUST" declare components
+ /// with no dependencies as empty elements, which in practice does
+ /// not always hold across BOM documents describing overlapping
+ /// codebases (e.g. a Maven module built standalone vs. as part of a
+ /// parent build). When true, differing (non-conflicting,
+ /// subset-of-each-other) direct-dependency lists for the same
+ /// component are merged (grown) rather than treated as a conflict.
+ ///
+ public bool MergeSubsetDependencies { get; set; }
+
+ ///
+ /// Treat back-references from Dependencies as an internal
+ /// detail owned by the referenced component/service (as the spec
+ /// implies), rather than an independent top-level concern. See also
+ /// https://github.com/CycloneDX/specification/discussions/320
+ ///
+ public bool TreatDependencyAsExtraProperty { get; set; }
+
+ ///
+ /// Refresh the merged BOM's own metadata (serial number, timestamp,
+ /// tool reference) after merging.
+ ///
+ public bool DoBomMetadataUpdate { get; set; }
+
+ /// See .
+ public bool DoBomMetadataUpdateNewSerialNumber { get; set; }
+
+ /// See .
+ public bool DoBomMetadataUpdateReferThisToolkit { get; set; }
+
+ ///
+ /// Reasonable default strategy settings, matching the behavior this
+ /// port aims to reproduce. Callers can tune the returned instance
+ /// further.
+ ///
+ public static MergeStrategy Default()
+ {
+ return new MergeStrategy
+ {
+ UseEntityMerge = true,
+ RenameConflictingComponents = true,
+ MergeSubsetDependencies = true,
+ TreatDependencyAsExtraProperty = true,
+ ComponentConflictResolution = ComponentConflictResolution.Squash_UpgradeScope,
+ DoBomMetadataUpdate = false,
+ DoBomMetadataUpdateNewSerialNumber = false,
+ DoBomMetadataUpdateReferThisToolkit = false,
+ };
+ }
+ }
+}
+#endif
diff --git a/src/CycloneDX.Core/Models/OrganizationalContact.cs b/src/CycloneDX.Core/Models/OrganizationalContact.cs
index de677b62..809dd0a7 100644
--- a/src/CycloneDX.Core/Models/OrganizationalContact.cs
+++ b/src/CycloneDX.Core/Models/OrganizationalContact.cs
@@ -15,6 +15,8 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) OWASP Foundation. All Rights Reserved.
+using System;
+using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml.Serialization;
using ProtoBuf;
@@ -22,7 +24,10 @@
namespace CycloneDX.Models
{
[ProtoContract]
- public class OrganizationalContact
+ public class OrganizationalContact : IEquatable, IHasBomRef
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlElement("name")]
[ProtoMember(1)]
@@ -40,5 +45,26 @@ public class OrganizationalContact
[XmlAttribute("bom-ref")]
[ProtoMember(4)]
public string BomRef { get; set; }
+
+ public override bool Equals(object obj)
+ {
+ var other = obj as OrganizationalContact;
+ if (other == null)
+ {
+ return false;
+ }
+
+ return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(other, Json.Serializer.SerializerOptionsForHash);
+ }
+
+ public bool Equals(OrganizationalContact obj)
+ {
+ return obj != null && JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(obj, Json.Serializer.SerializerOptionsForHash);
+ }
+
+ public override int GetHashCode()
+ {
+ return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode();
+ }
}
}
diff --git a/src/CycloneDX.Core/Models/OrganizationalEntity.cs b/src/CycloneDX.Core/Models/OrganizationalEntity.cs
index 4e366a04..fa419f45 100644
--- a/src/CycloneDX.Core/Models/OrganizationalEntity.cs
+++ b/src/CycloneDX.Core/Models/OrganizationalEntity.cs
@@ -26,6 +26,9 @@ namespace CycloneDX.Models
{
[ProtoContract]
public class OrganizationalEntity : IEquatable, IHasBomRef
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlElement("name")]
[ProtoMember(1)]
diff --git a/src/CycloneDX.Core/Models/PatentAssertion.cs b/src/CycloneDX.Core/Models/PatentAssertion.cs
index 587f7d0a..72d1e195 100644
--- a/src/CycloneDX.Core/Models/PatentAssertion.cs
+++ b/src/CycloneDX.Core/Models/PatentAssertion.cs
@@ -15,7 +15,9 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) OWASP Foundation. All Rights Reserved.
+using System;
using System.Collections.Generic;
+using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml.Serialization;
using ProtoBuf;
@@ -23,7 +25,10 @@
namespace CycloneDX.Models
{
[ProtoContract]
- public class PatentAssertion
+ public class PatentAssertion : IEquatable
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[JsonPropertyName("bom-ref")]
[XmlAttribute("bom-ref")]
@@ -47,5 +52,26 @@ public class PatentAssertion
[XmlElement("notes")]
[ProtoMember(5)]
public string Notes { get; set; }
+
+ public override bool Equals(object obj)
+ {
+ var other = obj as PatentAssertion;
+ if (other == null)
+ {
+ return false;
+ }
+
+ return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(other, Json.Serializer.SerializerOptionsForHash);
+ }
+
+ public bool Equals(PatentAssertion obj)
+ {
+ return obj != null && JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(obj, Json.Serializer.SerializerOptionsForHash);
+ }
+
+ public override int GetHashCode()
+ {
+ return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode();
+ }
}
}
diff --git a/src/CycloneDX.Core/Models/Property.cs b/src/CycloneDX.Core/Models/Property.cs
index a61b45f2..67899ae1 100644
--- a/src/CycloneDX.Core/Models/Property.cs
+++ b/src/CycloneDX.Core/Models/Property.cs
@@ -15,14 +15,19 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) OWASP Foundation. All Rights Reserved.
+using System;
using System.Collections.Generic;
+using System.Text.Json;
using System.Xml.Serialization;
using ProtoBuf;
namespace CycloneDX.Models
{
[ProtoContract]
- public class Property
+ public class Property : IEquatable
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlAttribute("name")]
[ProtoMember(1)]
@@ -31,5 +36,26 @@ public class Property
[XmlText]
[ProtoMember(2)]
public string Value { get; set; }
+
+ public override bool Equals(object obj)
+ {
+ var other = obj as Property;
+ if (other == null)
+ {
+ return false;
+ }
+
+ return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(other, Json.Serializer.SerializerOptionsForHash);
+ }
+
+ public bool Equals(Property obj)
+ {
+ return obj != null && JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(obj, Json.Serializer.SerializerOptionsForHash);
+ }
+
+ public override int GetHashCode()
+ {
+ return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode();
+ }
}
}
diff --git a/src/CycloneDX.Core/Models/Service.cs b/src/CycloneDX.Core/Models/Service.cs
index 53a12690..d7596ac6 100644
--- a/src/CycloneDX.Core/Models/Service.cs
+++ b/src/CycloneDX.Core/Models/Service.cs
@@ -28,6 +28,9 @@ namespace CycloneDX.Models
{
[ProtoContract]
public class Service: IEquatable, IHasBomRef
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
public Service()
{
diff --git a/src/CycloneDX.Core/Models/Tool.cs b/src/CycloneDX.Core/Models/Tool.cs
index a82b7d0b..10cae0ba 100644
--- a/src/CycloneDX.Core/Models/Tool.cs
+++ b/src/CycloneDX.Core/Models/Tool.cs
@@ -26,6 +26,9 @@ namespace CycloneDX.Models
[Obsolete("Tool is deprecated and will be removed in a future version")]
[ProtoContract]
public class Tool: IEquatable
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlElement("vendor")]
[ProtoMember(1)]
diff --git a/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs b/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs
index 8d160609..d8041728 100644
--- a/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs
+++ b/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs
@@ -26,6 +26,9 @@ namespace CycloneDX.Models.Vulnerabilities
{
[ProtoContract]
public class Vulnerability: IEquatable
+#if NET8_0_OR_GREATER
+ , IMergeable, IEquivalent
+#endif
{
[XmlAttribute("bom-ref")]
[JsonPropertyName("bom-ref")]
diff --git a/src/CycloneDX.Utils/Merge.cs b/src/CycloneDX.Utils/Merge.cs
index 31f4d51a..052f00cc 100644
--- a/src/CycloneDX.Utils/Merge.cs
+++ b/src/CycloneDX.Utils/Merge.cs
@@ -181,6 +181,284 @@ public static Bom FlatMerge(Bom bom1, Bom bom2)
return result;
}
+#if NET8_0_OR_GREATER
+ ///
+ /// Flat-merges two BOMs using a : unlike
+ /// the plain overload (which only
+ /// dedupes exactly-equal list entries), this attempts to reconcile
+ /// equivalent-but-not-equal entries (see )
+ /// -- most notably Components differing only by Scope -- instead of
+ /// keeping every near-duplicate as a separate entry.
+ ///
+ public static Bom FlatMerge(Bom bom1, Bom bom2, MergeStrategy strategy)
+ {
+ strategy ??= MergeStrategy.Default();
+
+ if (strategy.RenameConflictingComponents)
+ {
+ RenameBomRefCollisions(bom1, bom2, strategy);
+ }
+
+ if (strategy.ComponentConflictResolution == ComponentConflictResolution.Squash_RenameByScope)
+ {
+ ApplyRenameByScope(bom1, bom2, strategy);
+ }
+
+ var result = new Bom();
+
+#pragma warning disable 618
+ var tools = MergeableListHelper.Merge(bom1.Metadata?.Tools?.Tools, bom2.Metadata?.Tools?.Tools, strategy);
+#pragma warning restore 618
+ var toolsComponents = MergeableListHelper.Merge(bom1.Metadata?.Tools?.Components, bom2.Metadata?.Tools?.Components, strategy);
+ var toolsServices = MergeableListHelper.Merge(bom1.Metadata?.Tools?.Services, bom2.Metadata?.Tools?.Services, strategy);
+ if (tools != null || toolsComponents != null || toolsServices != null)
+ {
+ result.Metadata = new Metadata
+ {
+ Tools = new ToolChoices
+ {
+ Tools = tools,
+ Components = toolsComponents,
+ Services = toolsServices,
+ }
+ };
+ }
+
+ result.Components = MergeableListHelper.Merge(bom1.Components, bom2.Components, strategy);
+
+ if (!(bom2.Metadata?.Component is null))
+ {
+ // bom2's own subject often also appears as some other source
+ // bom's (thin, dependency-only) entry for the same real-world
+ // package -- an exact-equality Contains() check (as the
+ // non-strategy overload above uses) would miss that and add
+ // a second, duplicate-bom-ref entry instead of folding into
+ // the existing one. Reconcile via Equivalent()/MergeWith()
+ // the same way MergeableListHelper.Merge already does for
+ // list-to-list entries.
+ var ownComponent = bom2.Metadata.Component;
+ if (result.Components is null)
+ {
+ result.Components = new List();
+ }
+ var existing = result.Components.FirstOrDefault(c =>
+ c.Equals(ownComponent) || c.Equivalent(ownComponent, strategy));
+ if (existing != null)
+ {
+ existing.MergeWith(ownComponent, strategy);
+ }
+ else
+ {
+ result.Components.Add(ownComponent);
+ }
+ }
+
+ result.Services = MergeableListHelper.Merge(bom1.Services, bom2.Services, strategy);
+ result.ExternalReferences = MergeableListHelper.Merge(bom1.ExternalReferences, bom2.ExternalReferences, strategy);
+ // Dependency.MergeWith reconciles beyond exact-match: two entries
+ // for the same Ref have their (sub-)dependency lists unioned,
+ // gated by strategy.MergeSubsetDependencies (see Dependency.cs).
+ result.Dependencies = MergeableListHelper.Merge(bom1.Dependencies, bom2.Dependencies, strategy);
+ result.Compositions = MergeableListHelper.Merge(bom1.Compositions, bom2.Compositions, strategy);
+ result.Vulnerabilities = MergeableListHelper.Merge(bom1.Vulnerabilities, bom2.Vulnerabilities, strategy);
+ result.Annotations = MergeableListHelper.Merge(bom1.Annotations, bom2.Annotations, strategy);
+
+ if (bom1.Definitions != null || bom2.Definitions != null)
+ {
+ result.Definitions = new Definitions
+ {
+ Standards = MergeableListHelper.Merge(bom1.Definitions?.Standards, bom2.Definitions?.Standards, strategy)
+ };
+ }
+
+ if (bom1.Declarations != null || bom2.Declarations != null)
+ {
+ result.Declarations = new Declarations
+ {
+ Assessors = MergeableListHelper.Merge(bom1.Declarations?.Assessors, bom2.Declarations?.Assessors, strategy),
+ Attestations = MergeableListHelper.Merge(bom1.Declarations?.Attestations, bom2.Declarations?.Attestations, strategy),
+ Claims = MergeableListHelper.Merge(bom1.Declarations?.Claims, bom2.Declarations?.Claims, strategy),
+ };
+
+ if (bom1.Declarations?.Targets != null || bom2.Declarations?.Targets != null)
+ {
+ result.Declarations.Targets = new Targets
+ {
+ Organizations = MergeableListHelper.Merge(bom1.Declarations?.Targets?.Organizations, bom2.Declarations?.Targets?.Organizations, strategy),
+ Components = MergeableListHelper.Merge(bom1.Declarations?.Targets?.Components, bom2.Declarations?.Targets?.Components, strategy),
+ Services = MergeableListHelper.Merge(bom1.Declarations?.Targets?.Services, bom2.Declarations?.Targets?.Services, strategy),
+ };
+ }
+ }
+
+ if (strategy.DoBomMetadataUpdate)
+ {
+ result.BomMetadataUpdate(strategy.DoBomMetadataUpdateNewSerialNumber);
+ if (strategy.DoBomMetadataUpdateReferThisToolkit)
+ {
+ result.BomMetadataReferThisToolkit();
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// If the same non-null bom-ref identifies two different (not
+ /// IEquatable-equal) components across and
+ /// , rename bom2's copy (and its
+ /// back-references, via ) before merging,
+ /// so the merged document never ends up with one bom-ref value
+ /// silently pointing at two unrelated components.
+ ///
+ private static void RenameBomRefCollisions(Bom bom1, Bom bom2, MergeStrategy strategy)
+ {
+ if (bom1?.Components is null || bom2?.Components is null)
+ {
+ return;
+ }
+
+ foreach (var c1 in bom1.Components)
+ {
+ if (string.IsNullOrEmpty(c1.BomRef))
+ {
+ continue;
+ }
+ foreach (var c2 in bom2.Components)
+ {
+ if (c2.BomRef != c1.BomRef || c1.Equals(c2))
+ {
+ continue;
+ }
+
+ if (strategy.ComponentConflictResolution == ComponentConflictResolution.Squash_RenameByScope
+ && c1.Equivalent(c2, strategy))
+ {
+ // Same real-world identity, differing only by
+ // (at least) Scope -- ApplyRenameByScope handles
+ // this case precisely (predictable :scope=...
+ // suffixes); don't let this blunter same-bomref
+ // check rename it first with a ":2" suffix.
+ continue;
+ }
+
+ // The generic Components merge (MergeableListHelper.Merge,
+ // via Equivalent()/MergeWith()) is bom-ref-blind: if it's
+ // going to fold c2 into c1 successfully anyway, renaming
+ // c2's bom-ref here first would only leave a dangling
+ // reference behind -- the rename propagates into c2's own
+ // dependency graph, but the merged Components list ends
+ // up with just c1's (unrenamed) bom-ref once MergeWith
+ // succeeds. Only rename when the merge would actually
+ // refuse and leave both entries in place, mirroring
+ // Component.MergeWith's own refusal conditions exactly
+ // (KeepSeparate always refuses; otherwise only a genuine
+ // Scope conflict does).
+ var mergeWouldSucceed = c1.Equivalent(c2, strategy)
+ && strategy.ComponentConflictResolution != ComponentConflictResolution.KeepSeparate
+ && Component.TryMergeScope(c1.Scope, c2.Scope, strategy.ComponentConflictResolution, out _);
+ if (mergeWouldSucceed)
+ {
+ continue;
+ }
+
+ var conflictingRef = c2.BomRef;
+ var renamedRef = conflictingRef + ":2";
+ BomRefWalker.RewriteRefs(bom2, r => r == conflictingRef ? renamedRef : r);
+ }
+ }
+ }
+
+ private const string ScopeSuffixMarker = ":scope=";
+
+ private static bool IsScopeSuffixed(string bomRef) =>
+ !string.IsNullOrEmpty(bomRef) && bomRef.Contains(ScopeSuffixMarker, StringComparison.Ordinal);
+
+ private static string BaseRefOf(string bomRef)
+ {
+ if (string.IsNullOrEmpty(bomRef))
+ {
+ return bomRef;
+ }
+ var idx = bomRef.IndexOf(ScopeSuffixMarker, StringComparison.Ordinal);
+ return idx < 0 ? bomRef : bomRef.Substring(0, idx);
+ }
+
+ private static string ScopeSuffixedRef(string baseRef, Component.ComponentScope? scope) =>
+ $"{baseRef}{ScopeSuffixMarker}{(scope.HasValue ? scope.Value.ToString() : "Unspecified")}";
+
+ ///
+ /// Pre-merge pass for MergeStrategy.ComponentConflictResolution ==
+ /// Squash_RenameByScope: when an incoming component is
+ /// Equivalent (identity match ignoring Scope) to one already
+ /// accumulated but differs in Scope, split them into distinct,
+ /// suffixed bom-refs instead of letting the generic merge either
+ /// squash the Scope away or leave two entries silently sharing one
+ /// bom-ref. Only touches bom-refs once an actual conflict appears:
+ /// if every source agrees on Scope for a given identity, no suffix
+ /// is ever added. Mutates bom1 (retroactively, for the first-ever
+ /// split of a given identity -- cascading to its already-recorded
+ /// back-references) and bom2 (so its own back-references follow
+ /// whatever bom-ref its components end up merging under) in place.
+ ///
+ private static void ApplyRenameByScope(Bom bom1, Bom bom2, MergeStrategy strategy)
+ {
+ if (bom1?.Components is null || bom2?.Components is null)
+ {
+ return;
+ }
+
+ foreach (var incoming in bom2.Components.ToList())
+ {
+ if (string.IsNullOrEmpty(incoming.BomRef))
+ {
+ continue;
+ }
+
+ var matches = bom1.Components.Where(e => e.Equivalent(incoming, strategy)).ToList();
+ if (matches.Count == 0)
+ {
+ // First time this identity has been seen -- nothing to
+ // rename (yet); it'll be added as-is by the generic merge.
+ continue;
+ }
+
+ var sameScope = matches.FirstOrDefault(e => e.Scope == incoming.Scope);
+ if (sameScope != null)
+ {
+ // Will squash into sameScope during the generic merge
+ // below; make sure bom2's own back-refs to `incoming`
+ // already point at the bom-ref it's about to be merged
+ // under (which may itself be suffixed from an earlier fold).
+ if (!string.IsNullOrEmpty(sameScope.BomRef) && sameScope.BomRef != incoming.BomRef)
+ {
+ var oldRef = incoming.BomRef;
+ var targetRef = sameScope.BomRef;
+ BomRefWalker.RewriteRefs(bom2, r => r == oldRef ? targetRef : r);
+ }
+ continue;
+ }
+
+ // incoming's Scope doesn't match any existing partition for
+ // this identity -- it's a new partition.
+ var baseRef = BaseRefOf(matches[0].BomRef);
+ if (matches.Count == 1 && !IsScopeSuffixed(matches[0].BomRef))
+ {
+ // First-ever split for this identity: retroactively
+ // suffix the already-accumulated entry too, cascading to
+ // its back-references already recorded in bom1.
+ var existing = matches[0];
+ var existingOldRef = existing.BomRef;
+ var existingNewRef = ScopeSuffixedRef(baseRef, existing.Scope);
+ BomRefWalker.RewriteRefs(bom1, r => r == existingOldRef ? existingNewRef : r);
+ }
+
+ var incomingOldRef = incoming.BomRef;
+ var incomingNewRef = ScopeSuffixedRef(baseRef, incoming.Scope);
+ BomRefWalker.RewriteRefs(bom2, r => r == incomingOldRef ? incomingNewRef : r);
+ }
+ }
+#endif
///
/// Performs a flat merge of multiple BOMs.
@@ -197,7 +475,7 @@ public static Bom FlatMerge(Bom bom1, Bom bom2)
///
public static Bom FlatMerge(IEnumerable boms)
{
- return FlatMerge(boms, null);
+ return FlatMerge(boms, (Component)null);
}
///
@@ -260,6 +538,196 @@ public static Bom FlatMerge(IEnumerable boms, Component bomSubject)
return result;
}
+#if NET8_0_OR_GREATER
+ /// Strategy-aware equivalent of .
+ public static Bom FlatMerge(IEnumerable boms, MergeStrategy strategy)
+ {
+ return FlatMerge(boms, null, strategy);
+ }
+
+ /// Strategy-aware equivalent of .
+ public static Bom FlatMerge(IEnumerable boms, Component bomSubject, MergeStrategy strategy)
+ {
+ strategy ??= MergeStrategy.Default();
+ var result = new Bom();
+
+ foreach (var bom in boms)
+ {
+ result = FlatMerge(result, bom, strategy);
+ }
+
+ if (bomSubject != null)
+ {
+ if (result.Metadata == null)
+ {
+ result.Metadata = new Metadata();
+ }
+ result.Metadata.Component = bomSubject;
+ result.Metadata.Component.BomRef = ComponentBomRefNamespace(result.Metadata.Component);
+
+ var mainDependency = new Dependency
+ {
+ Ref = result.Metadata.Component.BomRef,
+ Dependencies = new List()
+ };
+
+ foreach (var bom in boms)
+ {
+ if (!(bom.Metadata?.Component is null))
+ {
+ mainDependency.Dependencies.Add(new Dependency { Ref = bom.Metadata.Component.BomRef });
+ }
+ }
+
+ if (result.Dependencies == null)
+ {
+ result.Dependencies = new List();
+ }
+ result.Dependencies.Add(mainDependency);
+ }
+
+ result = CleanupMetadataComponent(result, strategy);
+ result = CleanupEmptyLists(result);
+
+ if (strategy.DoBomMetadataUpdate)
+ {
+ result.BomMetadataUpdate(strategy.DoBomMetadataUpdateNewSerialNumber);
+ if (strategy.DoBomMetadataUpdateReferThisToolkit)
+ {
+ result.BomMetadataReferThisToolkit();
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// If the document's Metadata.Component shares a bom-ref with an
+ /// entry already present in its top-level Components list, merge
+ /// that entry into Metadata.Component and remove it from
+ /// Components. A bom-ref must be unique within a document, and the
+ /// subject of a BOM is not also one of its own components.
+ ///
+ public static Bom CleanupMetadataComponent(Bom result, MergeStrategy strategy = null)
+ {
+ var subject = result?.Metadata?.Component;
+ if (subject is null || string.IsNullOrEmpty(subject.BomRef) || result.Components is null)
+ {
+ return result;
+ }
+
+ var duplicate = result.Components.Find(c => c != null && c.BomRef == subject.BomRef);
+ if (duplicate != null)
+ {
+ // Best-effort: fold in whatever fields duplicate carries that
+ // subject doesn't. If the two aren't otherwise Equivalent
+ // (e.g. a malformed document where the same bom-ref was
+ // reused for something else entirely) MergeWith simply
+ // declines and subject is left as-is -- either way, the
+ // duplicate bom-ref cannot remain in Components.
+ subject.MergeWith(duplicate, strategy ?? MergeStrategy.Default());
+ result.Components.Remove(duplicate);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Replaces empty top-level list properties with null, so a
+ /// serialized document doesn't carry e.g. an empty "components": []
+ /// for a section that ended up with nothing in it after merging.
+ ///
+ public static Bom CleanupEmptyLists(Bom result)
+ {
+ if (result is null)
+ {
+ return result;
+ }
+
+ if (result.Metadata?.Tools?.Tools?.Count == 0) result.Metadata.Tools.Tools = null;
+ if (result.Components?.Count == 0) result.Components = null;
+ if (result.Services?.Count == 0) result.Services = null;
+ if (result.ExternalReferences?.Count == 0) result.ExternalReferences = null;
+ if (result.Dependencies?.Count == 0) result.Dependencies = null;
+ if (result.Compositions?.Count == 0) result.Compositions = null;
+ if (result.Vulnerabilities?.Count == 0) result.Vulnerabilities = null;
+ if (result.Annotations?.Count == 0) result.Annotations = null;
+
+ return result;
+ }
+
+ ///
+ /// Recursively replaces empty (non-null, zero-count) list properties
+ /// anywhere in the BOM's object graph with null, so a serialized
+ /// document doesn't carry redundant entries like a Component's
+ /// "licenses": [], a Dependency's "dependsOn": [] / "provides": [],
+ /// or a Pedigree's "variants": [] -- not just the top-level lists
+ /// handled by .
+ ///
+ /// Only properties that are actually written to JSON are touched:
+ /// members decorated with [JsonIgnore] (e.g. the Protobuf-only
+ /// mirror properties such as Dependency.Provides_Protobuf) are
+ /// skipped, since nulling those has side effects on the property
+ /// they mirror and serves no purpose for JSON output.
+ ///
+ public static Bom CleanupEmptyListsDeep(Bom bom)
+ {
+ if (bom != null)
+ {
+ PruneEmptyLists(bom, new HashSet