diff --git a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs index dd0d59fc5..81be72d75 100644 --- a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs @@ -58,20 +58,67 @@ public DotNetComponentDetector( private string? NormalizeDirectory(string? path) => string.IsNullOrEmpty(path) ? path : Path.TrimEndingDirectorySeparator(this.pathUtilityService.NormalizePath(path)); + /// + /// Given a path relative to sourceDirectory, and the same path in another filesystem, + /// determine what path could be replaced with root. + /// + /// Some path under root, including the root path. + /// Path to the same file as but in a different root. + /// Portion of that corresponds to root, or null if it can not be rebased. + private string? GetRootRebasePath(string rootBasedPath, string? rebasePath) + { + if (string.IsNullOrEmpty(rebasePath) || string.IsNullOrEmpty(this.sourceDirectory) || string.IsNullOrEmpty(rootBasedPath)) + { + return null; + } + + // sourceDirectory is normalized, normalize others + rootBasedPath = this.pathUtilityService.NormalizePath(rootBasedPath); + rebasePath = this.pathUtilityService.NormalizePath(rebasePath); + + // nothing to do if the paths are the same + if (rebasePath.Equals(rootBasedPath, StringComparison.Ordinal)) + { + return null; + } + + // find the relative path under root. + var rootRelativePath = this.pathUtilityService.NormalizePath(Path.GetRelativePath(this.sourceDirectory!, rootBasedPath)); + + // if the rebase path has the same relative portion, then we have a replacement. + if (rebasePath.EndsWith(rootRelativePath)) + { + return rebasePath[..^rootRelativePath.Length]; + } + + // The path didn't have a common relative path, it might have been copied from a completely different location since it was built. + // We cannot rebase the paths. + return null; + } + private async Task RunDotNetVersionAsync(string workingDirectoryPath, CancellationToken cancellationToken) { var workingDirectory = new DirectoryInfo(workingDirectoryPath); - var process = await this.commandLineInvocationService.ExecuteCommandAsync("dotnet", ["dotnet.exe"], workingDirectory, cancellationToken, "--version").ConfigureAwait(false); + try + { + var process = await this.commandLineInvocationService.ExecuteCommandAsync("dotnet", ["dotnet.exe"], workingDirectory, cancellationToken, "--version").ConfigureAwait(false); + + if (process.ExitCode != 0) + { + // debug only - it could be that dotnet is not actually on the path and specified directly by the build scripts. + this.Logger.LogDebug("Failed to invoke 'dotnet --version'. Return: {Return} StdErr: {StdErr} StdOut: {StdOut}.", process.ExitCode, process.StdErr, process.StdOut); + return null; + } - if (process.ExitCode != 0) + return process.StdOut.Trim(); + } + catch (InvalidOperationException ioe) { // debug only - it could be that dotnet is not actually on the path and specified directly by the build scripts. - this.Logger.LogDebug("Failed to invoke 'dotnet --version'. Return: {Return} StdErr: {StdErr} StdOut: {StdOut}.", process.ExitCode, process.StdErr, process.StdOut); + this.Logger.LogDebug("Failed to invoke 'dotnet --version'. {Message}", ioe.Message); return null; } - - return process.StdOut.Trim(); } public override Task ExecuteDetectorAsync(ScanRequest request, CancellationToken cancellationToken = default) @@ -86,7 +133,20 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID { var lockFile = this.lockFileFormat.Read(processRequest.ComponentStream.Stream, processRequest.ComponentStream.Location); + var projectAssetsDirectory = this.pathUtilityService.GetParentDirectory(processRequest.ComponentStream.Location); var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath; + var projectOutputPath = lockFile.PackageSpec.RestoreMetadata.OutputPath; + + // The output path should match the location that the assets file, if it doesn't we could be analyzing paths + // on a different filesystem root than they were created. + // Attempt to rebase paths based on the difference between this file's location and the output path. + var rebasePath = this.GetRootRebasePath(projectAssetsDirectory, projectOutputPath); + + if (rebasePath is not null) + { + projectPath = Path.Combine(this.sourceDirectory!, Path.GetRelativePath(rebasePath, projectPath)); + projectOutputPath = Path.Combine(this.sourceDirectory!, Path.GetRelativePath(rebasePath, projectOutputPath)); + } if (!this.fileUtilityService.Exists(projectPath)) { @@ -98,11 +158,13 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID var sdkVersion = await this.GetSdkVersionAsync(projectDirectory, cancellationToken); var projectName = lockFile.PackageSpec.RestoreMetadata.ProjectName; - var projectOutputPath = lockFile.PackageSpec.RestoreMetadata.OutputPath; if (!this.directoryUtilityService.Exists(projectOutputPath)) { this.Logger.LogWarning("Project output path {ProjectOutputPath} specified by {ProjectAssetsPath} does not exist.", projectOutputPath, processRequest.ComponentStream.Location); + + // default to use the location of the assets file. + projectOutputPath = projectAssetsDirectory; } var targetType = this.GetProjectType(projectOutputPath, projectName, cancellationToken); @@ -120,11 +182,11 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID { if (this.directoryUtilityService.Exists(projectOutputPath)) { - var namePattern = (projectName ?? "*") + ".dll"; + var namePattern = projectName ?? "*"; // look for the compiled output, first as dll then as exe. - var candidates = this.directoryUtilityService.EnumerateFiles(projectOutputPath, namePattern, SearchOption.AllDirectories) - .Concat(this.directoryUtilityService.EnumerateFiles(projectOutputPath, namePattern, SearchOption.AllDirectories)); + var candidates = this.directoryUtilityService.EnumerateFiles(projectOutputPath, namePattern + ".dll", SearchOption.AllDirectories) + .Concat(this.directoryUtilityService.EnumerateFiles(projectOutputPath, namePattern + ".exe", SearchOption.AllDirectories)); foreach (var candidate in candidates) { try diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs index 6877fa15a..bd0d98bb7 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs @@ -8,6 +8,7 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -62,7 +63,7 @@ public DotNetComponentDetectorTests() this.mockDirectoryUtilityService.Setup(x => x.Exists(It.IsAny())).Returns((string p) => this.DirectoryExists(p)); // ignore pattern and search option since we don't really need them for tests - this.mockDirectoryUtilityService.Setup(x => x.EnumerateFiles(It.IsAny(), It.IsAny(), It.IsAny())).Returns((string d, string p, SearchOption s) => this.EnumerateFilesRecursive(d)); + this.mockDirectoryUtilityService.Setup(x => x.EnumerateFiles(It.IsAny(), It.IsAny(), It.IsAny())).Returns((string d, string p, SearchOption s) => this.EnumerateFilesRecursive(d, p)); this.mockPathUtilityService.Setup(x => x.NormalizePath(It.IsAny())).Returns((string p) => p); // don't do normalization this.mockPathUtilityService.Setup(x => x.GetParentDirectory(It.IsAny())).Returns((string p) => Path.GetDirectoryName(p)); @@ -91,24 +92,30 @@ private Stream OpenFile(string path) private bool DirectoryExists(string directory) => this.files.ContainsKey(directory); - private IEnumerable EnumerateFilesRecursive(string directory) + private IEnumerable EnumerateFilesRecursive(string directory, string pattern) { if (this.files.TryGetValue(directory, out var fileNames)) { + // a basic approximation of globbing + var patternRegex = new Regex(pattern.Replace(".", "\\.").Replace("*", ".*")); + foreach (var fileName in fileNames.Keys) { var filePath = Path.Combine(directory, fileName); if (fileName.EndsWith(Path.DirectorySeparatorChar)) { - foreach (var subFile in this.EnumerateFilesRecursive(Path.TrimEndingDirectorySeparator(filePath))) + foreach (var subFile in this.EnumerateFilesRecursive(Path.TrimEndingDirectorySeparator(filePath), pattern)) { yield return subFile; } } else { - yield return filePath; + if (patternRegex.IsMatch(fileName)) + { + yield return filePath; + } } } } @@ -267,6 +274,54 @@ public async Task TestDotNetDetectorGlobalJsonRollForward_ReturnsSDKVersion() discoveredComponents.Where(component => component.Component.Id == "8.0.808 net8.0 unknown - DotNet").Should().ContainSingle(); } + [TestMethod] + public async Task TestDotNetDetectorGlobalJsonDotNetVersionFails_ReturnsSDKVersion() + { + var projectPath = Path.Combine(RootDir, "path", "to", "project"); + var projectAssets = ProjectAssets("projectName", "does-not-exist", projectPath, "net8.0"); + var globalJson = GlobalJson("8.0.100"); + this.AddFile(projectPath, null); + this.AddFile(Path.Combine(RootDir, "path", "global.json"), globalJson); + this.SetCommandResult(-1); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("project.assets.json", projectAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + detectedComponents.Should().HaveCount(2); + + var discoveredComponents = detectedComponents.ToArray(); + discoveredComponents.Where(component => component.Component.Id == "8.0.100 unknown unknown - DotNet").Should().ContainSingle(); + discoveredComponents.Where(component => component.Component.Id == "8.0.100 net8.0 unknown - DotNet").Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDotNetDetectorGlobalJsonDotNetVersionThrows_ReturnsSDKVersion() + { + var projectPath = Path.Combine(RootDir, "path", "to", "project"); + var projectAssets = ProjectAssets("projectName", "does-not-exist", projectPath, "net8.0"); + var globalJson = GlobalJson("8.0.100"); + this.AddFile(projectPath, null); + this.AddFile(Path.Combine(RootDir, "path", "global.json"), globalJson); + this.SetCommandResult((c, d) => throw new InvalidOperationException()); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("project.assets.json", projectAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + detectedComponents.Should().HaveCount(2); + + var discoveredComponents = detectedComponents.ToArray(); + discoveredComponents.Where(component => component.Component.Id == "8.0.100 unknown unknown - DotNet").Should().ContainSingle(); + discoveredComponents.Where(component => component.Component.Id == "8.0.100 net8.0 unknown - DotNet").Should().ContainSingle(); + } + [TestMethod] public async Task TestDotNetDetectorNoGlobalJson_ReturnsDotNetVersion() { @@ -388,6 +443,40 @@ public async Task TestDotNetDetectorMultipleProjectsWithDifferentOutputTypeAndSd discoveredComponents.Where(component => component.Component.Id == "1.2.3 net48 application - DotNet").Should().ContainSingle(); } + [TestMethod] + public async Task TestDotNetDetectorExe() + { + // dotnet from global.json will be 4.5.6 + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + // set up an application - not under global.json + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + var applicationAssets = ProjectAssets("application", applicationOutputPath, applicationProjectPath, "net4.8"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net4.8", "application.exe"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + detectedComponents.Should().HaveCount(2); + + var discoveredComponents = detectedComponents.ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 unknown unknown - DotNet").Should().ContainSingle(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net48 application - DotNet").Should().ContainSingle(); + } + [TestMethod] public async Task TestDotNetDetectorInvalidOutputAssembly() { @@ -458,4 +547,52 @@ public async Task TestDotNetDetectorNoGlobalJsonSourceRoot() var discoveredComponents = detectedComponents.ToArray(); discoveredComponents.Where(component => component.Component.Id == "0.0.0 net8.0 unknown - DotNet").Should().ContainSingle(); } + + [TestMethod] + public async Task TestDotNetDetectorRebasePaths() + { + // DetectorTestUtility runs under Path.GetTempPath() + var scanRoot = Path.TrimEndingDirectorySeparator(Path.GetTempPath()); + + // dotnet from global.json will be 4.5.6 + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(scanRoot, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + // make sure we find global.json and read it + this.SetCommandResult(-1); + + // set up a library project - under global.json + var libraryProjectName = "library"; + + var libraryProjectPath = Path.Combine(scanRoot, "path", "to", "project", $"{libraryProjectName}.csproj"); + var libraryBuildProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{libraryProjectName}.csproj"); + this.AddFile(libraryProjectPath, null); + + var libraryOutputPath = Path.Combine(Path.GetDirectoryName(libraryProjectPath), "obj"); + var libraryBuildOutputPath = Path.Combine(Path.GetDirectoryName(libraryBuildProjectPath), "obj"); + var libraryAssetsPath = Path.Combine(libraryOutputPath, "project.assets.json"); + + // use "build" paths to simulate an Assets file that has a different root. Here the build assets have RootDir, but the scanned filesystem has scanRoot. + var libraryAssets = ProjectAssets("library", libraryBuildOutputPath, libraryBuildProjectPath, "net8.0", "net6.0", "netstandard2.0"); + var libraryAssemblyStream = File.OpenRead(typeof(DotNetComponent).Assembly.Location); + this.AddFile(Path.Combine(libraryOutputPath, "Release", "net8.0", "library.dll"), libraryAssemblyStream); + this.AddFile(Path.Combine(libraryOutputPath, "Release", "net6.0", "library.dll"), libraryAssemblyStream); + this.AddFile(Path.Combine(libraryOutputPath, "Release", "netstandard2.0", "library.dll"), libraryAssemblyStream); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(libraryAssetsPath, libraryAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents(); + detectedComponents.Should().HaveCount(4); + + var discoveredComponents = detectedComponents.ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 unknown unknown - DotNet").Should().ContainSingle(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 library - DotNet").Should().ContainSingle(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net6.0 library - DotNet").Should().ContainSingle(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 netstandard2.0 library - DotNet").Should().ContainSingle(); + } }