From cf36d4a27c71429a54c4272235c65586493a6b78 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:50:50 +0200 Subject: [PATCH 1/2] 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 eb40e366af5e064098f474769d7e0763efcd516c Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 27 Aug 2026 00:53:06 +0200 Subject: [PATCH 2/2] 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 + + + + + + + + +