From f01e73565bbb312fe97a3151915b30a56afa794a Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:34:45 +0200 Subject: [PATCH 1/8] Introduce IBomEntity/IMergeable/IEquivalent interfaces and MergeStrategy Foundational types for a strategy-driven BOM merge engine: a marker interface (IBomEntity) plus IMergeable/IEquivalent, each with a default interface method body that falls back to the type's own IEquatable equality. Most model classes will need nothing more than declaring the interface (zero-body opt-in); only types with real reconciliation logic override the defaults. Compiled for net8.0+/net10.0 only (default interface methods require an ABI this library's netstandard2.0 target cannot provide); netstandard2.0 consumers keep today's merge behavior unchanged. MergeStrategy configures the merge: independent on/off toggles for subset-dependency merging, dependency-as-extra-property treatment, and metadata refresh, plus a ComponentConflictResolution enum (rather than more booleans) so new resolution algorithms can be added as new cases without reshaping this type or any call site that reads it. Signed-off-by: Jim Klimov --- .../Models/Interfaces/IBomEntity.cs | 100 +++++++++++++ src/CycloneDX.Core/Models/MergeStrategy.cs | 141 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 src/CycloneDX.Core/Models/Interfaces/IBomEntity.cs create mode 100644 src/CycloneDX.Core/Models/MergeStrategy.cs 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/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 From c2b4dac1f6182ce93677bf5e58b757101cb57754 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:34:55 +0200 Subject: [PATCH 2/8] Retarget mergeable model classes to IMergeable/IEquivalent Declares the new interfaces on every model type the merge engine needs to process generically as list elements (Tool, Service, ExternalReference, Dependency, Composition, Vulnerability, Annotation, Standard, Assessor, Attestation, Claim, OrganizationalEntity, Component, plus nested-list element types Hash, Property, OrganizationalContact, LicenseChoice, PatentAssertion). For types that already implement IEquatable, this is a one-line, zero-body change (the interface defaults handle it). Property, OrganizationalContact, LicenseChoice and PatentAssertion had no equality implementation at all; they gain the same JSON-hash-based Equals(object)/ Equals(T)/GetHashCode pattern already used by Component/Annotation/etc. elsewhere in this codebase (all three together, not just Equals(T)/ GetHashCode, to keep the object.Equals/GetHashCode contract consistent for callers that only see the non-generic Equals), so the interface defaults have something real to fall back on. Hash already had Equals(T)/GetHashCode but was missing the same Equals(object) override; added here too since this commit already touches the file. Hash gets a real (non-default) Equivalent/MergeWith: two hashes of the same algorithm should carry the same content; if one side is missing content, fill it in from the other, and treat a genuine content mismatch for the same algorithm as an unmergeable conflict. Component's real merge logic follows in a separate commit. Signed-off-by: Jim Klimov Co-Authored-By: Claude Sonnet 5 --- src/CycloneDX.Core/Models/Annotation.cs | 3 + src/CycloneDX.Core/Models/Component.cs | 3 + src/CycloneDX.Core/Models/Composition.cs | 3 + .../Models/Declarations/Assessor.cs | 3 + .../Models/Declarations/Attestation.cs | 3 + .../Models/Declarations/Claim.cs | 3 + .../Models/Definitions/Standard.cs | 3 + src/CycloneDX.Core/Models/Dependency.cs | 3 + .../Models/ExternalReference.cs | 3 + src/CycloneDX.Core/Models/Hash.cs | 66 ++++++++++++++++++- src/CycloneDX.Core/Models/LicenseChoice.cs | 26 +++++++- .../Models/OrganizationalContact.cs | 28 +++++++- .../Models/OrganizationalEntity.cs | 3 + src/CycloneDX.Core/Models/PatentAssertion.cs | 28 +++++++- src/CycloneDX.Core/Models/Property.cs | 28 +++++++- src/CycloneDX.Core/Models/Service.cs | 3 + src/CycloneDX.Core/Models/Tool.cs | 3 + .../Models/Vulnerabilities/Vulnerability.cs | 3 + 18 files changed, 210 insertions(+), 5 deletions(-) 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/Component.cs b/src/CycloneDX.Core/Models/Component.cs index 0a23ad65..807709a0 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 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/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/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")] From 4c6624cc5f23ef5b8cf8c078396499219a4ec027 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:35:53 +0200 Subject: [PATCH 3/8] Add strategy-aware MergeableListHelper.Merge Generic-constrained (T : IEquatable, IEquivalent, IMergeable) list merge: exact-equal items dedupe as before, equivalent-but-unequal items attempt MergeWith, everything else is kept as a separate entry. Falls back to the existing exact-match ListMergeHelper when a strategy disables entity merging. One implementation serves every mergeable list field in a Bom (components, services, hashes, external references, authors, ...) via real interface dispatch -- no reflection, no per-type branching. Signed-off-by: Jim Klimov --- src/CycloneDX.Utils/Merge.cs | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/CycloneDX.Utils/Merge.cs b/src/CycloneDX.Utils/Merge.cs index 31f4d51a..aa840914 100644 --- a/src/CycloneDX.Utils/Merge.cs +++ b/src/CycloneDX.Utils/Merge.cs @@ -71,6 +71,54 @@ public List Merge(List list1, List list2) } } +#if NET8_0_OR_GREATER + /// + /// Strategy-aware list merging for any element type implementing + /// IEquatable/IEquivalent/IMergeable. This one generic method replaces + /// the fork's per-type reflection-driven BomEntityListMergeHelper: the + /// same code merges List<Hash>, List<Component>, + /// List<OrganizationalContact>, etc., dispatching through real + /// interface calls instead of cached MethodInfo/Type lookups. + /// + internal 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 new ListMergeHelper().Merge(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; + } + } +#endif + public static class ListExtensions { public static void AddRangeIfNotNull(this List list, IEnumerable items) From 5e566bb407e01d2fb53d491901cac06977bf7896 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:39:03 +0200 Subject: [PATCH 4/8] Component: real Equivalent/MergeWith, field-by-field, no reflection Implements Component's merge logic as explicit per-field code instead of a PropertyInfo/switch(Type) walk: Equivalent() checks type/name required-equal, version/group/purl equal-if-both-present (bom-ref deliberately excluded -- that's the merge orchestration's concern, not a per-field check). MergeWith() folds scalar fields via ??, list fields via the new MergeableListHelper.Merge (lives in CycloneDX.Core rather than CycloneDX.Utils so Component can call it directly -- Utils depends on Core, not the reverse), and Scope via TryMergeScope, which handles the both-Excluded case explicitly: two components that are both Excluded-scope but differ in some unrelated field should still merge, not be treated as an unresolvable scope conflict just because neither side is Optional. MergeableListHelper also gains small single-value/string-list/ nullable-bool merge helpers used by Component's scalar and simple-list fields. Signed-off-by: Jim Klimov --- src/CycloneDX.Core/MergeableListHelper.cs | 127 +++++++++++++++++ src/CycloneDX.Core/Models/Component.cs | 162 ++++++++++++++++++++++ src/CycloneDX.Utils/Merge.cs | 48 ------- 3 files changed, 289 insertions(+), 48 deletions(-) create mode 100644 src/CycloneDX.Core/MergeableListHelper.cs 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/Component.cs b/src/CycloneDX.Core/Models/Component.cs index 807709a0..9f40dcfd 100644 --- a/src/CycloneDX.Core/Models/Component.cs +++ b/src/CycloneDX.Core/Models/Component.cs @@ -331,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.Utils/Merge.cs b/src/CycloneDX.Utils/Merge.cs index aa840914..31f4d51a 100644 --- a/src/CycloneDX.Utils/Merge.cs +++ b/src/CycloneDX.Utils/Merge.cs @@ -71,54 +71,6 @@ public List Merge(List list1, List list2) } } -#if NET8_0_OR_GREATER - /// - /// Strategy-aware list merging for any element type implementing - /// IEquatable/IEquivalent/IMergeable. This one generic method replaces - /// the fork's per-type reflection-driven BomEntityListMergeHelper: the - /// same code merges List<Hash>, List<Component>, - /// List<OrganizationalContact>, etc., dispatching through real - /// interface calls instead of cached MethodInfo/Type lookups. - /// - internal 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 new ListMergeHelper().Merge(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; - } - } -#endif - public static class ListExtensions { public static void AddRangeIfNotNull(this List list, IEnumerable items) From 0ad672732f052e111cffdf076a8e345b7361e1f2 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:40:56 +0200 Subject: [PATCH 5/8] Add BomRefWalker and Bom.RenameRef/BomMetadataUpdate/ReferThisToolkit BomRefWalker.RewriteRefs(bom, rewrite) generalizes the per-type ref- rewriting CycloneDXUtils.HierarchicalMerge already hand-rolls for bom-ref namespacing (NamespaceComponentBomRefs, NamespaceDependencyBomRefs, NamespaceCompositions, NamespaceVulnerabilitiesRefs, NamespaceAnnotationsBomRefs) into one reusable entry point parameterized on an arbitrary rewrite function instead of a fixed namespace prefix. Namespacing becomes RewriteRefs(bom, r => $"{ns}:{r}"); a single manual rename becomes RewriteRefs(bom, r => r == oldRef ? newRef : r). Covers Metadata.Component, Components, Services, Dependencies, Compositions, Vulnerabilities, and Annotations; the newer (CycloneDX 1.6) Declarations/Definitions sections are not yet walked -- a mechanical follow-up, not an architectural one. Bom.RenameRef builds on the walker to rename a bom-ref and every back-reference to it throughout a document in one pass. BomMetadataReferThisToolkit/BomMetadataUpdate stamp this library's (and the entry assembly's) Tool reference into Metadata.Tools, and refresh Version/SerialNumber/Timestamp. Signed-off-by: Jim Klimov --- src/CycloneDX.Core/BomRefWalker.cs | 205 +++++++++++++++++++++++++++++ src/CycloneDX.Core/Models/Bom.cs | 122 +++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 src/CycloneDX.Core/BomRefWalker.cs 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/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 From e3074cccd5531d32bf50316ab9aa1bebf8c464d1 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:45:18 +0200 Subject: [PATCH 6/8] Add strategy-aware FlatMerge/HierarchicalMerge overloads New FlatMerge(bom1, bom2, MergeStrategy) and HierarchicalMerge(boms, bomSubject, MergeStrategy) overloads sit alongside the existing fixed-behavior ones (which are untouched and keep today's exact behavior for existing callers). FlatMerge's overload swaps every ListMergeHelper call for MergeableListHelper.Merge(..., strategy), so Components/Services/Tools/Hashes/etc. attempt real reconciliation (Component's Scope-aware squash, Hash's content fill-in) instead of only deduping exact matches, and applies RenameConflictingComponents (pre-merge bom-ref collision detection via BomRefWalker) and the metadata-update toggles. Known gaps, called out rather than silently dropped: MergeSubsetDependencies isn't wired to real subset-detection logic yet (Dependency still merges via its IMergeable default), and HierarchicalMerge's strategy overload only adds the metadata-update toggles so far (namespacing already avoids the collisions FlatMerge's squash logic exists to resolve, so it needed less new behavior here). Signed-off-by: Jim Klimov --- src/CycloneDX.Utils/Merge.cs | 158 +++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/src/CycloneDX.Utils/Merge.cs b/src/CycloneDX.Utils/Merge.cs index 31f4d51a..547d6e79 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. @@ -498,6 +628,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) From a76cde257aa7cdf9180e03a7522d0210ec05cac2 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:46:18 +0200 Subject: [PATCH 7/8] Add focused tests for the new merge-strategy capability Unit tests (not full-BOM snapshots, for faster feedback) covering: Component.Equivalent's type/name/version-if-both-present rules and its deliberate bom-ref exclusion; Component.MergeWith's scope squash, excluded-vs-required conflict refusal, and the both-Excluded case (two components that are both Excluded-scope but differ in some unrelated field still merge); Hash.MergeWith's content fill-in/mismatch cases; FlatMerge(bom1, bom2, strategy) actually squashing equivalent components across two BOMs; and Bom.RenameRef rewriting both an identifier and its back-reference (plus the not-found no-op case). Signed-off-by: Jim Klimov --- .../MergeStrategyTests.cs | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 tests/CycloneDX.Utils.Tests/MergeStrategyTests.cs 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 From 139c3b02b574fb2ae497d0eabadeed453dd9254e Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:47:47 +0200 Subject: [PATCH 8/8] Add strategy-aware multi-BOM FlatMerge(IEnumerable, ...) overloads The earlier commit only added the 2-BOM FlatMerge(bom1, bom2, strategy) overload; the CLI's merge command (and anyone merging more than two documents) needs the IEnumerable form too. Also fixes a now- ambiguous overload resolution: the existing single-arg FlatMerge(boms) delegated via `FlatMerge(boms, null)`, which became ambiguous between the Component and MergeStrategy overloads once the latter existed -- disambiguated with an explicit (Component)null cast. Signed-off-by: Jim Klimov --- src/CycloneDX.Utils/Merge.cs | 63 +++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/CycloneDX.Utils/Merge.cs b/src/CycloneDX.Utils/Merge.cs index 547d6e79..d3bfdb0e 100644 --- a/src/CycloneDX.Utils/Merge.cs +++ b/src/CycloneDX.Utils/Merge.cs @@ -327,7 +327,7 @@ private static void RenameBomRefCollisions(Bom bom1, Bom bom2) /// public static Bom FlatMerge(IEnumerable boms) { - return FlatMerge(boms, null); + return FlatMerge(boms, (Component)null); } /// @@ -390,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. ///