diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index 6c61bc1..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."), @@ -41,7 +44,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); @@ -65,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) @@ -77,14 +83,30 @@ 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); +#else outputBom = CycloneDXUtils.HierarchicalMerge(inputBoms, bomSubject); +#endif } else { +#if NET8_0_OR_GREATER + outputBom = CycloneDXUtils.FlatMerge(inputBoms, mergeStrategy); +#else outputBom = CycloneDXUtils.FlatMerge(inputBoms); +#endif if (outputBom.Metadata is null) outputBom.Metadata = new Metadata(); if (bomSubject != null) { @@ -125,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 29d734a..f1776b7 100644 --- a/src/cyclonedx/Commands/MergeCommandOptions.cs +++ b/src/cyclonedx/Commands/MergeCommandOptions.cs @@ -15,12 +15,15 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. using System.Collections.Generic; +using CycloneDX.Models; 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; } @@ -29,5 +32,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 new file mode 100644 index 0000000..011d8b7 --- /dev/null +++ b/src/cyclonedx/Commands/RenameEntityCommand.cs @@ -0,0 +1,103 @@ +// 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)"); + try + { + 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)"); + } + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"Rename operation refused: {ex.Message}"); + return (int)ExitCode.ParameterValidationError; + } + + // 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); 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 + + + + + + + + +