Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/cyclonedx/Commands/MergeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ public static void Configure(RootCommand rootCommand)
new Option<bool>("--hierarchical", "Perform a hierarchical merge."),
new Option<string>("--group", "Provide the group of software the merged BOM describes."),
new Option<string>("--name", "Provide the name of software the merged BOM describes (required for hierarchical merging)."),
new Option<string>("--version", "Provide the version of software the merged BOM describes (required for hierarchical merging).")
new Option<string>("--version", "Provide the version of software the merged BOM describes (required for hierarchical merging)."),
#if NET8_0_OR_GREATER
new Option<ComponentConflictResolution>("--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<MergeCommandOptions>(Merge);
rootCommand.Add(subCommand);
Expand Down Expand Up @@ -77,14 +80,30 @@ public static async Task<int> 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)
{
Expand Down
4 changes: 4 additions & 0 deletions src/cyclonedx/Commands/MergeCommandOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
}
}
103 changes: 103 additions & 0 deletions src/cyclonedx/Commands/RenameEntityCommand.cs
Original file line number Diff line number Diff line change
@@ -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<string>("--input-file", "Input BOM filename."));
subCommand.Add(new Option<string>("--output-file", "Output BOM filename, will write to stdout if no value provided."));
subCommand.Add(new Option<string>("--old-ref", "Old value of \"bom-ref\" entity identifier (or \"ref\" values or certain list items pointing to it)."));
subCommand.Add(new Option<string>("--new-ref", "New value of \"bom-ref\" entity identifier (or \"ref\" values or certain list items pointing to it)."));
subCommand.Add(new Option<CycloneDXBomFormat>("--input-format", "Specify input file format."));
subCommand.Add(new Option<CycloneDXBomFormat>("--output-format", "Specify output file format."));
subCommand.Handler = CommandHandler.Create<RenameEntityCommandOptions>(RenameEntity);
rootCommand.Add(subCommand);
}

public static async Task<int> 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
31 changes: 31 additions & 0 deletions src/cyclonedx/Commands/RenameEntityCommandOptions.cs
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions src/cyclonedx/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public static async Task<int> 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);
Expand Down
100 changes: 100 additions & 0 deletions tests/cyclonedx.tests/RenameEntityTests.cs
Original file line number Diff line number Diff line change
@@ -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+<timestamp>.*?</timestamp>", ""); // 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*<tools>.*?</tools>", "", 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
26 changes: 26 additions & 0 deletions tests/cyclonedx.tests/Resources/RenameEntity/sbom1.json
Original file line number Diff line number Diff line change
@@ -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": [] }
]
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<bom xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" version="1" xmlns="http://cyclonedx.org/schema/bom/1.4">
<metadata>
<component type="application" bom-ref="app-1">
<name>thing1</name>
<version>1</version>
</component>
</metadata>
<components>
<component type="library" bom-ref="lib-new">
<name>acme-library</name>
<version>1.0.0</version>
</component>
</components>
<dependencies>
<dependency ref="app-1">
<dependency ref="lib-new" />
</dependency>
<dependency ref="lib-new" />
</dependencies>
</bom>