diff --git a/src/CycloneDX.Core/BomRefWalker.cs b/src/CycloneDX.Core/BomRefWalker.cs new file mode 100644 index 00000000..66efbbec --- /dev/null +++ b/src/CycloneDX.Core/BomRefWalker.cs @@ -0,0 +1,205 @@ +// This file is part of CycloneDX Library for .NET +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +#if NET8_0_OR_GREATER +using System; +using System.Collections.Generic; +using CycloneDX.Models; +using CycloneDX.Models.Vulnerabilities; + +namespace CycloneDX +{ + /// + /// Rewrites every "bom-ref"-shaped value in a document + /// -- both identifiers (Component/Service/Vulnerability/Annotation + /// BomRef) and back-references to them (Dependency.Ref, Composition's + /// Assemblies/Dependencies string lists, Vulnerability.Affects[].Ref, + /// Annotation subjects) -- through a caller-supplied function. + /// + /// + /// This generalizes traversal code CycloneDX.Utils.CycloneDXUtils. + /// HierarchicalMerge already hand-rolls for bom-ref namespacing (see + /// its private NamespaceComponentBomRefs/NamespaceDependencyBomRefs/ + /// NamespaceCompositions/NamespaceVulnerabilitiesRefs/ + /// NamespaceAnnotationsBomRefs methods) into one reusable entry point, + /// parameterized on an arbitrary rewrite function instead of always + /// prefixing a namespace. Namespacing becomes + /// RewriteRefs(bom, r => $"{ns}:{r}"); a manual single-ref + /// rename (as the CLI's rename-entity command needs) becomes + /// RewriteRefs(bom, r => r == oldRef ? newRef : r). + /// + /// Scope note: covers Metadata.Component, Components, Services, + /// Dependencies, Compositions, Vulnerabilities, and Annotations -- it + /// does not yet walk the newer (CycloneDX 1.6) Declarations/Definitions + /// sections. Extending it there is a mechanical follow-up, not an + /// architectural one: add another block below following the same + /// pattern. + /// + public static class BomRefWalker + { + public static void RewriteRefs(Bom bom, Func rewrite) + { + if (bom is null || rewrite is null) + { + return; + } + + if (bom.Metadata?.Component != null) + { + RewriteComponentTree(bom.Metadata.Component, rewrite); + } + if (bom.Metadata?.Tools?.Components != null) + { + foreach (var component in bom.Metadata.Tools.Components) + { + RewriteComponentTree(component, rewrite); + } + } + if (bom.Metadata?.Tools?.Services != null) + { + foreach (var service in bom.Metadata.Tools.Services) + { + service.BomRef = rewrite(service.BomRef); + } + } + + if (bom.Components != null) + { + foreach (var component in bom.Components) + { + RewriteComponentTree(component, rewrite); + } + } + + if (bom.Services != null) + { + foreach (var service in bom.Services) + { + service.BomRef = rewrite(service.BomRef); + } + } + + if (bom.Dependencies != null) + { + RewriteDependencyTree(bom.Dependencies, rewrite); + } + + if (bom.Compositions != null) + { + foreach (var composition in bom.Compositions) + { + RewriteStringListInPlace(composition.Assemblies, rewrite); + RewriteStringListInPlace(composition.Dependencies, rewrite); + } + } + + if (bom.Vulnerabilities != null) + { + foreach (var vulnerability in bom.Vulnerabilities) + { + vulnerability.BomRef = rewrite(vulnerability.BomRef); + if (vulnerability.Affects != null) + { + foreach (var affect in vulnerability.Affects) + { + affect.Ref = rewrite(affect.Ref); + } + } + } + } + + if (bom.Annotations != null) + { + foreach (var annotation in bom.Annotations) + { + annotation.BomRef = rewrite(annotation.BomRef); + if (annotation.XmlSubjects != null) + { + for (var i = 0; i < annotation.XmlSubjects.Count; i++) + { + annotation.XmlSubjects[i].Ref = rewrite(annotation.XmlSubjects[i].Ref); + } + } + if (annotation.Annotator?.Component != null) + { + RewriteComponentTree(annotation.Annotator.Component, rewrite); + } + if (annotation.Annotator?.Individual != null) + { + annotation.Annotator.Individual.BomRef = rewrite(annotation.Annotator.Individual.BomRef); + } + if (annotation.Annotator?.Organization != null) + { + annotation.Annotator.Organization.BomRef = rewrite(annotation.Annotator.Organization.BomRef); + } + if (annotation.Annotator?.Service != null) + { + annotation.Annotator.Service.BomRef = rewrite(annotation.Annotator.Service.BomRef); + } + } + } + } + + private static void RewriteComponentTree(Component topComponent, Func rewrite) + { + var pending = new Stack(); + pending.Push(topComponent); + while (pending.Count > 0) + { + var component = pending.Pop(); + if (component.Components != null) + { + foreach (var sub in component.Components) + { + pending.Push(sub); + } + } + component.BomRef = rewrite(component.BomRef); + } + } + + private static void RewriteDependencyTree(List dependencies, Func rewrite) + { + var pending = new Stack(dependencies); + while (pending.Count > 0) + { + var dependency = pending.Pop(); + if (dependency.Dependencies != null) + { + foreach (var sub in dependency.Dependencies) + { + pending.Push(sub); + } + } + dependency.Ref = rewrite(dependency.Ref); + } + } + + private static void RewriteStringListInPlace(List refs, Func rewrite) + { + if (refs is null) + { + return; + } + for (var i = 0; i < refs.Count; i++) + { + refs[i] = rewrite(refs[i]); + } + } + } +} +#endif diff --git a/src/CycloneDX.Core/MergeableListHelper.cs b/src/CycloneDX.Core/MergeableListHelper.cs new file mode 100644 index 00000000..ec756359 --- /dev/null +++ b/src/CycloneDX.Core/MergeableListHelper.cs @@ -0,0 +1,127 @@ +// This file is part of CycloneDX Library for .NET +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +#if NET8_0_OR_GREATER +using System; +using System.Collections.Generic; +using CycloneDX.Models; + +namespace CycloneDX +{ + /// + /// Strategy-aware list merging for any element type implementing + /// IEquatable/IEquivalent/IMergeable. One generic method merges + /// List<Hash>, List<Component>, List<OrganizationalContact>, + /// etc., dispatching through real interface calls rather than a + /// separate reflection-driven helper per element type. Lives in + /// CycloneDX.Core (rather than alongside CycloneDX.Utils/Merge.cs) + /// specifically so model classes like Component can call it directly + /// from their own MergeWith implementations. + /// + public static class MergeableListHelper + { + public static List Merge(List list1, List list2, MergeStrategy strategy) + where T : IEquatable, IEquivalent, IMergeable + { + if (list1 is null) return list2; + if (list2 is null) return list1; + if (strategy is null || !strategy.UseEntityMerge) + { + return ExactMatchMerge(list1, list2); + } + + var result = new List(list1); + foreach (var incoming in list2) + { + bool merged = false; + for (int i = 0; i < result.Count; i++) + { + var existing = result[i]; + if (existing.Equals(incoming) || existing.Equivalent(incoming, strategy)) + { + if (existing.MergeWith(incoming, strategy)) + { + result[i] = existing; + merged = true; + break; + } + } + } + if (!merged) + { + result.Add(incoming); + } + } + return result; + } + + /// + /// Cheap fallback: dedupe by exact equality only, no MergeWith + /// attempts. Mirrors CycloneDX.Utils.ListMergeHelper<T>'s + /// behavior (kept independently here to avoid a Core->Utils + /// dependency, which would invert this project's reference graph). + /// + private static List ExactMatchMerge(List list1, List list2) where T : IEquatable + { + var result = new List(list1); + foreach (var item in list2) + { + bool found = false; + foreach (var existing in result) + { + if (existing.Equals(item)) + { + found = true; + break; + } + } + if (!found) + { + result.Add(item); + } + } + return result; + } + + /// Take whichever of two nullable reference values is non-null, preferring . + public static T MergeSingle(T a, T b) where T : class => a ?? b; + + /// Union two string lists, preserving order, dropping duplicates. Null if both are null. + public static List MergeStringList(List a, List b) + { + if (a is null) return b; + if (b is null) return a; + var result = new List(a); + foreach (var s in b) + { + if (!result.Contains(s)) + { + result.Add(s); + } + } + return result; + } + + /// Nullable-bool "either says true" merge: null if both unset, else true if either is true. + public static bool? MergeNullableBoolOr(bool? a, bool? b) + { + if (!a.HasValue && !b.HasValue) return null; + return (a ?? false) || (b ?? false); + } + } +} +#endif diff --git a/src/CycloneDX.Core/Models/Annotation.cs b/src/CycloneDX.Core/Models/Annotation.cs index 58519055..610bb351 100644 --- a/src/CycloneDX.Core/Models/Annotation.cs +++ b/src/CycloneDX.Core/Models/Annotation.cs @@ -26,6 +26,9 @@ namespace CycloneDX.Models { [ProtoContract] public class Annotation : IEquatable +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlType("subject")] public class XmlAnnotationSubject diff --git a/src/CycloneDX.Core/Models/Bom.cs b/src/CycloneDX.Core/Models/Bom.cs index bbc90c50..49a0594f 100644 --- a/src/CycloneDX.Core/Models/Bom.cs +++ b/src/CycloneDX.Core/Models/Bom.cs @@ -205,5 +205,127 @@ public int NonNullableVersion public XmlElement XmlSignature { get; set; } [XmlIgnore] public SignatureChoice Signature { get; set; } + +#if NET8_0_OR_GREATER + /// + /// Rename a "bom-ref" identifier and every back-reference to it + /// throughout this document (Dependency.Ref, Composition + /// assemblies/dependencies, Vulnerability.Affects[].Ref, annotation + /// subjects, ...), via . + /// + /// + /// true if was found and rewritten + /// somewhere in the document; false if it was not present + /// (a non-fatal no-op) or the arguments were invalid. + /// + public bool RenameRef(string oldRef, string newRef) + { + if (string.IsNullOrEmpty(oldRef) || string.IsNullOrEmpty(newRef) || oldRef == newRef) + { + return false; + } + + bool found = false; + BomRefWalker.RewriteRefs(this, r => + { + if (r == oldRef) + { + found = true; + return newRef; + } + return r; + }); + return found; + } + + /// + /// Add a reference to this running build of cyclonedx-dotnet-library + /// (and, if different, the entry assembly -- typically a consuming + /// tool like cyclonedx-cli) into this document's Metadata/Tools. + /// Intended for use after processing that creates or modifies a + /// document, so any bugs in the processing are traceable to the + /// tool/library versions that produced the result. Avoids adding + /// exact-duplicate entries. + /// + public void BomMetadataReferThisToolkit() + { +#pragma warning disable 618 + var toolThisLibrary = new Tool + { + Vendor = "OWASP Foundation", + Name = System.Reflection.Assembly.GetExecutingAssembly().GetName().Name, + Version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString() + }; +#pragma warning restore 618 + + if (Metadata is null) + { + Metadata = new Metadata(); + } + + if (Metadata.Tools is null || Metadata.Tools.Tools is null) + { +#pragma warning disable 618 + Metadata.Tools = new ToolChoices + { + Tools = new List(new[] { toolThisLibrary }), + }; +#pragma warning restore 618 + } + else if (!Metadata.Tools.Tools.Contains(toolThisLibrary)) + { + Metadata.Tools.Tools.Add(toolThisLibrary); + } + + var entryAssembly = System.Reflection.Assembly.GetEntryAssembly(); + var toolThisScriptName = entryAssembly?.GetName()?.Name; + if (!string.IsNullOrEmpty(toolThisScriptName) && toolThisScriptName != toolThisLibrary.Name) + { +#pragma warning disable 618 + var toolThisScript = new Tool + { + Name = toolThisScriptName, + Vendor = toolThisScriptName.ToLowerInvariant().StartsWith("cyclonedx", StringComparison.Ordinal) ? "OWASP Foundation" : null, + Version = entryAssembly.GetName().Version.ToString() + }; +#pragma warning restore 618 + + if (!Metadata.Tools.Tools.Contains(toolThisScript)) + { + Metadata.Tools.Tools.Add(toolThisScript); + } + } + } + + /// + /// Refresh this document's own identity: Version/SerialNumber and + /// Metadata/Timestamp. Typically called after content + /// manipulations such as a merge or rename. Callers usually also + /// want separately. + /// + public void BomMetadataUpdate(bool generateNewSerialNumber) + { + if (Version is null || Version < 1 || string.IsNullOrEmpty(SerialNumber)) + { + generateNewSerialNumber = true; + } + + if (generateNewSerialNumber) + { + Version = 1; + SerialNumber = "urn:uuid:" + Guid.NewGuid().ToString(); + } + else + { + Version++; + } + + if (Metadata is null) + { + Metadata = new Metadata(); + } + Metadata.Timestamp = DateTime.Now; + } +#endif } } \ No newline at end of file diff --git a/src/CycloneDX.Core/Models/Component.cs b/src/CycloneDX.Core/Models/Component.cs index 0a23ad65..9f40dcfd 100644 --- a/src/CycloneDX.Core/Models/Component.cs +++ b/src/CycloneDX.Core/Models/Component.cs @@ -33,6 +33,9 @@ namespace CycloneDX.Models [XmlType("component")] [ProtoContract] public class Component: IEquatable, IHasBomRef +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [ProtoContract] public enum Classification @@ -328,5 +331,167 @@ public override int GetHashCode() { return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode(); } + +#if NET8_0_OR_GREATER + /// + /// Cheap pre-check for "plausibly the same real-world component, + /// worth attempting MergeWith on" -- not full equality. By spec, + /// "type" and "name" are the two required identifying properties; + /// "version"/"group"/"purl" are treated as equal-if-both-present + /// (so two components with the identical type/name but only one + /// side specifying a version are still considered equivalent, + /// rather than assumed to be different versions of the same + /// thing). bom-ref is deliberately NOT part of this check -- two + /// components can describe the same real-world thing under + /// different bom-ref values in different source documents, and + /// reconciling bom-ref identity is the merge orchestration's job + /// (see BomRefWalker), not this per-field check's. + /// + public bool Equivalent(Component other, MergeStrategy strategy) + { + if (other is null) + { + return false; + } + + return Type == other.Type + && !(Name is null) && !(other.Name is null) && Name == other.Name + && (Version is null || other.Version is null || Version == other.Version) + && (Group is null || other.Group is null || Group == other.Group) + && (Purl is null || other.Purl is null || Purl == other.Purl); + } + + /// + /// Attempt to fold 's data into this + /// component. Scalar fields prefer this instance's own value and + /// fall back to 's only when unset; list + /// fields are merged via ; Scope + /// is reconciled via , which is the one + /// place this can still refuse to merge (an Excluded/Required + /// clash is a genuine conflict, not something to silently paper + /// over) -- see . + /// + public bool MergeWith(Component other, MergeStrategy strategy) + { + if (other is null) + { + return false; + } + if (Equals(other)) + { + return true; + } + if (!Equivalent(other, strategy)) + { + return false; + } + + if (strategy.ComponentConflictResolution == ComponentConflictResolution.KeepSeparate) + { + return false; + } + + if (!TryMergeScope(Scope, other.Scope, strategy.ComponentConflictResolution, out var mergedScope)) + { + // Scope reconciliation refused (e.g. Excluded vs. Required) -- + // these are different enough real-world things to keep separate. + return false; + } + + Scope = mergedScope; + MimeType ??= other.MimeType; + Supplier = MergeableListHelper.MergeSingle(Supplier, other.Supplier); + Manufacturer = MergeableListHelper.MergeSingle(Manufacturer, other.Manufacturer); + Authors = MergeableListHelper.Merge(Authors, other.Authors, strategy); +#pragma warning disable 618 + Author ??= other.Author; +#pragma warning restore 618 + Publisher ??= other.Publisher; + VersionRange ??= other.VersionRange; + Description ??= other.Description; + Hashes = MergeableListHelper.Merge(Hashes, other.Hashes, strategy); + Licenses = MergeableListHelper.Merge(Licenses, other.Licenses, strategy); + Copyright ??= other.Copyright; + PatentAssertions = MergeableListHelper.Merge(PatentAssertions, other.PatentAssertions, strategy); + Cpe ??= other.Cpe; + Purl ??= other.Purl; + OmniborId = MergeableListHelper.MergeStringList(OmniborId, other.OmniborId); + Swhid = MergeableListHelper.MergeStringList(Swhid, other.Swhid); + Swid = MergeableListHelper.MergeSingle(Swid, other.Swid); + Modified = MergeableListHelper.MergeNullableBoolOr(Modified, other.Modified); + Pedigree = MergeableListHelper.MergeSingle(Pedigree, other.Pedigree); + ExternalReferences = MergeableListHelper.Merge(ExternalReferences, other.ExternalReferences, strategy); + Properties = MergeableListHelper.Merge(Properties, other.Properties, strategy); + Components = MergeableListHelper.Merge(Components, other.Components, strategy); + Evidence = MergeableListHelper.MergeSingle(Evidence, other.Evidence); + ReleaseNotes = MergeableListHelper.MergeSingle(ReleaseNotes, other.ReleaseNotes); + ModelCard = MergeableListHelper.MergeSingle(ModelCard, other.ModelCard); + Data = MergeableListHelper.MergeSingle(Data, other.Data); + CryptoProperties = MergeableListHelper.MergeSingle(CryptoProperties, other.CryptoProperties); + IsExternal = MergeableListHelper.MergeNullableBoolOr(IsExternal, other.IsExternal); + Tags = MergeableListHelper.MergeStringList(Tags, other.Tags); + XmlSignature = MergeableListHelper.MergeSingle(XmlSignature, other.XmlSignature); + Signature = MergeableListHelper.MergeSingle(Signature, other.Signature); + + return true; + } + + /// + /// Reconcile two Scope values for components that are otherwise + /// being merged into one (see MergeStrategy.ComponentConflictResolution's + /// XML docs for the domain rules). "Both sides already equal" is + /// handled uniformly first, including the both-Excluded case: two + /// components that are both Excluded-scope but differ in some + /// unrelated field should still merge, not be treated as a scope + /// conflict just because neither side is Optional. + /// + /// + /// false if the two scopes genuinely conflict (Excluded vs. + /// Required/unset) and the caller should not merge these two + /// components at all. + /// + private static bool TryMergeScope(ComponentScope? a, ComponentScope? b, ComponentConflictResolution resolution, out ComponentScope? merged) + { + if (a == b) + { + merged = a; + return true; + } + + bool aExcluded = a == ComponentScope.Excluded; + bool bExcluded = b == ComponentScope.Excluded; + + if (!aExcluded && !bExcluded) + { + // Neither side excludes the component. Per the spec, an absent + // (null/unset) Scope SHOULD be treated as required -- so unless + // both sides agree on "optional", the safe reading is whichever + // is more inclusive. SquashUpgradeScope always resolves to + // Required; plain Squash keeps the narrower "optional" reading + // when either side actually said so. + merged = resolution == ComponentConflictResolution.SquashUpgradeScope + ? ComponentScope.Required + : (a == ComponentScope.Optional || b == ComponentScope.Optional) + ? ComponentScope.Optional + : (ComponentScope?)null; + return true; + } + + // Exactly one side is Excluded (a == b above already handled both-Excluded). + var other = aExcluded ? b : a; + if (other == ComponentScope.Optional || other is null) + { + // Excluded dominates over a merely-optional/unspecified reading. + merged = ComponentScope.Excluded; + return true; + } + + // Excluded vs. Required is a genuine conflict: these describe + // different real-world usages of the same component and should + // not be silently squashed into one. + merged = null; + return false; + } +#endif } } \ No newline at end of file diff --git a/src/CycloneDX.Core/Models/Composition.cs b/src/CycloneDX.Core/Models/Composition.cs index fac023ec..304b4da6 100644 --- a/src/CycloneDX.Core/Models/Composition.cs +++ b/src/CycloneDX.Core/Models/Composition.cs @@ -27,6 +27,9 @@ namespace CycloneDX.Models { [ProtoContract] public class Composition : IXmlSerializable, IEquatable +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [ProtoContract] public enum AggregateType diff --git a/src/CycloneDX.Core/Models/Declarations/Assessor.cs b/src/CycloneDX.Core/Models/Declarations/Assessor.cs index 00f9e095..a1292426 100644 --- a/src/CycloneDX.Core/Models/Declarations/Assessor.cs +++ b/src/CycloneDX.Core/Models/Declarations/Assessor.cs @@ -27,6 +27,9 @@ namespace CycloneDX.Models { [ProtoContract] public class Assessor : IEquatable, IHasBomRef +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlAttribute("bom-ref")] [JsonPropertyName("bom-ref")] diff --git a/src/CycloneDX.Core/Models/Declarations/Attestation.cs b/src/CycloneDX.Core/Models/Declarations/Attestation.cs index d36eb48d..56b9f34e 100644 --- a/src/CycloneDX.Core/Models/Declarations/Attestation.cs +++ b/src/CycloneDX.Core/Models/Declarations/Attestation.cs @@ -27,6 +27,9 @@ namespace CycloneDX.Models { [ProtoContract] public class Attestation : IEquatable +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlElement("summary")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Declarations/Claim.cs b/src/CycloneDX.Core/Models/Declarations/Claim.cs index 8ec91fe2..f4aa307a 100644 --- a/src/CycloneDX.Core/Models/Declarations/Claim.cs +++ b/src/CycloneDX.Core/Models/Declarations/Claim.cs @@ -29,6 +29,9 @@ namespace CycloneDX.Models { [ProtoContract] public class Claim : IEquatable, IHasBomRef +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlAttribute("bom-ref")] [JsonPropertyName("bom-ref")] diff --git a/src/CycloneDX.Core/Models/Definitions/Standard.cs b/src/CycloneDX.Core/Models/Definitions/Standard.cs index e5fdcd54..22e098a4 100644 --- a/src/CycloneDX.Core/Models/Definitions/Standard.cs +++ b/src/CycloneDX.Core/Models/Definitions/Standard.cs @@ -26,6 +26,9 @@ namespace CycloneDX.Models { [ProtoContract] public class Standard : IEquatable, IHasBomRef +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlAttribute("bom-ref")] [JsonPropertyName("bom-ref")] diff --git a/src/CycloneDX.Core/Models/Dependency.cs b/src/CycloneDX.Core/Models/Dependency.cs index a11a9940..29847827 100644 --- a/src/CycloneDX.Core/Models/Dependency.cs +++ b/src/CycloneDX.Core/Models/Dependency.cs @@ -30,6 +30,9 @@ namespace CycloneDX.Models [XmlType("dependency")] [ProtoContract] public class Dependency : IEquatable +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlAttribute("ref")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/ExternalReference.cs b/src/CycloneDX.Core/Models/ExternalReference.cs index a83a2239..3a0d72dc 100644 --- a/src/CycloneDX.Core/Models/ExternalReference.cs +++ b/src/CycloneDX.Core/Models/ExternalReference.cs @@ -29,6 +29,9 @@ namespace CycloneDX.Models [SuppressMessage("Microsoft.Naming", "CA1707:IdentifiersShouldNotContainUnderscores")] [ProtoContract] public class ExternalReference : IEquatable +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [ProtoContract] public enum ExternalReferenceType diff --git a/src/CycloneDX.Core/Models/Hash.cs b/src/CycloneDX.Core/Models/Hash.cs index ca7b0754..08987fa0 100644 --- a/src/CycloneDX.Core/Models/Hash.cs +++ b/src/CycloneDX.Core/Models/Hash.cs @@ -15,6 +15,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. +using System; +using System.Text.Json; using System.Xml.Serialization; using ProtoBuf; @@ -22,7 +24,10 @@ namespace CycloneDX.Models { [XmlType("hash")] [ProtoContract] - public class Hash + public class Hash : IEquatable +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [ProtoContract] public enum HashAlgorithm @@ -66,5 +71,64 @@ public enum HashAlgorithm [XmlText] [ProtoMember(2)] public string Content { get; set; } + + public override bool Equals(object obj) + { + var other = obj as Hash; + if (other == null) + { + return false; + } + + return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(other, Json.Serializer.SerializerOptionsForHash); + } + + public bool Equals(Hash obj) + { + return obj != null && JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(obj, Json.Serializer.SerializerOptionsForHash); + } + + public override int GetHashCode() + { + return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode(); + } + +#if NET8_0_OR_GREATER + public bool Equivalent(Hash obj, MergeStrategy strategy) + { + return obj != null && Alg == obj.Alg; + } + + /// + /// Two hashes of the same algorithm should carry the same content; + /// if one side is missing content (e.g. partially populated by a + /// producer), fill it in from the other. A genuine content mismatch + /// for the same algorithm is a real conflict, not something this + /// merge silently resolves. + /// + public bool MergeWith(Hash obj, MergeStrategy strategy) + { + if (obj is null) + { + return false; + } + if (Equals(obj)) + { + return true; + } + if (!Equivalent(obj, strategy)) + { + return false; + } + + if (Content is null && !(obj.Content is null)) + { + Content = obj.Content; + return true; + } + + return Content == obj.Content; + } +#endif } } \ No newline at end of file diff --git a/src/CycloneDX.Core/Models/Interfaces/IBomEntity.cs b/src/CycloneDX.Core/Models/Interfaces/IBomEntity.cs new file mode 100644 index 00000000..aaad85b9 --- /dev/null +++ b/src/CycloneDX.Core/Models/Interfaces/IBomEntity.cs @@ -0,0 +1,100 @@ +// This file is part of CycloneDX Library for .NET +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +// Default interface methods require the target to support .NET Standard 2.1 / +// .NET Core 3.0 or later ABI features. This library also targets netstandard2.0 +// (for older .NET Framework consumers), which cannot support default bodies here, +// so the whole capability is only compiled in for the modern targets. Consumers +// on netstandard2.0 keep today's behavior (plain FlatMerge/HierarchicalMerge, +// no per-entity merge strategy) unchanged. +#if NET8_0_OR_GREATER +using System; + +namespace CycloneDX.Models +{ + /// + /// Marker interface for model types that participate in generic, + /// strategy-driven BOM-tree operations (equivalence checks, merging). + /// Deliberately empty: it exists only so and + /// share a common, checkable root, without + /// requiring a shared base class. + /// + public interface IBomEntity + { + } + + /// + /// Declares that two instances of can attempt to + /// combine into one during a BOM merge. + /// + /// + /// The default implementation is intentionally trivial: it merges only if + /// the two instances are already exactly equal per the type's own + /// implementation (the same equality every + /// model class in this library already provides). Most model types need + /// nothing more than this default and simply declare + /// : IMergeable<Foo> with no method body. Types with real + /// field-by-field reconciliation rules (for example Component, + /// where two BOM documents may describe the same component with + /// different scopes, hashes, or external references) override this + /// method with actual logic instead of relying on the default. + /// + /// This is the C# idiom this design deliberately favors over a shared + /// base class with reflection-driven dispatch: every implementing type + /// gets a uniform, compiler-checked contract for free, and only pays for + /// custom behavior where it actually has some. + /// + public interface IMergeable : IBomEntity + { + /// + /// Attempt to merge into this. + /// + /// The other instance to merge in. + /// Merge configuration/strategy toggles. + /// + /// true if this now represents the union of both + /// instances and can be dropped from the + /// containing list; false if the two instances could not be + /// merged (they should be kept as separate list entries). + /// + bool MergeWith(T other, MergeStrategy strategy) => + this is IEquatable equatable && other != null && equatable.Equals(other); + } + + /// + /// Declares that two instances of can be + /// evaluated for "close enough to be worth attempting a merge" -- a + /// weaker, type-specific relation than exact equality. + /// + /// + /// The default implementation defers to exact + /// equality; see for why this is the + /// deliberate default rather than an abstract requirement. + /// + public interface IEquivalent : IBomEntity + { + /// + /// Cheap pre-check: are these two instances plausibly the same + /// real-world entity (and thus worth attempting + /// on), even if not exactly + /// equal? + /// + bool Equivalent(T other, MergeStrategy strategy) => + this is IEquatable equatable && other != null && equatable.Equals(other); + } +} +#endif diff --git a/src/CycloneDX.Core/Models/LicenseChoice.cs b/src/CycloneDX.Core/Models/LicenseChoice.cs index 31518f25..7a04f8be 100644 --- a/src/CycloneDX.Core/Models/LicenseChoice.cs +++ b/src/CycloneDX.Core/Models/LicenseChoice.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using System.Linq.Expressions; +using System.Text.Json; using System.Text.Json.Serialization; using System.Xml.Serialization; using CycloneDX.Xml; @@ -28,7 +29,10 @@ namespace CycloneDX.Models [ProtoContract] - public class LicenseChoice + public class LicenseChoice : IEquatable +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlElement("license")] [ProtoMember(1)] @@ -66,6 +70,26 @@ public class LicenseChoice public List Properties { get; set; } public bool ShouldSerializeProperties() { return Properties?.Count > 0; } + public override bool Equals(object obj) + { + var other = obj as LicenseChoice; + if (other == null) + { + return false; + } + + return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(other, Json.Serializer.SerializerOptionsForHash); + } + + public bool Equals(LicenseChoice obj) + { + return obj != null && JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(obj, Json.Serializer.SerializerOptionsForHash); + } + + public override int GetHashCode() + { + return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode(); + } } // This is a workaround to serialize licenses correctly diff --git a/src/CycloneDX.Core/Models/MergeStrategy.cs b/src/CycloneDX.Core/Models/MergeStrategy.cs new file mode 100644 index 00000000..784af2a6 --- /dev/null +++ b/src/CycloneDX.Core/Models/MergeStrategy.cs @@ -0,0 +1,141 @@ +// This file is part of CycloneDX Library for .NET +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +#if NET8_0_OR_GREATER +namespace CycloneDX.Models +{ + /// + /// Selectable algorithm for resolving two Equivalent-but-not-equal + /// Components during a merge. Kept as its own enum (rather than + /// boolean toggles) specifically so new algorithms can be added as new + /// cases without reshaping or any of the + /// merge call sites that read it. + /// + public enum ComponentConflictResolution + { + /// Leave both components as separate, unrelated entries. + KeepSeparate, + + /// + /// Merge reconcilable fields into one entry. If Scope differs, + /// prefer keeping it unset/optional over silently widening it -- see + /// for + /// the alternative. + /// + Squash, + + /// + /// Same as , but when Scope differs + /// between the two, the more permissive value wins (e.g. "required" + /// beats "optional"). + /// + SquashUpgradeScope, + + /// + /// Reserved for a future strategy: when two equivalent components + /// differ only by Scope, keep both as distinct entries by + /// renaming one's (or both's) bom-ref with a scope-derived + /// suffix (and rewriting back-references accordingly) instead of + /// squashing or discarding either. Not yet implemented -- selecting + /// this value currently behaves like . + /// + RenameByScope, + } + + /// + /// Configuration steering how CycloneDXUtils.FlatMerge/ + /// HierarchicalMerge reconcile two or more BOM documents. + /// + public class MergeStrategy + { + /// + /// When true, list merging considers calling each item's + /// to reconcile + /// equivalent-but-not-equal entries; when false, list merging + /// only deduplicates exactly-equal entries (fast, but may leave + /// near-duplicate entries that a smarter merge could have combined). + /// + public bool UseEntityMerge { get; set; } + + /// + /// When merging BOM documents that include Equivalent + /// components with differing values of Scope (required/null + /// vs. optional vs. excluded), rename the conflicting components' + /// bom-ref (and their back-references) instead of silently + /// conflating them under one identity. + /// + public bool RenameConflictingComponents { get; set; } + + /// + /// Selects the algorithm used when two Equivalent components + /// are not fully equal. See . + /// + public ComponentConflictResolution ComponentConflictResolution { get; set; } + + /// + /// CycloneDX spec says dependency graphs "MUST" declare components + /// with no dependencies as empty elements, which in practice does + /// not always hold across BOM documents describing overlapping + /// codebases (e.g. a Maven module built standalone vs. as part of a + /// parent build). When true, differing (non-conflicting, + /// subset-of-each-other) direct-dependency lists for the same + /// component are merged (grown) rather than treated as a conflict. + /// + public bool MergeSubsetDependencies { get; set; } + + /// + /// Treat back-references from Dependencies as an internal + /// detail owned by the referenced component/service (as the spec + /// implies), rather than an independent top-level concern. See also + /// https://github.com/CycloneDX/specification/discussions/320 + /// + public bool TreatDependencyAsExtraProperty { get; set; } + + /// + /// Refresh the merged BOM's own metadata (serial number, timestamp, + /// tool reference) after merging. + /// + public bool DoBomMetadataUpdate { get; set; } + + /// See . + public bool DoBomMetadataUpdateNewSerialNumber { get; set; } + + /// See . + public bool DoBomMetadataUpdateReferThisToolkit { get; set; } + + /// + /// Reasonable default strategy settings, matching the behavior this + /// port aims to reproduce. Callers can tune the returned instance + /// further. + /// + public static MergeStrategy Default() + { + return new MergeStrategy + { + UseEntityMerge = true, + RenameConflictingComponents = true, + MergeSubsetDependencies = true, + TreatDependencyAsExtraProperty = true, + ComponentConflictResolution = ComponentConflictResolution.Squash, + DoBomMetadataUpdate = false, + DoBomMetadataUpdateNewSerialNumber = false, + DoBomMetadataUpdateReferThisToolkit = false, + }; + } + } +} +#endif diff --git a/src/CycloneDX.Core/Models/OrganizationalContact.cs b/src/CycloneDX.Core/Models/OrganizationalContact.cs index de677b62..809dd0a7 100644 --- a/src/CycloneDX.Core/Models/OrganizationalContact.cs +++ b/src/CycloneDX.Core/Models/OrganizationalContact.cs @@ -15,6 +15,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. +using System; +using System.Text.Json; using System.Text.Json.Serialization; using System.Xml.Serialization; using ProtoBuf; @@ -22,7 +24,10 @@ namespace CycloneDX.Models { [ProtoContract] - public class OrganizationalContact + public class OrganizationalContact : IEquatable, IHasBomRef +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlElement("name")] [ProtoMember(1)] @@ -40,5 +45,26 @@ public class OrganizationalContact [XmlAttribute("bom-ref")] [ProtoMember(4)] public string BomRef { get; set; } + + public override bool Equals(object obj) + { + var other = obj as OrganizationalContact; + if (other == null) + { + return false; + } + + return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(other, Json.Serializer.SerializerOptionsForHash); + } + + public bool Equals(OrganizationalContact obj) + { + return obj != null && JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(obj, Json.Serializer.SerializerOptionsForHash); + } + + public override int GetHashCode() + { + return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode(); + } } } diff --git a/src/CycloneDX.Core/Models/OrganizationalEntity.cs b/src/CycloneDX.Core/Models/OrganizationalEntity.cs index 4e366a04..fa419f45 100644 --- a/src/CycloneDX.Core/Models/OrganizationalEntity.cs +++ b/src/CycloneDX.Core/Models/OrganizationalEntity.cs @@ -26,6 +26,9 @@ namespace CycloneDX.Models { [ProtoContract] public class OrganizationalEntity : IEquatable, IHasBomRef +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlElement("name")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/PatentAssertion.cs b/src/CycloneDX.Core/Models/PatentAssertion.cs index 587f7d0a..72d1e195 100644 --- a/src/CycloneDX.Core/Models/PatentAssertion.cs +++ b/src/CycloneDX.Core/Models/PatentAssertion.cs @@ -15,7 +15,9 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. +using System; using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; using System.Xml.Serialization; using ProtoBuf; @@ -23,7 +25,10 @@ namespace CycloneDX.Models { [ProtoContract] - public class PatentAssertion + public class PatentAssertion : IEquatable +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [JsonPropertyName("bom-ref")] [XmlAttribute("bom-ref")] @@ -47,5 +52,26 @@ public class PatentAssertion [XmlElement("notes")] [ProtoMember(5)] public string Notes { get; set; } + + public override bool Equals(object obj) + { + var other = obj as PatentAssertion; + if (other == null) + { + return false; + } + + return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(other, Json.Serializer.SerializerOptionsForHash); + } + + public bool Equals(PatentAssertion obj) + { + return obj != null && JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(obj, Json.Serializer.SerializerOptionsForHash); + } + + public override int GetHashCode() + { + return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode(); + } } } diff --git a/src/CycloneDX.Core/Models/Property.cs b/src/CycloneDX.Core/Models/Property.cs index a61b45f2..67899ae1 100644 --- a/src/CycloneDX.Core/Models/Property.cs +++ b/src/CycloneDX.Core/Models/Property.cs @@ -15,14 +15,19 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. +using System; using System.Collections.Generic; +using System.Text.Json; using System.Xml.Serialization; using ProtoBuf; namespace CycloneDX.Models { [ProtoContract] - public class Property + public class Property : IEquatable +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlAttribute("name")] [ProtoMember(1)] @@ -31,5 +36,26 @@ public class Property [XmlText] [ProtoMember(2)] public string Value { get; set; } + + public override bool Equals(object obj) + { + var other = obj as Property; + if (other == null) + { + return false; + } + + return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(other, Json.Serializer.SerializerOptionsForHash); + } + + public bool Equals(Property obj) + { + return obj != null && JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash) == JsonSerializer.Serialize(obj, Json.Serializer.SerializerOptionsForHash); + } + + public override int GetHashCode() + { + return JsonSerializer.Serialize(this, Json.Serializer.SerializerOptionsForHash).GetHashCode(); + } } } diff --git a/src/CycloneDX.Core/Models/Service.cs b/src/CycloneDX.Core/Models/Service.cs index 53a12690..d7596ac6 100644 --- a/src/CycloneDX.Core/Models/Service.cs +++ b/src/CycloneDX.Core/Models/Service.cs @@ -28,6 +28,9 @@ namespace CycloneDX.Models { [ProtoContract] public class Service: IEquatable, IHasBomRef +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { public Service() { diff --git a/src/CycloneDX.Core/Models/Tool.cs b/src/CycloneDX.Core/Models/Tool.cs index a82b7d0b..10cae0ba 100644 --- a/src/CycloneDX.Core/Models/Tool.cs +++ b/src/CycloneDX.Core/Models/Tool.cs @@ -26,6 +26,9 @@ namespace CycloneDX.Models [Obsolete("Tool is deprecated and will be removed in a future version")] [ProtoContract] public class Tool: IEquatable +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlElement("vendor")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs b/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs index 8d160609..d8041728 100644 --- a/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs +++ b/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs @@ -26,6 +26,9 @@ namespace CycloneDX.Models.Vulnerabilities { [ProtoContract] public class Vulnerability: IEquatable +#if NET8_0_OR_GREATER + , IMergeable, IEquivalent +#endif { [XmlAttribute("bom-ref")] [JsonPropertyName("bom-ref")] diff --git a/src/CycloneDX.Utils/Merge.cs b/src/CycloneDX.Utils/Merge.cs index 31f4d51a..d3bfdb0e 100644 --- a/src/CycloneDX.Utils/Merge.cs +++ b/src/CycloneDX.Utils/Merge.cs @@ -181,6 +181,136 @@ public static Bom FlatMerge(Bom bom1, Bom bom2) return result; } +#if NET8_0_OR_GREATER + /// + /// Flat-merges two BOMs using a : unlike + /// the plain overload (which only + /// dedupes exactly-equal list entries), this attempts to reconcile + /// equivalent-but-not-equal entries (see ) + /// -- most notably Components differing only by Scope -- instead of + /// keeping every near-duplicate as a separate entry. + /// + public static Bom FlatMerge(Bom bom1, Bom bom2, MergeStrategy strategy) + { + strategy ??= MergeStrategy.Default(); + + if (strategy.RenameConflictingComponents) + { + RenameBomRefCollisions(bom1, bom2); + } + + var result = new Bom(); + +#pragma warning disable 618 + var tools = MergeableListHelper.Merge(bom1.Metadata?.Tools?.Tools, bom2.Metadata?.Tools?.Tools, strategy); +#pragma warning restore 618 + var toolsComponents = MergeableListHelper.Merge(bom1.Metadata?.Tools?.Components, bom2.Metadata?.Tools?.Components, strategy); + var toolsServices = MergeableListHelper.Merge(bom1.Metadata?.Tools?.Services, bom2.Metadata?.Tools?.Services, strategy); + if (tools != null || toolsComponents != null || toolsServices != null) + { + result.Metadata = new Metadata + { + Tools = new ToolChoices + { + Tools = tools, + Components = toolsComponents, + Services = toolsServices, + } + }; + } + + result.Components = MergeableListHelper.Merge(bom1.Components, bom2.Components, strategy); + + if (result.Components != null && !(bom2.Metadata?.Component is null) && !result.Components.Contains(bom2.Metadata.Component)) + { + result.Components.Add(bom2.Metadata.Component); + } + + result.Services = MergeableListHelper.Merge(bom1.Services, bom2.Services, strategy); + result.ExternalReferences = MergeableListHelper.Merge(bom1.ExternalReferences, bom2.ExternalReferences, strategy); + // Dependency reconciliation beyond exact-match (e.g. treating one + // side's dependency list as a subset of the other's, per + // strategy.MergeSubsetDependencies) is not yet implemented -- + // Dependency currently only merges via its IMergeable default + // (exact equality), same as the non-strategy overload. + result.Dependencies = MergeableListHelper.Merge(bom1.Dependencies, bom2.Dependencies, strategy); + result.Compositions = MergeableListHelper.Merge(bom1.Compositions, bom2.Compositions, strategy); + result.Vulnerabilities = MergeableListHelper.Merge(bom1.Vulnerabilities, bom2.Vulnerabilities, strategy); + result.Annotations = MergeableListHelper.Merge(bom1.Annotations, bom2.Annotations, strategy); + + if (bom1.Definitions != null || bom2.Definitions != null) + { + result.Definitions = new Definitions + { + Standards = MergeableListHelper.Merge(bom1.Definitions?.Standards, bom2.Definitions?.Standards, strategy) + }; + } + + if (bom1.Declarations != null || bom2.Declarations != null) + { + result.Declarations = new Declarations + { + Assessors = MergeableListHelper.Merge(bom1.Declarations?.Assessors, bom2.Declarations?.Assessors, strategy), + Attestations = MergeableListHelper.Merge(bom1.Declarations?.Attestations, bom2.Declarations?.Attestations, strategy), + Claims = MergeableListHelper.Merge(bom1.Declarations?.Claims, bom2.Declarations?.Claims, strategy), + }; + + if (bom1.Declarations?.Targets != null || bom2.Declarations?.Targets != null) + { + result.Declarations.Targets = new Targets + { + Organizations = MergeableListHelper.Merge(bom1.Declarations?.Targets?.Organizations, bom2.Declarations?.Targets?.Organizations, strategy), + Components = MergeableListHelper.Merge(bom1.Declarations?.Targets?.Components, bom2.Declarations?.Targets?.Components, strategy), + Services = MergeableListHelper.Merge(bom1.Declarations?.Targets?.Services, bom2.Declarations?.Targets?.Services, strategy), + }; + } + } + + if (strategy.DoBomMetadataUpdate) + { + result.BomMetadataUpdate(strategy.DoBomMetadataUpdateNewSerialNumber); + if (strategy.DoBomMetadataUpdateReferThisToolkit) + { + result.BomMetadataReferThisToolkit(); + } + } + + return result; + } + + /// + /// If the same non-null bom-ref identifies two different (not + /// IEquatable-equal) components across and + /// , rename bom2's copy (and its + /// back-references, via ) before merging, + /// so the merged document never ends up with one bom-ref value + /// silently pointing at two unrelated components. + /// + private static void RenameBomRefCollisions(Bom bom1, Bom bom2) + { + if (bom1?.Components is null || bom2?.Components is null) + { + return; + } + + foreach (var c1 in bom1.Components) + { + if (string.IsNullOrEmpty(c1.BomRef)) + { + continue; + } + foreach (var c2 in bom2.Components) + { + if (c2.BomRef == c1.BomRef && !c1.Equals(c2)) + { + var conflictingRef = c2.BomRef; + var renamedRef = conflictingRef + ":2"; + BomRefWalker.RewriteRefs(bom2, r => r == conflictingRef ? renamedRef : r); + } + } + } + } +#endif /// /// Performs a flat merge of multiple BOMs. @@ -197,7 +327,7 @@ public static Bom FlatMerge(Bom bom1, Bom bom2) /// public static Bom FlatMerge(IEnumerable boms) { - return FlatMerge(boms, null); + return FlatMerge(boms, (Component)null); } /// @@ -260,6 +390,67 @@ public static Bom FlatMerge(IEnumerable boms, Component bomSubject) return result; } +#if NET8_0_OR_GREATER + /// Strategy-aware equivalent of . + public static Bom FlatMerge(IEnumerable boms, MergeStrategy strategy) + { + return FlatMerge(boms, null, strategy); + } + + /// Strategy-aware equivalent of . + public static Bom FlatMerge(IEnumerable boms, Component bomSubject, MergeStrategy strategy) + { + strategy ??= MergeStrategy.Default(); + var result = new Bom(); + + foreach (var bom in boms) + { + result = FlatMerge(result, bom, strategy); + } + + if (bomSubject != null) + { + if (result.Metadata == null) + { + result.Metadata = new Metadata(); + } + result.Metadata.Component = bomSubject; + result.Metadata.Component.BomRef = ComponentBomRefNamespace(result.Metadata.Component); + + var mainDependency = new Dependency + { + Ref = result.Metadata.Component.BomRef, + Dependencies = new List() + }; + + foreach (var bom in boms) + { + if (!(bom.Metadata?.Component is null)) + { + mainDependency.Dependencies.Add(new Dependency { Ref = bom.Metadata.Component.BomRef }); + } + } + + if (result.Dependencies == null) + { + result.Dependencies = new List(); + } + result.Dependencies.Add(mainDependency); + } + + if (strategy.DoBomMetadataUpdate) + { + result.BomMetadataUpdate(strategy.DoBomMetadataUpdateNewSerialNumber); + if (strategy.DoBomMetadataUpdateReferThisToolkit) + { + result.BomMetadataReferThisToolkit(); + } + } + + return result; + } +#endif + /// /// Performs a hierarchical merge for multiple BOMs. /// @@ -498,6 +689,34 @@ bom.SerialNumber is null return result; } +#if NET8_0_OR_GREATER + /// + /// Hierarchical merge with a . Hierarchical + /// merge already keeps each source BOM's component subtree separate + /// (via bom-ref namespacing rather than deduplication), which is + /// what most of MergeStrategy's component-conflict-resolution + /// concern exists to handle for FlatMerge -- so this overload's + /// only behavioral addition today is applying the metadata-update + /// toggles afterwards. + /// + public static Bom HierarchicalMerge(IEnumerable boms, Component bomSubject, MergeStrategy strategy) + { + strategy ??= MergeStrategy.Default(); + var result = HierarchicalMerge(boms, bomSubject); + + if (strategy.DoBomMetadataUpdate) + { + result.BomMetadataUpdate(strategy.DoBomMetadataUpdateNewSerialNumber); + if (strategy.DoBomMetadataUpdateReferThisToolkit) + { + result.BomMetadataReferThisToolkit(); + } + } + + return result; + } +#endif + private static void NamespaceBomRefs(Component bomSubject, IEnumerable references) { if (references == null) diff --git a/tests/CycloneDX.Utils.Tests/MergeStrategyTests.cs b/tests/CycloneDX.Utils.Tests/MergeStrategyTests.cs new file mode 100644 index 00000000..19bc54ff --- /dev/null +++ b/tests/CycloneDX.Utils.Tests/MergeStrategyTests.cs @@ -0,0 +1,150 @@ +// This file is part of CycloneDX Library for .NET +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +#if NET8_0_OR_GREATER +using System.Collections.Generic; +using Xunit; +using CycloneDX; +using CycloneDX.Models; +using CycloneDX.Utils; + +namespace CycloneDX.Utils.Tests +{ + public class MergeStrategyTests + { + [Fact] + public void Component_Equivalent_MatchesOnTypeAndName_IgnoresBomRef() + { + var a = new Component { Name = "left-pad", Version = "1.0.0", BomRef = "ref-a" }; + var b = new Component { Name = "left-pad", Version = "1.0.0", BomRef = "ref-b" }; + + Assert.True(a.Equivalent(b, MergeStrategy.Default())); + } + + [Fact] + public void Component_Equivalent_False_WhenNameDiffers() + { + var a = new Component { Name = "left-pad", Version = "1.0.0" }; + var b = new Component { Name = "right-pad", Version = "1.0.0" }; + + Assert.False(a.Equivalent(b, MergeStrategy.Default())); + } + + [Fact] + public void Component_MergeWith_SquashesOptionalScopes() + { + var a = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Optional }; + var b = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Optional, Description = "pads strings" }; + + Assert.True(a.MergeWith(b, MergeStrategy.Default())); + Assert.Equal(Component.ComponentScope.Optional, a.Scope); + Assert.Equal("pads strings", a.Description); + } + + [Fact] + public void Component_MergeWith_RefusesExcludedVsRequiredConflict() + { + var a = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Excluded }; + var b = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Required }; + + Assert.False(a.MergeWith(b, MergeStrategy.Default())); + } + + [Fact] + public void Component_MergeWith_KeepsBothExcluded_EvenWithUnrelatedFieldDifference() + { + // Regression check for the bug this port fixes: two components + // that are both Excluded-scope but differ in some unrelated + // field must still merge, not be treated as a scope conflict. + var a = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Excluded }; + var b = new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Excluded, Copyright = "2024 Acme" }; + + Assert.True(a.MergeWith(b, MergeStrategy.Default())); + Assert.Equal(Component.ComponentScope.Excluded, a.Scope); + Assert.Equal("2024 Acme", a.Copyright); + } + + [Fact] + public void Hash_MergeWith_FillsInMissingContent() + { + var a = new Hash { Alg = Hash.HashAlgorithm.SHA_256, Content = null }; + var b = new Hash { Alg = Hash.HashAlgorithm.SHA_256, Content = "abc123" }; + + Assert.True(a.MergeWith(b, MergeStrategy.Default())); + Assert.Equal("abc123", a.Content); + } + + [Fact] + public void Hash_MergeWith_RefusesContentMismatch() + { + var a = new Hash { Alg = Hash.HashAlgorithm.SHA_256, Content = "abc123" }; + var b = new Hash { Alg = Hash.HashAlgorithm.SHA_256, Content = "def456" }; + + Assert.False(a.MergeWith(b, MergeStrategy.Default())); + } + + [Fact] + public void FlatMerge_WithStrategy_SquashesEquivalentComponentsAcrossBoms() + { + var bom1 = new Bom + { + Components = new List + { + new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Optional } + } + }; + var bom2 = new Bom + { + Components = new List + { + new Component { Name = "left-pad", Version = "1.0.0", Scope = Component.ComponentScope.Optional, Description = "pads strings" } + } + }; + + var result = CycloneDXUtils.FlatMerge(bom1, bom2, MergeStrategy.Default()); + + Assert.Single(result.Components); + Assert.Equal("pads strings", result.Components[0].Description); + } + + [Fact] + public void BomRenameRef_RewritesIdentifierAndBackReferences() + { + var bom = new Bom + { + Components = new List { new Component { Name = "left-pad", BomRef = "old-ref" } }, + Dependencies = new List + { + new Dependency { Ref = "root", Dependencies = new List { new Dependency { Ref = "old-ref" } } } + } + }; + + Assert.True(bom.RenameRef("old-ref", "new-ref")); + Assert.Equal("new-ref", bom.Components[0].BomRef); + Assert.Equal("new-ref", bom.Dependencies[0].Dependencies[0].Ref); + } + + [Fact] + public void BomRenameRef_ReturnsFalse_WhenRefNotPresent() + { + var bom = new Bom { Components = new List { new Component { Name = "left-pad", BomRef = "some-ref" } } }; + + Assert.False(bom.RenameRef("missing-ref", "new-ref")); + } + } +} +#endif