From e937b62215298f3052fb8b0977703520e48f36e2 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Tue, 15 Sep 2026 18:04:25 +0200 Subject: [PATCH 1/4] Wire merge command to the new MergeStrategy MergeCommand now calls CycloneDXUtils.FlatMerge/HierarchicalMerge with MergeStrategy.Default() when built against a library that has it (#if NET8_0_OR_GREATER, matching the library's own guard -- CLI is net10.0-only today but this keeps the two projects' conditional compilation symmetric and self-documenting), falling back to the plain overload otherwise. No new CLI flags: strategy toggles are not yet exposed at the command-line layer, so this only changes default merge behavior, not the command's surface. Signed-off-by: Jim Klimov --- src/cyclonedx/Commands/MergeCommand.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index 6c61bc1..742fe33 100644 --- a/src/cyclonedx/Commands/MergeCommand.cs +++ b/src/cyclonedx/Commands/MergeCommand.cs @@ -80,11 +80,19 @@ public static async Task Merge(MergeCommandOptions options) Bom outputBom; if (options.Hierarchical) { +#if NET8_0_OR_GREATER + outputBom = CycloneDXUtils.HierarchicalMerge(inputBoms, bomSubject, MergeStrategy.Default()); +#else outputBom = CycloneDXUtils.HierarchicalMerge(inputBoms, bomSubject); +#endif } else { +#if NET8_0_OR_GREATER + outputBom = CycloneDXUtils.FlatMerge(inputBoms, MergeStrategy.Default()); +#else outputBom = CycloneDXUtils.FlatMerge(inputBoms); +#endif if (outputBom.Metadata is null) outputBom.Metadata = new Metadata(); if (bomSubject != null) { From 0cc43c224ed7d41a34c2199fe4a80d840858e9bd Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:50:50 +0200 Subject: [PATCH 2/4] Add rename-entity command: rewrite a bom-ref and its back-references Adds a "rename-entity" command that renames a bom-ref and every back-reference to it throughout a BOM document, built on the library's new BomRefWalker-based Bom.RenameRef(old, new) API. Follows current System.CommandLine conventions (System.CommandLine.NamingConventionBinder, not the older System.CommandLine.Invocation namespace) and matches Convert/ MergeCommand's internal (not public) visibility. Verified end-to-end against a real fixture: renaming a component's bom-ref correctly rewrites both the dependsOn back-reference and the dependency's own ref entry, and stamps fresh SerialNumber/Timestamp/ Tools metadata via BomMetadataUpdate/BomMetadataReferThisToolkit. Wired into Program.cs (alphabetical position, between Merge and Sign), guarded by #if NET8_0_OR_GREATER to match the library capability it depends on. Signed-off-by: Jim Klimov --- src/cyclonedx/Commands/RenameEntityCommand.cs | 95 +++++++++++++++++++ .../Commands/RenameEntityCommandOptions.cs | 31 ++++++ src/cyclonedx/Program.cs | 3 + 3 files changed, 129 insertions(+) create mode 100644 src/cyclonedx/Commands/RenameEntityCommand.cs create mode 100644 src/cyclonedx/Commands/RenameEntityCommandOptions.cs diff --git a/src/cyclonedx/Commands/RenameEntityCommand.cs b/src/cyclonedx/Commands/RenameEntityCommand.cs new file mode 100644 index 0000000..8a5b5aa --- /dev/null +++ b/src/cyclonedx/Commands/RenameEntityCommand.cs @@ -0,0 +1,95 @@ +// This file is part of CycloneDX CLI Tool +// +// 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.CommandLine; +using System.CommandLine.NamingConventionBinder; +using System.Diagnostics.Contracts; +using System.Threading.Tasks; + +namespace CycloneDX.Cli.Commands +{ + internal static class RenameEntityCommand + { + internal static void Configure(RootCommand rootCommand) + { + Contract.Requires(rootCommand != null); + var subCommand = new Command("rename-entity", "Rename an entity identified by a \"bom-ref\" (including back-references to it) in the BOM document"); + subCommand.Add(new Option("--input-file", "Input BOM filename.")); + subCommand.Add(new Option("--output-file", "Output BOM filename, will write to stdout if no value provided.")); + subCommand.Add(new Option("--old-ref", "Old value of \"bom-ref\" entity identifier (or \"ref\" values or certain list items pointing to it).")); + subCommand.Add(new Option("--new-ref", "New value of \"bom-ref\" entity identifier (or \"ref\" values or certain list items pointing to it).")); + subCommand.Add(new Option("--input-format", "Specify input file format.")); + subCommand.Add(new Option("--output-format", "Specify output file format.")); + subCommand.Handler = CommandHandler.Create(RenameEntity); + rootCommand.Add(subCommand); + } + + public static async Task RenameEntity(RenameEntityCommandOptions options) + { + Contract.Requires(options != null); + var outputToConsole = string.IsNullOrEmpty(options.OutputFile); + + if (options.OutputFormat == CycloneDXBomFormat.autodetect) + { + options.OutputFormat = CliUtils.AutoDetectBomFormat(options.OutputFile); + if (options.OutputFormat == CycloneDXBomFormat.autodetect) + { + Console.WriteLine($"Unable to auto-detect output format"); + return (int)ExitCode.ParameterValidationError; + } + } + + Console.WriteLine($"Loading input document..."); + if (!outputToConsole) Console.WriteLine($"Processing input file {options.InputFile}"); + var bom = await CliUtils.InputBomHelper(options.InputFile, options.InputFormat).ConfigureAwait(false); + + if (bom is null) + { + Console.WriteLine($"Empty or absent input document"); + return (int)ExitCode.ParameterValidationError; + } + + Console.WriteLine($"Renaming \"{options.OldRef}\" to \"{options.NewRef}\" (this can take a while)"); + if (bom.RenameRef(options.OldRef, options.NewRef)) + { + Console.WriteLine($"Did not encounter any issues during the rename operation"); + } + else + { + Console.WriteLine($"Rename operation found nothing to do (e.g. old ref name not mentioned in the Bom document)"); + } + + // Ensure that the modified document has its own identity + // (new SerialNumber, Version=1, Timestamp...) and its Tools + // collection refers to this library and the program/tool + // like cyclonedx-cli which consumes it: + bom.BomMetadataUpdate(true); + bom.BomMetadataReferThisToolkit(); + + if (!outputToConsole) + { + Console.WriteLine("Writing output file..."); + Console.WriteLine($" Total {bom.Components?.Count ?? 0} components, {bom.Dependencies?.Count ?? 0} dependencies"); + } + + int res = await CliUtils.OutputBomHelper(bom, options.OutputFormat, options.OutputFile).ConfigureAwait(false); + return res; + } + } +} +#endif diff --git a/src/cyclonedx/Commands/RenameEntityCommandOptions.cs b/src/cyclonedx/Commands/RenameEntityCommandOptions.cs new file mode 100644 index 0000000..e07f872 --- /dev/null +++ b/src/cyclonedx/Commands/RenameEntityCommandOptions.cs @@ -0,0 +1,31 @@ +// This file is part of CycloneDX CLI Tool +// +// 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.Cli.Commands +{ + internal class RenameEntityCommandOptions + { + public string InputFile { get; set; } + public string OutputFile { get; set; } + public string OldRef { get; set; } + public string NewRef { get; set; } + public CycloneDXBomFormat InputFormat { get; set; } + public CycloneDXBomFormat OutputFormat { get; set; } + } +} +#endif diff --git a/src/cyclonedx/Program.cs b/src/cyclonedx/Program.cs index 237b4ae..7e6b91d 100644 --- a/src/cyclonedx/Program.cs +++ b/src/cyclonedx/Program.cs @@ -47,6 +47,9 @@ public static async Task Main(string[] args) DiffCommand.Configure(rootCommand); KeyGenCommand.Configure(rootCommand); MergeCommand.Configure(rootCommand); +#if NET8_0_OR_GREATER + RenameEntityCommand.Configure(rootCommand); +#endif SignCommand.Configure(rootCommand); ValidateCommand.Configure(rootCommand); VerifyCommand.Configure(rootCommand); From 8326d6e875f274be045fbd0351684b5a02a40bd2 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:53:06 +0200 Subject: [PATCH 3/4] Add regression tests for rename-entity Follows MergeTests.cs conventions: direct handler call, TempDirectory, Snapshooter with serialNumber/timestamp stripped (plus the tools list, whose contents are build/environment-specific -- assembly versions and "testhost" vs. the real CLI name under `dotnet test`). Covers a JSON and an XML output round-trip of the rewrite-identifier-and-back-refs case, plus a no-op case when the requested old-ref isn't present. Full suite: 132 passed / 0 failed (129 pre-existing + 3 new). Signed-off-by: Jim Klimov --- tests/cyclonedx.tests/RenameEntityTests.cs | 100 ++++++++++++++++++ .../Resources/RenameEntity/sbom1.json | 26 +++++ ....json_autodetect_sbom.json_autodetect.snap | 31 ++++++ ...s_sbom1.json_json_sbom.xml_autodetect.snap | 21 ++++ 4 files changed, 178 insertions(+) create mode 100644 tests/cyclonedx.tests/RenameEntityTests.cs create mode 100644 tests/cyclonedx.tests/Resources/RenameEntity/sbom1.json create mode 100644 tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_autodetect_sbom.json_autodetect.snap create mode 100644 tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_json_sbom.xml_autodetect.snap diff --git a/tests/cyclonedx.tests/RenameEntityTests.cs b/tests/cyclonedx.tests/RenameEntityTests.cs new file mode 100644 index 0000000..6387bc2 --- /dev/null +++ b/tests/cyclonedx.tests/RenameEntityTests.cs @@ -0,0 +1,100 @@ +// This file is part of CycloneDX CLI Tool +// +// 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.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Xunit; +using Snapshooter; +using Snapshooter.Xunit; +using CycloneDX.Cli.Commands; + +namespace CycloneDX.Cli.Tests +{ + public class RenameEntityTests + { + [Theory] + [InlineData("sbom1.json", CycloneDXBomFormat.autodetect, "sbom.json", CycloneDXBomFormat.autodetect)] + [InlineData("sbom1.json", CycloneDXBomFormat.json, "sbom.xml", CycloneDXBomFormat.autodetect)] + public async Task RenameEntity_RewritesIdentifierAndBackReferences( + string inputFilename, + CycloneDXBomFormat inputFormat, + string outputFilename, + CycloneDXBomFormat outputFormat + ) + { + using (var tempDirectory = new TempDirectory()) + { + var fullOutputPath = Path.Join(tempDirectory.DirectoryPath, outputFilename); + var options = new RenameEntityCommandOptions + { + InputFile = Path.Combine("Resources", "RenameEntity", inputFilename), + InputFormat = inputFormat, + OutputFile = fullOutputPath, + OutputFormat = outputFormat, + OldRef = "lib-old", + NewRef = "lib-new", + }; + + var exitCode = await RenameEntityCommand.RenameEntity(options).ConfigureAwait(false); + + Assert.Equal(0, exitCode); + var bom = File.ReadAllText(fullOutputPath); + bom = Regex.Replace(bom, @"\s*""serialNumber"": "".*?"",\r?\n", ""); // json + bom = Regex.Replace(bom, @"\s+serialNumber="".*?""", ""); // xml + bom = Regex.Replace(bom, @"\s*""timestamp"": "".*?"",\r?\n", ""); // json + bom = Regex.Replace(bom, @"\s+.*?", ""); // xml + // The tools list embeds this build's assembly names/versions + // (e.g. "testhost" under `dotnet test` vs. the real CLI + // executable otherwise), which are environment-specific -- + // strip the whole block before snapshotting. + bom = Regex.Replace(bom, @"\s*""tools"":\s*\[.*?\],?", "", RegexOptions.Singleline); // json + bom = Regex.Replace(bom, @"\s*.*?", "", RegexOptions.Singleline); // xml + + Assert.DoesNotContain("lib-old", bom); + Assert.Contains("lib-new", bom); + Snapshot.Match(bom, SnapshotNameExtension.Create(inputFilename, inputFormat, outputFilename, outputFormat)); + } + } + + [Fact] + public async Task RenameEntity_NoOp_WhenOldRefNotPresent() + { + using (var tempDirectory = new TempDirectory()) + { + var fullOutputPath = Path.Join(tempDirectory.DirectoryPath, "sbom.json"); + var options = new RenameEntityCommandOptions + { + InputFile = Path.Combine("Resources", "RenameEntity", "sbom1.json"), + InputFormat = CycloneDXBomFormat.autodetect, + OutputFile = fullOutputPath, + OutputFormat = CycloneDXBomFormat.autodetect, + OldRef = "does-not-exist", + NewRef = "lib-new", + }; + + var exitCode = await RenameEntityCommand.RenameEntity(options).ConfigureAwait(false); + + Assert.Equal(0, exitCode); + var bom = File.ReadAllText(fullOutputPath); + Assert.Contains("lib-old", bom); + Assert.DoesNotContain("lib-new", bom); + } + } + } +} +#endif diff --git a/tests/cyclonedx.tests/Resources/RenameEntity/sbom1.json b/tests/cyclonedx.tests/Resources/RenameEntity/sbom1.json new file mode 100644 index 0000000..49cf30b --- /dev/null +++ b/tests/cyclonedx.tests/Resources/RenameEntity/sbom1.json @@ -0,0 +1,26 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "app-1", + "name": "thing1", + "version": "1" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "lib-old", + "name": "acme-library", + "version": "1.0.0" + } + ], + "dependencies": [ + { "ref": "app-1", "dependsOn": ["lib-old"] }, + { "ref": "lib-old", "dependsOn": [] } + ] +} diff --git a/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_autodetect_sbom.json_autodetect.snap b/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_autodetect_sbom.json_autodetect.snap new file mode 100644 index 0000000..a20decf --- /dev/null +++ b/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_autodetect_sbom.json_autodetect.snap @@ -0,0 +1,31 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "app-1", + "name": "thing1", + "version": "1" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "lib-new", + "name": "acme-library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "app-1", + "dependsOn": [ + "lib-new" + ] + }, + { + "ref": "lib-new" + } + ] +} diff --git a/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_json_sbom.xml_autodetect.snap b/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_json_sbom.xml_autodetect.snap new file mode 100644 index 0000000..d883e99 --- /dev/null +++ b/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_json_sbom.xml_autodetect.snap @@ -0,0 +1,21 @@ + + + + + thing1 + 1 + + + + + acme-library + 1.0.0 + + + + + + + + + From 780e111bd52646d2dc38d274957228f869a26fe8 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 11:02:46 +0200 Subject: [PATCH 4/4] Expose --component-conflict-resolution on merge; handle RenameRef refusal merge gains --component-conflict-resolution , defaulting to the library's MergeStrategy.Default() (Squash_UpgradeScope) when not specified. This closes a pre-existing gap: the library-side strategy was never selectable from the CLI at all -- it was always hardcoded to Default() internally. Verified end-to-end: merging two BOMs where the same component is "required" in one and "excluded" in the other with --component-conflict-resolution Squash_RenameByScope produces two distinct components (lp:scope=Required / lp:scope=Excluded) with each source's dependsOn correctly pointing at its own variant. rename-entity now catches the InvalidOperationException Bom.RenameRef throws when the requested new-ref collides with an existing identifier, reporting it as a clean parameter-validation error instead of an unhandled crash. Signed-off-by: Jim Klimov --- src/cyclonedx/Commands/MergeCommand.cs | 17 ++++++++++++++--- src/cyclonedx/Commands/MergeCommandOptions.cs | 4 ++++ src/cyclonedx/Commands/RenameEntityCommand.cs | 16 ++++++++++++---- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index 742fe33..184f34f 100644 --- a/src/cyclonedx/Commands/MergeCommand.cs +++ b/src/cyclonedx/Commands/MergeCommand.cs @@ -41,7 +41,10 @@ public static void Configure(RootCommand rootCommand) new Option("--hierarchical", "Perform a hierarchical merge."), new Option("--group", "Provide the group of software the merged BOM describes."), new Option("--name", "Provide the name of software the merged BOM describes (required for hierarchical merging)."), - new Option("--version", "Provide the version of software the merged BOM describes (required for hierarchical merging).") + new Option("--version", "Provide the version of software the merged BOM describes (required for hierarchical merging)."), +#if NET8_0_OR_GREATER + new Option("--component-conflict-resolution", "How to resolve two equivalent (same type/name/version/group/purl) but not-identical Components, e.g. differing only by Scope. Default: squash, preferring the more permissive Scope."), +#endif }; subCommand.Handler = CommandHandler.Create(Merge); rootCommand.Add(subCommand); @@ -77,11 +80,19 @@ public static async Task Merge(MergeCommandOptions options) Version = options.Version, }; +#if NET8_0_OR_GREATER + var mergeStrategy = MergeStrategy.Default(); + if (options.ComponentConflictResolution.HasValue) + { + mergeStrategy.ComponentConflictResolution = options.ComponentConflictResolution.Value; + } +#endif + Bom outputBom; if (options.Hierarchical) { #if NET8_0_OR_GREATER - outputBom = CycloneDXUtils.HierarchicalMerge(inputBoms, bomSubject, MergeStrategy.Default()); + outputBom = CycloneDXUtils.HierarchicalMerge(inputBoms, bomSubject, mergeStrategy); #else outputBom = CycloneDXUtils.HierarchicalMerge(inputBoms, bomSubject); #endif @@ -89,7 +100,7 @@ public static async Task Merge(MergeCommandOptions options) else { #if NET8_0_OR_GREATER - outputBom = CycloneDXUtils.FlatMerge(inputBoms, MergeStrategy.Default()); + outputBom = CycloneDXUtils.FlatMerge(inputBoms, mergeStrategy); #else outputBom = CycloneDXUtils.FlatMerge(inputBoms); #endif diff --git a/src/cyclonedx/Commands/MergeCommandOptions.cs b/src/cyclonedx/Commands/MergeCommandOptions.cs index 29d734a..36e07d7 100644 --- a/src/cyclonedx/Commands/MergeCommandOptions.cs +++ b/src/cyclonedx/Commands/MergeCommandOptions.cs @@ -15,6 +15,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. using System.Collections.Generic; +using CycloneDX.Models; namespace CycloneDX.Cli.Commands { @@ -29,5 +30,8 @@ internal class MergeCommandOptions public string Group { get; set; } public string Name { get; set; } public string Version { get; set; } +#if NET8_0_OR_GREATER + public ComponentConflictResolution? ComponentConflictResolution { get; set; } +#endif } } diff --git a/src/cyclonedx/Commands/RenameEntityCommand.cs b/src/cyclonedx/Commands/RenameEntityCommand.cs index 8a5b5aa..011d8b7 100644 --- a/src/cyclonedx/Commands/RenameEntityCommand.cs +++ b/src/cyclonedx/Commands/RenameEntityCommand.cs @@ -65,13 +65,21 @@ public static async Task RenameEntity(RenameEntityCommandOptions options) } Console.WriteLine($"Renaming \"{options.OldRef}\" to \"{options.NewRef}\" (this can take a while)"); - if (bom.RenameRef(options.OldRef, options.NewRef)) + try { - Console.WriteLine($"Did not encounter any issues during the rename operation"); + if (bom.RenameRef(options.OldRef, options.NewRef)) + { + Console.WriteLine($"Did not encounter any issues during the rename operation"); + } + else + { + Console.WriteLine($"Rename operation found nothing to do (e.g. old ref name not mentioned in the Bom document)"); + } } - else + catch (InvalidOperationException ex) { - Console.WriteLine($"Rename operation found nothing to do (e.g. old ref name not mentioned in the Bom document)"); + Console.WriteLine($"Rename operation refused: {ex.Message}"); + return (int)ExitCode.ParameterValidationError; } // Ensure that the modified document has its own identity