Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f01e735
Introduce IBomEntity/IMergeable<T>/IEquivalent<T> interfaces and Merg…
jimklimov Aug 26, 2026
c2b4dac
Retarget mergeable model classes to IMergeable<T>/IEquivalent<T>
jimklimov Aug 26, 2026
4c6624c
Add strategy-aware MergeableListHelper.Merge<T>
jimklimov Aug 26, 2026
5e566bb
Component: real Equivalent/MergeWith, field-by-field, no reflection
jimklimov Aug 26, 2026
0ad6727
Add BomRefWalker and Bom.RenameRef/BomMetadataUpdate/ReferThisToolkit
jimklimov Aug 26, 2026
e3074cc
Add strategy-aware FlatMerge/HierarchicalMerge overloads
jimklimov Aug 26, 2026
a76cde2
Add focused tests for the new merge-strategy capability
jimklimov Aug 26, 2026
139c3b0
Add strategy-aware multi-BOM FlatMerge(IEnumerable<Bom>, ...) overloads
jimklimov Aug 26, 2026
578100f
Flip default scope-conflict resolution to Squash_UpgradeScope; rename…
jimklimov Aug 27, 2026
3331ec7
Bom.RenameRef: refuse rather than silently collide
jimklimov Aug 27, 2026
356abf7
Dependency: real Equivalent/MergeWith for subset-dependency merging
jimklimov Aug 27, 2026
f7c0583
Implement Squash_RenameByScope: split scope-conflicting components by…
jimklimov Aug 27, 2026
30fd0b6
Merge.cs: fix stale comment on Dependency reconciliation
jimklimov Sep 17, 2026
32ac0f8
Add CleanupMetadataComponent/CleanupEmptyLists, call from strategy me…
jimklimov Sep 2, 2026
625d2b1
FlatMerge: reconcile a bom's own Metadata.Component via Equivalent(),…
jimklimov Sep 3, 2026
7bb8f23
RenameBomRefCollisions: don't rename pairs the generic merge will squ…
jimklimov Sep 3, 2026
7117d23
Bom: add AttachDanglingComponents to close gaps in the dependency graph
jimklimov Sep 4, 2026
2e3a2c2
Merge.cs: add CleanupEmptyListsDeep, a recursive empty-list pruner
jimklimov Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions src/CycloneDX.Core/BomRefWalker.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Rewrites every "bom-ref"-shaped value in a <see cref="Bom"/> 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.
/// </summary>
/// <remarks>
/// 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
/// <c>RewriteRefs(bom, r => $"{ns}:{r}")</c>; a manual single-ref
/// rename (as the CLI's <c>rename-entity</c> command needs) becomes
/// <c>RewriteRefs(bom, r => r == oldRef ? newRef : r)</c>.
///
/// 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.
/// </remarks>
public static class BomRefWalker
{
public static void RewriteRefs(Bom bom, Func<string, string> 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<string, string> rewrite)
{
var pending = new Stack<Component>();
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<Dependency> dependencies, Func<string, string> rewrite)
{
var pending = new Stack<Dependency>(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<string> refs, Func<string, string> rewrite)
{
if (refs is null)
{
return;
}
for (var i = 0; i < refs.Count; i++)
{
refs[i] = rewrite(refs[i]);
}
}
}
}
#endif
127 changes: 127 additions & 0 deletions src/CycloneDX.Core/MergeableListHelper.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Strategy-aware list merging for any element type implementing
/// IEquatable/IEquivalent/IMergeable. One generic method merges
/// List&lt;Hash&gt;, List&lt;Component&gt;, List&lt;OrganizationalContact&gt;,
/// 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.
/// </summary>
public static class MergeableListHelper
{
public static List<T> Merge<T>(List<T> list1, List<T> list2, MergeStrategy strategy)
where T : IEquatable<T>, IEquivalent<T>, IMergeable<T>
{
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<T>(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;
}

/// <summary>
/// Cheap fallback: dedupe by exact equality only, no MergeWith
/// attempts. Mirrors CycloneDX.Utils.ListMergeHelper&lt;T&gt;'s
/// behavior (kept independently here to avoid a Core-&gt;Utils
/// dependency, which would invert this project's reference graph).
/// </summary>
private static List<T> ExactMatchMerge<T>(List<T> list1, List<T> list2) where T : IEquatable<T>
{
var result = new List<T>(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;
}

/// <summary>Take whichever of two nullable reference values is non-null, preferring <paramref name="a"/>.</summary>
public static T MergeSingle<T>(T a, T b) where T : class => a ?? b;

/// <summary>Union two string lists, preserving order, dropping duplicates. Null if both are null.</summary>
public static List<string> MergeStringList(List<string> a, List<string> b)
{
if (a is null) return b;
if (b is null) return a;
var result = new List<string>(a);
foreach (var s in b)
{
if (!result.Contains(s))
{
result.Add(s);
}
}
return result;
}

/// <summary>Nullable-bool "either says true" merge: null if both unset, else true if either is true.</summary>
public static bool? MergeNullableBoolOr(bool? a, bool? b)
{
if (!a.HasValue && !b.HasValue) return null;
return (a ?? false) || (b ?? false);
}
}
}
#endif
3 changes: 3 additions & 0 deletions src/CycloneDX.Core/Models/Annotation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ namespace CycloneDX.Models
{
[ProtoContract]
public class Annotation : IEquatable<Annotation>
#if NET8_0_OR_GREATER
, IMergeable<Annotation>, IEquivalent<Annotation>
#endif
{
[XmlType("subject")]
public class XmlAnnotationSubject
Expand Down
Loading