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..49a0594f 100644
--- a/src/CycloneDX.Core/Models/Bom.cs
+++ b/src/CycloneDX.Core/Models/Bom.cs
@@ -205,5 +205,127 @@ 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.
+ ///
+ public bool RenameRef(string oldRef, string newRef)
+ {
+ if (string.IsNullOrEmpty(oldRef) || string.IsNullOrEmpty(newRef) || oldRef == newRef)
+ {
+ return false;
+ }
+
+ 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;
+ }
+#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..9f40dcfd 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,167 @@ 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.
+ ///
+ private static bool TryMergeScope(ComponentScope? a, ComponentScope? b, ComponentConflictResolution resolution, out ComponentScope? merged)
+ {
+ if (a == b)
+ {
+ merged = a;
+ return true;
+ }
+
+ 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. SquashUpgradeScope always resolves to
+ // Required; plain Squash keeps the narrower "optional" reading
+ // when either side actually said so.
+ merged = resolution == ComponentConflictResolution.SquashUpgradeScope
+ ? 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..29847827 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)]
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..784af2a6
--- /dev/null
+++ b/src/CycloneDX.Core/Models/MergeStrategy.cs
@@ -0,0 +1,141 @@
+// 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,
+ /// prefer keeping it unset/optional over silently widening it -- see
+ /// for
+ /// the alternative.
+ ///
+ Squash,
+
+ ///
+ /// Same as , but when Scope differs
+ /// between the two, the more permissive value wins (e.g. "required"
+ /// beats "optional").
+ ///
+ SquashUpgradeScope,
+
+ ///
+ /// Reserved for a future strategy: when two equivalent components
+ /// differ only by Scope, keep both as distinct entries by
+ /// renaming one's (or both's) bom-ref with a scope-derived
+ /// suffix (and rewriting back-references accordingly) instead of
+ /// squashing or discarding either. Not yet implemented -- selecting
+ /// this value currently behaves like .
+ ///
+ 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,
+ 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..d3bfdb0e 100644
--- a/src/CycloneDX.Utils/Merge.cs
+++ b/src/CycloneDX.Utils/Merge.cs
@@ -181,6 +181,136 @@ 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);
+ }
+
+ 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 (result.Components != null && !(bom2.Metadata?.Component is null) && !result.Components.Contains(bom2.Metadata.Component))
+ {
+ result.Components.Add(bom2.Metadata.Component);
+ }
+
+ result.Services = MergeableListHelper.Merge(bom1.Services, bom2.Services, strategy);
+ result.ExternalReferences = MergeableListHelper.Merge(bom1.ExternalReferences, bom2.ExternalReferences, strategy);
+ // Dependency reconciliation beyond exact-match (e.g. treating one
+ // side's dependency list as a subset of the other's, per
+ // strategy.MergeSubsetDependencies) is not yet implemented --
+ // Dependency currently only merges via its IMergeable default
+ // (exact equality), same as the non-strategy overload.
+ 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)
+ {
+ 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))
+ {
+ var conflictingRef = c2.BomRef;
+ var renamedRef = conflictingRef + ":2";
+ BomRefWalker.RewriteRefs(bom2, r => r == conflictingRef ? renamedRef : r);
+ }
+ }
+ }
+ }
+#endif
///
/// Performs a flat merge of multiple BOMs.
@@ -197,7 +327,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 +390,67 @@ 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);
+ }
+
+ if (strategy.DoBomMetadataUpdate)
+ {
+ result.BomMetadataUpdate(strategy.DoBomMetadataUpdateNewSerialNumber);
+ if (strategy.DoBomMetadataUpdateReferThisToolkit)
+ {
+ result.BomMetadataReferThisToolkit();
+ }
+ }
+
+ return result;
+ }
+#endif
+
///
/// Performs a hierarchical merge for multiple BOMs.
///
@@ -498,6 +689,34 @@ bom.SerialNumber is null
return result;
}
+#if NET8_0_OR_GREATER
+ ///
+ /// Hierarchical merge with a . Hierarchical
+ /// merge already keeps each source BOM's component subtree separate
+ /// (via bom-ref namespacing rather than deduplication), which is
+ /// what most of MergeStrategy's component-conflict-resolution
+ /// concern exists to handle for FlatMerge -- so this overload's
+ /// only behavioral addition today is applying the metadata-update
+ /// toggles afterwards.
+ ///
+ public static Bom HierarchicalMerge(IEnumerable boms, Component bomSubject, MergeStrategy strategy)
+ {
+ strategy ??= MergeStrategy.Default();
+ var result = HierarchicalMerge(boms, bomSubject);
+
+ if (strategy.DoBomMetadataUpdate)
+ {
+ result.BomMetadataUpdate(strategy.DoBomMetadataUpdateNewSerialNumber);
+ if (strategy.DoBomMetadataUpdateReferThisToolkit)
+ {
+ result.BomMetadataReferThisToolkit();
+ }
+ }
+
+ return result;
+ }
+#endif
+
private static void NamespaceBomRefs(Component bomSubject, IEnumerable references)
{
if (references == null)
diff --git a/tests/CycloneDX.Utils.Tests/MergeStrategyTests.cs b/tests/CycloneDX.Utils.Tests/MergeStrategyTests.cs
new file mode 100644
index 00000000..19bc54ff
--- /dev/null
+++ b/tests/CycloneDX.Utils.Tests/MergeStrategyTests.cs
@@ -0,0 +1,150 @@
+// 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.Collections.Generic;
+using Xunit;
+using CycloneDX;
+using CycloneDX.Models;
+using CycloneDX.Utils;
+
+namespace CycloneDX.Utils.Tests
+{
+ public class MergeStrategyTests
+ {
+ [Fact]
+ public void Component_Equivalent_MatchesOnTypeAndName_IgnoresBomRef()
+ {
+ var a = new Component { Name = "left-pad", Version = "1.0.0", BomRef = "ref-a" };
+ var b = new Component { Name = "left-pad", Version = "1.0.0", BomRef = "ref-b" };
+
+ Assert.True(a.Equivalent(b, MergeStrategy.Default()));
+ }
+
+ [Fact]
+ public void Component_Equivalent_False_WhenNameDiffers()
+ {
+ var a = new Component { Name = "left-pad", Version = "1.0.0" };
+ var b = new Component { Name = "right-pad", Version = "1.0.0" };
+
+ Assert.False(a.Equivalent(b, MergeStrategy.Default()));
+ }
+
+ [Fact]
+ public void Component_MergeWith_SquashesOptionalScopes()
+ {
+ var a = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Optional };
+ var b = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Optional, Description = "pads strings" };
+
+ Assert.True(a.MergeWith(b, MergeStrategy.Default()));
+ Assert.Equal(Component.ComponentScope.Optional, a.Scope);
+ Assert.Equal("pads strings", a.Description);
+ }
+
+ [Fact]
+ public void Component_MergeWith_RefusesExcludedVsRequiredConflict()
+ {
+ var a = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Excluded };
+ var b = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Required };
+
+ Assert.False(a.MergeWith(b, MergeStrategy.Default()));
+ }
+
+ [Fact]
+ public void Component_MergeWith_KeepsBothExcluded_EvenWithUnrelatedFieldDifference()
+ {
+ // Regression check for the bug this port fixes: two components
+ // that are both Excluded-scope but differ in some unrelated
+ // field must still merge, not be treated as a scope conflict.
+ var a = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Excluded };
+ var b = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Excluded, Copyright = "2024 Acme" };
+
+ Assert.True(a.MergeWith(b, MergeStrategy.Default()));
+ Assert.Equal(Component.ComponentScope.Excluded, a.Scope);
+ Assert.Equal("2024 Acme", a.Copyright);
+ }
+
+ [Fact]
+ public void Hash_MergeWith_FillsInMissingContent()
+ {
+ var a = new Hash { Alg = Hash.HashAlgorithm.SHA_256, Content = null };
+ var b = new Hash { Alg = Hash.HashAlgorithm.SHA_256, Content = "abc123" };
+
+ Assert.True(a.MergeWith(b, MergeStrategy.Default()));
+ Assert.Equal("abc123", a.Content);
+ }
+
+ [Fact]
+ public void Hash_MergeWith_RefusesContentMismatch()
+ {
+ var a = new Hash { Alg = Hash.HashAlgorithm.SHA_256, Content = "abc123" };
+ var b = new Hash { Alg = Hash.HashAlgorithm.SHA_256, Content = "def456" };
+
+ Assert.False(a.MergeWith(b, MergeStrategy.Default()));
+ }
+
+ [Fact]
+ public void FlatMerge_WithStrategy_SquashesEquivalentComponentsAcrossBoms()
+ {
+ var bom1 = new Bom
+ {
+ Components = new List
+ {
+ new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Optional }
+ }
+ };
+ var bom2 = new Bom
+ {
+ Components = new List
+ {
+ new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Optional, Description = "pads strings" }
+ }
+ };
+
+ var result = CycloneDXUtils.FlatMerge(bom1, bom2, MergeStrategy.Default());
+
+ Assert.Single(result.Components);
+ Assert.Equal("pads strings", result.Components[0].Description);
+ }
+
+ [Fact]
+ public void BomRenameRef_RewritesIdentifierAndBackReferences()
+ {
+ var bom = new Bom
+ {
+ Components = new List { new Component { Name = "left-pad", BomRef = "old-ref" } },
+ Dependencies = new List
+ {
+ new Dependency { Ref = "root", Dependencies = new List { new Dependency { Ref = "old-ref" } } }
+ }
+ };
+
+ Assert.True(bom.RenameRef("old-ref", "new-ref"));
+ Assert.Equal("new-ref", bom.Components[0].BomRef);
+ Assert.Equal("new-ref", bom.Dependencies[0].Dependencies[0].Ref);
+ }
+
+ [Fact]
+ public void BomRenameRef_ReturnsFalse_WhenRefNotPresent()
+ {
+ var bom = new Bom { Components = new List { new Component { Name = "left-pad", BomRef = "some-ref" } } };
+
+ Assert.False(bom.RenameRef("missing-ref", "new-ref"));
+ }
+ }
+}
+#endif