Skip to content

Commit

Permalink
add support for solution filter file (CycloneDX#853)
Browse files Browse the repository at this point in the history
Signed-off-by: Michael Hauer <[email protected]>
  • Loading branch information
Hauer Michael (LAD1-IT) authored and michha committed Jan 13, 2025
1 parent ef81800 commit 61ed07c
Show file tree
Hide file tree
Showing 6 changed files with 141 additions and 58 deletions.
59 changes: 48 additions & 11 deletions CycloneDX.Tests/SolutionFileServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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));
}

Expand Down Expand Up @@ -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<string>(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),
Expand Down Expand Up @@ -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<string>(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),
Expand Down Expand Up @@ -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<string>(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<string, MockFileData>
{
{ 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<IProjectFileService>();
mockProjectFileService
.SetupSequence(s => s.RecursivelyGetProjectReferencesAsync(It.IsAny<string>()))
.ReturnsAsync(new HashSet<DotnetDependency>())
.ReturnsAsync(new HashSet<DotnetDependency>())
.ReturnsAsync(new HashSet<DotnetDependency>());
var solutionFileService = new SolutionFileService(mockFileSystem, mockProjectFileService.Object);

var projects = await solutionFileService.GetSolutionProjectReferencesAsync(XFS.Path(@"c:\SolutionPath\SolutionFile.slnf")).ConfigureAwait(true);
var sortedProjects = new List<string>(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));
}
}
}
19 changes: 19 additions & 0 deletions CycloneDX/Models/SolutionFilterFileModel.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
10 changes: 4 additions & 6 deletions CycloneDX/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -29,7 +27,7 @@ public static Task<int> Main(string[] args)
{


var SolutionOrProjectFile = new Argument<string>("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<string>("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<string>(new[] { "--framework", "-tfm" }, "The target framework to use. If not defined, all will be aggregated.");
var runtime = new Option<string>(new[] { "--runtime", "-rt" }, "The runtime to use. If not defined, all will be aggregated.");
var outputDirectory = new Option<string>(new[] { "--output", "-o" }, description: "The directory to write the BOM");
Expand Down Expand Up @@ -59,8 +57,8 @@ public static Task<int> Main(string[] args)
//Deprecated args
var disableGithubLicenses = new Option<bool>(new[] { "--disable-github-licenses", "-dgl" }, "(Deprecated, this is the default setting now");
var outputFilenameDeprecated = new Option<string>(new[] { "-f" }, "(Deprecated use -fn instead) Optionally provide a filename for the BOM (default: bom.xml or bom.json).");
var excludeDevDeprecated = new Option<bool>(new[] {"-d" }, "(Deprecated use -ed instead) Exclude development dependencies from the BOM.");
var scanProjectDeprecated = new Option<bool>(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<bool>(new[] { "-d" }, "(Deprecated use -ed instead) Exclude development dependencies from the BOM.");
var scanProjectDeprecated = new Option<bool>(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<string>(new[] { "--out", }, description: "(Deprecated use -output instead) The directory to write the BOM");


Expand Down Expand Up @@ -131,7 +129,7 @@ public static Task<int> 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);
Expand Down
33 changes: 18 additions & 15 deletions CycloneDX/Runner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -71,13 +71,13 @@ public async Task<int> 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;
Expand Down Expand Up @@ -151,6 +151,8 @@ public async Task<int> 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)))
Expand All @@ -162,7 +164,8 @@ public async Task<int> 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))
{
Expand All @@ -174,20 +177,20 @@ public async Task<int> 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);
Expand All @@ -209,7 +212,7 @@ public async Task<int> 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;
}
}
Expand All @@ -226,7 +229,7 @@ public async Task<int> HandleCommandAsync(RunOptions options)
topLevelComponent.Name = setName;
}


if (excludeDev)
{
foreach (var item in packages.Where(p => p.IsDevDependency))
Expand All @@ -239,7 +242,7 @@ public async Task<int> HandleCommandAsync(RunOptions options)
}




// get all the components and dependency graph from the NuGet packages
var components = new HashSet<Component>();
Expand Down Expand Up @@ -423,7 +426,7 @@ public async Task<int> HandleCommandAsync(RunOptions options)
Console.WriteLine("Writing to: " + bomFilePath);
this.fileSystem.File.WriteAllText(bomFilePath, bomContents);

return 0;
return 0;
}


Expand Down Expand Up @@ -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)
Expand Down
76 changes: 51 additions & 25 deletions CycloneDX/Services/SolutionFileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -44,34 +45,59 @@ public SolutionFileService(IFileSystem fileSystem, IProjectFileService projectFi
/// <returns></returns>
public async Task<HashSet<string>> GetSolutionProjectReferencesAsync(string solutionFilePath)
{
var solutionFolder = _fileSystem.Path.GetDirectoryName(solutionFilePath);
var projects = new HashSet<string>();
using (var reader = _fileSystem.File.OpenText(solutionFilePath))
HashSet<string> projects;
if (solutionFilePath.ToLowerInvariant().EndsWith(".sln", StringComparison.OrdinalIgnoreCase))
projects = await GetProjectsForSolution(solutionFilePath).ConfigureAwait(false);
else
projects = await GetProjectsForSolutionFilter(solutionFilePath).ConfigureAwait(false);

foreach (var project in projects.ToArray())
{
string line;
var projectReferences = await _projectFileService.RecursivelyGetProjectReferencesAsync(project).ConfigureAwait(false);
projects.UnionWith(projectReferences.Select(dep => dep.Path));
}

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;
}

private async Task<HashSet<string>> GetProjectsForSolutionFilter(string mainFile)
{
await using var stream = _fileSystem.File.OpenRead(mainFile);
var solutionFilterFile = await JsonSerializer.DeserializeAsync<SolutionFilterFileModel>(stream).ConfigureAwait(false);
Console.WriteLine(solutionFilterFile.Solution.Projects);

var solutionFolder = _fileSystem.Path.GetDirectoryName(mainFile);
var projects = new HashSet<string>();
foreach (string relativeProjectPath in solutionFilterFile.Solution.Projects)
{
var projectFile = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(solutionFolder, relativeProjectPath));
if (Utils.IsSupportedProjectType(projectFile)) projects.Add(projectFile);
}

var projectList = new List<string>(projects);
foreach (var project in projectList)
return projects;
}

private async Task<HashSet<string>> GetProjectsForSolution(string mainFile)
{
var solutionFolder = _fileSystem.Path.GetDirectoryName(mainFile);
var projects = new HashSet<string>();
using var reader = _fileSystem.File.OpenText(mainFile);
string line;

while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
{
var projectReferences = await _projectFileService.RecursivelyGetProjectReferencesAsync(project).ConfigureAwait(false);
projects.UnionWith(projectReferences.Select(dep => dep.Path));
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;
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Usage:
CycloneDX <path> [options]
Arguments:
<path> 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.
<path> 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 <framework> The target framework to use. If not defined, all will be aggregated.
Expand Down

0 comments on commit 61ed07c

Please sign in to comment.