From e937b62215298f3052fb8b0977703520e48f36e2 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Tue, 15 Sep 2026 18:04:25 +0200 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 From 4796bd4a744c63c8d47d0214ccf061fc5b85cea0 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Wed, 2 Sep 2026 23:32:20 +0200 Subject: [PATCH 05/12] Add --input-files-list / --input-files-nul-list to merge This lets callers pass files listing BOM filenames (one per line, or 0x00-separated for the --input-files-nul-list variant, e.g. from `find -print0`) instead of one --input-files argument per BOM, to avoid OS/shell command-line length or argument-count limits when merging many BOMs, a real constraint once the number of input files grows large. Entries are deduplicated against --input-files and each other as they are collected. Non-absolute paths are resolved relative to the current working directory. No library changes needed -- this feature only builds a longer input-file list before the existing InputBoms(...) call. Signed-off-by: Jim Klimov --- src/cyclonedx/Commands/MergeCommand.cs | 59 ++++++++++++++++++- src/cyclonedx/Commands/MergeCommandOptions.cs | 2 + 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index 184f34f..b638fb9 100644 --- a/src/cyclonedx/Commands/MergeCommand.cs +++ b/src/cyclonedx/Commands/MergeCommand.cs @@ -19,6 +19,7 @@ using System.Diagnostics.Contracts; using System.CommandLine; using System.CommandLine.Invocation; +using System.IO; using System.Threading.Tasks; using CycloneDX.Models; using CycloneDX.Utils; @@ -34,6 +35,8 @@ public static void Configure(RootCommand rootCommand) var subCommand = new System.CommandLine.Command("merge", "Merge two or more BOMs") { new Option>("--input-files", "Input BOM filenames (separate filenames with a space).") { AllowMultipleArgumentsPerToken = true }, + new Option>("--input-files-list", "One or more text file(s) with input BOM filenames (one per line). Combined with --input-files, useful to exceed OS/shell command-line length limits when merging many BOMs.") { AllowMultipleArgumentsPerToken = true }, + new Option>("--input-files-nul-list", "One or more text-like file(s) with input BOM filenames (separated by 0x00 characters, e.g. from `find -print0`).") { AllowMultipleArgumentsPerToken = true }, new Option("--output-file", "Output BOM filename, will write to stdout if no value provided."), new Option("--input-format", "Specify input file format."), new Option("--output-format", "Specify output file format."), @@ -68,7 +71,7 @@ public static async Task Merge(MergeCommandOptions options) return (int)ExitCode.ParameterValidationError; } - var inputBoms = await InputBoms(options.InputFiles, options.InputFormat, outputToConsole).ConfigureAwait(false); + var inputBoms = await InputBoms(DetermineInputFiles(options), options.InputFormat, outputToConsole).ConfigureAwait(false); Component bomSubject = null; if (options.Group != null || options.Name != null || options.Version != null) @@ -144,6 +147,60 @@ public static async Task Merge(MergeCommandOptions options) return await CliUtils.OutputBomHelper(outputBom, (ConvertFormat)options.OutputFormat, options.OutputVersion, options.OutputFile).ConfigureAwait(false); } + /// + /// Combines --input-files with any filenames listed inside + /// --input-files-list (one per line) and --input-files-nul-list + /// (0x00-separated) files, deduplicating as it goes. Lets callers + /// exceed OS/shell command-line length or argument-count limits + /// when merging many BOMs, by passing a generated list file + /// instead of one --input-files argument per BOM. + /// + private static List DetermineInputFiles(MergeCommandOptions options) + { + var inputFiles = options.InputFiles != null ? new List(options.InputFiles) : new List(); + + if (options.InputFilesList != null) + { + foreach (var oneList in options.InputFilesList) + { + Console.WriteLine($"Adding to input file list from {oneList}"); + var count = 0; + foreach (var line in File.ReadAllLines(oneList)) + { + if (string.IsNullOrEmpty(line) || inputFiles.Contains(line)) + { + continue; + } + inputFiles.Add(line); + count++; + } + Console.WriteLine($"Got {count} new entries from {oneList}"); + } + } + + if (options.InputFilesNulList != null) + { + foreach (var oneList in options.InputFilesNulList) + { + Console.WriteLine($"Adding to input file list from {oneList}"); + var count = 0; + foreach (var line in File.ReadAllText(oneList).Split('\0')) + { + if (string.IsNullOrEmpty(line) || inputFiles.Contains(line)) + { + continue; + } + inputFiles.Add(line); + count++; + } + Console.WriteLine($"Got {count} new entries from {oneList}"); + } + } + + Console.WriteLine($"Determined {inputFiles.Count} input file(s) to merge"); + return inputFiles; + } + private static async Task> InputBoms(IEnumerable inputFilenames, CycloneDXBomFormat inputFormat, bool outputToConsole) { var boms = new List(); diff --git a/src/cyclonedx/Commands/MergeCommandOptions.cs b/src/cyclonedx/Commands/MergeCommandOptions.cs index 36e07d7..f1776b7 100644 --- a/src/cyclonedx/Commands/MergeCommandOptions.cs +++ b/src/cyclonedx/Commands/MergeCommandOptions.cs @@ -22,6 +22,8 @@ namespace CycloneDX.Cli.Commands internal class MergeCommandOptions { public IList InputFiles { get; set; } + public IList InputFilesList { get; set; } + public IList InputFilesNulList { get; set; } public string OutputFile { get; set; } public CycloneDXBomFormat InputFormat { get; set; } public CycloneDXBomFormat OutputFormat { get; set; } From 6530aed3e128f98917002dac18aeb3d50b8c0c33 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Wed, 2 Sep 2026 23:43:01 +0200 Subject: [PATCH 06/12] Pass the BOM subject into FlatMerge so its dependency graph is linked Previously, when a flat merge specified a subject component (--group/ --name/--version), MergeCommand set Metadata.Component directly and never called the FlatMerge overload that accepts a subject component. As a result the merged document's dependency graph never linked the subject to the components each input BOM contributed -- no "...` entry ever appeared, unlike a hierarchical merge with the same options. Calling FlatMerge(inputBoms, bomSubject, ...) instead lets the library build that dependency entry itself (namespacing the subject's bom-ref and linking it to each input BOM's own metadata component), matching what hierarchical merge already does. The "pick the first input BOM's component as a default" fallback still runs, but only when no subject was requested at all. Signed-off-by: Jim Klimov --- src/cyclonedx/Commands/MergeCommand.cs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index b638fb9..466f252 100644 --- a/src/cyclonedx/Commands/MergeCommand.cs +++ b/src/cyclonedx/Commands/MergeCommand.cs @@ -103,17 +103,12 @@ public static async Task Merge(MergeCommandOptions options) else { #if NET8_0_OR_GREATER - outputBom = CycloneDXUtils.FlatMerge(inputBoms, mergeStrategy); + outputBom = CycloneDXUtils.FlatMerge(inputBoms, bomSubject, mergeStrategy); #else - outputBom = CycloneDXUtils.FlatMerge(inputBoms); + outputBom = CycloneDXUtils.FlatMerge(inputBoms, bomSubject); #endif if (outputBom.Metadata is null) outputBom.Metadata = new Metadata(); - if (bomSubject != null) - { - // use the params provided if possible - outputBom.Metadata.Component = bomSubject; - } - else + if (bomSubject is null) { // otherwise use the first non-null component from the input BOMs as the default foreach (var bom in inputBoms) From 379d813d9630014a885cd70be4b8362d57ea0508 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Wed, 2 Sep 2026 23:45:23 +0200 Subject: [PATCH 07/12] Merge: stamp Tools metadata on the merged document Replaces manually setting Version/SerialNumber/Timestamp inline with Bom.BomMetadataUpdate(true) and Bom.BomMetadataReferThisToolkit(). The manual version never recorded which library/tool produced the merged document at all -- Metadata.Tools was left exactly as whichever input BOM happened to contribute one, or absent entirely. The merged document now carries an entry for this library and the running program, the same way validate/convert/sign already leave a traceable record of what processed a document. Updates MergeTests' snapshot-cleanup regex to also strip the tools block before comparison, since its contents (assembly name/version) are build-environment-specific -- same treatment already used for other commands' tests. Signed-off-by: Jim Klimov --- src/cyclonedx/Commands/MergeCommand.cs | 8 ++++++++ tests/cyclonedx.tests/MergeTests.cs | 6 ++++++ ....json_sbom2.json_autodetect_sbom.json_autodetect_.snap | 3 ++- ..._sbom1.json_sbom2.json_autodetect_sbom.json_json_.snap | 3 ++- ..._sbom1.json_sbom2.json_json_sbom.json_autodetect_.snap | 3 ++- ...m1.json_sbom2.json_json_sbom.json_autodetect_v1_4.snap | 3 ++- ...m1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap | 3 ++- ....json_sbom2.json_autodetect_sbom.json_autodetect_.snap | 2 +- 8 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index 466f252..2ef7d31 100644 --- a/src/cyclonedx/Commands/MergeCommand.cs +++ b/src/cyclonedx/Commands/MergeCommand.cs @@ -122,6 +122,13 @@ public static async Task Merge(MergeCommandOptions options) } } + // Ensure that the merged document has its own identity (new + // SerialNumber, Version=1, Timestamp...) and that its Tools + // collection records the library and program that produced it. +#if NET8_0_OR_GREATER + outputBom.BomMetadataUpdate(true); + outputBom.BomMetadataReferThisToolkit(); +#else outputBom.Version = 1; outputBom.SerialNumber = "urn:uuid:" + System.Guid.NewGuid().ToString(); if (outputBom.Metadata == null) @@ -132,6 +139,7 @@ public static async Task Merge(MergeCommandOptions options) { outputBom.Metadata.Timestamp = DateTime.Now; } +#endif if (!outputToConsole) { diff --git a/tests/cyclonedx.tests/MergeTests.cs b/tests/cyclonedx.tests/MergeTests.cs index 56b9c62..b09d3dc 100644 --- a/tests/cyclonedx.tests/MergeTests.cs +++ b/tests/cyclonedx.tests/MergeTests.cs @@ -79,6 +79,12 @@ public async Task Merge( 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 Snapshot.Match(bom, SnapshotNameExtension.Create(hierarchical ? "Hierarchical" : "Flat", snapshotInputFilenames, inputFormat, outputFilename, outputFormat, outputVersion)); } } diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap index 3858ba3..61351ab 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap @@ -1,7 +1,8 @@ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, - "metadata": { "component": { + "metadata": { + "component": { "type": "application", "name": "thing1", "version": "1" diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_json_.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_json_.snap index 3858ba3..61351ab 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_json_.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_json_.snap @@ -1,7 +1,8 @@ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, - "metadata": { "component": { + "metadata": { + "component": { "type": "application", "name": "thing1", "version": "1" diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_.snap index 3858ba3..61351ab 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_.snap @@ -1,7 +1,8 @@ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, - "metadata": { "component": { + "metadata": { + "component": { "type": "application", "name": "thing1", "version": "1" diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_v1_4.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_v1_4.snap index 00f59bb..31cf7f5 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_v1_4.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_v1_4.snap @@ -1,7 +1,8 @@ { "bomFormat": "CycloneDX", "specVersion": "1.4", "version": 1, - "metadata": { "component": { + "metadata": { + "component": { "type": "application", "name": "thing1", "version": "1" diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap index 1a5e2df..e152876 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap @@ -1,7 +1,8 @@ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, - "metadata": { "component": { + "metadata": { + "component": { "type": "application", "name": "thing1", "version": "1", diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Hierarchical_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Hierarchical_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap index bea7dfc..7175663 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Hierarchical_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Hierarchical_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap @@ -1,7 +1,7 @@ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, - "metadata": { "tools": {}, + "metadata": { "component": { "type": "application", "bom-ref": "Thing@1", From 3d44485a532e833325a00dcf3de8e0326b3a47f1 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Wed, 2 Sep 2026 23:49:50 +0200 Subject: [PATCH 08/12] Merge: clean up a duplicated metadata component and empty lists Calls the library's new CleanupMetadataComponent/CleanupEmptyLists on the merged document. Complements the library-side change (which already covers this for a merge that requests an explicit BOM subject): when MergeCommand falls back to picking the first input BOM's own metadata component as the subject, that same component can also be present in the merged Components list, producing two entries with the same bom-ref -- a specification violation the library-level cleanup couldn't see, since the CLI's fallback selection happens after the library call returns. CleanupEmptyLists also drops now-empty top-level lists (e.g. an empty "vulnerabilities": []) that a flat merge of BOMs with no vulnerabilities previously left in the output. Signed-off-by: Jim Klimov --- src/cyclonedx/Commands/MergeCommand.cs | 10 +++++++++- ...xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap | 4 +--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index 2ef7d31..72f1a6a 100644 --- a/src/cyclonedx/Commands/MergeCommand.cs +++ b/src/cyclonedx/Commands/MergeCommand.cs @@ -110,7 +110,10 @@ public static async Task Merge(MergeCommandOptions options) if (outputBom.Metadata is null) outputBom.Metadata = new Metadata(); if (bomSubject is null) { - // otherwise use the first non-null component from the input BOMs as the default + // otherwise use the first non-null component from the input + // BOMs as the default; note CleanupMetadataComponent below, + // since that same component may also already be present + // in outputBom.Components. foreach (var bom in inputBoms) { if(bom.Metadata != null && bom.Metadata.Component != null) @@ -122,6 +125,11 @@ public static async Task Merge(MergeCommandOptions options) } } +#if NET8_0_OR_GREATER + outputBom = CycloneDXUtils.CleanupMetadataComponent(outputBom, mergeStrategy); + outputBom = CycloneDXUtils.CleanupEmptyLists(outputBom); +#endif + // Ensure that the merged document has its own identity (new // SerialNumber, Version=1, Timestamp...) and that its Tools // collection records the library and program that produced it. diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap index e152876..634d23b 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap @@ -34,7 +34,5 @@ "version": "1", "patentAssertions": [] } - ], - "vulnerabilities": [], - "annotations": [] + ] } From d72c9fadec2dab85bbf3776571f32e1d14488ef3 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Wed, 2 Sep 2026 23:51:28 +0200 Subject: [PATCH 09/12] Add --validate-output / --validate-output-relaxed to merge Validates the merged document against its own spec version before writing it out. --validate-output aborts (does not write the file) if validation fails; --validate-output-relaxed still writes the file -- useful for troubleshooting a failing merge -- but the command still reports failure via its exit code either way. Without either flag, behavior is unchanged (no validation is performed). Signed-off-by: Jim Klimov --- src/cyclonedx/Commands/MergeCommand.cs | 40 ++++++++++++++++++- src/cyclonedx/Commands/MergeCommandOptions.cs | 2 + 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index 72f1a6a..be02944 100644 --- a/src/cyclonedx/Commands/MergeCommand.cs +++ b/src/cyclonedx/Commands/MergeCommand.cs @@ -45,6 +45,8 @@ public static void Configure(RootCommand rootCommand) 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("--validate-output", "Validate the merged document before writing it, and do not write it if validation fails."), + new Option("--validate-output-relaxed", "Validate the merged document, but still write it (for troubleshooting) even if validation fails."), #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 @@ -149,13 +151,49 @@ public static async Task Merge(MergeCommandOptions options) } #endif + ValidationResult validationResult = null; + if (options.ValidateOutput || options.ValidateOutputRelaxed) + { + Console.WriteLine("Validating merged BOM..."); + validationResult = Json.Validator.Validate(Json.Serializer.Serialize(outputBom), outputBom.SpecVersion); + + if (validationResult.Messages != null) + { + foreach (var message in validationResult.Messages) + { + Console.WriteLine(message); + } + } + + if (validationResult.Valid) + { + Console.WriteLine("Merged BOM validated successfully."); + } + else + { + Console.WriteLine("Merged BOM is not valid."); + if (!options.ValidateOutputRelaxed) + { + Console.WriteLine("NOT writing output file..."); + return (int)ExitCode.SignatureFailedVerification; + } + } + } + if (!outputToConsole) { Console.WriteLine("Writing output file..."); Console.WriteLine($" Total {outputBom.Components?.Count ?? 0} components"); } - return await CliUtils.OutputBomHelper(outputBom, (ConvertFormat)options.OutputFormat, options.OutputVersion, options.OutputFile).ConfigureAwait(false); + var res = await CliUtils.OutputBomHelper(outputBom, (ConvertFormat)options.OutputFormat, options.OutputVersion, options.OutputFile).ConfigureAwait(false); + if (validationResult != null && !validationResult.Valid) + { + // Relaxed mode: the file was still written above, but the + // command as a whole should still report failure. + return (int)ExitCode.SignatureFailedVerification; + } + return res; } /// diff --git a/src/cyclonedx/Commands/MergeCommandOptions.cs b/src/cyclonedx/Commands/MergeCommandOptions.cs index f1776b7..de0ab87 100644 --- a/src/cyclonedx/Commands/MergeCommandOptions.cs +++ b/src/cyclonedx/Commands/MergeCommandOptions.cs @@ -32,6 +32,8 @@ internal class MergeCommandOptions public string Group { get; set; } public string Name { get; set; } public string Version { get; set; } + public bool ValidateOutput { get; set; } + public bool ValidateOutputRelaxed { get; set; } #if NET8_0_OR_GREATER public ComponentConflictResolution? ComponentConflictResolution { get; set; } #endif From addf77296923c472b80a4724667c2168c17d55a4 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Thu, 3 Sep 2026 01:47:45 +0200 Subject: [PATCH 10/12] merge: apply --output-version before --validate-output, not after FlatMerge/HierarchicalMerge never set SpecVersion on their result (it defaults to v1_0). The only place merge ever assigned a real value was inside CliUtils.OutputBomHelper's write path, which runs after the --validate-output/--validate-output-relaxed check -- so validation was always checking a throwaway spec version, never the one --output-version actually requested (or the library's current version, if it wasn't given at all). A merge targeting an older schema version could pass --validate-output cleanly while the version that mattered was never actually tested. Assigns outputBom.SpecVersion from --output-version (falling back to SpecificationVersionHelpers.CurrentVersion, matching OutputBomHelper's own default) before the validate-output block, so validation reflects what will actually be written. Signed-off-by: Jim Klimov --- src/cyclonedx/Commands/MergeCommand.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index be02944..b27ddb3 100644 --- a/src/cyclonedx/Commands/MergeCommand.cs +++ b/src/cyclonedx/Commands/MergeCommand.cs @@ -132,6 +132,16 @@ public static async Task Merge(MergeCommandOptions options) outputBom = CycloneDXUtils.CleanupEmptyLists(outputBom); #endif + // FlatMerge/HierarchicalMerge never set SpecVersion on their + // result, so it defaults to v1_0 unless assigned here. Apply the + // requested --output-version (or the library's current version, + // matching OutputBomHelper's own default) before the + // --validate-output check below, so validation reflects the + // spec version that will actually be written -- rather than + // OutputBomHelper silently overriding it afterwards, by which + // point validation has already run against the wrong target. + outputBom.SpecVersion = options.OutputVersion ?? SpecificationVersionHelpers.CurrentVersion; + // Ensure that the merged document has its own identity (new // SerialNumber, Version=1, Timestamp...) and that its Tools // collection records the library and program that produced it. From 1e1b59a211dd7cb891bfed8feb692070727258aa Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Fri, 4 Sep 2026 14:55:26 +0200 Subject: [PATCH 11/12] merge: add --attach-dangling-components to close the dependency graph FlatMerge only unions each input's own Components and Dependencies -- it never guarantees the result stays one connected graph. A flat merge of many independently-generated documents (one per module in a large multi-module build being the common case) can easily leave some of them unreachable from the merged document's subject: still present in the flat components list, but invisible to any consumer that walks the dependency graph from the root instead (Dependency-Track being a common example). Confirmed on a real ~360-module merge: 89 components never targeted by any dependsOn edge, all silently invisible to a graph-walking consumer despite validating cleanly. --attach-dangling-components runs the library's new Bom.AttachDanglingComponents after the merge, reporting what it found grouped by scope bucket. --attach-dangling-components-ref lets the caller name an existing bom-ref to attach under (falling back to the merge subject if not given or not found). Signed-off-by: Jim Klimov --- src/cyclonedx/Commands/MergeCommand.cs | 17 +++ src/cyclonedx/Commands/MergeCommandOptions.cs | 2 + .../AttachDanglingComponentsTests.cs | 119 ++++++++++++++++++ .../AttachDanglingComponents/sbom1.json | 47 +++++++ 4 files changed, 185 insertions(+) create mode 100644 tests/cyclonedx.tests/AttachDanglingComponentsTests.cs create mode 100644 tests/cyclonedx.tests/Resources/AttachDanglingComponents/sbom1.json diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index b27ddb3..a18e0ee 100644 --- a/src/cyclonedx/Commands/MergeCommand.cs +++ b/src/cyclonedx/Commands/MergeCommand.cs @@ -49,6 +49,8 @@ public static void Configure(RootCommand rootCommand) new Option("--validate-output-relaxed", "Validate the merged document, but still write it (for troubleshooting) even if validation fails."), #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."), + new Option("--attach-dangling-components", "Attach any components no dependsOn edge reaches (grouped by Scope into synthetic components) so consumers that walk the dependency graph from the subject, rather than scanning the flat components list, don't silently miss them."), + new Option("--attach-dangling-components-ref", "Existing bom-ref to attach dangling components under (with --attach-dangling-components). Defaults to the merge subject if not given or not found."), #endif }; subCommand.Handler = CommandHandler.Create(Merge); @@ -130,6 +132,21 @@ public static async Task Merge(MergeCommandOptions options) #if NET8_0_OR_GREATER outputBom = CycloneDXUtils.CleanupMetadataComponent(outputBom, mergeStrategy); outputBom = CycloneDXUtils.CleanupEmptyLists(outputBom); + + if (options.AttachDanglingComponents) + { + var attached = outputBom.AttachDanglingComponents(options.AttachDanglingComponentsRef); + if (attached.Count > 0) + { + var totalAttached = 0; + foreach (var bucket in attached.Values) totalAttached += bucket.Count; + Console.WriteLine($"Attached {totalAttached} component(s) unreachable from the dependency graph, in {attached.Count} scope bucket(s):"); + foreach (var bucket in attached) + { + Console.WriteLine($" {bucket.Key}: {bucket.Value.Count} component(s)"); + } + } + } #endif // FlatMerge/HierarchicalMerge never set SpecVersion on their diff --git a/src/cyclonedx/Commands/MergeCommandOptions.cs b/src/cyclonedx/Commands/MergeCommandOptions.cs index de0ab87..89b639a 100644 --- a/src/cyclonedx/Commands/MergeCommandOptions.cs +++ b/src/cyclonedx/Commands/MergeCommandOptions.cs @@ -36,6 +36,8 @@ internal class MergeCommandOptions public bool ValidateOutputRelaxed { get; set; } #if NET8_0_OR_GREATER public ComponentConflictResolution? ComponentConflictResolution { get; set; } + public bool AttachDanglingComponents { get; set; } + public string AttachDanglingComponentsRef { get; set; } #endif } } diff --git a/tests/cyclonedx.tests/AttachDanglingComponentsTests.cs b/tests/cyclonedx.tests/AttachDanglingComponentsTests.cs new file mode 100644 index 0000000..dc8769b --- /dev/null +++ b/tests/cyclonedx.tests/AttachDanglingComponentsTests.cs @@ -0,0 +1,119 @@ +// 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.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Xunit; +using CycloneDX.Cli.Commands; + +namespace CycloneDX.Cli.Tests +{ + public class AttachDanglingComponentsTests + { + [Fact] + public async Task Merge_AttachDanglingComponents_ClosesTheDependencyGraph() + { + using (var tempDirectory = new TempDirectory()) + { + var fullOutputPath = Path.Join(tempDirectory.DirectoryPath, "sbom.json"); + var options = new MergeCommandOptions + { + InputFiles = new List { Path.Combine("Resources", "AttachDanglingComponents", "sbom1.json") }, + InputFormat = CycloneDXBomFormat.autodetect, + OutputFile = fullOutputPath, + OutputFormat = CycloneDXBomFormat.autodetect, + AttachDanglingComponents = true, + }; + + var exitCode = await MergeCommand.Merge(options).ConfigureAwait(false); + + Assert.Equal(0, exitCode); + using var doc = JsonDocument.Parse(File.ReadAllText(fullOutputPath)); + var root = doc.RootElement; + + var componentRefs = new HashSet(); + foreach (var c in root.GetProperty("components").EnumerateArray()) + { + componentRefs.Add(c.GetProperty("bom-ref").GetString()); + } + + // orphan-lib (required) and orphan-test-lib (excluded) had no + // incoming dependsOn edge -- each should now have its own + // scope-bucketed attachment component. + Assert.Contains("unreferenced-components:scope=Required", componentRefs); + Assert.Contains("unreferenced-components:scope=Excluded", componentRefs); + + var outgoing = new Dictionary>(); + foreach (var d in root.GetProperty("dependencies").EnumerateArray()) + { + var list = new List(); + if (d.TryGetProperty("dependsOn", out var dependsOn)) + { + foreach (var t in dependsOn.EnumerateArray()) list.Add(t.GetString()); + } + outgoing[d.GetProperty("ref").GetString()] = list; + } + + // Every component must now be reachable from the subject via + // a directed walk of dependsOn edges. + var seen = new HashSet { "app" }; + var stack = new Stack(); + stack.Push("app"); + while (stack.Count > 0) + { + var current = stack.Pop(); + if (!outgoing.TryGetValue(current, out var children)) continue; + foreach (var child in children) + { + if (seen.Add(child)) stack.Push(child); + } + } + + foreach (var bomRef in componentRefs) + { + Assert.True(seen.Contains(bomRef), $"'{bomRef}' is not reachable from the subject after AttachDanglingComponents"); + } + } + } + + [Fact] + public async Task Merge_WithoutAttachDanglingComponents_LeavesGraphAsIs() + { + using (var tempDirectory = new TempDirectory()) + { + var fullOutputPath = Path.Join(tempDirectory.DirectoryPath, "sbom.json"); + var options = new MergeCommandOptions + { + InputFiles = new List { Path.Combine("Resources", "AttachDanglingComponents", "sbom1.json") }, + InputFormat = CycloneDXBomFormat.autodetect, + OutputFile = fullOutputPath, + OutputFormat = CycloneDXBomFormat.autodetect, + AttachDanglingComponents = false, + }; + + var exitCode = await MergeCommand.Merge(options).ConfigureAwait(false); + + Assert.Equal(0, exitCode); + var bom = File.ReadAllText(fullOutputPath); + Assert.DoesNotContain("unreferenced-components", bom); + } + } + } +} +#endif diff --git a/tests/cyclonedx.tests/Resources/AttachDanglingComponents/sbom1.json b/tests/cyclonedx.tests/Resources/AttachDanglingComponents/sbom1.json new file mode 100644 index 0000000..6f42dd9 --- /dev/null +++ b/tests/cyclonedx.tests/Resources/AttachDanglingComponents/sbom1.json @@ -0,0 +1,47 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "app", + "name": "app", + "version": "1" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "linked-lib", + "name": "linked-lib", + "version": "1.0.0", + "scope": "required" + }, + { + "type": "library", + "bom-ref": "orphan-lib", + "name": "orphan-lib", + "version": "1.0.0", + "scope": "required" + }, + { + "type": "library", + "bom-ref": "orphan-test-lib", + "name": "orphan-test-lib", + "version": "1.0.0", + "scope": "excluded" + } + ], + "dependencies": [ + { + "ref": "app", + "dependsOn": ["linked-lib"] + }, + { + "ref": "linked-lib", + "dependsOn": [] + } + ] +} From 640aa50b1b0bf54d11c2486f92bf151524af4036 Mon Sep 17 00:00:00 2001 From: Jim Klimov Date: Tue, 15 Sep 2026 17:14:34 +0200 Subject: [PATCH 12/12] convert/merge: add --strip-empty-lists Omits empty list properties ("licenses": [], "dependsOn": [], "provides": [], a Pedigree's "variants": [], etc.) anywhere in the document instead of writing them out. Schema-valid either way (none of these are required or carry minItems in the 1.4-1.7 schemas), this is purely about cutting clutter from the output. Matters most when the output spec version equals the library's current version: BomUtils.GetBomForSerialization serializes that case without a copy, so empty lists survive as written. For every older target version, the Protobuf-based deep copy CopyBomAndDowngrade already collapses them to null on its own, so the flag is a no-op there -- verified byte-identical output with/without the flag at --output-version v1_4. Backed by CycloneDXUtils.CleanupEmptyListsDeep in the library (a recursive counterpart to the top-level-only CleanupEmptyLists merge already applies unconditionally). For merge, it runs before --validate-output so the validated content matches what's written. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Jim Klimov --- src/cyclonedx/CliUtils.cs | 13 ++++++++++++- src/cyclonedx/Commands/ConvertCommand.cs | 3 ++- src/cyclonedx/Commands/ConvertCommandOptions.cs | 1 + src/cyclonedx/Commands/MergeCommand.cs | 10 +++++++++- src/cyclonedx/Commands/MergeCommandOptions.cs | 1 + 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/cyclonedx/CliUtils.cs b/src/cyclonedx/CliUtils.cs index 1f2911e..748ef4e 100644 --- a/src/cyclonedx/CliUtils.cs +++ b/src/cyclonedx/CliUtils.cs @@ -22,6 +22,7 @@ using CycloneDX.Spdx.Interop; using CycloneDX.Cli.Commands; using CycloneDX.Cli.Serialization; +using CycloneDX.Utils; namespace CycloneDX.Cli { @@ -174,7 +175,7 @@ public static async Task OutputBomHelper(Bom bom, CycloneDXBomFormat format return 0; } - public static async Task OutputBomHelper(Bom bom, ConvertFormat format, SpecificationVersion? outputVersion, string filename) + public static async Task OutputBomHelper(Bom bom, ConvertFormat format, SpecificationVersion? outputVersion, string filename, bool stripEmptyLists = false) { if (filename == null && format == ConvertFormat.autodetect) { @@ -193,6 +194,16 @@ public static async Task OutputBomHelper(Bom bom, ConvertFormat format, Spe bom.SpecVersion = outputVersion.HasValue ? outputVersion.Value : SpecificationVersionHelpers.CurrentVersion; + // Downgrading to an older spec version already collapses empty + // (non-null, zero-count) lists to null as a side effect of the + // protobuf deep-copy CopyBomAndDowngrade uses -- but the current + // spec version is serialized without going through that copy, + // so its empty lists survive unless pruned here explicitly. + if (stripEmptyLists) + { + CycloneDXUtils.CleanupEmptyListsDeep(bom); + } + using var stream = filename == null ? Console.OpenStandardOutput() : File.Create(filename); switch (format) diff --git a/src/cyclonedx/Commands/ConvertCommand.cs b/src/cyclonedx/Commands/ConvertCommand.cs index 14e4e7e..25754f7 100644 --- a/src/cyclonedx/Commands/ConvertCommand.cs +++ b/src/cyclonedx/Commands/ConvertCommand.cs @@ -35,6 +35,7 @@ internal static void Configure(RootCommand rootCommand) subCommand.Add(new Option("--input-format", "Specify input file format.")); subCommand.Add(new Option("--output-format", "Specify output file format.")); subCommand.Add(new Option("--output-version", "Specify output BOM specification version. (ignored for CSV and SPDX formats)")); + subCommand.Add(new Option("--strip-empty-lists", "Omit empty list properties (e.g. \"licenses\": [], \"dependsOn\": []) from the output instead of writing them out. Schema-valid either way; this just avoids redundant clutter.")); subCommand.Handler = CommandHandler.Create(Convert); rootCommand.Add(subCommand); } @@ -62,7 +63,7 @@ public static async Task Convert(ConvertCommandOptions options) } } - return await CliUtils.OutputBomHelper(inputBom, options.OutputFormat, options.OutputVersion, options.OutputFile).ConfigureAwait(false); + return await CliUtils.OutputBomHelper(inputBom, options.OutputFormat, options.OutputVersion, options.OutputFile, options.StripEmptyLists).ConfigureAwait(false); } } } diff --git a/src/cyclonedx/Commands/ConvertCommandOptions.cs b/src/cyclonedx/Commands/ConvertCommandOptions.cs index 856c969..008f4b7 100644 --- a/src/cyclonedx/Commands/ConvertCommandOptions.cs +++ b/src/cyclonedx/Commands/ConvertCommandOptions.cs @@ -24,5 +24,6 @@ internal class ConvertCommandOptions public ConvertFormat InputFormat { get; set; } public ConvertFormat OutputFormat { get; set; } public SpecificationVersion? OutputVersion { get; set; } + public bool StripEmptyLists { get; set; } } } \ No newline at end of file diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index a18e0ee..05a304c 100644 --- a/src/cyclonedx/Commands/MergeCommand.cs +++ b/src/cyclonedx/Commands/MergeCommand.cs @@ -47,6 +47,7 @@ public static void Configure(RootCommand rootCommand) new Option("--version", "Provide the version of software the merged BOM describes (required for hierarchical merging)."), new Option("--validate-output", "Validate the merged document before writing it, and do not write it if validation fails."), new Option("--validate-output-relaxed", "Validate the merged document, but still write it (for troubleshooting) even if validation fails."), + new Option("--strip-empty-lists", "Omit empty list properties (e.g. \"licenses\": [], \"dependsOn\": []) from the output instead of writing them out. Schema-valid either way; this just avoids redundant clutter."), #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."), new Option("--attach-dangling-components", "Attach any components no dependsOn edge reaches (grouped by Scope into synthetic components) so consumers that walk the dependency graph from the subject, rather than scanning the flat components list, don't silently miss them."), @@ -178,6 +179,13 @@ public static async Task Merge(MergeCommandOptions options) } #endif + if (options.StripEmptyLists) + { + // Applied before validation so the validated document + // matches what OutputBomHelper actually writes below. + CycloneDXUtils.CleanupEmptyListsDeep(outputBom); + } + ValidationResult validationResult = null; if (options.ValidateOutput || options.ValidateOutputRelaxed) { @@ -213,7 +221,7 @@ public static async Task Merge(MergeCommandOptions options) Console.WriteLine($" Total {outputBom.Components?.Count ?? 0} components"); } - var res = await CliUtils.OutputBomHelper(outputBom, (ConvertFormat)options.OutputFormat, options.OutputVersion, options.OutputFile).ConfigureAwait(false); + var res = await CliUtils.OutputBomHelper(outputBom, (ConvertFormat)options.OutputFormat, options.OutputVersion, options.OutputFile, options.StripEmptyLists).ConfigureAwait(false); if (validationResult != null && !validationResult.Valid) { // Relaxed mode: the file was still written above, but the diff --git a/src/cyclonedx/Commands/MergeCommandOptions.cs b/src/cyclonedx/Commands/MergeCommandOptions.cs index 89b639a..6feda4f 100644 --- a/src/cyclonedx/Commands/MergeCommandOptions.cs +++ b/src/cyclonedx/Commands/MergeCommandOptions.cs @@ -34,6 +34,7 @@ internal class MergeCommandOptions public string Version { get; set; } public bool ValidateOutput { get; set; } public bool ValidateOutputRelaxed { get; set; } + public bool StripEmptyLists { get; set; } #if NET8_0_OR_GREATER public ComponentConflictResolution? ComponentConflictResolution { get; set; } public bool AttachDanglingComponents { get; set; }