From f01e73565bbb312fe97a3151915b30a56afa794a Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:34:45 +0200 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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. /// From 578100f67c5fcb7683a6433941cf2820d2683e34 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 10:54:31 +0200 Subject: [PATCH 09/13] Flip default scope-conflict resolution to Squash_UpgradeScope; rename enum MergeStrategy.Default()'s initial choice for resolving a Required-vs- Optional Scope conflict picked the narrower Optional reading. On reflection that is the wrong default: the specification says an absent/ambiguous Scope SHOULD be treated as required, so silently downgrading a Required dependency to Optional risks under-reporting it in downstream tooling (e.g. vulnerability scanners that key off Scope). Flips MergeStrategy.Default() to Squash_UpgradeScope and renames the enum values to contrast directly: Squash -> Squash_DowngradeScope, SquashUpgradeScope -> Squash_UpgradeScope. Also reserves Squash_RenameByScope (replacing the placeholder RenameByScope) and wires TryMergeScope to refuse merging differently-scoped components under it, ahead of implementing the actual scope-partitioning pass in a follow-up commit. Signed-off-by: Jim Klimov --- src/CycloneDX.Core/Models/Component.cs | 21 +++++++++--- src/CycloneDX.Core/Models/MergeStrategy.cs | 40 +++++++++++++--------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/CycloneDX.Core/Models/Component.cs b/src/CycloneDX.Core/Models/Component.cs index 9f40dcfd..36fb9821 100644 --- a/src/CycloneDX.Core/Models/Component.cs +++ b/src/CycloneDX.Core/Models/Component.cs @@ -458,6 +458,19 @@ private static bool TryMergeScope(ComponentScope? a, ComponentScope? b, Componen return true; } + if (resolution == ComponentConflictResolution.Squash_RenameByScope) + { + // Scope partitioning under this strategy is handled by a + // dedicated pre-pass (CycloneDXUtils' RenameByScope pass) + // that splits differently-scoped components into separate, + // suffixed bom-refs *before* the generic per-field merge + // ever runs -- so by the time two Components reach here with + // different Scope values, they should not be merged at all; + // treat it as a refusal rather than silently squashing. + merged = null; + return false; + } + bool aExcluded = a == ComponentScope.Excluded; bool bExcluded = b == ComponentScope.Excluded; @@ -466,10 +479,10 @@ private static bool TryMergeScope(ComponentScope? a, ComponentScope? b, Componen // 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 + // is more inclusive. Squash_UpgradeScope (the default) always + // resolves to Required; Squash_DowngradeScope keeps the + // narrower "optional" reading when either side actually said so. + merged = resolution == ComponentConflictResolution.Squash_UpgradeScope ? ComponentScope.Required : (a == ComponentScope.Optional || b == ComponentScope.Optional) ? ComponentScope.Optional diff --git a/src/CycloneDX.Core/Models/MergeStrategy.cs b/src/CycloneDX.Core/Models/MergeStrategy.cs index 784af2a6..8ed2fffa 100644 --- a/src/CycloneDX.Core/Models/MergeStrategy.cs +++ b/src/CycloneDX.Core/Models/MergeStrategy.cs @@ -32,28 +32,36 @@ public enum ComponentConflictResolution /// /// Merge reconcilable fields into one entry. If Scope differs, - /// prefer keeping it unset/optional over silently widening it -- see - /// for - /// the alternative. + /// the more permissive value wins (e.g. "required" beats "optional") + /// -- the spec's own guidance is that an absent/ambiguous Scope + /// SHOULD be treated as required, so this errs toward not + /// under-reporting a dependency that's required somewhere. This is + /// the default: it can produce more false-positive "required" + /// warnings downstream than , but + /// won't silently downgrade a genuinely required dependency to + /// merely optional. /// - Squash, + Squash_UpgradeScope, /// - /// Same as , but when Scope differs - /// between the two, the more permissive value wins (e.g. "required" - /// beats "optional"). + /// Same as , but when Scope + /// differs, prefer keeping it unset/optional over widening it. /// - SquashUpgradeScope, + Squash_DowngradeScope, /// - /// 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 . + /// When two equivalent components differ only by Scope, don't + /// squash or discard either -- keep them as distinct entries by + /// suffixing one's (or both's) bom-ref with + /// :scope=<value> and rewriting back-references + /// accordingly, so e.g. "required for production" and "excluded for + /// tests" both survive the merge intact. Non-Scope fields still + /// squash normally within each scope partition. The original, + /// unsuffixed bom-ref is kept for whichever scope was seen first; + /// suffixes only appear once an actual conflict shows up, so a + /// document where every source agrees on Scope is unaffected. /// - RenameByScope, + Squash_RenameByScope, } /// @@ -130,7 +138,7 @@ public static MergeStrategy Default() RenameConflictingComponents = true, MergeSubsetDependencies = true, TreatDependencyAsExtraProperty = true, - ComponentConflictResolution = ComponentConflictResolution.Squash, + ComponentConflictResolution = ComponentConflictResolution.Squash_UpgradeScope, DoBomMetadataUpdate = false, DoBomMetadataUpdateNewSerialNumber = false, DoBomMetadataUpdateReferThisToolkit = false, From 3331ec710113a01b7123851a6194bc39b2f472f0 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 10:56:22 +0200 Subject: [PATCH 10/13] Bom.RenameRef: refuse rather than silently collide RenameRef previously rewrote every occurrence of oldRef to newRef unconditionally -- if newRef already identified (or was referenced by) some other entity in the document, the rename would silently make two different entities share one bom-ref, or repoint an existing back-reference at the wrong entity. Now does a read-only collection pass first (reusing BomRefWalker's traversal, so "what counts as a ref" can't drift between the check and the real rewrite) and throws InvalidOperationException if newRef is already in use, instead of performing the corrupting rename. Signed-off-by: Jim Klimov --- src/CycloneDX.Core/Models/Bom.cs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/CycloneDX.Core/Models/Bom.cs b/src/CycloneDX.Core/Models/Bom.cs index 49a0594f..9fe6643d 100644 --- a/src/CycloneDX.Core/Models/Bom.cs +++ b/src/CycloneDX.Core/Models/Bom.cs @@ -218,6 +218,13 @@ public int NonNullableVersion /// somewhere in the document; false if it was not present /// (a non-fatal no-op) or the arguments were invalid. /// + /// + /// is already used as a bom-ref identifier + /// or back-reference somewhere else in this document. Renaming + /// anyway would silently make two different entities share one + /// bom-ref (or repoint an existing back-reference at the wrong + /// entity) -- refused rather than done. + /// public bool RenameRef(string oldRef, string newRef) { if (string.IsNullOrEmpty(oldRef) || string.IsNullOrEmpty(newRef) || oldRef == newRef) @@ -225,6 +232,30 @@ public bool RenameRef(string oldRef, string newRef) return false; } + // Read-only pass (rewrite function returns its input unchanged, + // just records it) to check for a collision before touching + // anything -- reuses the same traversal RewriteRefs uses for + // the real rewrite, so "what counts as a ref" can't drift + // between the check and the actual rename. + var existingRefs = new HashSet(); + BomRefWalker.RewriteRefs(this, r => + { + if (!string.IsNullOrEmpty(r)) + { + existingRefs.Add(r); + } + return r; + }); + + if (!existingRefs.Contains(oldRef)) + { + return false; + } + if (existingRefs.Contains(newRef)) + { + throw new InvalidOperationException($"Cannot rename \"{oldRef}\" to \"{newRef}\": \"{newRef}\" is already used as a bom-ref (or a reference to one) elsewhere in this document."); + } + bool found = false; BomRefWalker.RewriteRefs(this, r => { From 356abf753529cf593f64c7d2a3c09c0035ae9470 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 10:56:31 +0200 Subject: [PATCH 11/13] Dependency: real Equivalent/MergeWith for subset-dependency merging Independent of the RenameRef collision fix (different code path: this is about the generic Components/Dependencies list merge, not the rename-entity walker) -- addresses the gap where two BOMs describing the same component with different direct-dependency lists (e.g. a Maven module built standalone vs. as part of a parent build) would each contribute their own entry, since Dependency previously only merged via IMergeable's exact-equality default. Equivalent() matches on Ref alone. MergeWith() unions the two Dependencies sub-lists (via the same MergeableListHelper.Merge used everywhere else, so it recurses correctly into nested dependency trees), gated by MergeStrategy.MergeSubsetDependencies: when that's false, a genuine difference between the two dependsOn sets is treated as a real conflict (refuse, keep both entries as before) rather than silently unioned. Signed-off-by: Jim Klimov --- src/CycloneDX.Core/Models/Dependency.cs | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/CycloneDX.Core/Models/Dependency.cs b/src/CycloneDX.Core/Models/Dependency.cs index 29847827..16eab172 100644 --- a/src/CycloneDX.Core/Models/Dependency.cs +++ b/src/CycloneDX.Core/Models/Dependency.cs @@ -90,6 +90,60 @@ public override int GetHashCode() { return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode(); } + +#if NET8_0_OR_GREATER + /// Two Dependency entries describe the same graph node if they share a Ref. + public bool Equivalent(Dependency other, MergeStrategy strategy) + { + return other != null && !string.IsNullOrEmpty(Ref) && Ref == other.Ref; + } + + /// + /// Without this override, Dependency only merges via the + /// IMergeable<T> default (exact equality) -- so two BOMs + /// describing the same component with different direct-dependency + /// lists (e.g. a module built standalone vs. as part of a larger + /// build) would each contribute their own `<dependency ref="X">` + /// entry, leaving the merged document with two different entries + /// for the same Ref instead of one combined entry. This unions the + /// two Dependencies (sub-)lists instead, gated by + /// : when that's + /// false, a genuine difference in dependsOn contents is treated as + /// a conflict (refuse, keep both entries) rather than silently + /// combined. + /// + public bool MergeWith(Dependency other, MergeStrategy strategy) + { + if (other is null) + { + return false; + } + if (Equals(other)) + { + return true; + } + if (!Equivalent(other, strategy)) + { + return false; + } + + if (!strategy.MergeSubsetDependencies && !DependsOnSetsEqual(Dependencies, other.Dependencies)) + { + return false; + } + + Dependencies = MergeableListHelper.Merge(Dependencies, other.Dependencies, strategy); + Provides = MergeableListHelper.MergeSingle(Provides, other.Provides); + return true; + } + + private static bool DependsOnSetsEqual(List a, List b) + { + var aRefs = a?.Select(d => d.Ref).ToHashSet() ?? new HashSet(); + var bRefs = b?.Select(d => d.Ref).ToHashSet() ?? new HashSet(); + return aRefs.SetEquals(bRefs); + } +#endif } [ProtoContract] From f7c0583779080d75af544ce72f5bbb5815d3325d Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 11:00:44 +0200 Subject: [PATCH 12/13] Implement Squash_RenameByScope: split scope-conflicting components by bom-ref When two Equivalent components (same type/name/version-if-present) differ in Scope, they are kept as two distinct entries instead of squashed or refused, suffixed :scope= (e.g. "lp:scope=Required" / "lp:scope=Excluded"), with every back-reference in the document rewritten to match (via BomRefWalker, per-source-bom, before the generic Components merge runs). No suffix is ever added unless a real conflict appears: if every source agrees on Scope for a given identity, the original bom-ref is kept as-is (ApplyRenameByScope only mutates on an actual scope mismatch). A retroactive rename handles the first-ever split (the already-accumulated entry, and any of its back-references already recorded from earlier merged BOMs, get suffixed too, not just the new arrival); a third+ incoming copy matching an existing scope partition squashes into it independently of the others. TryMergeScope now refuses to merge differing scopes at all under this resolution (partitioning happens via bom-ref splitting instead, not field-level squashing). RenameBomRefCollisions (the existing same- bomref-but-unequal check for RenameConflictingComponents) now skips pairs that are Equivalent to each other when this resolution is active, so it doesn't rename the pair with a blunt ":2" suffix before ApplyRenameByScope gets a chance to do it precisely. Fixes a stale-closure bug caught by a test during development: the rewrite lambda in ApplyRenameByScope compared against `incoming.BomRef` read live from the (mutating) property instead of a captured snapshot, so a component's own bom-ref rename would silently poison the comparison used for its own back-references later in the same walk pass. Signed-off-by: Jim Klimov --- src/CycloneDX.Utils/Merge.cs | 110 ++++++++++++- .../MergeStrategyTests.cs | 152 ++++++++++++++++++ 2 files changed, 260 insertions(+), 2 deletions(-) diff --git a/src/CycloneDX.Utils/Merge.cs b/src/CycloneDX.Utils/Merge.cs index d3bfdb0e..493241f9 100644 --- a/src/CycloneDX.Utils/Merge.cs +++ b/src/CycloneDX.Utils/Merge.cs @@ -196,7 +196,12 @@ public static Bom FlatMerge(Bom bom1, Bom bom2, MergeStrategy strategy) if (strategy.RenameConflictingComponents) { - RenameBomRefCollisions(bom1, bom2); + RenameBomRefCollisions(bom1, bom2, strategy); + } + + if (strategy.ComponentConflictResolution == ComponentConflictResolution.Squash_RenameByScope) + { + ApplyRenameByScope(bom1, bom2, strategy); } var result = new Bom(); @@ -286,7 +291,7 @@ public static Bom FlatMerge(Bom bom1, Bom bom2, MergeStrategy strategy) /// 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) + private static void RenameBomRefCollisions(Bom bom1, Bom bom2, MergeStrategy strategy) { if (bom1?.Components is null || bom2?.Components is null) { @@ -303,6 +308,17 @@ private static void RenameBomRefCollisions(Bom bom1, Bom bom2) { if (c2.BomRef == c1.BomRef && !c1.Equals(c2)) { + if (strategy.ComponentConflictResolution == ComponentConflictResolution.Squash_RenameByScope + && c1.Equivalent(c2, strategy)) + { + // Same real-world identity, differing only by + // (at least) Scope -- ApplyRenameByScope handles + // this case precisely (predictable :scope=... + // suffixes); don't let this blunter same-bomref + // check rename it first with a ":2" suffix. + continue; + } + var conflictingRef = c2.BomRef; var renamedRef = conflictingRef + ":2"; BomRefWalker.RewriteRefs(bom2, r => r == conflictingRef ? renamedRef : r); @@ -310,6 +326,96 @@ private static void RenameBomRefCollisions(Bom bom1, Bom bom2) } } } + + private const string ScopeSuffixMarker = ":scope="; + + private static bool IsScopeSuffixed(string bomRef) => + !string.IsNullOrEmpty(bomRef) && bomRef.Contains(ScopeSuffixMarker, StringComparison.Ordinal); + + private static string BaseRefOf(string bomRef) + { + if (string.IsNullOrEmpty(bomRef)) + { + return bomRef; + } + var idx = bomRef.IndexOf(ScopeSuffixMarker, StringComparison.Ordinal); + return idx < 0 ? bomRef : bomRef.Substring(0, idx); + } + + private static string ScopeSuffixedRef(string baseRef, Component.ComponentScope? scope) => + $"{baseRef}{ScopeSuffixMarker}{(scope.HasValue ? scope.Value.ToString() : "Unspecified")}"; + + /// + /// Pre-merge pass for MergeStrategy.ComponentConflictResolution == + /// Squash_RenameByScope: when an incoming component is + /// Equivalent (identity match ignoring Scope) to one already + /// accumulated but differs in Scope, split them into distinct, + /// suffixed bom-refs instead of letting the generic merge either + /// squash the Scope away or leave two entries silently sharing one + /// bom-ref. Only touches bom-refs once an actual conflict appears: + /// if every source agrees on Scope for a given identity, no suffix + /// is ever added. Mutates bom1 (retroactively, for the first-ever + /// split of a given identity -- cascading to its already-recorded + /// back-references) and bom2 (so its own back-references follow + /// whatever bom-ref its components end up merging under) in place. + /// + private static void ApplyRenameByScope(Bom bom1, Bom bom2, MergeStrategy strategy) + { + if (bom1?.Components is null || bom2?.Components is null) + { + return; + } + + foreach (var incoming in bom2.Components.ToList()) + { + if (string.IsNullOrEmpty(incoming.BomRef)) + { + continue; + } + + var matches = bom1.Components.Where(e => e.Equivalent(incoming, strategy)).ToList(); + if (matches.Count == 0) + { + // First time this identity has been seen -- nothing to + // rename (yet); it'll be added as-is by the generic merge. + continue; + } + + var sameScope = matches.FirstOrDefault(e => e.Scope == incoming.Scope); + if (sameScope != null) + { + // Will squash into sameScope during the generic merge + // below; make sure bom2's own back-refs to `incoming` + // already point at the bom-ref it's about to be merged + // under (which may itself be suffixed from an earlier fold). + if (!string.IsNullOrEmpty(sameScope.BomRef) && sameScope.BomRef != incoming.BomRef) + { + var oldRef = incoming.BomRef; + var targetRef = sameScope.BomRef; + BomRefWalker.RewriteRefs(bom2, r => r == oldRef ? targetRef : r); + } + continue; + } + + // incoming's Scope doesn't match any existing partition for + // this identity -- it's a new partition. + var baseRef = BaseRefOf(matches[0].BomRef); + if (matches.Count == 1 && !IsScopeSuffixed(matches[0].BomRef)) + { + // First-ever split for this identity: retroactively + // suffix the already-accumulated entry too, cascading to + // its back-references already recorded in bom1. + var existing = matches[0]; + var existingOldRef = existing.BomRef; + var existingNewRef = ScopeSuffixedRef(baseRef, existing.Scope); + BomRefWalker.RewriteRefs(bom1, r => r == existingOldRef ? existingNewRef : r); + } + + var incomingOldRef = incoming.BomRef; + var incomingNewRef = ScopeSuffixedRef(baseRef, incoming.Scope); + BomRefWalker.RewriteRefs(bom2, r => r == incomingOldRef ? incomingNewRef : r); + } + } #endif /// diff --git a/tests/CycloneDX.Utils.Tests/MergeStrategyTests.cs b/tests/CycloneDX.Utils.Tests/MergeStrategyTests.cs index 19bc54ff..29a77fb8 100644 --- a/tests/CycloneDX.Utils.Tests/MergeStrategyTests.cs +++ b/tests/CycloneDX.Utils.Tests/MergeStrategyTests.cs @@ -145,6 +145,158 @@ public void BomRenameRef_ReturnsFalse_WhenRefNotPresent() Assert.False(bom.RenameRef("missing-ref", "new-ref")); } + + [Fact] + public void BomRenameRef_Throws_WhenNewRefAlreadyInUse() + { + var bom = new Bom + { + Components = new List + { + new Component { Name = "left-pad", BomRef = "old-ref" }, + new Component { Name = "right-pad", BomRef = "already-taken" } + } + }; + + Assert.Throws(() => bom.RenameRef("old-ref", "already-taken")); + // Refused, so nothing should have been touched. + Assert.Equal("old-ref", bom.Components[0].BomRef); + } + + [Fact] + public void Default_Strategy_UpgradesConflictingScope_ToRequired() + { + // Locks in the flipped default: a Required-vs-Optional conflict + // must not silently downgrade to Optional. + Assert.Equal(ComponentConflictResolution.Squash_UpgradeScope, MergeStrategy.Default().ComponentConflictResolution); + + var a = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Required }; + var b = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Optional }; + + Assert.True(a.MergeWith(b, MergeStrategy.Default())); + Assert.Equal(Component.ComponentScope.Required, a.Scope); + } + + [Fact] + public void Squash_DowngradeScope_PrefersOptionalOverRequired() + { + var strategy = MergeStrategy.Default(); + strategy.ComponentConflictResolution = ComponentConflictResolution.Squash_DowngradeScope; + + var a = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Required }; + var b = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Optional }; + + Assert.True(a.MergeWith(b, strategy)); + Assert.Equal(Component.ComponentScope.Optional, a.Scope); + } + + [Fact] + public void Dependency_MergeWith_UnionsSubsetDependsOnLists() + { + var a = new Dependency { Ref = "app", Dependencies = new List { new Dependency { Ref = "lib-a" } } }; + var b = new Dependency { Ref = "app", Dependencies = new List { new Dependency { Ref = "lib-b" } } }; + + Assert.True(a.MergeWith(b, MergeStrategy.Default())); + Assert.Equal(2, a.Dependencies.Count); + Assert.Contains(a.Dependencies, d => d.Ref == "lib-a"); + Assert.Contains(a.Dependencies, d => d.Ref == "lib-b"); + } + + [Fact] + public void Dependency_MergeWith_RefusesDiffering_WhenSubsetMergeDisabled() + { + var strategy = MergeStrategy.Default(); + strategy.MergeSubsetDependencies = false; + + var a = new Dependency { Ref = "app", Dependencies = new List { new Dependency { Ref = "lib-a" } } }; + var b = new Dependency { Ref = "app", Dependencies = new List { new Dependency { Ref = "lib-b" } } }; + + Assert.False(a.MergeWith(b, strategy)); + } + + [Fact] + public void FlatMerge_RenameByScope_SplitsConflictingComponentsAndFixesUpBackReferences() + { + var strategy = MergeStrategy.Default(); + strategy.ComponentConflictResolution = ComponentConflictResolution.Squash_RenameByScope; + + var bom1 = new Bom + { + Components = new List + { + new Component { Name = "left-pad", Version = "1.0.0", BomRef = "lp", Scope = Component.ComponentScope.Required } + }, + Dependencies = new List { new Dependency { Ref = "lp" } } + }; + var bom2 = new Bom + { + Components = new List + { + new Component { Name = "left-pad", Version = "1.0.0", BomRef = "lp", Scope = Component.ComponentScope.Excluded } + }, + Dependencies = new List { new Dependency { Ref = "lp" } } + }; + + var result = CycloneDXUtils.FlatMerge(bom1, bom2, strategy); + + Assert.Equal(2, result.Components.Count); + var required = Assert.Single(result.Components, c => c.Scope == Component.ComponentScope.Required); + var excluded = Assert.Single(result.Components, c => c.Scope == Component.ComponentScope.Excluded); + Assert.Equal("lp:scope=Required", required.BomRef); + Assert.Equal("lp:scope=Excluded", excluded.BomRef); + + Assert.Equal(2, result.Dependencies.Count); + Assert.Contains(result.Dependencies, d => d.Ref == "lp:scope=Required"); + Assert.Contains(result.Dependencies, d => d.Ref == "lp:scope=Excluded"); + } + + [Fact] + public void FlatMerge_RenameByScope_DoesNotSuffixWhenAllSourcesAgree() + { + var strategy = MergeStrategy.Default(); + strategy.ComponentConflictResolution = ComponentConflictResolution.Squash_RenameByScope; + + var bom1 = new Bom + { + Components = new List { new Component { Name = "left-pad", Version = "1.0.0", BomRef = "lp", Scope = Component.ComponentScope.Required } } + }; + var bom2 = new Bom + { + Components = new List { new Component { Name = "left-pad", Version = "1.0.0", BomRef = "lp", Scope = Component.ComponentScope.Required, Description = "pads strings" } } + }; + + var result = CycloneDXUtils.FlatMerge(bom1, bom2, strategy); + + var merged = Assert.Single(result.Components); + Assert.Equal("lp", merged.BomRef); + Assert.Equal("pads strings", merged.Description); + } + + [Fact] + public void FlatMerge_RenameByScope_ThirdCopySquashesIntoExistingPartition() + { + var strategy = MergeStrategy.Default(); + strategy.ComponentConflictResolution = ComponentConflictResolution.Squash_RenameByScope; + + var bom1 = new Bom + { + Components = new List { new Component { Name = "left-pad", Version = "1.0.0", BomRef = "lp", Scope = Component.ComponentScope.Required } } + }; + var bom2 = new Bom + { + Components = new List { new Component { Name = "left-pad", Version = "1.0.0", BomRef = "lp", Scope = Component.ComponentScope.Excluded } } + }; + var bom3 = new Bom + { + Components = new List { new Component { Name = "left-pad", Version = "1.0.0", BomRef = "lp", Scope = Component.ComponentScope.Required, Copyright = "2024 Acme" } } + }; + + var result = CycloneDXUtils.FlatMerge(new[] { bom1, bom2, bom3 }, strategy); + + Assert.Equal(2, result.Components.Count); + var required = Assert.Single(result.Components, c => c.Scope == Component.ComponentScope.Required); + Assert.Equal("2024 Acme", required.Copyright); + } } } #endif From 30fd0b644762735c2558708c5eb15a43c1a6995b Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 17 Sep 2026 12:24:06 +0200 Subject: [PATCH 13/13] Merge.cs: fix stale comment on Dependency reconciliation The comment above result.Dependencies still claimed reconciliation beyond exact-match was 'not yet implemented', left over from before this same PR's 'Dependency: real Equivalent/MergeWith for subset- dependency merging' commit gave Dependency real MergeWith logic. Update the comment to describe what actually happens now. Signed-off-by: Jim Klimov Co-Authored-By: Claude Sonnet 5 --- src/CycloneDX.Utils/Merge.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/CycloneDX.Utils/Merge.cs b/src/CycloneDX.Utils/Merge.cs index 493241f9..8a38b227 100644 --- a/src/CycloneDX.Utils/Merge.cs +++ b/src/CycloneDX.Utils/Merge.cs @@ -233,11 +233,9 @@ public static Bom FlatMerge(Bom bom1, Bom bom2, MergeStrategy strategy) 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. + // Dependency.MergeWith reconciles beyond exact-match: two entries + // for the same Ref have their (sub-)dependency lists unioned, + // gated by strategy.MergeSubsetDependencies (see Dependency.cs). result.Dependencies = MergeableListHelper.Merge(bom1.Dependencies, bom2.Dependencies, strategy); result.Compositions = MergeableListHelper.Merge(bom1.Compositions, bom2.Compositions, strategy); result.Vulnerabilities = MergeableListHelper.Merge(bom1.Vulnerabilities, bom2.Vulnerabilities, strategy);