diff --git a/CycloneDX.Tests/SolutionFileServiceTests.cs b/CycloneDX.Tests/SolutionFileServiceTests.cs index 81982df7..1e754c8c 100644 --- a/CycloneDX.Tests/SolutionFileServiceTests.cs +++ b/CycloneDX.Tests/SolutionFileServiceTests.cs @@ -16,15 +16,14 @@ // Copyright (c) OWASP Foundation. All Rights Reserved. using System.Collections.Generic; -using System.Threading.Tasks; -using Xunit; using System.IO.Abstractions.TestingHelpers; +using System.Threading.Tasks; using CycloneDX.Interfaces; -using XFS = System.IO.Abstractions.TestingHelpers.MockUnixSupport; -using Moq; -using CycloneDX.Services; using CycloneDX.Models; -using System.IO; +using CycloneDX.Services; +using Moq; +using Xunit; +using XFS = System.IO.Abstractions.TestingHelpers.MockUnixSupport; namespace CycloneDX.Tests { @@ -47,8 +46,8 @@ public async Task GetSolutionProjectReferences_ReturnsProjectThatExists() var solutionFileService = new SolutionFileService(mockFileSystem, mockProjectFileService.Object); var projects = await solutionFileService.GetSolutionProjectReferencesAsync(XFS.Path(@"c:\SolutionPath\SolutionFile.sln")).ConfigureAwait(true); - - Assert.Collection(projects, + + Assert.Collection(projects, item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project\Project.csproj"), item)); } @@ -77,7 +76,7 @@ public async Task GetSolutionProjectReferences_ReturnsListOfProjects() var projects = await solutionFileService.GetSolutionProjectReferencesAsync(XFS.Path(@"c:\SolutionPath\SolutionFile.sln")).ConfigureAwait(true); var sortedProjects = new List(projects); sortedProjects.Sort(); - + Assert.Collection(sortedProjects, item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project1\Project1.csproj"), item), item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project2\Project2.csproj"), item), @@ -112,7 +111,7 @@ public async Task GetSolutionProjectReferences_ReturnsListOfProjectsIncludingFSh var projects = await solutionFileService.GetSolutionProjectReferencesAsync(XFS.Path(@"c:\SolutionPath\SolutionFile.sln")).ConfigureAwait(true); var sortedProjects = new List(projects); sortedProjects.Sort(); - + Assert.Collection(sortedProjects, item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project1\Project1.csproj"), item), item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project2\Project2.fsproj"), item), @@ -147,10 +146,48 @@ public async Task GetSolutionProjectReferences_ReturnsListOfProjects_IncludingSe var projects = await solutionFileService.GetSolutionProjectReferencesAsync(XFS.Path(@"c:\SolutionPath\SolutionFile.sln")).ConfigureAwait(true); var sortedProjects = new List(projects); sortedProjects.Sort(); - + Assert.Collection(sortedProjects, item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project1\Project1.csproj"), item), item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project2\Project2.csproj"), item)); } + + [Fact] + public async Task GetSolutionFilterProjectReferences_ReturnsListOfProjects() + { + var mockFileSystem = new MockFileSystem(new Dictionary + { + { XFS.Path(@"c:\SolutionPath\SolutionFile.slnf"), new MockFileData(@" +{ + ""solution"": { + ""path"": ""SolutionFile.sln"", + ""projects"": [ + ""Project1\\Project1.csproj"", + ""Project2\\Project2.csproj"", + ""Project3\\Project3.csproj"" + ] + } +}")}, + { XFS.Path(@"c:\SolutionPath\Project1\Project1.csproj"), Helpers.GetEmptyProjectFile() }, + { XFS.Path(@"c:\SolutionPath\Project2\Project2.csproj"), Helpers.GetEmptyProjectFile() }, + { XFS.Path(@"c:\SolutionPath\Project3\Project3.csproj"), Helpers.GetEmptyProjectFile() }, + }); + var mockProjectFileService = new Mock(); + mockProjectFileService + .SetupSequence(s => s.RecursivelyGetProjectReferencesAsync(It.IsAny())) + .ReturnsAsync(new HashSet()) + .ReturnsAsync(new HashSet()) + .ReturnsAsync(new HashSet()); + var solutionFileService = new SolutionFileService(mockFileSystem, mockProjectFileService.Object); + + var projects = await solutionFileService.GetSolutionProjectReferencesAsync(XFS.Path(@"c:\SolutionPath\SolutionFile.slnf")).ConfigureAwait(true); + var sortedProjects = new List(projects); + sortedProjects.Sort(); + + Assert.Collection(sortedProjects, + item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project1\Project1.csproj"), item), + item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project2\Project2.csproj"), item), + item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project3\Project3.csproj"), item)); + } } } diff --git a/CycloneDX/Models/SolutionFilterFileModel.cs b/CycloneDX/Models/SolutionFilterFileModel.cs new file mode 100644 index 00000000..a1701d72 --- /dev/null +++ b/CycloneDX/Models/SolutionFilterFileModel.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace CycloneDX.Models +{ + public class SolutionFilterFileModel + { + [JsonPropertyName("solution")] + public SolutionFilterSolution Solution { get; set; } + } + + public class SolutionFilterSolution + { + [JsonPropertyName("path")] + public string Path { get; init; } + + [JsonPropertyName("projects")] + public string[] Projects { get; init; } + } +} diff --git a/CycloneDX/Program.cs b/CycloneDX/Program.cs index 00ed3bcb..2346b01a 100644 --- a/CycloneDX/Program.cs +++ b/CycloneDX/Program.cs @@ -15,9 +15,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. -using System; using System.CommandLine; -using System.IO; using System.Threading.Tasks; using CycloneDX.Models; @@ -29,7 +27,7 @@ public static Task Main(string[] args) { - var SolutionOrProjectFile = new Argument("path", description: "The path to a .sln, .csproj, .fsproj, .vbproj, .xsproj, or packages.config file or the path to a directory which will be recursively analyzed for packages.config files."); + var SolutionOrProjectFile = new Argument("path", description: "The path to a .sln, .slnf, .csproj, .fsproj, .vbproj, .xsproj, or packages.config file or the path to a directory which will be recursively analyzed for packages.config files."); var framework = new Option(new[] { "--framework", "-tfm" }, "The target framework to use. If not defined, all will be aggregated."); var runtime = new Option(new[] { "--runtime", "-rt" }, "The runtime to use. If not defined, all will be aggregated."); var outputDirectory = new Option(new[] { "--output", "-o" }, description: "The directory to write the BOM"); @@ -59,8 +57,8 @@ public static Task Main(string[] args) //Deprecated args var disableGithubLicenses = new Option(new[] { "--disable-github-licenses", "-dgl" }, "(Deprecated, this is the default setting now"); var outputFilenameDeprecated = new Option(new[] { "-f" }, "(Deprecated use -fn instead) Optionally provide a filename for the BOM (default: bom.xml or bom.json)."); - var excludeDevDeprecated = new Option(new[] {"-d" }, "(Deprecated use -ed instead) Exclude development dependencies from the BOM."); - var scanProjectDeprecated = new Option(new[] {"-r" }, "(Deprecated use -rs instead) To be used with a single project file, it will recursively scan project references of the supplied project file."); + var excludeDevDeprecated = new Option(new[] { "-d" }, "(Deprecated use -ed instead) Exclude development dependencies from the BOM."); + var scanProjectDeprecated = new Option(new[] { "-r" }, "(Deprecated use -rs instead) To be used with a single project file, it will recursively scan project references of the supplied project file."); var outputDirectoryDeprecated = new Option(new[] { "--out", }, description: "(Deprecated use -output instead) The directory to write the BOM"); @@ -131,7 +129,7 @@ public static Task Main(string[] args) setVersion = context.ParseResult.GetValueForOption(setVersion), setType = context.ParseResult.GetValueForOption(setType), includeProjectReferences = context.ParseResult.GetValueForOption(includeProjectReferences) - }; + }; Runner runner = new Runner(); var taskStatus = await runner.HandleCommandAsync(options); diff --git a/CycloneDX/Runner.cs b/CycloneDX/Runner.cs index 10c0e23e..a7bc6a9e 100644 --- a/CycloneDX/Runner.cs +++ b/CycloneDX/Runner.cs @@ -23,8 +23,8 @@ using System.Net.Http; using System.Reflection; using System.Threading.Tasks; -using CycloneDX.Models; using CycloneDX.Interfaces; +using CycloneDX.Models; using CycloneDX.Services; using static CycloneDX.Models.Component; @@ -71,13 +71,13 @@ public async Task HandleCommandAsync(RunOptions options) string outputFilename = options.outputFilename; bool json = options.json; bool excludeDev = options.excludeDev; - bool excludetestprojects = options.excludeTestProjects; + bool excludetestprojects = options.excludeTestProjects; bool scanProjectReferences = options.scanProjectReferences; bool noSerialNumber = options.noSerialNumber; string githubUsername = options.githubUsername; string githubT = options.githubT; - string githubBT = options.githubBT; - bool disablePackageRestore = options.disablePackageRestore; + string githubBT = options.githubBT; + bool disablePackageRestore = options.disablePackageRestore; int dotnetCommandTimeout = options.dotnetCommandTimeout; string baseIntermediateOutputPath = options.baseIntermediateOutputPath; string importMetadataPath = options.importMetadataPath; @@ -151,6 +151,8 @@ public async Task HandleCommandAsync(RunOptions options) && (SolutionOrProjectFile.ToLowerInvariant().EndsWith(".sln", StringComparison.OrdinalIgnoreCase) || + SolutionOrProjectFile.ToLowerInvariant().EndsWith(".slnf", StringComparison.OrdinalIgnoreCase) + || fileSystem.Directory.Exists(fullSolutionOrProjectFilePath) || this.fileSystem.Path.GetFileName(SolutionOrProjectFile).ToLowerInvariant().Equals("packages.config", StringComparison.OrdinalIgnoreCase))) @@ -162,7 +164,8 @@ public async Task HandleCommandAsync(RunOptions options) try { - if (SolutionOrProjectFile.ToLowerInvariant().EndsWith(".sln", StringComparison.OrdinalIgnoreCase)) + if (SolutionOrProjectFile.ToLowerInvariant().EndsWith(".sln", StringComparison.OrdinalIgnoreCase) || + SolutionOrProjectFile.ToLowerInvariant().EndsWith(".slnf", StringComparison.OrdinalIgnoreCase)) { if (!fileSystem.File.Exists(SolutionOrProjectFile)) { @@ -174,20 +177,20 @@ public async Task HandleCommandAsync(RunOptions options) } else if (Utils.IsSupportedProjectType(SolutionOrProjectFile) && scanProjectReferences) { - if(!fileSystem.File.Exists(SolutionOrProjectFile)) + if (!fileSystem.File.Exists(SolutionOrProjectFile)) { Console.Error.WriteLine($"No file found at path {SolutionOrProjectFile}"); - return (int)ExitCode.InvalidOptions; + return (int)ExitCode.InvalidOptions; } packages = await projectFileService.RecursivelyGetProjectDotnetDependencysAsync(fullSolutionOrProjectFilePath, baseIntermediateOutputPath, excludetestprojects, framework, runtime).ConfigureAwait(false); topLevelComponent.Name = fileSystem.Path.GetFileNameWithoutExtension(SolutionOrProjectFile); } else if (Utils.IsSupportedProjectType(SolutionOrProjectFile)) - { - if(!fileSystem.File.Exists(SolutionOrProjectFile)) + { + if (!fileSystem.File.Exists(SolutionOrProjectFile)) { Console.Error.WriteLine($"No file found at path {SolutionOrProjectFile}"); - return (int)ExitCode.InvalidOptions; + return (int)ExitCode.InvalidOptions; } packages = await projectFileService.GetProjectDotnetDependencysAsync(fullSolutionOrProjectFilePath, baseIntermediateOutputPath, excludetestprojects, framework, runtime).ConfigureAwait(false); topLevelComponent.Name = fileSystem.Path.GetFileNameWithoutExtension(SolutionOrProjectFile); @@ -209,7 +212,7 @@ public async Task HandleCommandAsync(RunOptions options) } else { - Console.Error.WriteLine($"Only .sln, .csproj, .fsproj, .vbproj, .xsproj, and packages.config files are supported"); + Console.Error.WriteLine($"Only .sln, .slnf, .csproj, .fsproj, .vbproj, .xsproj, and packages.config files are supported"); return (int)ExitCode.InvalidOptions; } } @@ -226,7 +229,7 @@ public async Task HandleCommandAsync(RunOptions options) topLevelComponent.Name = setName; } - + if (excludeDev) { foreach (var item in packages.Where(p => p.IsDevDependency)) @@ -239,7 +242,7 @@ public async Task HandleCommandAsync(RunOptions options) } - + // get all the components and dependency graph from the NuGet packages var components = new HashSet(); @@ -423,7 +426,7 @@ public async Task HandleCommandAsync(RunOptions options) Console.WriteLine("Writing to: " + bomFilePath); this.fileSystem.File.WriteAllText(bomFilePath, bomContents); - return 0; + return 0; } @@ -465,7 +468,7 @@ private static void SetMetadataComponentIfNecessary(Bom bom, Component topLevelC { bom.Metadata.Timestamp = DateTime.UtcNow; } - + } internal static Bom ReadMetaDataFromFile(Bom bom, string templatePath) diff --git a/CycloneDX/Services/SolutionFileService.cs b/CycloneDX/Services/SolutionFileService.cs index bddb211e..0dce8ce4 100755 --- a/CycloneDX/Services/SolutionFileService.cs +++ b/CycloneDX/Services/SolutionFileService.cs @@ -17,10 +17,11 @@ using System; using System.Collections.Generic; -using System.Text.RegularExpressions; -using System.Threading.Tasks; using System.IO.Abstractions; using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; using CycloneDX.Interfaces; using CycloneDX.Models; @@ -44,31 +45,17 @@ public SolutionFileService(IFileSystem fileSystem, IProjectFileService projectFi /// public async Task> GetSolutionProjectReferencesAsync(string solutionFilePath) { - var solutionFolder = _fileSystem.Path.GetDirectoryName(solutionFilePath); - var projects = new HashSet(); - using (var reader = _fileSystem.File.OpenText(solutionFilePath)) + HashSet projects; + if (solutionFilePath.ToLowerInvariant().EndsWith(".sln", StringComparison.OrdinalIgnoreCase)) { - string line; - - while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) - { - if (!line.StartsWith("Project", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - var regex = new Regex("(.*) = \"(.*?)\", \"(.*?)\""); - var match = regex.Match(line); - if (match.Success) - { - var relativeProjectPath = match.Groups[3].Value.Replace('\\', _fileSystem.Path.DirectorySeparatorChar); - var projectFile = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(solutionFolder, relativeProjectPath)); - if (Utils.IsSupportedProjectType(projectFile)) projects.Add(projectFile); - } - } + projects = await GetProjectsForSolution(solutionFilePath).ConfigureAwait(false); + } + else + { + projects = await GetProjectsForSolutionFilter(solutionFilePath).ConfigureAwait(false); } - var projectList = new List(projects); - foreach (var project in projectList) + foreach (var project in projects.ToArray()) { var projectReferences = await _projectFileService.RecursivelyGetProjectReferencesAsync(project).ConfigureAwait(false); projects.UnionWith(projectReferences.Select(dep => dep.Path)); @@ -77,6 +64,49 @@ public async Task> GetSolutionProjectReferencesAsync(string solu return projects; } + private async Task> GetProjectsForSolutionFilter(string mainFile) + { + await using var stream = _fileSystem.File.OpenRead(mainFile); + var solutionFilterFile = await JsonSerializer.DeserializeAsync(stream).ConfigureAwait(false); + Console.WriteLine(solutionFilterFile.Solution.Projects); + + var solutionFolder = _fileSystem.Path.GetDirectoryName(mainFile); + var projects = new HashSet(); + foreach (string relativeProjectPath in solutionFilterFile.Solution.Projects) + { + var projectFile = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(solutionFolder, relativeProjectPath)); + if (Utils.IsSupportedProjectType(projectFile)) projects.Add(projectFile); + } + + return projects; + } + + private async Task> GetProjectsForSolution(string mainFile) + { + var solutionFolder = _fileSystem.Path.GetDirectoryName(mainFile); + var projects = new HashSet(); + using var reader = _fileSystem.File.OpenText(mainFile); + string line; + + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + { + if (!line.StartsWith("Project", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + var regex = new Regex("(.*) = \"(.*?)\", \"(.*?)\""); + var match = regex.Match(line); + if (match.Success) + { + var relativeProjectPath = match.Groups[3].Value.Replace('\\', _fileSystem.Path.DirectorySeparatorChar); + var projectFile = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(solutionFolder, relativeProjectPath)); + if (Utils.IsSupportedProjectType(projectFile)) projects.Add(projectFile); + } + } + + return projects; + } + /// /// Analyzes a single Solution file for NuGet package references in referenced project files /// diff --git a/README.md b/README.md index 72ef809b..adc8abf6 100755 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Usage: CycloneDX [options] Arguments: - The path to a .sln, .csproj, .fsproj, .vbproj, .xsproj, or packages.config file or the path to a directory which will be recursively analyzed for packages.config files. + The path to a .sln, .slnf, .csproj, .fsproj, .vbproj, .xsproj, or packages.config file or the path to a directory which will be recursively analyzed for packages.config files. Options: -tfm, --framework The target framework to use. If not defined, all will be aggregated.