Skip to content

Commit 385fd71

Browse files
authored
Fix dotnet detector for exes / newer version not on path (#1367)
* Fix dotnet detector for exes / newer version not on path We weren't handling EXEs correctly - simply missed this case. When a global.json mentions a version that doesn't exist on the path, the `dotnet --version` command will fail, and the `commandLineInvocationService` will throw InvalidOperationException since it cannot distinguish this failure from a missing command. Catch this exception and treat it like a failure so we fallback to parsing `global.json`. * Support moved project.assets.json files We've seen cases where the project.assets.json files refer to paths with a different root than the current scan. One example is building inside a container then running on the filesystem mounted outside the container. Address this by comparing the relative path to the root, and finding if the same relative path is present in the assets file's output path.
1 parent 255e011 commit 385fd71

File tree

2 files changed

+212
-13
lines changed

2 files changed

+212
-13
lines changed

src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,67 @@ public DotNetComponentDetector(
5858

5959
private string? NormalizeDirectory(string? path) => string.IsNullOrEmpty(path) ? path : Path.TrimEndingDirectorySeparator(this.pathUtilityService.NormalizePath(path));
6060

61+
/// <summary>
62+
/// Given a path relative to sourceDirectory, and the same path in another filesystem,
63+
/// determine what path could be replaced with root.
64+
/// </summary>
65+
/// <param name="rootBasedPath">Some path under root, including the root path.</param>
66+
/// <param name="rebasePath">Path to the same file as <paramref name="rootBasedPath"/> but in a different root.</param>
67+
/// <returns>Portion of <paramref name="rebasePath"/> that corresponds to root, or null if it can not be rebased.</returns>
68+
private string? GetRootRebasePath(string rootBasedPath, string? rebasePath)
69+
{
70+
if (string.IsNullOrEmpty(rebasePath) || string.IsNullOrEmpty(this.sourceDirectory) || string.IsNullOrEmpty(rootBasedPath))
71+
{
72+
return null;
73+
}
74+
75+
// sourceDirectory is normalized, normalize others
76+
rootBasedPath = this.pathUtilityService.NormalizePath(rootBasedPath);
77+
rebasePath = this.pathUtilityService.NormalizePath(rebasePath);
78+
79+
// nothing to do if the paths are the same
80+
if (rebasePath.Equals(rootBasedPath, StringComparison.Ordinal))
81+
{
82+
return null;
83+
}
84+
85+
// find the relative path under root.
86+
var rootRelativePath = this.pathUtilityService.NormalizePath(Path.GetRelativePath(this.sourceDirectory!, rootBasedPath));
87+
88+
// if the rebase path has the same relative portion, then we have a replacement.
89+
if (rebasePath.EndsWith(rootRelativePath))
90+
{
91+
return rebasePath[..^rootRelativePath.Length];
92+
}
93+
94+
// The path didn't have a common relative path, it might have been copied from a completely different location since it was built.
95+
// We cannot rebase the paths.
96+
return null;
97+
}
98+
6199
private async Task<string?> RunDotNetVersionAsync(string workingDirectoryPath, CancellationToken cancellationToken)
62100
{
63101
var workingDirectory = new DirectoryInfo(workingDirectoryPath);
64102

65-
var process = await this.commandLineInvocationService.ExecuteCommandAsync("dotnet", ["dotnet.exe"], workingDirectory, cancellationToken, "--version").ConfigureAwait(false);
103+
try
104+
{
105+
var process = await this.commandLineInvocationService.ExecuteCommandAsync("dotnet", ["dotnet.exe"], workingDirectory, cancellationToken, "--version").ConfigureAwait(false);
106+
107+
if (process.ExitCode != 0)
108+
{
109+
// debug only - it could be that dotnet is not actually on the path and specified directly by the build scripts.
110+
this.Logger.LogDebug("Failed to invoke 'dotnet --version'. Return: {Return} StdErr: {StdErr} StdOut: {StdOut}.", process.ExitCode, process.StdErr, process.StdOut);
111+
return null;
112+
}
66113

67-
if (process.ExitCode != 0)
114+
return process.StdOut.Trim();
115+
}
116+
catch (InvalidOperationException ioe)
68117
{
69118
// debug only - it could be that dotnet is not actually on the path and specified directly by the build scripts.
70-
this.Logger.LogDebug("Failed to invoke 'dotnet --version'. Return: {Return} StdErr: {StdErr} StdOut: {StdOut}.", process.ExitCode, process.StdErr, process.StdOut);
119+
this.Logger.LogDebug("Failed to invoke 'dotnet --version'. {Message}", ioe.Message);
71120
return null;
72121
}
73-
74-
return process.StdOut.Trim();
75122
}
76123

77124
public override Task<IndividualDetectorScanResult> ExecuteDetectorAsync(ScanRequest request, CancellationToken cancellationToken = default)
@@ -86,7 +133,20 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID
86133
{
87134
var lockFile = this.lockFileFormat.Read(processRequest.ComponentStream.Stream, processRequest.ComponentStream.Location);
88135

136+
var projectAssetsDirectory = this.pathUtilityService.GetParentDirectory(processRequest.ComponentStream.Location);
89137
var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath;
138+
var projectOutputPath = lockFile.PackageSpec.RestoreMetadata.OutputPath;
139+
140+
// The output path should match the location that the assets file, if it doesn't we could be analyzing paths
141+
// on a different filesystem root than they were created.
142+
// Attempt to rebase paths based on the difference between this file's location and the output path.
143+
var rebasePath = this.GetRootRebasePath(projectAssetsDirectory, projectOutputPath);
144+
145+
if (rebasePath is not null)
146+
{
147+
projectPath = Path.Combine(this.sourceDirectory!, Path.GetRelativePath(rebasePath, projectPath));
148+
projectOutputPath = Path.Combine(this.sourceDirectory!, Path.GetRelativePath(rebasePath, projectOutputPath));
149+
}
90150

91151
if (!this.fileUtilityService.Exists(projectPath))
92152
{
@@ -98,11 +158,13 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID
98158
var sdkVersion = await this.GetSdkVersionAsync(projectDirectory, cancellationToken);
99159

100160
var projectName = lockFile.PackageSpec.RestoreMetadata.ProjectName;
101-
var projectOutputPath = lockFile.PackageSpec.RestoreMetadata.OutputPath;
102161

103162
if (!this.directoryUtilityService.Exists(projectOutputPath))
104163
{
105164
this.Logger.LogWarning("Project output path {ProjectOutputPath} specified by {ProjectAssetsPath} does not exist.", projectOutputPath, processRequest.ComponentStream.Location);
165+
166+
// default to use the location of the assets file.
167+
projectOutputPath = projectAssetsDirectory;
106168
}
107169

108170
var targetType = this.GetProjectType(projectOutputPath, projectName, cancellationToken);
@@ -120,11 +182,11 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID
120182
{
121183
if (this.directoryUtilityService.Exists(projectOutputPath))
122184
{
123-
var namePattern = (projectName ?? "*") + ".dll";
185+
var namePattern = projectName ?? "*";
124186

125187
// look for the compiled output, first as dll then as exe.
126-
var candidates = this.directoryUtilityService.EnumerateFiles(projectOutputPath, namePattern, SearchOption.AllDirectories)
127-
.Concat(this.directoryUtilityService.EnumerateFiles(projectOutputPath, namePattern, SearchOption.AllDirectories));
188+
var candidates = this.directoryUtilityService.EnumerateFiles(projectOutputPath, namePattern + ".dll", SearchOption.AllDirectories)
189+
.Concat(this.directoryUtilityService.EnumerateFiles(projectOutputPath, namePattern + ".exe", SearchOption.AllDirectories));
128190
foreach (var candidate in candidates)
129191
{
130192
try

test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs

Lines changed: 141 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ namespace Microsoft.ComponentDetection.Detectors.Tests;
88
using System.Reflection;
99
using System.Runtime.InteropServices;
1010
using System.Text.Json;
11+
using System.Text.RegularExpressions;
1112
using System.Threading;
1213
using System.Threading.Tasks;
1314
using FluentAssertions;
@@ -62,7 +63,7 @@ public DotNetComponentDetectorTests()
6263
this.mockDirectoryUtilityService.Setup(x => x.Exists(It.IsAny<string>())).Returns((string p) => this.DirectoryExists(p));
6364

6465
// ignore pattern and search option since we don't really need them for tests
65-
this.mockDirectoryUtilityService.Setup(x => x.EnumerateFiles(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<SearchOption>())).Returns((string d, string p, SearchOption s) => this.EnumerateFilesRecursive(d));
66+
this.mockDirectoryUtilityService.Setup(x => x.EnumerateFiles(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<SearchOption>())).Returns((string d, string p, SearchOption s) => this.EnumerateFilesRecursive(d, p));
6667

6768
this.mockPathUtilityService.Setup(x => x.NormalizePath(It.IsAny<string>())).Returns((string p) => p); // don't do normalization
6869
this.mockPathUtilityService.Setup(x => x.GetParentDirectory(It.IsAny<string>())).Returns((string p) => Path.GetDirectoryName(p));
@@ -91,24 +92,30 @@ private Stream OpenFile(string path)
9192

9293
private bool DirectoryExists(string directory) => this.files.ContainsKey(directory);
9394

94-
private IEnumerable<string> EnumerateFilesRecursive(string directory)
95+
private IEnumerable<string> EnumerateFilesRecursive(string directory, string pattern)
9596
{
9697
if (this.files.TryGetValue(directory, out var fileNames))
9798
{
99+
// a basic approximation of globbing
100+
var patternRegex = new Regex(pattern.Replace(".", "\\.").Replace("*", ".*"));
101+
98102
foreach (var fileName in fileNames.Keys)
99103
{
100104
var filePath = Path.Combine(directory, fileName);
101105

102106
if (fileName.EndsWith(Path.DirectorySeparatorChar))
103107
{
104-
foreach (var subFile in this.EnumerateFilesRecursive(Path.TrimEndingDirectorySeparator(filePath)))
108+
foreach (var subFile in this.EnumerateFilesRecursive(Path.TrimEndingDirectorySeparator(filePath), pattern))
105109
{
106110
yield return subFile;
107111
}
108112
}
109113
else
110114
{
111-
yield return filePath;
115+
if (patternRegex.IsMatch(fileName))
116+
{
117+
yield return filePath;
118+
}
112119
}
113120
}
114121
}
@@ -267,6 +274,54 @@ public async Task TestDotNetDetectorGlobalJsonRollForward_ReturnsSDKVersion()
267274
discoveredComponents.Where(component => component.Component.Id == "8.0.808 net8.0 unknown - DotNet").Should().ContainSingle();
268275
}
269276

277+
[TestMethod]
278+
public async Task TestDotNetDetectorGlobalJsonDotNetVersionFails_ReturnsSDKVersion()
279+
{
280+
var projectPath = Path.Combine(RootDir, "path", "to", "project");
281+
var projectAssets = ProjectAssets("projectName", "does-not-exist", projectPath, "net8.0");
282+
var globalJson = GlobalJson("8.0.100");
283+
this.AddFile(projectPath, null);
284+
this.AddFile(Path.Combine(RootDir, "path", "global.json"), globalJson);
285+
this.SetCommandResult(-1);
286+
287+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
288+
.WithFile("project.assets.json", projectAssets)
289+
.ExecuteDetectorAsync();
290+
291+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
292+
293+
var detectedComponents = componentRecorder.GetDetectedComponents();
294+
detectedComponents.Should().HaveCount(2);
295+
296+
var discoveredComponents = detectedComponents.ToArray();
297+
discoveredComponents.Where(component => component.Component.Id == "8.0.100 unknown unknown - DotNet").Should().ContainSingle();
298+
discoveredComponents.Where(component => component.Component.Id == "8.0.100 net8.0 unknown - DotNet").Should().ContainSingle();
299+
}
300+
301+
[TestMethod]
302+
public async Task TestDotNetDetectorGlobalJsonDotNetVersionThrows_ReturnsSDKVersion()
303+
{
304+
var projectPath = Path.Combine(RootDir, "path", "to", "project");
305+
var projectAssets = ProjectAssets("projectName", "does-not-exist", projectPath, "net8.0");
306+
var globalJson = GlobalJson("8.0.100");
307+
this.AddFile(projectPath, null);
308+
this.AddFile(Path.Combine(RootDir, "path", "global.json"), globalJson);
309+
this.SetCommandResult((c, d) => throw new InvalidOperationException());
310+
311+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
312+
.WithFile("project.assets.json", projectAssets)
313+
.ExecuteDetectorAsync();
314+
315+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
316+
317+
var detectedComponents = componentRecorder.GetDetectedComponents();
318+
detectedComponents.Should().HaveCount(2);
319+
320+
var discoveredComponents = detectedComponents.ToArray();
321+
discoveredComponents.Where(component => component.Component.Id == "8.0.100 unknown unknown - DotNet").Should().ContainSingle();
322+
discoveredComponents.Where(component => component.Component.Id == "8.0.100 net8.0 unknown - DotNet").Should().ContainSingle();
323+
}
324+
270325
[TestMethod]
271326
public async Task TestDotNetDetectorNoGlobalJson_ReturnsDotNetVersion()
272327
{
@@ -388,6 +443,40 @@ public async Task TestDotNetDetectorMultipleProjectsWithDifferentOutputTypeAndSd
388443
discoveredComponents.Where(component => component.Component.Id == "1.2.3 net48 application - DotNet").Should().ContainSingle();
389444
}
390445

446+
[TestMethod]
447+
public async Task TestDotNetDetectorExe()
448+
{
449+
// dotnet from global.json will be 4.5.6
450+
var globalJson = GlobalJson("4.5.6");
451+
var globalJsonDir = Path.Combine(RootDir, "path");
452+
this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson);
453+
454+
this.SetCommandResult(0, "4.5.6");
455+
456+
// set up an application - not under global.json
457+
var applicationProjectName = "application";
458+
var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj");
459+
this.AddFile(applicationProjectPath, null);
460+
var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj");
461+
var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json");
462+
var applicationAssets = ProjectAssets("application", applicationOutputPath, applicationProjectPath, "net4.8");
463+
var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location);
464+
this.AddFile(Path.Combine(applicationOutputPath, "Release", "net4.8", "application.exe"), applicationAssemblyStream);
465+
466+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
467+
.WithFile(applicationAssetsPath, applicationAssets)
468+
.ExecuteDetectorAsync();
469+
470+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
471+
472+
var detectedComponents = componentRecorder.GetDetectedComponents();
473+
detectedComponents.Should().HaveCount(2);
474+
475+
var discoveredComponents = detectedComponents.ToArray();
476+
discoveredComponents.Where(component => component.Component.Id == "4.5.6 unknown unknown - DotNet").Should().ContainSingle();
477+
discoveredComponents.Where(component => component.Component.Id == "4.5.6 net48 application - DotNet").Should().ContainSingle();
478+
}
479+
391480
[TestMethod]
392481
public async Task TestDotNetDetectorInvalidOutputAssembly()
393482
{
@@ -458,4 +547,52 @@ public async Task TestDotNetDetectorNoGlobalJsonSourceRoot()
458547
var discoveredComponents = detectedComponents.ToArray();
459548
discoveredComponents.Where(component => component.Component.Id == "0.0.0 net8.0 unknown - DotNet").Should().ContainSingle();
460549
}
550+
551+
[TestMethod]
552+
public async Task TestDotNetDetectorRebasePaths()
553+
{
554+
// DetectorTestUtility runs under Path.GetTempPath()
555+
var scanRoot = Path.TrimEndingDirectorySeparator(Path.GetTempPath());
556+
557+
// dotnet from global.json will be 4.5.6
558+
var globalJson = GlobalJson("4.5.6");
559+
var globalJsonDir = Path.Combine(scanRoot, "path");
560+
this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson);
561+
562+
// make sure we find global.json and read it
563+
this.SetCommandResult(-1);
564+
565+
// set up a library project - under global.json
566+
var libraryProjectName = "library";
567+
568+
var libraryProjectPath = Path.Combine(scanRoot, "path", "to", "project", $"{libraryProjectName}.csproj");
569+
var libraryBuildProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{libraryProjectName}.csproj");
570+
this.AddFile(libraryProjectPath, null);
571+
572+
var libraryOutputPath = Path.Combine(Path.GetDirectoryName(libraryProjectPath), "obj");
573+
var libraryBuildOutputPath = Path.Combine(Path.GetDirectoryName(libraryBuildProjectPath), "obj");
574+
var libraryAssetsPath = Path.Combine(libraryOutputPath, "project.assets.json");
575+
576+
// 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.
577+
var libraryAssets = ProjectAssets("library", libraryBuildOutputPath, libraryBuildProjectPath, "net8.0", "net6.0", "netstandard2.0");
578+
var libraryAssemblyStream = File.OpenRead(typeof(DotNetComponent).Assembly.Location);
579+
this.AddFile(Path.Combine(libraryOutputPath, "Release", "net8.0", "library.dll"), libraryAssemblyStream);
580+
this.AddFile(Path.Combine(libraryOutputPath, "Release", "net6.0", "library.dll"), libraryAssemblyStream);
581+
this.AddFile(Path.Combine(libraryOutputPath, "Release", "netstandard2.0", "library.dll"), libraryAssemblyStream);
582+
583+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
584+
.WithFile(libraryAssetsPath, libraryAssets)
585+
.ExecuteDetectorAsync();
586+
587+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
588+
589+
var detectedComponents = componentRecorder.GetDetectedComponents();
590+
detectedComponents.Should().HaveCount(4);
591+
592+
var discoveredComponents = detectedComponents.ToArray();
593+
discoveredComponents.Where(component => component.Component.Id == "4.5.6 unknown unknown - DotNet").Should().ContainSingle();
594+
discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 library - DotNet").Should().ContainSingle();
595+
discoveredComponents.Where(component => component.Component.Id == "4.5.6 net6.0 library - DotNet").Should().ContainSingle();
596+
discoveredComponents.Where(component => component.Component.Id == "4.5.6 netstandard2.0 library - DotNet").Should().ContainSingle();
597+
}
461598
}

0 commit comments

Comments
 (0)