diff --git a/.github/workflows/build_on_push.yaml b/.github/workflows/build_on_push.yaml index e7b257f..928dbed 100644 --- a/.github/workflows/build_on_push.yaml +++ b/.github/workflows/build_on_push.yaml @@ -16,7 +16,13 @@ jobs: 6.x 8.x - - name: Install dependencies + - name: Restore Tools + run: > + dotnet + tool + restore + + - name: Restore Dependencies run: > dotnet restore @@ -34,3 +40,31 @@ jobs: test --no-build --configuration Release + + - name: Generate SBOM + run: > + dotnet + sbom-tool + generate + -b src/DemaConsulting.SpdxTool/bin/Release + -bc src/DemaConsulting.SpdxTool + -pn DemaConsulting.SpdxTool + -pv 0.0.0-cibuild + -ps DemaConsulting + -nsb https://DemaConsulting.com/SpdxTool + + - name: Run SBOM Workflow + run: > + dotnet + src/DemaConsulting.SpdxTool/bin/Release/net8.0/DemaConsulting.SpdxTool.dll + run-workflow + spdx-workflow.yaml + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: artifacts + path: | + **/manifest.spdx.json + **/manifest.spdx.json.sha256 + manifest.spdx.summary.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3562b8c..5e8dce5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -31,7 +31,7 @@ jobs: tool restore - - name: Install dependencies + - name: Restore Dependencies run: > dotnet restore @@ -64,13 +64,12 @@ jobs: -ps DemaConsulting -nsb https://DemaConsulting.com/SpdxTool - - name: Generate SBOM Summary + - name: Run SBOM Workflow run: > dotnet src/DemaConsulting.SpdxTool/bin/Release/net8.0/DemaConsulting.SpdxTool.dll - to-markdown - src/DemaConsulting.SpdxTool/bin/Release/_manifest/spdx_2.2/manifest.spdx.json - manifest.spdx.summary.md + run-workflow + spdx-workflow.yaml - name: Create Dotnet Tool run: > diff --git a/DemaConsulting.SpdxTool.sln.DotSettings b/DemaConsulting.SpdxTool.sln.DotSettings index 489f800..af14ace 100644 --- a/DemaConsulting.SpdxTool.sln.DotSettings +++ b/DemaConsulting.SpdxTool.sln.DotSettings @@ -1,3 +1,4 @@  True + True True \ No newline at end of file diff --git a/README.md b/README.md index d2301d2..ae9b37c 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,13 @@ Options: Commands: help Display extended help about a command + add-package Add package to SPDX document (workflow only). + copy-package Copy package information from one SPDX document to another. + query [arguments] Query program output for value + rename-id Rename an element ID in an SPDX document. run-workflow Runs the workflow file + sha256 Generate or verify sha256 hashes of files to-markdown Create Markdown summary for SPDX document - rename-id Rename an element ID in an SPDX document. - copy-package Copy package information from one SPDX document to another. ``` @@ -50,6 +53,11 @@ Commands: The SpdxTool can be driven using workflow yaml files of the following format: ```yaml +# Workflow parameters +parameters: + parameter-name: value + +# Workflow steps steps: - command: inputs: @@ -57,35 +65,78 @@ steps: - command: inputs: - + input1: value + input2: ${{ parameter-name }} ``` -## YAML Commands +## YAML Variables -The following are the supported commands and their formats: +Variables are specified at the top of the workflow file in a parameters section: ```yaml -steps: +# Workflow parameters +parameters: + parameter1: value1 + parameter2: value2 +``` - # Run a separate workflow file -- command: run-workflow +Variables can be expanded in step inputs using the dollar expansion syntax + +```yaml +# Workflow steps +steps: +- command: inputs: - file: other-workflow-file.yaml - parameters: - + input1: ${{ parameter1 }} + input2: Insert ${{ parameter2 }} in the middle +``` - # Create a summary markdown from the specified SPDX document -- command: to-markdown +Variables can be overridden on the command line: + +``` +spdx-tool run-workflow workflow.yaml parameter1=command parameter2=line +``` + +Variables can be changed at runtime by some steps: + +```yaml +# Workflow parameters +parameters: + dotnet-version: unknown + +steps: +- command: query inputs: - spdx: input.spdx.json - markdown: output.md + output: dotnet-version + pattern: '(?\d+\.\d+\.\d+)' + program: dotnet + arguments: + - '--version' +``` - # Rename the SPDX-ID of an element in an SPDX document -- command: rename-id + +## YAML Commands + +The following are the supported commands and their formats: + +```yaml +steps: + + # Add a package to an SPDX document +- command: add-package inputs: + package: + id: + name: + copyright: + version: + download: + license: # optional + purl: # optional + cpe23: # optional spdx: - old: - new: + relationship: + element: # Copy a package from one SPDX document to another SPDX document - command: copy-package @@ -95,4 +146,40 @@ steps: package: relationship: element: + + # Query information from the output of a program +- command: query + inputs: + output: + pattern: + program: + arguments: + - + - + + # Rename the SPDX-ID of an element in an SPDX document +- command: rename-id + inputs: + spdx: + old: + new: + + # Run a separate workflow file +- command: run-workflow + inputs: + file: other-workflow-file.yaml + parameters: + + + # Perform Sha256 operations on the specified file +- command: help + inputs: + operation: generate | verify + file: + + # Create a summary markdown from the specified SPDX document +- command: to-markdown + inputs: + spdx: input.spdx.json + markdown: output.md ``` diff --git a/spdx-workflow.yaml b/spdx-workflow.yaml new file mode 100644 index 0000000..0bf2294 --- /dev/null +++ b/spdx-workflow.yaml @@ -0,0 +1,45 @@ +# This workflow demonstrates using spdx-tool to manipulate an SPDX document +# adding new packages, updating the sha256 digest, and generating a +# summary markdown document describing the contents. + + +# Workflow Parameters +parameters: + dotnet-version: unknown + spdx: src/DemaConsulting.SpdxTool/bin/Release/_manifest/spdx_2.2/manifest.spdx.json + summary-markdown: manifest.spdx.summary.md + +# Steps +steps: + + # Query the version of dotnet +- command: query + inputs: + output: dotnet-version + pattern: '(?\d+\.\d+\.\d+)' + program: dotnet + arguments: + - '--version' + + # Add DotNet SDK as a build tool of the package +- command: add-package + inputs: + package: + id: SPDXRef-Package-DotNetSDK + name: DotNet SDK ${{ dotnet-version }} + version: ${{ dotnet-version }} + download: https://dotnet.microsoft.com/download + spdx: ${{ spdx }} + relationship: BUILD_TOOL_OF + element: SPDXRef-RootPackage + + # Update the Sha256 digest on the SPDX document +- command: sha256 + inputs: + operation: generate + file: ${{ spdx }} + +- command: to-markdown + inputs: + spdx: ${{ spdx }} + markdown: ${{ summary-markdown }} \ No newline at end of file diff --git a/src/DemaConsulting.SpdxTool/Commands/AddPackageCommand.cs b/src/DemaConsulting.SpdxTool/Commands/AddPackageCommand.cs new file mode 100644 index 0000000..678f56a --- /dev/null +++ b/src/DemaConsulting.SpdxTool/Commands/AddPackageCommand.cs @@ -0,0 +1,185 @@ +using DemaConsulting.SpdxModel; +using DemaConsulting.SpdxModel.IO; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace DemaConsulting.SpdxTool.Commands; + +/// +/// Add a package to an SPDX document +/// +public class AddPackageCommand : Command +{ + /// + /// Singleton instance of this command + /// + public static readonly AddPackageCommand Instance = new(); + + /// + /// Entry information for this command + /// + public static readonly CommandEntry Entry = new( + "add-package", + "add-package", + "Add package to SPDX document (workflow only).", + new[] + { + "This command adds a package to an SPDX document.", + "", + " - command: add-package", + " inputs:", + " package:", + " id: ", + " name: ", + " copyright: ", + " version: ", + " download: ", + " license: # optional", + " purl: # optional", + " cpe23: # optional", + " spdx: ", + " relationship: ", + " element: ", + "", + "The argument describes the relationship to .", + "The argument is the name of an element in the file.", + "", + "The is defined by the SPDX specification, and is usually one of:", + " DESCRIBES, DESCRIBED_BY, CONTAINS, BUILD_TOOL_OF, ..." + }, + Instance); + + /// + /// Private constructor - this is a singleton + /// + private AddPackageCommand() + { + } + + /// + public override void Run(string[] args) + { + throw new CommandUsageException("'add-package' command is only valid in a workflow"); + } + + /// + public override void Run(YamlMappingNode step, Dictionary variables) + { + // Get the step inputs + var inputs = GetMapMap(step, "inputs"); + + // Get the package map + var packageMap = GetMapMap(inputs, "package") ?? + throw new YamlException(step.Start, step.End, "'add-package' missing 'package' input"); + + // Get the 'spdx' input + var spdxFile = GetMapString(inputs, "spdx", variables) ?? + throw new YamlException(step.Start, step.End, "'copy-package' missing 'spdx' input"); + + // Get the 'relationship' input + var relationship = GetMapString(inputs, "relationship", variables) ?? + throw new YamlException(step.Start, step.End, "'copy-package' missing 'relationship' input"); + + // Get the 'element' input + var element = GetMapString(inputs, "element", variables) ?? + throw new YamlException(step.Start, step.End, "'copy-package' missing 'element' input"); + + // Construct the package + var package = new SpdxPackage + { + // Get the package ID + Id = GetMapString(packageMap, "id", variables) ?? + throw new YamlException(step.Start, step.End, "'add-package' missing package 'id' input"), + + // Get the package name + Name = GetMapString(packageMap, "name", variables) ?? + throw new YamlException(step.Start, step.End, "'add-package' missing package 'name' input"), + + // Get the package version + Version = GetMapString(packageMap, "version", variables), + + // Get the download location + DownloadLocation = GetMapString(packageMap, "download", variables) ?? + throw new YamlException(step.Start, step.End, "'add-package' missing package 'download' input"), + + // Get the package copyright + CopyrightText = GetMapString(packageMap, "copyright", variables), + + // Get the package license + ConcludedLicense = GetMapString(packageMap, "license", variables) ?? "NOASSERTION", + DeclaredLicense = GetMapString(packageMap, "license", variables) ?? "NOASSERTION" + }; + + // Append the PURL if specified + var purl = GetMapString(packageMap, "purl", variables); + if (!string.IsNullOrEmpty(purl)) + package.ExternalReferences = package.ExternalReferences.Append( + new SpdxExternalReference + { + Category = SpdxReferenceCategory.PackageManager, + Type = "purl", + Locator = purl + }).ToArray(); + + // Append the CPE23 if specified + var cpe23 = GetMapString(packageMap, "cpe23", variables); + if (!string.IsNullOrEmpty(cpe23)) + package.ExternalReferences = package.ExternalReferences.Append( + new SpdxExternalReference + { + Category = SpdxReferenceCategory.Security, + Type = "cpe23Type", + Locator = cpe23 + }).ToArray(); + + // Add the package + AddPackage(package, spdxFile, relationship, element); + } + + /// + /// Add a package to the SPDX document + /// + /// Package to add + /// SPDX file + /// Relationship type + /// Element to relate package to + /// On usage error + public static void AddPackage(SpdxPackage package, string spdxFile, string relationshipName, string elementId) + { + // Verify to file exists + if (!File.Exists(spdxFile)) + throw new CommandUsageException($"File not found: {spdxFile}"); + + // Verify package ID + if (package.Id.Length == 0 || package.Id == "SPDXRef-DOCUMENT") + throw new CommandUsageException("Invalid package ID"); + + // Parse the relationship + var relationship = SpdxRelationshipTypeExtensions.FromText(relationshipName); + if (relationship == SpdxRelationshipType.Missing) + throw new CommandUsageException("Invalid relationship"); + + // Verify element name + if (elementId.Length == 0) + throw new CommandUsageException("Invalid element name"); + + // Load the SPDX document + var doc = Spdx2JsonDeserializer.Deserialize(File.ReadAllText(spdxFile)); + + // Add the package (if not already present) + if (!Array.Exists(doc.Packages, p => p.Id == package.Id)) + doc.Packages = doc.Packages.Append(package).ToArray(); + + // Add the relationship + doc.Relationships = doc.Relationships.Append( + new SpdxRelationship + { + Id = package.Id, + RelationshipType = relationship, + RelatedSpdxElement = elementId + }).ToArray(); + + // Save the SPDX document + File.WriteAllText(spdxFile, Spdx2JsonSerializer.Serialize(doc)); + } +} \ No newline at end of file diff --git a/src/DemaConsulting.SpdxTool/Commands/Command.cs b/src/DemaConsulting.SpdxTool/Commands/Command.cs index 23322c4..c503731 100644 --- a/src/DemaConsulting.SpdxTool/Commands/Command.cs +++ b/src/DemaConsulting.SpdxTool/Commands/Command.cs @@ -101,4 +101,4 @@ public static string Expand(string text, Dictionary variables) // Get the parameter return map.Children.TryGetValue(key, out var value) ? Expand(value.ToString(), variables) : null; } -} +} \ No newline at end of file diff --git a/src/DemaConsulting.SpdxTool/Commands/CommandRegistry.cs b/src/DemaConsulting.SpdxTool/Commands/CommandRegistry.cs index 4e587b4..74fecb1 100644 --- a/src/DemaConsulting.SpdxTool/Commands/CommandRegistry.cs +++ b/src/DemaConsulting.SpdxTool/Commands/CommandRegistry.cs @@ -11,10 +11,13 @@ public static class CommandsRegistry private static readonly Dictionary InternalCommands = new() { { HelpCommand.Entry.Name, HelpCommand.Entry }, - { RunWorkflowCommand.Entry.Name, RunWorkflowCommand.Entry }, - { ToMarkdownCommand.Entry.Name, ToMarkdownCommand.Entry }, + { AddPackageCommand.Entry.Name, AddPackageCommand.Entry }, + { CopyPackageCommand.Entry.Name, CopyPackageCommand.Entry }, + { QueryCommand.Entry.Name, QueryCommand.Entry }, { RenameIdCommand.Entry.Name, RenameIdCommand.Entry }, - { CopyPackageCommand.Entry.Name, CopyPackageCommand.Entry } + { RunWorkflowCommand.Entry.Name, RunWorkflowCommand.Entry }, + { Sha256Command.Entry.Name, Sha256Command.Entry }, + { ToMarkdownCommand.Entry.Name, ToMarkdownCommand.Entry } }; /// diff --git a/src/DemaConsulting.SpdxTool/Commands/CopyPackageCommand.cs b/src/DemaConsulting.SpdxTool/Commands/CopyPackageCommand.cs index 7417d1a..0c896b0 100644 --- a/src/DemaConsulting.SpdxTool/Commands/CopyPackageCommand.cs +++ b/src/DemaConsulting.SpdxTool/Commands/CopyPackageCommand.cs @@ -39,10 +39,10 @@ public class CopyPackageCommand : Command " element: ", "", "The argument is the name of a package in to copy.", - "The argument describes the relationship to .", + "The argument describes the relationship to .", "The argument is the name of an element in the file.", "", - "The is defined by the SPDX specification, and is usually one of:", + "The is defined by the SPDX specification, and is usually one of:", " DESCRIBES, DESCRIBED_BY, CONTAINS, BUILD_TOOL_OF, ..." }, Instance); @@ -100,11 +100,11 @@ public override void Run(YamlMappingNode step, Dictionary variab /// /// Source SPDX document filename /// Destination SPDX document filename - /// Package to copy + /// Package to copy /// Relationship of package to element in destination - /// Destination element - public static void CopyPackage(string fromFile, string toFile, string packageName, string relationshipName, - string elementName) + /// Destination element + public static void CopyPackage(string fromFile, string toFile, string packageId, string relationshipName, + string elementId) { // Verify from file exists if (!File.Exists(fromFile)) @@ -115,7 +115,7 @@ public static void CopyPackage(string fromFile, string toFile, string packageNam throw new CommandUsageException($"File not found: {toFile}"); // Verify package name - if (packageName.Length == 0 || packageName == "SPDXRef-DOCUMENT") + if (packageId.Length == 0 || packageId == "SPDXRef-DOCUMENT") throw new CommandUsageException("Invalid package name"); // Parse the relationship @@ -124,7 +124,7 @@ public static void CopyPackage(string fromFile, string toFile, string packageNam throw new CommandUsageException("Invalid relationship"); // Verify element name - if (elementName.Length == 0) + if (elementId.Length == 0) throw new CommandUsageException("Invalid element name"); // Read the SPDX documents @@ -132,8 +132,8 @@ public static void CopyPackage(string fromFile, string toFile, string packageNam var toDoc = Spdx2JsonDeserializer.Deserialize(File.ReadAllText(toFile)); // Verify the package exists in the source - var package = Array.Find(fromDoc.Packages, p => p.Id == packageName) ?? - throw new CommandErrorException($"Package {packageName} not found in {fromFile}"); + var package = Array.Find(fromDoc.Packages, p => p.Id == packageId) ?? + throw new CommandErrorException($"Package {packageId} not found in {fromFile}"); // Verify the package does not exist in the destination if (Array.Exists(toDoc.Packages, p => p.Id == package.Id)) @@ -160,9 +160,9 @@ public static void CopyPackage(string fromFile, string toFile, string packageNam // Append the relationship to the destination document var newRelationship = new SpdxRelationship { - Id = elementName, + Id = package.Id, RelationshipType = relationship, - RelatedSpdxElement = package.Id + RelatedSpdxElement = elementId }; toDoc.Relationships = toDoc.Relationships.Append(newRelationship).ToArray(); diff --git a/src/DemaConsulting.SpdxTool/Commands/QueryCommand.cs b/src/DemaConsulting.SpdxTool/Commands/QueryCommand.cs new file mode 100644 index 0000000..2c35c92 --- /dev/null +++ b/src/DemaConsulting.SpdxTool/Commands/QueryCommand.cs @@ -0,0 +1,153 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace DemaConsulting.SpdxTool.Commands; + +/// +/// Query a program output for a value +/// +public class QueryCommand : Command +{ + /// + /// Singleton instance of this command + /// + public static readonly QueryCommand Instance = new(); + + /// + /// Entry information for this command + /// + public static readonly CommandEntry Entry = new( + "query", + "query [arguments]", + "Query program output for value", + new[] + { + "This command executes a program and inspects the output for a value.", + "When executed in a workflow this can be used to set a variable.", + "", + "From the command-line this can be used as:", + " spdx-tool query [arguments]", + "", + "From a YAML file this can be used as:", + " - command: query", + " inputs:", + " output: ", + " pattern: ", + " program: ", + " arguments:", + " - ", + " - " + }, + Instance); + + /// + /// Private constructor - this is a singleton + /// + private QueryCommand() + { + } + + /// + public override void Run(string[] args) + { + // Report an error if the number of arguments is not 1 + if (args.Length < 2) + throw new CommandUsageException("'query' command missing arguments"); + + // Generate the markdown + var found = Query(args[0], args[1], args.Skip(2).ToArray()); + + // Write the found value to the console + Console.WriteLine(found); + } + + /// + public override void Run(YamlMappingNode step, Dictionary variables) + { + // Get the step inputs + var inputs = GetMapMap(step, "inputs"); + + // Get the 'output' input + var output = GetMapString(inputs, "output", variables) ?? + throw new YamlException(step.Start, step.End, "'query' command missing 'output' input"); + + // Get the 'pattern' input + var pattern = GetMapString(inputs, "pattern", variables) ?? + throw new YamlException(step.Start, step.End, "'query' command missing 'pattern' input"); + + // Get the 'program' input + var program = GetMapString(inputs, "program", variables) ?? + throw new YamlException(step.Start, step.End, "'query' command missing 'program' input"); + + // Get the arguments + var argumentsSequence = GetMapSequence(inputs, "arguments"); + var arguments = argumentsSequence?.Children.Select(c => Expand(c.ToString(), variables)).ToArray() ?? + Array.Empty(); + + // Generate the markdown + var found = Query(pattern, program, arguments); + + // Save the output to the variables + variables[output] = found; + } + + /// + /// Run a program and query the output for a value + /// + /// Regular expression pattern to capture 'value' + /// Program to execute + /// Program arguments + /// Captured value + /// On bad usage + /// On error + public static string Query(string pattern, string program, string[] arguments) + { + // Construct the regular expression + var regex = new Regex(pattern); + if (!regex.GetGroupNames().Contains("value")) + throw new CommandUsageException("Pattern must contain a 'value' capture group"); + + // Construct the start information + var startInfo = new ProcessStartInfo(program) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + // Add the arguments + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + + // Start the process + Process process; + try + { + process = Process.Start(startInfo) ?? + throw new CommandErrorException($"Unable to start program '{program}'"); + } + catch + { + throw new CommandErrorException($"Unable to start program '{program}'"); + } + + // Wait for the process to exit + process.WaitForExit(); + if (process.ExitCode != 0) + throw new CommandErrorException($"Program '{program}' exited with code {process.ExitCode}"); + + // Save the output and return the exit code + var output = process.StandardOutput.ReadToEnd().Trim(); + + // Find the match + var match = regex.Match(output); + if (match == null) + throw new CommandErrorException($"Pattern '{pattern}' not found in program output"); + + // Return the captured value + return match.Groups["value"].Value; + } +} \ No newline at end of file diff --git a/src/DemaConsulting.SpdxTool/Commands/RenameIdCommand.cs b/src/DemaConsulting.SpdxTool/Commands/RenameIdCommand.cs index 0a46e7e..f80d14b 100644 --- a/src/DemaConsulting.SpdxTool/Commands/RenameIdCommand.cs +++ b/src/DemaConsulting.SpdxTool/Commands/RenameIdCommand.cs @@ -70,7 +70,7 @@ public override void Run(YamlMappingNode step, Dictionary variab throw new YamlException(step.Start, step.End, "'rename-id' command missing 'new' input"); // Get the 'old' input - var oldId = GetMapString(inputs, "old", variables) ?? + var oldId = GetMapString(inputs, "old", variables) ?? throw new YamlException(step.Start, step.End, "'rename-id' command missing 'spdx' input"); // Rename the ID diff --git a/src/DemaConsulting.SpdxTool/Commands/RunWorkflowCommand.cs b/src/DemaConsulting.SpdxTool/Commands/RunWorkflowCommand.cs index cc06e13..661c949 100644 --- a/src/DemaConsulting.SpdxTool/Commands/RunWorkflowCommand.cs +++ b/src/DemaConsulting.SpdxTool/Commands/RunWorkflowCommand.cs @@ -77,13 +77,12 @@ public override void Run(YamlMappingNode step, Dictionary variab var inputs = GetMapMap(step, "input"); // Get the 'file' input - var file = GetMapString(inputs, "file", variables) ?? + var file = GetMapString(inputs, "file", variables) ?? throw new YamlException(step.Start, step.End, "'run-workflow' command missing 'file' input"); // Get the parameters var parameters = new Dictionary(); if (GetMapMap(inputs, "parameters") is { } parametersMap) - { // Process all the parameters foreach (var (keyNode, valueNode) in parametersMap.Children) { @@ -91,7 +90,6 @@ public override void Run(YamlMappingNode step, Dictionary variab var value = valueNode.ToString(); parameters[key] = Expand(value, variables); } - } // Execute the workflow Execute(file, parameters); @@ -124,7 +122,6 @@ public static void Execute(string workflowFile, Dictionary param // Process the parameters definitions into local variables var variables = new Dictionary(); if (GetMapMap(root, "parameters") is { } parametersMap) - { // Process all the parameters foreach (var (keyNode, valueNode) in parametersMap.Children) { @@ -132,7 +129,6 @@ public static void Execute(string workflowFile, Dictionary param var value = Expand(valueNode.ToString(), variables); variables[key] = Expand(value, parameters); } - } // Apply the provided parameters to our variables foreach (var (key, value) in parameters) diff --git a/src/DemaConsulting.SpdxTool/Commands/Sha256Command.cs b/src/DemaConsulting.SpdxTool/Commands/Sha256Command.cs new file mode 100644 index 0000000..5ee71ba --- /dev/null +++ b/src/DemaConsulting.SpdxTool/Commands/Sha256Command.cs @@ -0,0 +1,154 @@ +using System.Security.Cryptography; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace DemaConsulting.SpdxTool.Commands; + +/// +/// Sha256 command +/// +public class Sha256Command : Command +{ + /// + /// Singleton instance of this command + /// + public static readonly Sha256Command Instance = new(); + + /// + /// Entry information for this command + /// + public static readonly CommandEntry Entry = new( + "sha256", + "sha256 ", + "Generate or verify sha256 hashes of files", + new[] + { + "This command generates or verifies sha256 hashes.", + "", + "From the command-line this can be used as:", + " spdx-tool sha256 generate ", + " spdx-tool sha256 verify ", + "", + "From a YAML file this can be used as:", + " - command: help", + " inputs:", + " operation: generate | verify", + " file: ", + }, + Instance); + + /// + /// Private constructor - this is a singleton + /// + private Sha256Command() + { + } + + /// + public override void Run(string[] args) + { + // Report an error if the number of arguments is not 2 + if (args.Length != 2) + throw new CommandUsageException("'sha256' command missing arguments"); + + // Do the Sha256 operation + DoSha256(args[0], args[1]); + } + + /// + public override void Run(YamlMappingNode step, Dictionary variables) + { + // Get the step inputs + var inputs = GetMapMap(step, "inputs"); + + // Get the 'operation' input + var operation = GetMapString(inputs, "operation", variables) ?? + throw new YamlException(step.Start, step.End, "'sha256' command missing 'operation' input"); + + // Get the 'file' input + var file = GetMapString(inputs, "file", variables) ?? + throw new YamlException(step.Start, step.End, "'sha256' command missing 'file' input"); + + // Do the Sha256 operation + DoSha256(operation, file); + } + + /// + /// Do the requested Sha256 operation + /// + /// Operation to perform (generate or verify) + /// File to perform operation on + /// On usage error + public static void DoSha256(string operation, string file) + { + switch (operation) + { + case "generate": + GenerateSha256(file); + break; + + case "verify": + VerifySha256(file); + break; + + default: + throw new CommandUsageException($"'sha256' command invalid operation '{operation}'"); + } + } + + /// + /// Generate a Sha256 hash for a file + /// + /// File to generate hash for + public static void GenerateSha256(string file) + { + // Calculate the digest + var digest = CalculateSha256(file); + + // Write the digest + File.WriteAllText(file + ".sha256", digest); + } + + /// + /// Verify a Sha256 hash for a file + /// + /// + /// + public static void VerifySha256(string file) + { + // Read the digest + var digest = File.ReadAllText(file + ".sha256").Trim(); + + // Calculate the digest + var calculated = CalculateSha256(file); + + // Verify the digest + if (digest != calculated) + throw new CommandErrorException($"Sha256 hash mismatch for '{file}'"); + + // Report the digest is OK + Console.WriteLine($"Sha256 Digest OK for '{file}'"); + } + + /// + /// Calculate the Sha256 hash of a file + /// + /// File to hash + /// Sh256 hash + /// On error + public static string CalculateSha256(string file) + { + try + { + // Calculate the Sha256 digest of the file + using var stream = new FileStream(file, FileMode.Open); + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + catch (Exception ex) + { + throw new CommandErrorException($"Error calculating sha256 hash for '{file}': {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/src/DemaConsulting.SpdxTool/Commands/ToMarkdownCommand.cs b/src/DemaConsulting.SpdxTool/Commands/ToMarkdownCommand.cs index fadeda7..ddb033e 100644 --- a/src/DemaConsulting.SpdxTool/Commands/ToMarkdownCommand.cs +++ b/src/DemaConsulting.SpdxTool/Commands/ToMarkdownCommand.cs @@ -62,7 +62,7 @@ public override void Run(YamlMappingNode step, Dictionary variab var inputs = GetMapMap(step, "inputs"); // Get the 'spdx' input - var spdxFile = GetMapString(inputs, "spdx", variables) ?? + var spdxFile = GetMapString(inputs, "spdx", variables) ?? throw new YamlException(step.Start, step.End, "'to-markdown' command missing 'spdx' input"); // Get the 'markdown' input diff --git a/src/DemaConsulting.SpdxTool/Program.cs b/src/DemaConsulting.SpdxTool/Program.cs index e5aacb0..87bf6df 100644 --- a/src/DemaConsulting.SpdxTool/Program.cs +++ b/src/DemaConsulting.SpdxTool/Program.cs @@ -26,14 +26,14 @@ public static class Program public static void Main(string[] args) { // Handle printing usage information - if (args.Length == 0 || args.Contains("-h") || args.Contains("--help")) + if (args.Length == 0 || args[0] == "-h" || args[0] == "--help") { PrintUsage(); Environment.Exit(1); } // Handle querying for version - if (args.Contains("-v") || args.Contains("--version")) + if (args.Length == 1 && (args[0] == "-v" || args[0] == "--version")) { Console.WriteLine(Version); Environment.Exit(0); diff --git a/test/DemaConsulting.SpdxTool.Tests/TestAddPackageCommand.cs b/test/DemaConsulting.SpdxTool.Tests/TestAddPackageCommand.cs new file mode 100644 index 0000000..3388223 --- /dev/null +++ b/test/DemaConsulting.SpdxTool.Tests/TestAddPackageCommand.cs @@ -0,0 +1,204 @@ +using DemaConsulting.SpdxModel.IO; +using DemaConsulting.SpdxModel; + +namespace DemaConsulting.SpdxTool.Tests; + +[TestClass] +public class TestAddPackageCommand +{ + [TestMethod] + public void AddPackageCommandLine() + { + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "add-package"); + + // Verify error reported + Assert.AreEqual(1, exitCode); + Assert.IsTrue(output.Contains("'add-package' command is only valid in a workflow")); + } + + [TestMethod] + public void AddPackageSimple() + { + // SPDX contents + const string spdxContents = "{\r\n" + + " \"files\": [],\r\n" + + " \"packages\": [" + + " {\r\n" + + " \"SPDXID\": \"SPDXRef-Package-1\",\r\n" + + " \"name\": \"Test Package\",\r\n" + + " \"versionInfo\": \"1.0.0\",\r\n" + + " \"downloadLocation\": \"https://github.com/demaconsulting/SpdxTool\",\r\n" + + " \"licenseConcluded\": \"MIT\"\r\n" + + " }\r\n" + + " ],\r\n" + + " \"relationships\": [" + + " {\r\n" + + " \"spdxElementId\": \"SPDXRef-DOCUMENT\",\r\n" + + " \"relatedSpdxElement\": \"SPDXRef-Package-1\",\r\n" + + " \"relationshipType\": \"DESCRIBES\"\r\n" + + " }\r\n" + + " ],\r\n" + + " \"spdxVersion\": \"SPDX-2.2\",\r\n" + + " \"dataLicense\": \"CC0-1.0\",\r\n" + + " \"SPDXID\": \"SPDXRef-DOCUMENT\",\r\n" + + " \"name\": \"Test Document\",\r\n" + + " \"documentNamespace\": \"https://sbom.spdx.org\",\r\n" + + " \"creationInfo\": {\r\n" + + " \"created\": \"2021-10-01T00:00:00Z\",\r\n" + + " \"creators\": [ \"Person: Malcolm Nixon\" ]\r\n" + + " },\r\n" + + " \"documentDescribes\": [ \"SPDXRef-Package-1\" ]\r\n" + + "}"; + + // Workflow contents + const string workflowContents = "steps:\n" + + "- command: add-package\n" + + " inputs:\n" + + " package:\n" + + " id: SPDXRef-Package-2\n" + + " name: Test Package 2\n" + + " version: 2.0.0\n" + + " download: https://dotnet.microsoft.com/download\n" + + " purl: pkg:nuget/BogusPackage@2.0.0\n" + + " spdx: spdx.json\n" + + " relationship: BUILD_TOOL_OF\n" + + " element: SPDXRef-Package-1\n"; + + try + { + // Write the SPDX files + File.WriteAllText("spdx.json", spdxContents); + File.WriteAllText("workflow.yaml", workflowContents); + + // Run the command + var exitCode = Runner.Run( + out _, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "run-workflow", + "workflow.yaml"); + + // Verify success + Assert.AreEqual(0, exitCode); + + // Read the SPDX document + Assert.IsTrue(File.Exists("spdx.json")); + var doc = Spdx2JsonDeserializer.Deserialize(File.ReadAllText("spdx.json")); + + // Verify both packages present + Assert.AreEqual(2, doc.Packages.Length); + Assert.AreEqual("SPDXRef-Package-1", doc.Packages[0].Id); + Assert.AreEqual("SPDXRef-Package-2", doc.Packages[1].Id); + + // Verify the relationship + Assert.AreEqual(2, doc.Relationships.Length); + Assert.AreEqual("SPDXRef-Package-2", doc.Relationships[1].Id); + Assert.AreEqual(SpdxRelationshipType.BuildToolOf, doc.Relationships[1].RelationshipType); + Assert.AreEqual("SPDXRef-Package-1", doc.Relationships[1].RelatedSpdxElement); + } + finally + { + File.Delete("spdx.json"); + File.Delete("workflow.yaml"); + } + } + + [TestMethod] + public void AddPackageFromQuery() + { + // SPDX contents + const string spdxContents = "{\r\n" + + " \"files\": [],\r\n" + + " \"packages\": [" + + " {\r\n" + + " \"SPDXID\": \"SPDXRef-Package-1\",\r\n" + + " \"name\": \"Test Package\",\r\n" + + " \"versionInfo\": \"1.0.0\",\r\n" + + " \"downloadLocation\": \"https://github.com/demaconsulting/SpdxTool\",\r\n" + + " \"licenseConcluded\": \"MIT\"\r\n" + + " }\r\n" + + " ],\r\n" + + " \"relationships\": [" + + " {\r\n" + + " \"spdxElementId\": \"SPDXRef-DOCUMENT\",\r\n" + + " \"relatedSpdxElement\": \"SPDXRef-Package-1\",\r\n" + + " \"relationshipType\": \"DESCRIBES\"\r\n" + + " }\r\n" + + " ],\r\n" + + " \"spdxVersion\": \"SPDX-2.2\",\r\n" + + " \"dataLicense\": \"CC0-1.0\",\r\n" + + " \"SPDXID\": \"SPDXRef-DOCUMENT\",\r\n" + + " \"name\": \"Test Document\",\r\n" + + " \"documentNamespace\": \"https://sbom.spdx.org\",\r\n" + + " \"creationInfo\": {\r\n" + + " \"created\": \"2021-10-01T00:00:00Z\",\r\n" + + " \"creators\": [ \"Person: Malcolm Nixon\" ]\r\n" + + " },\r\n" + + " \"documentDescribes\": [ \"SPDXRef-Package-1\" ]\r\n" + + "}"; + + // Workflow contents + const string workflowContents = "steps:\n" + + "- command: query\n" + + " inputs:\n" + + " output: dotnet_version\n" + + " pattern: '(?\\d+\\.\\d+\\.\\d+)'\n" + + " program: dotnet\n" + + " arguments:\n" + + " - --version\n" + + "- command: add-package\n" + + " inputs:\n" + + " package:\n" + + " id: SPDXRef-Package-DotNet\n" + + " name: DotNet SDK\n" + + " version: ${{ dotnet_version }}\n" + + " download: https://dotnet.microsoft.com/download\n" + + " license: MIT\n" + + " spdx: spdx.json\n" + + " relationship: BUILD_TOOL_OF\n" + + " element: SPDXRef-Package-1\n"; + + try + { + // Write the SPDX files + File.WriteAllText("spdx.json", spdxContents); + File.WriteAllText("workflow.yaml", workflowContents); + + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "run-workflow", + "workflow.yaml"); + + // Verify success + Assert.AreEqual(0, exitCode); + + // Read the SPDX document + Assert.IsTrue(File.Exists("spdx.json")); + var doc = Spdx2JsonDeserializer.Deserialize(File.ReadAllText("spdx.json")); + + // Verify both packages present + Assert.AreEqual(2, doc.Packages.Length); + Assert.AreEqual("SPDXRef-Package-1", doc.Packages[0].Id); + Assert.AreEqual("SPDXRef-Package-DotNet", doc.Packages[1].Id); + + // Verify the relationship + Assert.AreEqual(2, doc.Relationships.Length); + Assert.AreEqual("SPDXRef-Package-DotNet", doc.Relationships[1].Id); + Assert.AreEqual(SpdxRelationshipType.BuildToolOf, doc.Relationships[1].RelationshipType); + Assert.AreEqual("SPDXRef-Package-1", doc.Relationships[1].RelatedSpdxElement); + } + finally + { + File.Delete("spdx.json"); + File.Delete("workflow.yaml"); + } + } +} \ No newline at end of file diff --git a/test/DemaConsulting.SpdxTool.Tests/TestCopyPackageCommand.cs b/test/DemaConsulting.SpdxTool.Tests/TestCopyPackageCommand.cs index 5ef2f8a..c61b755 100644 --- a/test/DemaConsulting.SpdxTool.Tests/TestCopyPackageCommand.cs +++ b/test/DemaConsulting.SpdxTool.Tests/TestCopyPackageCommand.cs @@ -119,7 +119,7 @@ public void CopyPackage() "from.spdx.json", "to.spdx.json", "SPDXRef-Package-2", - "CONTAINS", + "CONTAINED_BY", "SPDXRef-Package-1"); // Verify success @@ -136,9 +136,9 @@ public void CopyPackage() // Verify the relationship Assert.AreEqual(2, doc.Relationships.Length); - Assert.AreEqual("SPDXRef-Package-1", doc.Relationships[1].Id); - Assert.AreEqual(SpdxRelationshipType.Contains, doc.Relationships[1].RelationshipType); - Assert.AreEqual("SPDXRef-Package-2", doc.Relationships[1].RelatedSpdxElement); + Assert.AreEqual("SPDXRef-Package-2", doc.Relationships[1].Id); + Assert.AreEqual(SpdxRelationshipType.ContainedBy, doc.Relationships[1].RelationshipType); + Assert.AreEqual("SPDXRef-Package-1", doc.Relationships[1].RelatedSpdxElement); } finally { diff --git a/test/DemaConsulting.SpdxTool.Tests/TestQueryCommand.cs b/test/DemaConsulting.SpdxTool.Tests/TestQueryCommand.cs new file mode 100644 index 0000000..56c84b9 --- /dev/null +++ b/test/DemaConsulting.SpdxTool.Tests/TestQueryCommand.cs @@ -0,0 +1,75 @@ +using System.Text.RegularExpressions; + +namespace DemaConsulting.SpdxTool.Tests; + +[TestClass] +public class TestQueryCommand +{ + [TestMethod] + public void QueryCommandMissingArguments() + { + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "query"); + + // Verify error reported + Assert.AreEqual(1, exitCode); + Assert.IsTrue(output.Contains("'query' command missing arguments")); + } + + [TestMethod] + public void QueryCommandBadPattern() + { + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "query", + "pattern", + "dotnet", + "--version"); + + // Verify error reported + Assert.AreEqual(1, exitCode); + Assert.IsTrue(output.Contains("Pattern must contain a 'value' capture group")); + } + + [TestMethod] + public void QueryCommandInvalidProgram() + { + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "query", + @"(?\d+\.\d+\.\d+)", + "does-not-exist"); + + // Verify error reported + Assert.AreEqual(1, exitCode); + Assert.IsTrue(output.Contains("Unable to start program 'does-not-exist'")); + } + + [TestMethod] + public void QueryDotNet() + { + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "query", + @"(?\d+\.\d+\.\d+)", + "dotnet", + "--version"); + + // Verify error reported + Assert.AreEqual(0, exitCode); + Assert.IsTrue(Regex.IsMatch(output, @"\d+\.\d+\.\d+")); + } +} \ No newline at end of file diff --git a/test/DemaConsulting.SpdxTool.Tests/TestSha256Command.cs b/test/DemaConsulting.SpdxTool.Tests/TestSha256Command.cs new file mode 100644 index 0000000..dcedeee --- /dev/null +++ b/test/DemaConsulting.SpdxTool.Tests/TestSha256Command.cs @@ -0,0 +1,141 @@ +namespace DemaConsulting.SpdxTool.Tests; + +[TestClass] +public class TestSha256Command +{ + [TestMethod] + public void Sha256CommandMissingArguments() + { + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "sha256"); + + // Verify error reported + Assert.AreEqual(1, exitCode); + Assert.IsTrue(output.Contains("'sha256' command missing arguments")); + } + + [TestMethod] + public void Sha256CommandGenerateMissingFile() + { + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "sha256", + "generate", + "missing-file.txt"); + + // Verify error reported + Assert.AreEqual(1, exitCode); + Assert.IsTrue(output.Contains("Error calculating sha256 hash for 'missing-file.txt'")); + } + + [TestMethod] + public void Sha256CommandGenerate() + { + try + { + File.WriteAllText("test.txt", "The quick brown fox jumps over the lazy dog"); + + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "sha256", + "generate", + "test.txt"); + + // Verify success reported + Assert.AreEqual(0, exitCode); + + // Verify the hash file was created + Assert.IsTrue(File.Exists("test.txt.sha256")); + var digest = File.ReadAllText("test.txt.sha256"); + Assert.AreEqual("d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", digest); + } + finally + { + File.Delete("test.txt"); + File.Delete("test.txt.sha256"); + } + } + + [TestMethod] + public void Sha256CommandVerifyMissingFile() + { + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "sha256", + "verify", + "missing-file.txt"); + + // Verify error reported + Assert.AreEqual(1, exitCode); + Assert.IsTrue(output.Contains("Error: Could not find file")); + } + + [TestMethod] + public void Sha256CommandVerifyBad() + { + try + { + File.WriteAllText("test.txt", "Test string"); + File.WriteAllText("test.txt.sha256", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "sha256", + "verify", + "test.txt"); + + // Verify error reported + Assert.AreEqual(1, exitCode); + Assert.IsTrue(output.Contains("Sha256 hash mismatch for 'test.txt'")); + } + finally + { + File.Delete("test.txt"); + File.Delete("test.txt.sha256"); + } + } + + [TestMethod] + public void Sha256CommandVerifyGood() + { + try + { + File.WriteAllText("test.txt", "The quick brown fox jumps over the lazy dog"); + File.WriteAllText("test.txt.sha256", "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592"); + + // Run the command + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "sha256", + "verify", + "test.txt"); + + // Verify success reported + Assert.AreEqual(0, exitCode); + Assert.IsTrue(output.Contains("Sha256 Digest OK for 'test.txt'")); + } + finally + { + File.Delete("test.txt"); + File.Delete("test.txt.sha256"); + } + } +} \ No newline at end of file