diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d41315362..e0f9ae08c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -43,7 +43,8 @@ jobs:
- name: Run tests
# The '--' separator forwards options to the Microsoft Testing Platform runner.
# After upgrading to .NET 10 SDK, these can be passed directly without '--'.
- run: dotnet test --no-build --configuration Debug -- --coverage --coverage-output-format cobertura
+ # Override the default Integration-test exclusion from test/Directory.Build.props.
+ run: dotnet test --no-build --configuration Debug -p:TestingPlatformCommandLineArguments="--coverage --coverage-output-format cobertura"
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7e2a15933..56fc419fb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -61,6 +61,20 @@ Analysis rulesets are defined in [analyzers.ruleset](analyzers.ruleset) and vali
### Testing
-L0s are defined in `Microsoft.ComponentDetection.*.Tests`.
+**Unit tests (L0s)** are defined in `Microsoft.ComponentDetection.*.Tests` and should be fast, isolated, and free of external process calls.
-Verification tests are run on the sample projects defined in [microsoft/componentdetection-verification](https://github.com/microsoft/componentdetection-verification).
+**Integration tests** spawn real processes (e.g. `dotnet build`, `dotnet restore`) and are therefore slower and environment-dependent. Tag these with `[TestCategory("Integration")]` so they can be filtered during local development.
+
+By default, `dotnet test` **excludes** Integration tests (configured in `test/Directory.Build.props`). CI runs all tests including Integration (see `.github/workflows/build.yml`).
+
+To run only Integration tests locally:
+```bash
+dotnet test -- --filter "TestCategory=Integration"
+```
+
+To run everything (same as CI):
+```bash
+dotnet test -p:TestingPlatformCommandLineArguments=""
+```
+
+**Verification tests** are run on the sample projects defined in [microsoft/componentdetection-verification](https://github.com/microsoft/componentdetection-verification).
diff --git a/Directory.Packages.props b/Directory.Packages.props
index dd8034204..7b0ce6adf 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,6 +16,7 @@
+
diff --git a/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj b/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj
index adebad6c7..02f8d02ea 100644
--- a/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj
+++ b/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj
@@ -5,11 +5,13 @@
+
+
diff --git a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs
index 871dba663..6d92ae056 100644
--- a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs
+++ b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs
@@ -1,16 +1,8 @@
namespace Microsoft.ComponentDetection.Detectors.DotNet;
-using System;
-using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.IO;
-using System.Linq;
-using System.Reflection.PortableExecutable;
-using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
-using global::NuGet.Frameworks;
using global::NuGet.ProjectModel;
using Microsoft.ComponentDetection.Contracts;
using Microsoft.ComponentDetection.Contracts.Internal;
@@ -19,18 +11,8 @@ namespace Microsoft.ComponentDetection.Detectors.DotNet;
public class DotNetComponentDetector : FileComponentDetector
{
- private const string GlobalJsonFileName = "global.json";
- private readonly ICommandLineInvocationService commandLineInvocationService;
- private readonly IDirectoryUtilityService directoryUtilityService;
- private readonly IFileUtilityService fileUtilityService;
- private readonly IPathUtilityService pathUtilityService;
+ private readonly DotNetProjectInfoProvider projectInfoProvider;
private readonly LockFileFormat lockFileFormat = new();
- private readonly ConcurrentDictionary sdkVersionCache = [];
- private readonly JsonDocumentOptions jsonDocumentOptions =
- new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true };
-
- private string? sourceDirectory;
- private string? sourceFileRootDirectory;
public DotNetComponentDetector(
IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
@@ -41,13 +23,15 @@ public DotNetComponentDetector(
IObservableDirectoryWalkerFactory walkerFactory,
ILogger logger)
{
- this.commandLineInvocationService = commandLineInvocationService;
this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
- this.directoryUtilityService = directoryUtilityService;
- this.fileUtilityService = fileUtilityService;
- this.pathUtilityService = pathUtilityService;
this.Scanner = walkerFactory;
this.Logger = logger;
+ this.projectInfoProvider = new DotNetProjectInfoProvider(
+ commandLineInvocationService,
+ directoryUtilityService,
+ fileUtilityService,
+ pathUtilityService,
+ logger);
}
public override string Id => "DotNet";
@@ -60,93 +44,9 @@ public DotNetComponentDetector(
public override IEnumerable Categories => ["DotNet"];
- private static string TrimAllEndingDirectorySeparators(string path)
- {
- string last;
-
- do
- {
- last = path;
- path = Path.TrimEndingDirectorySeparator(last);
- }
- while (!ReferenceEquals(last, path));
-
- return path;
- }
-
- [return: NotNullIfNotNull(nameof(path))]
- private string? NormalizeDirectory(string? path) => string.IsNullOrEmpty(path) ? path : TrimAllEndingDirectorySeparators(this.pathUtilityService.NormalizePath(path));
-
- ///
- /// Given a path under sourceDirectory, and the same path in another filesystem,
- /// determine what path could be replaced with sourceDirectory.
- ///
- /// Some directory path under sourceDirectory, including sourceDirectory.
- /// Path to the same directory as but in a different root.
- /// Portion of that corresponds to root, or null if it can not be rebased.
- private string? GetRootRebasePath(string sourceDirectoryBasedPath, string? rebasePath)
- {
- if (string.IsNullOrEmpty(rebasePath) || string.IsNullOrEmpty(this.sourceDirectory) || string.IsNullOrEmpty(sourceDirectoryBasedPath))
- {
- return null;
- }
-
- // sourceDirectory is normalized, normalize others
- sourceDirectoryBasedPath = this.NormalizeDirectory(sourceDirectoryBasedPath);
- rebasePath = this.NormalizeDirectory(rebasePath);
-
- // nothing to do if the paths are the same
- if (rebasePath.Equals(sourceDirectoryBasedPath, StringComparison.Ordinal))
- {
- return null;
- }
-
- // find the relative path under sourceDirectory.
- var sourceDirectoryRelativePath = this.NormalizeDirectory(Path.GetRelativePath(this.sourceDirectory!, sourceDirectoryBasedPath));
-
- this.Logger.LogDebug("Attempting to rebase {RebasePath} to {SourceDirectoryBasedPath} using relative {SourceDirectoryRelativePath}", rebasePath, sourceDirectoryBasedPath, sourceDirectoryRelativePath);
-
- // if the rebase path has the same relative portion, then we have a replacement.
- if (rebasePath.EndsWith(sourceDirectoryRelativePath))
- {
- return rebasePath[..^sourceDirectoryRelativePath.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);
-
- 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;
- }
-
- 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'. {Message}", ioe.Message);
- return null;
- }
- }
-
public override Task ExecuteDetectorAsync(ScanRequest request, CancellationToken cancellationToken = default)
{
- this.sourceDirectory = this.NormalizeDirectory(request.SourceDirectory.FullName);
- this.sourceFileRootDirectory = this.NormalizeDirectory(request.SourceFileRoot?.FullName);
-
+ this.projectInfoProvider.Initialize(request.SourceDirectory.FullName, request.SourceFileRoot?.FullName);
return base.ExecuteDetectorAsync(request, cancellationToken);
}
@@ -163,215 +63,10 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID
return;
}
- 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))
- {
- // Could be the assets file was not actually from this build
- this.Logger.LogWarning("Project path {ProjectPath} specified by {ProjectAssetsPath} does not exist.", projectPath, processRequest.ComponentStream.Location);
- }
-
- var projectDirectory = this.pathUtilityService.GetParentDirectory(projectPath);
- var sdkVersion = await this.GetSdkVersionAsync(projectDirectory, cancellationToken);
-
- var projectName = lockFile.PackageSpec.RestoreMetadata.ProjectName;
-
- 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);
-
- var componentReporter = this.ComponentRecorder.CreateSingleFileComponentRecorder(projectPath);
- foreach (var target in lockFile.Targets ?? [])
- {
- var targetFramework = target.TargetFramework;
- var isSelfContained = this.IsSelfContained(lockFile.PackageSpec, targetFramework, target);
- var targetTypeWithSelfContained = this.GetTargetTypeWithSelfContained(targetType, isSelfContained);
-
- componentReporter.RegisterUsage(new DetectedComponent(new DotNetComponent(sdkVersion, targetFramework?.GetShortFolderName(), targetTypeWithSelfContained)));
- }
- }
-
- private string? GetProjectType(string projectOutputPath, string projectName, CancellationToken cancellationToken)
- {
- if (this.directoryUtilityService.Exists(projectOutputPath) &&
- projectName is not null &&
- projectName.IndexOfAny(Path.GetInvalidFileNameChars()) == -1)
- {
- try
- {
- // look for the compiled output, first as dll then as exe.
- var candidates = this.directoryUtilityService.EnumerateFiles(projectOutputPath, projectName + ".dll", SearchOption.AllDirectories)
- .Concat(this.directoryUtilityService.EnumerateFiles(projectOutputPath, projectName + ".exe", SearchOption.AllDirectories));
- foreach (var candidate in candidates)
- {
- try
- {
- return this.IsApplication(candidate) ? "application" : "library";
- }
- catch (Exception e)
- {
- this.Logger.LogWarning("Failed to open output assembly {AssemblyPath} error {Message}.", candidate, e.Message);
- }
- }
- }
- catch (IOException e)
- {
- this.Logger.LogWarning("Failed to enumerate output directory {OutputPath} error {Message}.", projectOutputPath, e.Message);
- }
- }
-
- return null;
- }
-
- private bool IsApplication(string assemblyPath)
- {
- using var peReader = new PEReader(this.fileUtilityService.MakeFileStream(assemblyPath));
-
- // despite the name `IsExe` this is actually based of the CoffHeader Characteristics
- return peReader.PEHeaders.IsExe;
- }
-
- private bool IsSelfContained(PackageSpec packageSpec, NuGetFramework? targetFramework, LockFileTarget target)
- {
- // PublishAot projects reference Microsoft.DotNet.ILCompiler, which implies
- // native AOT compilation and therefore a self-contained deployment.
- if (target?.Libraries != null &&
- target.Libraries.Any(lib => "Microsoft.DotNet.ILCompiler".Equals(lib.Name, StringComparison.OrdinalIgnoreCase)))
- {
- return true;
- }
-
- if (packageSpec?.TargetFrameworks == null || targetFramework == null)
- {
- return false;
- }
-
- var targetFrameworkInfo = packageSpec.TargetFrameworks.FirstOrDefault(tf => tf.FrameworkName == targetFramework);
- if (targetFrameworkInfo == null)
- {
- return false;
- }
-
- var frameworkReferences = targetFrameworkInfo.FrameworkReferences;
- var packageDownloads = targetFrameworkInfo.DownloadDependencies;
-
- if (frameworkReferences == null || frameworkReferences.Count == 0 || packageDownloads.IsDefaultOrEmpty)
- {
- return false;
- }
-
- foreach (var frameworkRef in frameworkReferences)
- {
- var frameworkName = frameworkRef.Name;
- var hasRuntimeDownload = packageDownloads.Any(pd => pd.Name.StartsWith($"{frameworkName}.Runtime", StringComparison.OrdinalIgnoreCase));
-
- if (hasRuntimeDownload)
- {
- return true;
- }
- }
-
- return false;
- }
-
- private string? GetTargetTypeWithSelfContained(string? targetType, bool isSelfContained)
- {
- if (string.IsNullOrWhiteSpace(targetType))
- {
- return targetType;
- }
-
- return isSelfContained ? $"{targetType}-selfcontained" : targetType;
- }
-
- ///
- /// Recursively get the sdk version from the project directory or parent directories.
- ///
- /// Directory to start the search.
- /// Cancellation token to halt the search.
- /// Sdk version found, or null if no version can be detected.
- private async Task GetSdkVersionAsync(string? projectDirectory, CancellationToken cancellationToken)
- {
- // normalize since we need to use as a key
- projectDirectory = this.NormalizeDirectory(projectDirectory);
-
- if (string.IsNullOrWhiteSpace(projectDirectory))
- {
- // not expected
- return null;
- }
-
- if (this.sdkVersionCache.TryGetValue(projectDirectory, out var sdkVersion))
- {
- return sdkVersion;
- }
-
- var parentDirectory = this.pathUtilityService.GetParentDirectory(projectDirectory);
- var globalJsonPath = Path.Combine(projectDirectory, GlobalJsonFileName);
-
- if (this.fileUtilityService.Exists(globalJsonPath))
- {
- sdkVersion = await this.RunDotNetVersionAsync(projectDirectory, cancellationToken);
-
- if (string.IsNullOrWhiteSpace(sdkVersion))
- {
- var globalJson = await JsonDocument.ParseAsync(this.fileUtilityService.MakeFileStream(globalJsonPath), cancellationToken: cancellationToken, options: this.jsonDocumentOptions).ConfigureAwait(false);
- if (globalJson.RootElement.TryGetProperty("sdk", out var sdk))
- {
- if (sdk.TryGetProperty("version", out var version))
- {
- sdkVersion = version.GetString();
- }
- }
- }
-
- if (!string.IsNullOrWhiteSpace(sdkVersion))
- {
- var globalJsonComponent = new DetectedComponent(new DotNetComponent(sdkVersion));
- var recorder = this.ComponentRecorder.CreateSingleFileComponentRecorder(globalJsonPath);
- recorder.RegisterUsage(globalJsonComponent, isExplicitReferencedDependency: true);
- return sdkVersion;
- }
-
- // global.json may be malformed, or the sdk version may not be specified.
- }
-
- if (projectDirectory.Equals(this.sourceDirectory, StringComparison.OrdinalIgnoreCase) ||
- projectDirectory.Equals(this.sourceFileRootDirectory, StringComparison.OrdinalIgnoreCase) ||
- string.IsNullOrEmpty(parentDirectory) ||
- projectDirectory.Equals(parentDirectory, StringComparison.OrdinalIgnoreCase))
- {
- // if we are at the source directory, source file root, or have reached a root directory, run `dotnet --version`
- // this could fail if dotnet is not on the path, or if the global.json is malformed
- sdkVersion = await this.RunDotNetVersionAsync(projectDirectory, cancellationToken);
- }
- else
- {
- // recurse up the directory tree
- sdkVersion = await this.GetSdkVersionAsync(parentDirectory, cancellationToken);
- }
-
- this.sdkVersionCache[projectDirectory] = sdkVersion;
-
- return sdkVersion;
+ await this.projectInfoProvider.RegisterDotNetComponentsAsync(
+ lockFile,
+ processRequest.ComponentStream.Location,
+ this.ComponentRecorder,
+ cancellationToken);
}
}
diff --git a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetProjectInfoProvider.cs b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetProjectInfoProvider.cs
new file mode 100644
index 000000000..e94d01d31
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetProjectInfoProvider.cs
@@ -0,0 +1,293 @@
+namespace Microsoft.ComponentDetection.Detectors.DotNet;
+
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Reflection.PortableExecutable;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using global::NuGet.ProjectModel;
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.TypedComponent;
+using Microsoft.Extensions.Logging;
+using NuGetLockFileUtilities = Microsoft.ComponentDetection.Detectors.NuGet.LockFileUtilities;
+
+///
+/// Resolves DotNet project and SDK information from the environment.
+/// Handles SDK version resolution, project type detection, path rebasing,
+/// and DotNet component registration. Used by both DotNetComponentDetector
+/// and MSBuildBinaryLogComponentDetector.
+///
+internal class DotNetProjectInfoProvider
+{
+ private const string GlobalJsonFileName = "global.json";
+
+ private readonly ICommandLineInvocationService commandLineInvocationService;
+ private readonly IDirectoryUtilityService directoryUtilityService;
+ private readonly IFileUtilityService fileUtilityService;
+ private readonly IPathUtilityService pathUtilityService;
+ private readonly ILogger logger;
+ private readonly ConcurrentDictionary sdkVersionCache = [];
+ private readonly JsonDocumentOptions jsonDocumentOptions =
+ new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true };
+
+ private string? sourceDirectory;
+ private string? sourceFileRootDirectory;
+
+ public DotNetProjectInfoProvider(
+ ICommandLineInvocationService commandLineInvocationService,
+ IDirectoryUtilityService directoryUtilityService,
+ IFileUtilityService fileUtilityService,
+ IPathUtilityService pathUtilityService,
+ ILogger logger)
+ {
+ this.commandLineInvocationService = commandLineInvocationService;
+ this.directoryUtilityService = directoryUtilityService;
+ this.fileUtilityService = fileUtilityService;
+ this.pathUtilityService = pathUtilityService;
+ this.logger = logger;
+ }
+
+ ///
+ /// Initializes source directory paths for path rebasing. Call once per scan.
+ ///
+ public void Initialize(string? sourceDirectory, string? sourceFileRootDirectory)
+ {
+ this.sourceDirectory = this.NormalizeDirectory(sourceDirectory);
+ this.sourceFileRootDirectory = this.NormalizeDirectory(sourceFileRootDirectory);
+ }
+
+ ///
+ /// Registers DotNet components from a lock file, determining SDK version and project type.
+ /// This is the complete DotNet component registration logic shared between DotNetComponentDetector
+ /// and MSBuildBinaryLogComponentDetector's fallback path.
+ ///
+ /// The lock file to analyze.
+ /// The location of the project.assets.json file.
+ /// The component recorder to register components with.
+ /// Cancellation token.
+ /// A task representing the asynchronous operation.
+ public async Task RegisterDotNetComponentsAsync(
+ LockFile lockFile,
+ string assetsFileLocation,
+ IComponentRecorder componentRecorder,
+ CancellationToken cancellationToken)
+ {
+ if (lockFile.PackageSpec?.RestoreMetadata is null)
+ {
+ return;
+ }
+
+ var projectAssetsDirectory = this.pathUtilityService.GetParentDirectory(assetsFileLocation);
+ var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath;
+ var projectOutputPath = lockFile.PackageSpec.RestoreMetadata.OutputPath;
+
+ // The output path should match the location of 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))
+ {
+ // Could be the assets file was not actually from this build
+ this.logger.LogWarning("Project path {ProjectPath} specified by {ProjectAssetsPath} does not exist.", projectPath, assetsFileLocation);
+ }
+
+ var projectDirectory = this.pathUtilityService.GetParentDirectory(projectPath);
+ var sdkVersion = await this.GetSdkVersionAsync(projectDirectory, componentRecorder, cancellationToken);
+
+ var projectName = lockFile.PackageSpec.RestoreMetadata.ProjectName;
+
+ if (!this.directoryUtilityService.Exists(projectOutputPath))
+ {
+ this.logger.LogWarning("Project output path {ProjectOutputPath} specified by {ProjectAssetsPath} does not exist.", projectOutputPath, assetsFileLocation);
+
+ // default to use the location of the assets file.
+ projectOutputPath = projectAssetsDirectory;
+ }
+
+ var targetType = this.GetProjectType(projectOutputPath, projectName);
+
+ var singleFileComponentRecorder = componentRecorder.CreateSingleFileComponentRecorder(projectPath);
+ foreach (var target in lockFile.Targets ?? [])
+ {
+ var targetFramework = target.TargetFramework;
+ var isSelfContained = NuGetLockFileUtilities.IsSelfContainedFromLockFile(lockFile.PackageSpec, targetFramework, target);
+ var targetTypeWithSelfContained = NuGetLockFileUtilities.GetTargetTypeWithSelfContained(targetType, isSelfContained);
+
+ singleFileComponentRecorder.RegisterUsage(new DetectedComponent(new DotNetComponent(sdkVersion, targetFramework?.GetShortFolderName(), targetTypeWithSelfContained)));
+ }
+ }
+
+ [return: NotNullIfNotNull(nameof(path))]
+ private string? NormalizeDirectory(string? path) => PathRebasingUtility.NormalizeDirectory(path);
+
+ ///
+ /// Given a path under sourceDirectory, and the same path in another filesystem,
+ /// determine what path could be replaced with sourceDirectory.
+ ///
+ /// Some directory path under sourceDirectory, including sourceDirectory.
+ /// Path to the same directory as but in a different root.
+ /// Portion of that corresponds to root, or null if it can not be rebased.
+ internal string? GetRootRebasePath(string sourceDirectoryBasedPath, string? rebasePath)
+ {
+ var result = PathRebasingUtility.GetRebaseRoot(this.sourceDirectory, sourceDirectoryBasedPath, rebasePath);
+
+ if (result != null)
+ {
+ this.logger.LogDebug(
+ "Rebasing paths from {RebasePath} to {SourceDirectoryBasedPath}",
+ rebasePath,
+ sourceDirectoryBasedPath);
+ }
+
+ return result;
+ }
+
+ internal string? GetProjectType(string projectOutputPath, string projectName)
+ {
+ if (this.directoryUtilityService.Exists(projectOutputPath) &&
+ projectName is not null &&
+ projectName.IndexOfAny(Path.GetInvalidFileNameChars()) == -1)
+ {
+ try
+ {
+ // look for the compiled output, first as dll then as exe.
+ var candidates = this.directoryUtilityService.EnumerateFiles(projectOutputPath, projectName + ".dll", SearchOption.AllDirectories)
+ .Concat(this.directoryUtilityService.EnumerateFiles(projectOutputPath, projectName + ".exe", SearchOption.AllDirectories));
+ foreach (var candidate in candidates)
+ {
+ try
+ {
+ return this.IsApplication(candidate) ? "application" : "library";
+ }
+ catch (Exception e)
+ {
+ this.logger.LogWarning("Failed to open output assembly {AssemblyPath} error {Message}.", candidate, e.Message);
+ }
+ }
+ }
+ catch (IOException e)
+ {
+ this.logger.LogWarning("Failed to enumerate output directory {OutputPath} error {Message}.", projectOutputPath, e.Message);
+ }
+ }
+
+ return null;
+ }
+
+ private bool IsApplication(string assemblyPath)
+ {
+ using var peReader = new PEReader(this.fileUtilityService.MakeFileStream(assemblyPath));
+
+ // despite the name `IsExe` this is actually based of the CoffHeader Characteristics
+ return peReader.PEHeaders.IsExe;
+ }
+
+ ///
+ /// Recursively get the sdk version from the project directory or parent directories.
+ ///
+ /// Directory to start the search.
+ /// Component recorder for registering global.json components.
+ /// Cancellation token to halt the search.
+ /// Sdk version found, or null if no version can be detected.
+ internal async Task GetSdkVersionAsync(string? projectDirectory, IComponentRecorder componentRecorder, CancellationToken cancellationToken)
+ {
+ // normalize since we need to use as a key
+ projectDirectory = this.NormalizeDirectory(projectDirectory);
+
+ if (string.IsNullOrWhiteSpace(projectDirectory))
+ {
+ // not expected
+ return null;
+ }
+
+ if (this.sdkVersionCache.TryGetValue(projectDirectory, out var sdkVersion))
+ {
+ return sdkVersion;
+ }
+
+ var parentDirectory = this.pathUtilityService.GetParentDirectory(projectDirectory);
+ var globalJsonPath = Path.Combine(projectDirectory, GlobalJsonFileName);
+
+ if (this.fileUtilityService.Exists(globalJsonPath))
+ {
+ sdkVersion = await this.RunDotNetVersionAsync(projectDirectory, cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(sdkVersion))
+ {
+ var globalJson = await JsonDocument.ParseAsync(this.fileUtilityService.MakeFileStream(globalJsonPath), cancellationToken: cancellationToken, options: this.jsonDocumentOptions).ConfigureAwait(false);
+ if (globalJson.RootElement.TryGetProperty("sdk", out var sdk))
+ {
+ if (sdk.TryGetProperty("version", out var version))
+ {
+ sdkVersion = version.GetString();
+ }
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(sdkVersion))
+ {
+ var globalJsonComponent = new DetectedComponent(new DotNetComponent(sdkVersion));
+ var recorder = componentRecorder.CreateSingleFileComponentRecorder(globalJsonPath);
+ recorder.RegisterUsage(globalJsonComponent, isExplicitReferencedDependency: true);
+ return sdkVersion;
+ }
+
+ // global.json may be malformed, or the sdk version may not be specified.
+ }
+
+ if (projectDirectory.Equals(this.sourceDirectory, StringComparison.OrdinalIgnoreCase) ||
+ projectDirectory.Equals(this.sourceFileRootDirectory, StringComparison.OrdinalIgnoreCase) ||
+ string.IsNullOrEmpty(parentDirectory) ||
+ projectDirectory.Equals(parentDirectory, StringComparison.OrdinalIgnoreCase))
+ {
+ // if we are at the source directory, source file root, or have reached a root directory, run `dotnet --version`
+ // this could fail if dotnet is not on the path, or if the global.json is malformed
+ sdkVersion = await this.RunDotNetVersionAsync(projectDirectory, cancellationToken);
+ }
+ else
+ {
+ // recurse up the directory tree
+ sdkVersion = await this.GetSdkVersionAsync(parentDirectory, componentRecorder, cancellationToken);
+ }
+
+ this.sdkVersionCache[projectDirectory] = sdkVersion;
+
+ return sdkVersion;
+ }
+
+ private async Task RunDotNetVersionAsync(string workingDirectoryPath, CancellationToken cancellationToken)
+ {
+ var workingDirectory = new DirectoryInfo(workingDirectoryPath);
+
+ 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;
+ }
+
+ 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'. {Message}", ioe.Message);
+ return null;
+ }
+ }
+}
diff --git a/src/Microsoft.ComponentDetection.Detectors/dotnet/PathRebasingUtility.cs b/src/Microsoft.ComponentDetection.Detectors/dotnet/PathRebasingUtility.cs
new file mode 100644
index 000000000..69221e3b0
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Detectors/dotnet/PathRebasingUtility.cs
@@ -0,0 +1,167 @@
+namespace Microsoft.ComponentDetection.Detectors.DotNet;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+///
+/// Utility for rebasing absolute paths from one filesystem root to another.
+///
+///
+/// When component detection scans build outputs produced on a different machine (e.g., a CI agent),
+/// the absolute paths recorded in artifacts like binlogs and project.assets.json will not match
+/// the paths on the scanning machine. This utility detects and compensates for that by finding
+/// the common relative suffix between two representations of the same directory and deriving
+/// the root prefix that needs to be substituted.
+///
+internal static class PathRebasingUtility
+{
+ ///
+ /// Normalizes a path by replacing backslashes with forward slashes.
+ /// Windows accepts / as a separator, so forward-slash normalization works everywhere.
+ ///
+ /// The path with all backslashes replaced by forward slashes.
+ internal static string NormalizePath(string path) => path.Replace('\\', Path.AltDirectorySeparatorChar);
+
+ ///
+ /// Normalizes a directory path: forward slashes, no trailing separator.
+ /// Returns null/empty passthrough for null/empty input.
+ ///
+ /// The normalized directory path, or null/empty for null/empty input.
+ internal static string? NormalizeDirectory(string? path) =>
+ string.IsNullOrEmpty(path) ? path : TrimAllEndingDirectorySeparators(NormalizePath(path));
+
+ ///
+ /// Given a path known to be under on the scanning machine,
+ /// and the same directory as it appears in a build artifact (binlog, lock file, etc.),
+ /// determine the root prefix in the artifact that corresponds to .
+ ///
+ /// The scanning machine's source directory (already normalized).
+ /// Path to a directory under on the scanning machine.
+ /// Path to the same directory as it appears in the build artifact.
+ ///
+ /// The root prefix of that can be replaced with ,
+ /// or null if the paths cannot be rebased (same root, or no common relative suffix).
+ ///
+ internal static string? GetRebaseRoot(string? sourceDirectory, string sourceDirectoryBasedPath, string? artifactPath)
+ {
+ if (string.IsNullOrEmpty(artifactPath) || string.IsNullOrEmpty(sourceDirectory) || string.IsNullOrEmpty(sourceDirectoryBasedPath))
+ {
+ return null;
+ }
+
+ sourceDirectoryBasedPath = NormalizeDirectory(sourceDirectoryBasedPath)!;
+ artifactPath = NormalizeDirectory(artifactPath)!;
+
+ // Nothing to do if the paths are the same (no rebasing needed).
+ if (artifactPath.Equals(sourceDirectoryBasedPath, StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ // Find the relative path under sourceDirectory.
+ var sourceDirectoryRelativePath = NormalizeDirectory(Path.GetRelativePath(sourceDirectory, sourceDirectoryBasedPath))!;
+
+ // If the artifact path has the same relative portion, extract the root prefix.
+ if (artifactPath.EndsWith(sourceDirectoryRelativePath, StringComparison.Ordinal))
+ {
+ return artifactPath[..^sourceDirectoryRelativePath.Length];
+ }
+
+ // The path didn't have a common relative suffix — it might have been copied from
+ // a completely different location since it was built. We cannot rebase.
+ return null;
+ }
+
+ ///
+ /// Rebases an absolute path from one root to another.
+ ///
+ /// The absolute path to rebase (from the build artifact).
+ /// The root prefix from the build artifact (as returned by ).
+ /// The root on the scanning machine (typically sourceDirectory).
+ ///
+ /// The rebased path under , or the normalized input
+ /// unchanged when it is not rooted or not under .
+ ///
+ internal static string RebasePath(string path, string originalRoot, string newRoot)
+ {
+ var normalizedPath = NormalizeDirectory(path)!;
+
+ if (!Path.IsPathRooted(normalizedPath))
+ {
+ return normalizedPath;
+ }
+
+ var normalizedOriginal = NormalizeDirectory(originalRoot)!;
+ var normalizedNew = NormalizeDirectory(newRoot)!;
+ var relative = Path.GetRelativePath(normalizedOriginal, normalizedPath);
+
+ // If the path is outside the original root the relative result will start
+ // with ".." or remain rooted (Windows cross-drive). Return unchanged.
+ if (Path.IsPathRooted(relative) || relative.StartsWith("..", StringComparison.Ordinal))
+ {
+ return normalizedPath;
+ }
+
+ return NormalizePath(Path.Combine(normalizedNew, relative));
+ }
+
+ ///
+ /// Searches a dictionary for a key that matches the given scan-machine path after rebasing.
+ /// Computes the relative path of under
+ /// and looks for a dictionary key whose normalized form ends with the same relative suffix.
+ ///
+ /// The dictionary value type.
+ /// The dictionary keyed by build-machine paths.
+ /// The scanning machine's source directory (normalized).
+ /// The path on the scanning machine to look up.
+ ///
+ /// If a match is found, outputs the build-machine root prefix that can be used with
+ /// to convert other build-machine paths. null if no match is found.
+ ///
+ /// The matched value, or default if no match is found.
+ internal static TValue? FindByRelativePath(
+ IEnumerable> dictionary,
+ string sourceDirectory,
+ string scanMachinePath,
+ out string? rebaseRoot)
+ {
+ rebaseRoot = null;
+
+ var normalizedScanPath = NormalizePath(scanMachinePath);
+ var relativePath = NormalizePath(Path.GetRelativePath(sourceDirectory, normalizedScanPath));
+
+ // If the path isn't under sourceDirectory, we can't match by suffix.
+ if (relativePath.StartsWith("..", StringComparison.Ordinal))
+ {
+ return default;
+ }
+
+ foreach (var kvp in dictionary)
+ {
+ var normalizedKey = NormalizePath(kvp.Key);
+ if (normalizedKey.EndsWith(relativePath, StringComparison.OrdinalIgnoreCase))
+ {
+ // Derive the build-machine root from this match.
+ rebaseRoot = normalizedKey[..^relativePath.Length];
+ return kvp.Value;
+ }
+ }
+
+ return default;
+ }
+
+ private static string TrimAllEndingDirectorySeparators(string path)
+ {
+ string last;
+
+ do
+ {
+ last = path;
+ path = Path.TrimEndingDirectorySeparator(last);
+ }
+ while (!ReferenceEquals(last, path));
+
+ return path;
+ }
+}
diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/BinLogProcessor.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/BinLogProcessor.cs
new file mode 100644
index 000000000..e2ccdfd35
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Detectors/nuget/BinLogProcessor.cs
@@ -0,0 +1,440 @@
+namespace Microsoft.ComponentDetection.Detectors.NuGet;
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Logging.StructuredLogger;
+using Microsoft.ComponentDetection.Detectors.DotNet;
+using Microsoft.Extensions.Logging;
+
+///
+/// Processes MSBuild binary log files to extract project information.
+///
+internal class BinLogProcessor : IBinLogProcessor
+{
+ private readonly Microsoft.Extensions.Logging.ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger for diagnostic messages.
+ public BinLogProcessor(Microsoft.Extensions.Logging.ILogger logger) => this.logger = logger;
+
+ ///
+ public IReadOnlyList ExtractProjectInfo(string binlogPath, string? sourceDirectory = null)
+ {
+ // Maps project path to the primary MSBuildProjectInfo for that project
+ var projectInfoByPath = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Pre-compute source directory info for path rebasing.
+ // When the binlog was built on a different machine, BinLogFilePath (recorded at the start
+ // of the log) lets us derive the root substitution. The rebasePath function is set once
+ // the BinLogFilePath message is seen and is applied inline to all path-valued properties.
+ var normalizedSourceDir = PathRebasingUtility.NormalizeDirectory(sourceDirectory);
+ var binlogDir = PathRebasingUtility.NormalizeDirectory(Path.GetDirectoryName(binlogPath));
+ Func? rebasePath = null;
+
+ try
+ {
+ var reader = new BinLogReader();
+
+ // Maps evaluation ID to MSBuildProjectInfo being populated
+ var projectInfoByEvaluationId = new Dictionary();
+
+ // Maps project instance ID to evaluation ID
+ var projectInstanceToEvaluationMap = new Dictionary();
+
+ // Hook into status events to capture property evaluations
+ reader.StatusEventRaised += (sender, e) =>
+ {
+ if (e?.BuildEventContext?.EvaluationId >= 0 &&
+ e is ProjectEvaluationFinishedEventArgs projectEvalArgs)
+ {
+ // Reuse existing project info if one was created during evaluation
+ // (e.g., from EnvironmentVariableReadEventArgs or PropertyInitialValueSetEventArgs)
+ if (!projectInfoByEvaluationId.TryGetValue(e.BuildEventContext.EvaluationId, out var projectInfo))
+ {
+ projectInfo = new MSBuildProjectInfo();
+ projectInfoByEvaluationId[e.BuildEventContext.EvaluationId] = projectInfo;
+ }
+
+ this.PopulateFromEvaluation(projectEvalArgs, projectInfo, rebasePath);
+ }
+ };
+
+ // Hook into project started to map project instance to evaluation and capture project path
+ reader.ProjectStarted += (sender, e) =>
+ {
+ if (e?.BuildEventContext?.EvaluationId >= 0 &&
+ e?.BuildEventContext?.ProjectInstanceId >= 0)
+ {
+ projectInstanceToEvaluationMap[e.BuildEventContext.ProjectInstanceId] = e.BuildEventContext.EvaluationId;
+
+ // Set the project path on the MSBuildProjectInfo
+ if (!string.IsNullOrEmpty(e.ProjectFile) &&
+ projectInfoByEvaluationId.TryGetValue(e.BuildEventContext.EvaluationId, out var projectInfo))
+ {
+ projectInfo.ProjectPath = rebasePath != null ? rebasePath(e.ProjectFile) : e.ProjectFile;
+ }
+ }
+ };
+
+ // Hook into message events to capture BinLogFilePath from the initial build messages.
+ // MSBuild's BinaryLogger writes "BinLogFilePath=" as a BuildMessageEventArgs
+ // with SenderName "BinaryLogger" at the start of the log. This arrives before any
+ // evaluation or project events, so we can compute the rebase function here and apply
+ // it to all subsequent path-valued properties.
+ // https://github.com/dotnet/msbuild/blob/7d73e8e9074fe9a4420e38cd22d45645b28a11f7/src/Build/Logging/BinaryLogger/BinaryLogger.cs#L473
+ reader.MessageRaised += (sender, e) =>
+ {
+ if (rebasePath == null &&
+ binlogDir != null &&
+ normalizedSourceDir != null &&
+ e is BuildMessageEventArgs msg &&
+ msg.SenderName == "BinaryLogger" &&
+ msg.Message != null &&
+ msg.Message.StartsWith("BinLogFilePath=", StringComparison.Ordinal))
+ {
+ var originalBinLogFilePath = msg.Message["BinLogFilePath=".Length..];
+ var originalBinlogDir = PathRebasingUtility.NormalizeDirectory(Path.GetDirectoryName(originalBinLogFilePath));
+ var rebaseRoot = PathRebasingUtility.GetRebaseRoot(normalizedSourceDir, binlogDir, originalBinlogDir);
+ if (rebaseRoot != null)
+ {
+ this.logger.LogDebug(
+ "Rebasing binlog paths from build-machine root '{RebaseRoot}' to scan-machine root '{SourceDirectory}'",
+ rebaseRoot,
+ normalizedSourceDir);
+ rebasePath = path => PathRebasingUtility.RebasePath(path, rebaseRoot, normalizedSourceDir!);
+ }
+ }
+ };
+
+ // Hook into any event to capture property reassignments and item changes during build
+ reader.AnyEventRaised += (sender, e) =>
+ {
+ this.HandleBuildEvent(e, projectInstanceToEvaluationMap, projectInfoByEvaluationId, rebasePath);
+ };
+
+ // Hook into project finished to collect final project info and establish hierarchy
+ reader.ProjectFinished += (sender, e) =>
+ {
+ if (e?.BuildEventContext?.ProjectInstanceId >= 0 &&
+ projectInstanceToEvaluationMap.TryGetValue(e.BuildEventContext.ProjectInstanceId, out var evaluationId) &&
+ projectInfoByEvaluationId.TryGetValue(evaluationId, out var projectInfo) &&
+ !string.IsNullOrEmpty(projectInfo.ProjectPath))
+ {
+ this.AddOrMergeProjectInfo(projectInfo, projectInfoByPath);
+ }
+ };
+
+ reader.Replay(binlogPath);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogWarning(ex, "Error parsing binlog: {BinlogPath}", binlogPath);
+ }
+
+ return [.. projectInfoByPath.Values];
+ }
+
+ ///
+ /// Adds a project info to the results, merging with existing entries for the same project path.
+ /// Outer builds become the primary entry; inner builds are added as children.
+ ///
+ private void AddOrMergeProjectInfo(
+ MSBuildProjectInfo projectInfo,
+ Dictionary projectInfoByPath)
+ {
+ if (!projectInfoByPath.TryGetValue(projectInfo.ProjectPath!, out var existing))
+ {
+ // First time seeing this project - add it
+ projectInfoByPath[projectInfo.ProjectPath!] = projectInfo;
+ return;
+ }
+
+ // We've seen this project before - determine how to merge
+ if (projectInfo.IsOuterBuild && !existing.IsOuterBuild)
+ {
+ // New build is outer, existing is inner - outer becomes primary
+ // Move existing to be an inner build of the new outer build
+ projectInfo.InnerBuilds.Add(existing);
+
+ // Also move any inner builds that were already collected
+ foreach (var inner in existing.InnerBuilds)
+ {
+ projectInfo.InnerBuilds.Add(inner);
+ }
+
+ existing.InnerBuilds.Clear();
+
+ // Replace in the lookup
+ projectInfoByPath[projectInfo.ProjectPath!] = projectInfo;
+ }
+ else if (existing.IsOuterBuild && !projectInfo.IsOuterBuild && !string.IsNullOrEmpty(projectInfo.TargetFramework))
+ {
+ // Existing is outer, new is inner - de-duplicate by TargetFramework
+ var matchingInner = existing.InnerBuilds.FirstOrDefault(
+ ib => string.Equals(ib.TargetFramework, projectInfo.TargetFramework, StringComparison.OrdinalIgnoreCase));
+ if (matchingInner != null)
+ {
+ // Same TFM seen again (e.g., build + publish pass) - merge
+ matchingInner.MergeWith(projectInfo);
+ }
+ else
+ {
+ existing.InnerBuilds.Add(projectInfo);
+ }
+ }
+ else if (!existing.IsOuterBuild && !projectInfo.IsOuterBuild && !string.IsNullOrEmpty(projectInfo.TargetFramework))
+ {
+ // Both are single-TFM builds - check if they share the same TFM
+ if (string.Equals(existing.TargetFramework, projectInfo.TargetFramework, StringComparison.OrdinalIgnoreCase))
+ {
+ // Same project, same TFM (e.g. build + publish) - merge as superset
+ existing.MergeWith(projectInfo);
+ }
+ else
+ {
+ // Different TFMs (no outer build seen yet) - add to InnerBuilds of the first one
+ // The first one acts as a placeholder until we see an outer build
+ existing.InnerBuilds.Add(projectInfo);
+ }
+ }
+ else if (existing.IsOuterBuild && projectInfo.IsOuterBuild)
+ {
+ // Both are outer builds (e.g. build + publish of a multi-targeted project)
+ // Merge inner builds: for matching TFMs, merge; for new TFMs, add
+ foreach (var newInner in projectInfo.InnerBuilds)
+ {
+ var matchingInner = existing.InnerBuilds.FirstOrDefault(
+ ib => string.Equals(ib.TargetFramework, newInner.TargetFramework, StringComparison.OrdinalIgnoreCase));
+ if (matchingInner != null)
+ {
+ matchingInner.MergeWith(newInner);
+ }
+ else
+ {
+ existing.InnerBuilds.Add(newInner);
+ }
+ }
+
+ // Merge the outer build properties/items too
+ existing.MergeWith(projectInfo);
+ }
+ else
+ {
+ // Fallback: merge properties/items as superset
+ existing.MergeWith(projectInfo);
+ }
+ }
+
+ ///
+ /// Populates project info from evaluation results (properties and items).
+ ///
+ private void PopulateFromEvaluation(ProjectEvaluationFinishedEventArgs projectEvalArgs, MSBuildProjectInfo projectInfo, Func? rebasePath)
+ {
+ // Extract properties
+ if (projectEvalArgs?.Properties != null)
+ {
+ // Handle different property collection types based on MSBuild version
+ // Newer MSBuild versions may provide IDictionary
+ if (projectEvalArgs.Properties is IDictionary propertiesDict)
+ {
+ foreach (var kvp in propertiesDict)
+ {
+ this.SetPropertyWithRebase(projectInfo, kvp.Key, kvp.Value, rebasePath);
+ }
+ }
+ else
+ {
+ // Older format uses IEnumerable with DictionaryEntry or KeyValuePair
+ foreach (var property in projectEvalArgs.Properties)
+ {
+ string? key = null;
+ string? value = null;
+
+ if (property is DictionaryEntry entry)
+ {
+ key = entry.Key as string;
+ value = entry.Value as string;
+ }
+ else if (property is KeyValuePair kvp)
+ {
+ key = kvp.Key;
+ value = kvp.Value;
+ }
+
+ if (!string.IsNullOrEmpty(key))
+ {
+ this.SetPropertyWithRebase(projectInfo, key, value ?? string.Empty, rebasePath);
+ }
+ }
+ }
+ }
+
+ // Extract items
+ if (projectEvalArgs?.Items != null)
+ {
+ // Items is a flat IList where each entry has:
+ // Key = item type (string, e.g., "PackageReference")
+ // Value = single ITaskItem (TaskItemData from binlog deserialization)
+ foreach (var itemEntry in projectEvalArgs.Items)
+ {
+ if (itemEntry is DictionaryEntry entry &&
+ entry.Key is string itemType &&
+ MSBuildProjectInfo.IsItemTypeOfInterest(itemType, out var isPath) &&
+ entry.Value is ITaskItem taskItem)
+ {
+ if (isPath && rebasePath != null)
+ {
+ // Rebase the item spec if it's a path
+ taskItem.ItemSpec = rebasePath(taskItem.ItemSpec);
+ }
+
+ projectInfo.TryAddOrUpdateItem(itemType, taskItem);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Handles build events to capture property and item changes during target execution.
+ ///
+ private void HandleBuildEvent(
+ BuildEventArgs? args,
+ Dictionary projectInstanceToEvaluationMap,
+ Dictionary projectInfoByEvaluationId,
+ Func? rebasePath)
+ {
+ if (!this.TryGetProjectInfo(args, projectInstanceToEvaluationMap, projectInfoByEvaluationId, out var projectInfo))
+ {
+ return;
+ }
+
+ switch (args)
+ {
+ // Property reassignments (when a property value changes during the build)
+ case PropertyReassignmentEventArgs propertyReassignment:
+ this.SetPropertyWithRebase(projectInfo, propertyReassignment.PropertyName, propertyReassignment.NewValue, rebasePath);
+ break;
+
+ // Initial property value set events
+ case PropertyInitialValueSetEventArgs propertyInitialValueSet:
+ this.SetPropertyWithRebase(projectInfo, propertyInitialValueSet.PropertyName, propertyInitialValueSet.PropertyValue, rebasePath);
+ break;
+
+ // Environment variable reads during evaluation - MSBuild promotes env vars to properties
+ case EnvironmentVariableReadEventArgs envVarRead when
+ !string.IsNullOrEmpty(envVarRead.EnvironmentVariableName):
+ this.SetPropertyWithRebase(projectInfo, envVarRead.EnvironmentVariableName, envVarRead.Message ?? string.Empty, rebasePath);
+ break;
+
+ // Task parameter events which can contain item arrays for add/remove/update
+ case TaskParameterEventArgs taskParameter when
+ taskParameter.Items is IList taskItems:
+ this.ProcessTaskParameterItems(taskParameter.Kind, taskParameter.ItemType, taskItems, projectInfo, rebasePath);
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ ///
+ /// Tries to get the project info for a build event.
+ ///
+ private bool TryGetProjectInfo(
+ BuildEventArgs? args,
+ Dictionary projectInstanceToEvaluationMap,
+ Dictionary projectInfoByEvaluationId,
+ out MSBuildProjectInfo projectInfo)
+ {
+ projectInfo = null!;
+
+ // Try ProjectInstanceId first (available during build/target execution)
+ if (args?.BuildEventContext?.ProjectInstanceId >= 0 &&
+ projectInstanceToEvaluationMap.TryGetValue(args.BuildEventContext.ProjectInstanceId, out var evaluationId) &&
+ projectInfoByEvaluationId.TryGetValue(evaluationId, out projectInfo!))
+ {
+ return true;
+ }
+
+ // Fall back to EvaluationId (available during evaluation, before ProjectStarted)
+ if (args?.BuildEventContext?.EvaluationId >= 0)
+ {
+ if (!projectInfoByEvaluationId.TryGetValue(args.BuildEventContext.EvaluationId, out projectInfo!))
+ {
+ // Create lazily for evaluation-time events that fire before ProjectEvaluationFinished
+ projectInfo = new MSBuildProjectInfo();
+ projectInfoByEvaluationId[args.BuildEventContext.EvaluationId] = projectInfo;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Processes task parameter items for add/remove operations.
+ ///
+ private void ProcessTaskParameterItems(
+ TaskParameterMessageKind kind,
+ string itemType,
+ IList items,
+ MSBuildProjectInfo projectInfo,
+ Func? rebasePath)
+ {
+ if (!MSBuildProjectInfo.IsItemTypeOfInterest(itemType, out var isPath))
+ {
+ return;
+ }
+
+ if (kind == TaskParameterMessageKind.RemoveItem)
+ {
+ foreach (var item in items)
+ {
+ var itemSpec = isPath && rebasePath != null ? rebasePath(item.ItemSpec) : item.ItemSpec;
+ projectInfo.TryRemoveItem(itemType, itemSpec);
+ }
+ }
+ else if (kind == TaskParameterMessageKind.TaskInput ||
+ kind == TaskParameterMessageKind.AddItem ||
+ kind == TaskParameterMessageKind.TaskOutput)
+ {
+ foreach (var item in items)
+ {
+ if (isPath && rebasePath != null)
+ {
+ // Rebase the item spec if it's a path
+ item.ItemSpec = rebasePath(item.ItemSpec);
+ }
+
+ projectInfo.TryAddOrUpdateItem(itemType, item);
+ }
+ }
+
+ // SkippedTargetInputs and SkippedTargetOutputs are informational and don't modify items
+ }
+
+ ///
+ /// Sets a property on a project info, rebasing the value first if it is a path property.
+ ///
+ private void SetPropertyWithRebase(MSBuildProjectInfo projectInfo, string propertyName, string value, Func? rebasePath)
+ {
+ if (!MSBuildProjectInfo.IsPropertyOfInterest(propertyName, out var isPath))
+ {
+ return;
+ }
+
+ if (isPath && rebasePath != null && !string.IsNullOrEmpty(value))
+ {
+ value = rebasePath(value);
+ }
+
+ projectInfo.TrySetProperty(propertyName, value);
+ }
+}
diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/IBinLogProcessor.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/IBinLogProcessor.cs
new file mode 100644
index 000000000..2bbdea69d
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Detectors/nuget/IBinLogProcessor.cs
@@ -0,0 +1,24 @@
+namespace Microsoft.ComponentDetection.Detectors.NuGet;
+
+using System.Collections.Generic;
+
+///
+/// Interface for processing MSBuild binary log files to extract project information.
+///
+internal interface IBinLogProcessor
+{
+ ///
+ /// Extracts project information from a binary log file.
+ /// All absolute paths in the returned objects
+ /// (e.g., , )
+ /// are rebased to be relative to when the binlog was produced
+ /// on a different machine.
+ ///
+ /// Path to the binary log file on the scanning machine.
+ ///
+ /// The source directory on the scanning machine, used to rebase paths when the binlog
+ /// was produced on a different machine. May be null to skip rebasing.
+ ///
+ /// Collection of project information extracted from the binlog, with paths rebased.
+ IReadOnlyList ExtractProjectInfo(string binlogPath, string? sourceDirectory = null);
+}
diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/LockFileUtilities.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/LockFileUtilities.cs
new file mode 100644
index 000000000..7a682556e
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Detectors/nuget/LockFileUtilities.cs
@@ -0,0 +1,427 @@
+namespace Microsoft.ComponentDetection.Detectors.NuGet;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using global::NuGet.Frameworks;
+using global::NuGet.Packaging.Core;
+using global::NuGet.ProjectModel;
+using global::NuGet.Versioning;
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.TypedComponent;
+using Microsoft.Extensions.Logging;
+
+// LockFileUtilities also includes self-contained detection helpers shared by
+// DotNetComponentDetector and MSBuildBinaryLogComponentDetector.
+
+///
+/// Shared utility methods for processing NuGet lock files (project.assets.json).
+/// Used by both NuGetProjectModelProjectCentricComponentDetector and MSBuildBinaryLogComponentDetector.
+///
+public static class LockFileUtilities
+{
+ ///
+ /// Dependency type constant for project references in project.assets.json.
+ ///
+ public const string ProjectDependencyType = "project";
+
+ ///
+ /// Gets the framework references for a given lock file target.
+ ///
+ /// The lock file to analyze.
+ /// The target framework to get references for.
+ /// Array of framework reference names.
+ public static string[] GetFrameworkReferences(LockFile lockFile, LockFileTarget target)
+ {
+ var frameworkInformation = lockFile.PackageSpec?.TargetFrameworks
+ .FirstOrDefault(x => x.FrameworkName.Equals(target.TargetFramework));
+
+ if (frameworkInformation == null)
+ {
+ return [];
+ }
+
+ // Add directly referenced frameworks
+ var results = frameworkInformation.FrameworkReferences.Select(x => x.Name);
+
+ // Add transitive framework references
+ results = results.Concat(target.Libraries.SelectMany(l => l.FrameworkReferences));
+
+ return results.Distinct().ToArray();
+ }
+
+ ///
+ /// Determines if a library is a development dependency based on its content.
+ /// A placeholder item is an empty file that doesn't exist with name _._ meant to indicate
+ /// an empty folder in a nuget package, but also used by NuGet when a package's assets are excluded.
+ ///
+ /// The library to check.
+ /// The lock file containing library metadata.
+ /// True if the library is a development dependency.
+ public static bool IsADevelopmentDependency(LockFileTargetLibrary library, LockFile lockFile)
+ {
+ static bool IsAPlaceholderItem(LockFileItem item) =>
+ Path.GetFileName(item.Path).Equals(PackagingCoreConstants.EmptyFolder, StringComparison.OrdinalIgnoreCase);
+
+ // All(IsAPlaceholderItem) checks if the collection is empty or all items are placeholders.
+ return library.RuntimeAssemblies.All(IsAPlaceholderItem) &&
+ library.RuntimeTargets.All(IsAPlaceholderItem) &&
+ library.ResourceAssemblies.All(IsAPlaceholderItem) &&
+ library.NativeLibraries.All(IsAPlaceholderItem) &&
+ library.ContentFiles.All(IsAPlaceholderItem) &&
+ library.Build.All(IsAPlaceholderItem) &&
+ library.BuildMultiTargeting.All(IsAPlaceholderItem) &&
+
+ // The SDK looks at the library for analyzers using the following heuristic:
+ // https://github.com/dotnet/sdk/blob/d7fe6e66d8f67dc93c5c294a75f42a2924889196/src/Tasks/Microsoft.NET.Build.Tasks/NuGetUtils.NuGet.cs#L43
+ (!lockFile.GetLibrary(library.Name, library.Version)?.Files
+ .Any(file => file.StartsWith("analyzers", StringComparison.Ordinal)
+ && file.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
+ && !file.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)) ?? false);
+ }
+
+ ///
+ /// Gets the top-level libraries (direct dependencies) from a lock file.
+ ///
+ /// The lock file to analyze.
+ /// List of top-level library information.
+ public static List<(string Name, Version? Version, VersionRange? VersionRange)> GetTopLevelLibraries(LockFile lockFile)
+ {
+ var toBeFilled = new List<(string Name, Version? Version, VersionRange? VersionRange)>();
+
+ if (lockFile.PackageSpec?.TargetFrameworks != null)
+ {
+ foreach (var framework in lockFile.PackageSpec.TargetFrameworks)
+ {
+ foreach (var dependency in framework.Dependencies)
+ {
+ toBeFilled.Add((dependency.Name, Version: null, dependency.LibraryRange.VersionRange));
+ }
+ }
+ }
+
+ var projectDirectory = lockFile.PackageSpec?.RestoreMetadata?.ProjectPath != null
+ ? Path.GetDirectoryName(lockFile.PackageSpec.RestoreMetadata.ProjectPath)
+ : null;
+
+ if (projectDirectory != null && lockFile.Libraries != null)
+ {
+ var librariesWithAbsolutePath = lockFile.Libraries
+ .Where(x => x.Type == ProjectDependencyType)
+ .Select(x => (library: x, absoluteProjectPath: Path.GetFullPath(Path.Combine(projectDirectory, x.Path))))
+ .ToDictionary(x => x.absoluteProjectPath, x => x.library);
+
+ if (lockFile.PackageSpec?.RestoreMetadata?.TargetFrameworks != null)
+ {
+ foreach (var restoreMetadataTargetFramework in lockFile.PackageSpec.RestoreMetadata.TargetFrameworks)
+ {
+ foreach (var projectReference in restoreMetadataTargetFramework.ProjectReferences)
+ {
+ if (librariesWithAbsolutePath.TryGetValue(Path.GetFullPath(projectReference.ProjectPath), out var library))
+ {
+ toBeFilled.Add((library.Name, library.Version?.Version, null));
+ }
+ }
+ }
+ }
+ }
+
+ return toBeFilled;
+ }
+
+ ///
+ /// Looks up a library in project.assets.json given a version (preferred) or version range.
+ ///
+ /// The list of libraries to search.
+ /// The dependency name to find.
+ /// The specific version to match (mutually exclusive with versionRange).
+ /// The version range to match (mutually exclusive with version).
+ /// Optional logger for debug messages.
+ /// The matching library, or null if not found.
+ public static LockFileLibrary? GetLibraryComponentWithDependencyLookup(
+ IList? libraries,
+ string dependencyId,
+ Version? version,
+ VersionRange? versionRange,
+ ILogger? logger = null)
+ {
+ if (libraries == null)
+ {
+ return null;
+ }
+
+ if ((version == null && versionRange == null) || (version != null && versionRange != null))
+ {
+ logger?.LogDebug("Either version or versionRange must be specified, but not both for {DependencyId}.", dependencyId);
+ return null;
+ }
+
+ var matchingLibraryNames = libraries.Where(x => string.Equals(x.Name, dependencyId, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (matchingLibraryNames.Count == 0)
+ {
+ logger?.LogDebug("No library found matching: {DependencyId}", dependencyId);
+ return null;
+ }
+
+ LockFileLibrary? matchingLibrary;
+ if (version != null)
+ {
+ matchingLibrary = matchingLibraryNames.FirstOrDefault(x => x.Version?.Version?.Equals(version) ?? false);
+ }
+ else
+ {
+ matchingLibrary = matchingLibraryNames.FirstOrDefault(x => x.Version != null && versionRange!.Satisfies(x.Version));
+ }
+
+ if (matchingLibrary == null)
+ {
+ matchingLibrary = matchingLibraryNames.First();
+ var versionString = versionRange != null ? versionRange.ToNormalizedString() : version?.ToString();
+ logger?.LogWarning(
+ "Couldn't satisfy lookup for {Version}. Falling back to first found component for {MatchingLibraryName}, resolving to version {MatchingLibraryVersion}.",
+ versionString,
+ matchingLibrary.Name,
+ matchingLibrary.Version);
+ }
+
+ return matchingLibrary;
+ }
+
+ ///
+ /// Navigates the dependency graph and registers components with the component recorder.
+ ///
+ /// The lock file target containing dependency information.
+ /// Set of component IDs that are explicitly referenced.
+ /// The component recorder to register with.
+ /// The library to process.
+ /// The parent component ID, or null for root dependencies.
+ /// Function to determine if a library is a development dependency.
+ /// Set of already visited dependency IDs to prevent cycles.
+ public static void NavigateAndRegister(
+ LockFileTarget target,
+ HashSet explicitlyReferencedComponentIds,
+ ISingleFileComponentRecorder singleFileComponentRecorder,
+ LockFileTargetLibrary library,
+ string? parentComponentId,
+ Func isDevelopmentDependency,
+ HashSet? visited = null)
+ {
+ if (library.Type == ProjectDependencyType)
+ {
+ return;
+ }
+
+ visited ??= [];
+
+ var libraryComponent = new DetectedComponent(new NuGetComponent(library.Name, library.Version?.ToNormalizedString() ?? "0.0.0"));
+
+ singleFileComponentRecorder.RegisterUsage(
+ libraryComponent,
+ explicitlyReferencedComponentIds.Contains(libraryComponent.Component.Id),
+ parentComponentId,
+ isDevelopmentDependency: isDevelopmentDependency(library),
+ targetFramework: target.TargetFramework?.GetShortFolderName());
+
+ foreach (var dependency in library.Dependencies)
+ {
+ if (visited.Contains(dependency.Id))
+ {
+ continue;
+ }
+
+ var targetLibrary = target.GetTargetLibrary(dependency.Id);
+
+ if (targetLibrary != null)
+ {
+ visited.Add(dependency.Id);
+ NavigateAndRegister(
+ target,
+ explicitlyReferencedComponentIds,
+ singleFileComponentRecorder,
+ targetLibrary,
+ libraryComponent.Component.Id,
+ isDevelopmentDependency,
+ visited);
+ }
+ }
+ }
+
+ ///
+ /// Registers PackageDownload dependencies from the lock file.
+ ///
+ /// The component recorder to register with.
+ /// The lock file containing PackageDownload references.
+ ///
+ /// Optional callback to determine if a package download is a development dependency.
+ /// Parameters are (packageName, targetFramework). Defaults to always returning true since
+ /// PackageDownload usage does not make it part of the application.
+ ///
+ public static void RegisterPackageDownloads(
+ ISingleFileComponentRecorder singleFileComponentRecorder,
+ LockFile lockFile,
+ Func? isDevelopmentDependency = null)
+ {
+ if (lockFile.PackageSpec?.TargetFrameworks == null)
+ {
+ return;
+ }
+
+ // Default: PackageDownload is always a development dependency
+ isDevelopmentDependency ??= (_, _) => true;
+
+ foreach (var framework in lockFile.PackageSpec.TargetFrameworks)
+ {
+ var tfm = framework.FrameworkName;
+
+ foreach (var packageDownload in framework.DownloadDependencies)
+ {
+ if (packageDownload?.Name is null || packageDownload?.VersionRange?.MinVersion is null)
+ {
+ continue;
+ }
+
+ var libraryComponent = new DetectedComponent(new NuGetComponent(packageDownload.Name, packageDownload.VersionRange.MinVersion.ToNormalizedString()));
+
+ singleFileComponentRecorder.RegisterUsage(
+ libraryComponent,
+ isExplicitReferencedDependency: true,
+ parentComponentId: null,
+ isDevelopmentDependency: isDevelopmentDependency(packageDownload.Name, tfm),
+ targetFramework: tfm?.GetShortFolderName());
+ }
+ }
+ }
+
+ ///
+ /// Resolves the top-level (explicit) dependencies from a lock file into actual library entries
+ /// and builds the set of component IDs for those dependencies.
+ ///
+ /// The lock file to analyze.
+ /// Optional logger for warning messages.
+ /// A tuple of the resolved library list and their component ID set.
+ internal static (List Libraries, HashSet ComponentIds) ResolveExplicitDependencies(
+ LockFile lockFile,
+ ILogger? logger = null)
+ {
+ var libraries = new List();
+ foreach (var lib in GetTopLevelLibraries(lockFile))
+ {
+ var resolved = GetLibraryComponentWithDependencyLookup(lockFile.Libraries, lib.Name, lib.Version, lib.VersionRange, logger);
+ if (resolved != null)
+ {
+ libraries.Add(resolved);
+ }
+ else
+ {
+ logger?.LogWarning(
+ "Could not resolve top-level dependency {DependencyName}. The project.assets.json may be malformed.",
+ lib.Name);
+ }
+ }
+
+ var componentIds = libraries
+ .Select(x => new NuGetComponent(x.Name, x.Version.ToNormalizedString()).Id)
+ .ToHashSet();
+
+ return (libraries, componentIds);
+ }
+
+ ///
+ /// Processes a lock file using the standard project-centric approach:
+ /// resolves explicit dependencies, walks the dependency graph for each target,
+ /// and registers PackageDownload dependencies.
+ ///
+ /// The lock file to process.
+ /// The component recorder to register with.
+ /// Optional logger for warning messages.
+ internal static void ProcessLockFile(
+ LockFile lockFile,
+ ISingleFileComponentRecorder singleFileComponentRecorder,
+ ILogger? logger = null)
+ {
+ var (explicitReferencedDependencies, explicitlyReferencedComponentIds) = ResolveExplicitDependencies(lockFile, logger);
+
+ foreach (var target in lockFile.Targets)
+ {
+ var frameworkReferences = GetFrameworkReferences(lockFile, target);
+ var frameworkPackages = FrameworkPackages.GetFrameworkPackages(target.TargetFramework, frameworkReferences, target);
+
+ bool IsFrameworkOrDevelopmentDependency(LockFileTargetLibrary library) =>
+ frameworkPackages.Any(fp => fp.IsAFrameworkComponent(library.Name, library.Version)) ||
+ IsADevelopmentDependency(library, lockFile);
+
+ foreach (var library in explicitReferencedDependencies.Select(x => target.GetTargetLibrary(x.Name)).Where(x => x != null))
+ {
+ NavigateAndRegister(target, explicitlyReferencedComponentIds, singleFileComponentRecorder, library!, null, IsFrameworkOrDevelopmentDependency);
+ }
+ }
+
+ RegisterPackageDownloads(singleFileComponentRecorder, lockFile);
+ }
+
+ ///
+ /// Determines if a project is self-contained by inspecting the lock file.
+ /// Checks for Microsoft.DotNet.ILCompiler in target libraries (indicates PublishAot)
+ /// and runtime download dependencies matching framework references (indicates SelfContained).
+ ///
+ /// The package spec from the lock file.
+ /// The target framework to check.
+ /// The lock file target containing library information.
+ /// True if the project appears to be self-contained.
+ public static bool IsSelfContainedFromLockFile(PackageSpec? packageSpec, NuGetFramework? targetFramework, LockFileTarget target)
+ {
+ // PublishAot projects reference Microsoft.DotNet.ILCompiler
+ if (target?.Libraries != null &&
+ target.Libraries.Any(lib => "Microsoft.DotNet.ILCompiler".Equals(lib.Name, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ if (packageSpec?.TargetFrameworks == null || targetFramework == null)
+ {
+ return false;
+ }
+
+ var targetFrameworkInfo = packageSpec.TargetFrameworks.FirstOrDefault(tf => tf.FrameworkName == targetFramework);
+ if (targetFrameworkInfo == null)
+ {
+ return false;
+ }
+
+ var frameworkReferences = targetFrameworkInfo.FrameworkReferences;
+ var packageDownloads = targetFrameworkInfo.DownloadDependencies;
+
+ if (frameworkReferences == null || frameworkReferences.Count == 0 || packageDownloads.IsDefaultOrEmpty)
+ {
+ return false;
+ }
+
+ foreach (var frameworkRef in frameworkReferences)
+ {
+ if (packageDownloads.Any(pd => pd.Name.StartsWith($"{frameworkRef.Name}.Runtime", StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Appends "-selfcontained" suffix to the project type when the project is self-contained.
+ ///
+ /// The base target type (e.g., "application" or "library").
+ /// Whether the project is self-contained.
+ /// The target type with optional "-selfcontained" suffix.
+ public static string? GetTargetTypeWithSelfContained(string? targetType, bool isSelfContained)
+ {
+ if (string.IsNullOrWhiteSpace(targetType))
+ {
+ return targetType;
+ }
+
+ return isSelfContained ? $"{targetType}-selfcontained" : targetType;
+ }
+}
diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/MSBuildBinaryLogComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/MSBuildBinaryLogComponentDetector.cs
new file mode 100644
index 000000000..25b9dd70c
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Detectors/nuget/MSBuildBinaryLogComponentDetector.cs
@@ -0,0 +1,604 @@
+namespace Microsoft.ComponentDetection.Detectors.NuGet;
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reactive.Linq;
+using System.Reactive.Threading.Tasks;
+using System.Threading;
+using System.Threading.Tasks;
+using global::NuGet.Frameworks;
+using global::NuGet.ProjectModel;
+using Microsoft.Build.Framework;
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.Internal;
+using Microsoft.ComponentDetection.Contracts.TypedComponent;
+using Microsoft.ComponentDetection.Detectors.DotNet;
+using Microsoft.Extensions.Logging;
+using Task = System.Threading.Tasks.Task;
+
+///
+/// An experimental detector that combines MSBuild binlog information with NuGet project.assets.json
+/// to provide enhanced component detection with project-level classifications.
+/// This detector is intended to replace both DotNetComponentDetector and NuGetProjectModelProjectCentricComponentDetector.
+///
+///
+///
+/// Logic consistency notes with replaced detectors:
+///
+///
+/// NuGet component detection (from NuGetProjectModelProjectCentricComponentDetector):
+/// - Uses the same LockFileUtilities methods for processing project.assets.json
+/// - Maintains the same logic for determining framework packages and development dependencies
+/// - Registers PackageDownload dependencies the same way
+/// - Uses project path from RestoreMetadata.ProjectPath for component recorder (consistent behavior).
+///
+///
+/// DotNet component detection (from DotNetComponentDetector):
+/// - SDK version: Binlog provides NETCoreSdkVersion which is the actual version used during build
+/// (more accurate than running `dotnet --version` which may differ due to global.json rollforward)
+/// - Target type: Uses OutputType property from binlog to determine "application" vs "library"
+/// (DotNetComponentDetector uses PE header inspection which requires compiled output to exist.)
+/// - Target frameworks: Uses TargetFramework/TargetFrameworks properties from binlog.
+/// (DotNetComponentDetector uses targets from project.assets.json which is equivalent.)
+///
+///
+/// Additional enhancements:
+/// - IsTestProject classification: All dependencies of test projects are marked as dev dependencies.
+/// - Fallback mode: When no binlog info is available, falls back to standard NuGet detection.
+///
+///
+public class MSBuildBinaryLogComponentDetector : FileComponentDetector, IExperimentalDetector
+{
+ private readonly IBinLogProcessor binLogProcessor;
+ private readonly IFileUtilityService fileUtilityService;
+ private readonly DotNetProjectInfoProvider projectInfoProvider;
+ private readonly LockFileFormat lockFileFormat = new();
+
+ ///
+ /// Stores project information extracted from binlogs, keyed by assets file path.
+ ///
+ ///
+ /// All binlog files are processed before any assets files (guaranteed by ),
+ /// so by the time an assets file is processed this dictionary contains the merged superset of project
+ /// info from every binlog that referenced that project. This is intentional: a repository may produce
+ /// separate binlogs for different build passes (e.g., build vs. publish). Properties like
+ /// or are
+ /// typically only set in the publish pass, so merging ensures those properties are available when
+ /// processing the shared project.assets.json.
+ ///
+ /// Memory impact: each is roughly 15–16 KB (including inner builds
+ /// and PackageReference/PackageDownload dictionaries). A repository with 100K projects would use
+ /// approximately 1.5 GB for project info storage alone — significant but proportional to the
+ /// multi-GB binlog files that such a repository would produce.
+ ///
+ private readonly ConcurrentDictionary projectInfoByAssetsFile = new(StringComparer.OrdinalIgnoreCase);
+
+ // Source directory passed to BinLogProcessor for path rebasing.
+ private string? sourceDirectory;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Factory for creating component streams.
+ /// Factory for directory walking.
+ /// Service for command line invocation.
+ /// Service for directory operations.
+ /// Service for file operations.
+ /// Service for path operations.
+ /// Logger for diagnostic messages.
+ public MSBuildBinaryLogComponentDetector(
+ IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
+ IObservableDirectoryWalkerFactory walkerFactory,
+ ICommandLineInvocationService commandLineInvocationService,
+ IDirectoryUtilityService directoryUtilityService,
+ IFileUtilityService fileUtilityService,
+ IPathUtilityService pathUtilityService,
+ ILogger logger)
+ {
+ this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
+ this.Scanner = walkerFactory;
+ this.binLogProcessor = new BinLogProcessor(logger);
+ this.fileUtilityService = fileUtilityService;
+ this.Logger = logger;
+ this.projectInfoProvider = new DotNetProjectInfoProvider(
+ commandLineInvocationService,
+ directoryUtilityService,
+ fileUtilityService,
+ pathUtilityService,
+ logger);
+ }
+
+ ///
+ /// Initializes a new instance of the class
+ /// with an explicit for testing.
+ ///
+ internal MSBuildBinaryLogComponentDetector(
+ IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
+ IObservableDirectoryWalkerFactory walkerFactory,
+ ICommandLineInvocationService commandLineInvocationService,
+ IDirectoryUtilityService directoryUtilityService,
+ IFileUtilityService fileUtilityService,
+ IPathUtilityService pathUtilityService,
+ IBinLogProcessor binLogProcessor,
+ ILogger logger)
+ {
+ this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
+ this.Scanner = walkerFactory;
+ this.binLogProcessor = binLogProcessor;
+ this.fileUtilityService = fileUtilityService;
+ this.Logger = logger;
+ this.projectInfoProvider = new DotNetProjectInfoProvider(
+ commandLineInvocationService,
+ directoryUtilityService,
+ fileUtilityService,
+ pathUtilityService,
+ logger);
+ }
+
+ ///
+ public override string Id => "MSBuildBinaryLog";
+
+ ///
+ public override IEnumerable Categories => [Enum.GetName(typeof(DetectorClass), DetectorClass.NuGet)!];
+
+ ///
+ public override IList SearchPatterns { get; } = ["*.binlog", "project.assets.json"];
+
+ ///
+ public override IEnumerable SupportedComponentTypes { get; } = [ComponentType.NuGet, ComponentType.DotNet];
+
+ ///
+ public override int Version { get; } = 1;
+
+ ///
+ public override Task ExecuteDetectorAsync(ScanRequest request, CancellationToken cancellationToken = default)
+ {
+ this.sourceDirectory = request.SourceDirectory.FullName;
+ this.projectInfoProvider.Initialize(request.SourceDirectory.FullName, request.SourceFileRoot?.FullName);
+ return base.ExecuteDetectorAsync(request, cancellationToken);
+ }
+
+ ///
+ protected override async Task> OnPrepareDetectionAsync(
+ IObservable processRequests,
+ IDictionary detectorArgs,
+ CancellationToken cancellationToken = default)
+ {
+ // Collect all requests and sort them so binlogs are processed first
+ // This ensures we have project info available when processing assets files
+ var allRequests = await processRequests.ToList();
+
+ this.Logger.LogDebug("Preparing detection: collected {Count} files", allRequests.Count);
+
+ // Separate binlogs and assets files
+ var binlogRequests = allRequests
+ .Where(r => r.ComponentStream.Location.EndsWith(".binlog", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ var assetsRequests = allRequests
+ .Where(r => r.ComponentStream.Location.EndsWith("project.assets.json", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ this.Logger.LogDebug("Found {BinlogCount} binlog files and {AssetsCount} assets files", binlogRequests.Count, assetsRequests.Count);
+
+ // Return binlogs first, then assets files
+ var orderedRequests = binlogRequests.Concat(assetsRequests);
+
+ return orderedRequests.ToObservable();
+ }
+
+ ///
+ protected override async Task OnFileFoundAsync(
+ ProcessRequest processRequest,
+ IDictionary detectorArgs,
+ CancellationToken cancellationToken = default)
+ {
+ var fileExtension = Path.GetExtension(processRequest.ComponentStream.Location);
+
+ if (fileExtension.Equals(".binlog", StringComparison.OrdinalIgnoreCase))
+ {
+ this.ProcessBinlogFile(processRequest);
+ }
+ else if (processRequest.ComponentStream.Location.EndsWith("project.assets.json", StringComparison.OrdinalIgnoreCase))
+ {
+ await this.ProcessAssetsFileAsync(processRequest, cancellationToken);
+ }
+ }
+
+ ///
+ /// Determines whether a project should be classified as development-only.
+ /// A project is development-only if IsTestProject=true, IsShipping=false, or IsDevelopment=true.
+ ///
+ private static bool IsDevelopmentOnlyProject(MSBuildProjectInfo projectInfo) =>
+ projectInfo.IsTestProject == true ||
+ projectInfo.IsShipping == false ||
+ projectInfo.IsDevelopment == true;
+
+ ///
+ /// Gets the IsDevelopmentDependency metadata override for a package from the specified items.
+ ///
+ /// The item dictionary to check (e.g., PackageReference or PackageDownload).
+ /// The package name to look up.
+ ///
+ /// True if explicitly marked as a development dependency,
+ /// false if explicitly marked as NOT a development dependency,
+ /// null if no explicit override is set or the package is not in the items.
+ ///
+ private static bool? GetDevelopmentDependencyOverride(IDictionary items, string packageName)
+ {
+ if (items.TryGetValue(packageName, out var item))
+ {
+ var metadataValue = item.GetMetadata("IsDevelopmentDependency");
+ if (!string.IsNullOrEmpty(metadataValue))
+ {
+ return string.Equals(metadataValue, "true", StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Determines if a project is self-contained using MSBuild properties from the binlog.
+ /// Reads the SelfContained and PublishAot properties directly.
+ ///
+ private static bool IsSelfContainedFromProjectInfo(MSBuildProjectInfo projectInfo) =>
+ projectInfo.SelfContained == true || projectInfo.PublishAot == true;
+
+ private void ProcessBinlogFile(ProcessRequest processRequest)
+ {
+ var binlogPath = processRequest.ComponentStream.Location;
+ var assetsFilesFound = new List();
+
+ try
+ {
+ this.Logger.LogDebug("Processing binlog file: {BinlogPath}", binlogPath);
+
+ var projectInfos = this.binLogProcessor.ExtractProjectInfo(binlogPath, this.sourceDirectory);
+
+ if (projectInfos.Count == 0)
+ {
+ this.Logger.LogInformation("No project information could be extracted from binlog: {BinlogPath}", binlogPath);
+ return;
+ }
+
+ foreach (var projectInfo in projectInfos)
+ {
+ this.IndexProjectInfo(projectInfo, assetsFilesFound);
+ this.LogMissingAssetsWarnings(projectInfo);
+ }
+
+ // Log summary warning if no assets files were found
+ if (assetsFilesFound.Count == 0 && projectInfos.Count > 0)
+ {
+ this.Logger.LogWarning(
+ "Binlog {BinlogPath} contained {ProjectCount} project(s) but no project.assets.json files were referenced. NuGet restore may not have run.",
+ binlogPath,
+ projectInfos.Count);
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogWarning(ex, "Failed to process binlog file: {BinlogPath}", binlogPath);
+ }
+ }
+
+ private void IndexProjectInfo(MSBuildProjectInfo projectInfo, List assetsFilesFound)
+ {
+ // Index by assets file path for lookup when processing lock files.
+ // Use AddOrUpdate+MergeWith so that multiple binlogs for the same project
+ // (e.g., build and publish passes) form a superset rather than keeping only the first.
+ if (!string.IsNullOrEmpty(projectInfo.ProjectAssetsFile))
+ {
+ this.projectInfoByAssetsFile.AddOrUpdate(
+ projectInfo.ProjectAssetsFile,
+ _ => projectInfo,
+ (_, existing) => existing.MergeWith(projectInfo));
+ assetsFilesFound.Add(projectInfo.ProjectAssetsFile);
+ }
+ }
+
+ private void LogMissingAssetsWarnings(MSBuildProjectInfo projectInfo)
+ {
+ if (string.IsNullOrEmpty(projectInfo.ProjectAssetsFile))
+ {
+ this.Logger.LogWarning(
+ "No ProjectAssetsFile property found in binlog for project: {ProjectPath}. NuGet dependencies may not be detected.",
+ projectInfo.ProjectPath);
+ }
+ else if (!this.fileUtilityService.Exists(projectInfo.ProjectAssetsFile))
+ {
+ this.Logger.LogWarning(
+ "Project.assets.json referenced in binlog does not exist: {AssetsFile} (from project {ProjectPath})",
+ projectInfo.ProjectAssetsFile,
+ projectInfo.ProjectPath);
+ }
+ }
+
+ ///
+ /// Registers a DotNet component based on SDK version from the binlog.
+ ///
+ ///
+ /// This is equivalent to DotNetComponentDetector's behavior but uses the SDK version
+ /// directly from the binlog (NETCoreSdkVersion property) rather than running `dotnet --version`.
+ /// The binlog value is more accurate as it represents the actual SDK used during the build.
+ ///
+ /// For target type (application/library), we use the OutputType property from the binlog
+ /// which is equivalent to what DotNetComponentDetector determines by inspecting the PE headers.
+ ///
+ /// When a lock file is available, self-contained detection uses both binlog properties
+ /// (SelfContained, PublishAot) and the lock file heuristic (ILCompiler in libraries,
+ /// runtime download dependencies matching framework references) for comprehensive coverage.
+ ///
+ private void RegisterDotNetComponent(MSBuildProjectInfo projectInfo, LockFile? lockFile = null)
+ {
+ if (string.IsNullOrEmpty(projectInfo.NETCoreSdkVersion) || string.IsNullOrEmpty(projectInfo.ProjectPath))
+ {
+ return;
+ }
+
+ var singleFileComponentRecorder = this.ComponentRecorder.CreateSingleFileComponentRecorder(projectInfo.ProjectPath);
+
+ // Determine target type from OutputType property
+ // This is equivalent to DotNetComponentDetector's IsApplication check via PE headers
+ string? targetType = null;
+ if (!string.IsNullOrEmpty(projectInfo.OutputType))
+ {
+ targetType = projectInfo.OutputType.Equals("Exe", StringComparison.OrdinalIgnoreCase) ||
+ projectInfo.OutputType.Equals("WinExe", StringComparison.OrdinalIgnoreCase)
+ ? "application"
+ : "library";
+ }
+
+ // Primary self-contained check from binlog properties (SelfContained, PublishAot)
+ var isSelfContainedFromBinlog = IsSelfContainedFromProjectInfo(projectInfo);
+
+ if (lockFile != null)
+ {
+ // When lock file is available, check per-target self-contained
+ // combining binlog properties and lock file heuristics
+ foreach (var target in lockFile.Targets)
+ {
+ var isSelfContained = isSelfContainedFromBinlog ||
+ LockFileUtilities.IsSelfContainedFromLockFile(lockFile.PackageSpec, target.TargetFramework, target);
+ var projectType = LockFileUtilities.GetTargetTypeWithSelfContained(targetType, isSelfContained);
+ var frameworkName = target.TargetFramework?.GetShortFolderName();
+
+ singleFileComponentRecorder.RegisterUsage(
+ new DetectedComponent(new DotNetComponent(projectInfo.NETCoreSdkVersion, frameworkName, projectType)));
+ }
+
+ // If no targets in lock file, fall through to binlog-only registration below
+ if (lockFile.Targets.Count > 0)
+ {
+ return;
+ }
+ }
+
+ // Binlog-only path: no lock file available or no targets in lock file
+ var projectTypeFromBinlog = LockFileUtilities.GetTargetTypeWithSelfContained(targetType, isSelfContainedFromBinlog);
+
+ // Get target frameworks from binlog properties
+ var targetFrameworks = new List();
+ if (!string.IsNullOrEmpty(projectInfo.TargetFramework))
+ {
+ targetFrameworks.Add(projectInfo.TargetFramework);
+ }
+ else if (!string.IsNullOrEmpty(projectInfo.TargetFrameworks))
+ {
+ targetFrameworks.AddRange(projectInfo.TargetFrameworks.Split(';', StringSplitOptions.RemoveEmptyEntries));
+ }
+
+ // Register a DotNet component for each target framework
+ if (targetFrameworks.Count > 0)
+ {
+ foreach (var framework in targetFrameworks)
+ {
+ singleFileComponentRecorder.RegisterUsage(
+ new DetectedComponent(new DotNetComponent(projectInfo.NETCoreSdkVersion, framework, projectTypeFromBinlog)));
+ }
+ }
+ else
+ {
+ // No target framework info available, register with just SDK version
+ singleFileComponentRecorder.RegisterUsage(
+ new DetectedComponent(new DotNetComponent(projectInfo.NETCoreSdkVersion, targetFramework: null, projectTypeFromBinlog)));
+ }
+ }
+
+ private async Task ProcessAssetsFileAsync(ProcessRequest processRequest, CancellationToken cancellationToken)
+ {
+ var assetsFilePath = processRequest.ComponentStream.Location;
+
+ try
+ {
+ var lockFile = this.lockFileFormat.Read(processRequest.ComponentStream.Stream, assetsFilePath);
+
+ this.RecordLockfileVersion(lockFile.Version);
+
+ if (lockFile.PackageSpec == null)
+ {
+ this.Logger.LogDebug("Lock file {LockFilePath} does not contain a PackageSpec.", assetsFilePath);
+ return;
+ }
+
+ // Try to find matching binlog info
+ var projectInfo = this.FindProjectInfoForAssetsFile(assetsFilePath);
+
+ if (projectInfo != null)
+ {
+ // We have binlog info, use enhanced processing
+ this.ProcessLockFileWithProjectInfo(lockFile, projectInfo, assetsFilePath);
+ }
+ else
+ {
+ // Fallback to standard processing without binlog info
+ // This matches NuGetProjectModelProjectCentricComponentDetector + DotNetComponentDetector behavior
+ this.Logger.LogDebug(
+ "No binlog information found for assets file: {AssetsFile}. Using fallback processing.",
+ assetsFilePath);
+ await this.ProcessLockFileFallbackAsync(lockFile, assetsFilePath, cancellationToken);
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogWarning(ex, "Failed to process NuGet lockfile: {LockFile}", assetsFilePath);
+ }
+ }
+
+ ///
+ /// Finds the associated with the given assets file path.
+ /// Paths are already rebased to the scanning machine by .
+ ///
+ private MSBuildProjectInfo? FindProjectInfoForAssetsFile(string assetsFilePath)
+ {
+ this.projectInfoByAssetsFile.TryGetValue(assetsFilePath, out var projectInfo);
+ return projectInfo;
+ }
+
+ ///
+ /// Processes a lock file with enhanced project info from the binlog.
+ ///
+ ///
+ /// This method uses the same core logic as NuGetProjectModelProjectCentricComponentDetector:
+ /// - Gets top-level libraries via GetTopLevelLibraries
+ /// - Determines framework packages and dev dependencies
+ /// - Navigates dependency graph via NavigateAndRegister
+ /// - Registers PackageDownload dependencies
+ ///
+ /// Enhancements from binlog:
+ /// - If project sets IsTestProject=true, IsShipping=false, or IsDevelopment=true,
+ /// all dependencies are marked as development dependencies.
+ /// - Per-package IsDevelopmentDependency metadata overrides are applied transitively.
+ ///
+ private void ProcessLockFileWithProjectInfo(LockFile lockFile, MSBuildProjectInfo projectInfo, string assetsFilePath)
+ {
+ var (explicitReferencedDependencies, explicitlyReferencedComponentIds) = LockFileUtilities.ResolveExplicitDependencies(lockFile, this.Logger);
+
+ // Use project path from RestoreMetadata (consistent with NuGetProjectModelProjectCentricComponentDetector).
+ // BinLogProcessor has already rebased projectInfo.ProjectPath to the scanning machine.
+ // RestoreMetadata.ProjectPath comes from the lock file which is on the same machine as the assets file.
+ // Fall back to the assets file path to avoid collisions when no project path is available.
+ var recorderLocation = lockFile.PackageSpec?.RestoreMetadata?.ProjectPath
+ ?? projectInfo.ProjectPath
+ ?? assetsFilePath;
+ var singleFileComponentRecorder = this.ComponentRecorder.CreateSingleFileComponentRecorder(recorderLocation);
+
+ // Get the project info for the target framework (use inner build if available)
+ MSBuildProjectInfo GetProjectInfoForTarget(LockFileTarget target)
+ {
+ if (target.TargetFramework != null)
+ {
+ var innerBuild = projectInfo.InnerBuilds.FirstOrDefault(
+ ib => !string.IsNullOrEmpty(ib.TargetFramework) &&
+ NuGetFramework.Parse(ib.TargetFramework).Equals(target.TargetFramework));
+ if (innerBuild != null)
+ {
+ return innerBuild;
+ }
+ }
+
+ return projectInfo;
+ }
+
+ foreach (var target in lockFile.Targets)
+ {
+ var targetProjectInfo = GetProjectInfoForTarget(target);
+ var frameworkReferences = LockFileUtilities.GetFrameworkReferences(lockFile, target);
+ var frameworkPackages = FrameworkPackages.GetFrameworkPackages(target.TargetFramework, frameworkReferences, target);
+
+ // Base logic: check if library is a framework component or dev dependency in lock file
+ bool IsFrameworkOrDevDependency(LockFileTargetLibrary library) =>
+ frameworkPackages.Any(fp => fp.IsAFrameworkComponent(library.Name, library.Version)) ||
+ LockFileUtilities.IsADevelopmentDependency(library, lockFile);
+
+ foreach (var dependency in explicitReferencedDependencies)
+ {
+ var library = target.GetTargetLibrary(dependency.Name);
+ if (library?.Name == null)
+ {
+ continue;
+ }
+
+ // Combine project-level and per-package overrides into a single value.
+ // When set, this applies transitively to all dependencies of this package.
+ var devDependencyOverride = IsDevelopmentOnlyProject(targetProjectInfo)
+ ? true
+ : GetDevelopmentDependencyOverride(targetProjectInfo.PackageReference, library.Name);
+
+ LockFileUtilities.NavigateAndRegister(
+ target,
+ explicitlyReferencedComponentIds,
+ singleFileComponentRecorder,
+ library,
+ null,
+ devDependencyOverride.HasValue ? _ => devDependencyOverride.Value : IsFrameworkOrDevDependency);
+ }
+ }
+
+ // Register PackageDownload dependencies with dev-dependency overrides
+ LockFileUtilities.RegisterPackageDownloads(
+ singleFileComponentRecorder,
+ lockFile,
+ (packageName, framework) => this.IsPackageDownloadDevDependency(packageName, framework, projectInfo));
+
+ // Register DotNet component with combined binlog + lock file self-contained detection
+ this.RegisterDotNetComponent(projectInfo, lockFile);
+ }
+
+ ///
+ /// Determines if a PackageDownload is a development dependency based on project info.
+ ///
+ private bool IsPackageDownloadDevDependency(string packageName, NuGetFramework? framework, MSBuildProjectInfo projectInfo)
+ {
+ // Get the project info for this framework (use inner build if available)
+ var targetProjectInfo = projectInfo;
+ if (framework != null)
+ {
+ var innerBuild = projectInfo.InnerBuilds.FirstOrDefault(
+ ib => !string.IsNullOrEmpty(ib.TargetFramework) &&
+ NuGetFramework.Parse(ib.TargetFramework).Equals(framework));
+ if (innerBuild != null)
+ {
+ targetProjectInfo = innerBuild;
+ }
+ }
+
+ // Project-level override: all deps are dev deps
+ if (IsDevelopmentOnlyProject(targetProjectInfo))
+ {
+ return true;
+ }
+
+ // Check for explicit item metadata override
+ var devOverride = GetDevelopmentDependencyOverride(targetProjectInfo.PackageDownload, packageName);
+ if (devOverride.HasValue)
+ {
+ return devOverride.Value;
+ }
+
+ // Default: PackageDownload is a dev dependency
+ return true;
+ }
+
+ ///
+ /// Processes a lock file without binlog info (fallback mode).
+ ///
+ ///
+ /// This method exactly matches NuGetProjectModelProjectCentricComponentDetector's behavior
+ /// to ensure no loss of information when binlog data is not available.
+ ///
+ private async Task ProcessLockFileFallbackAsync(LockFile lockFile, string location, CancellationToken cancellationToken)
+ {
+ var projectPath = lockFile.PackageSpec?.RestoreMetadata?.ProjectPath ?? location;
+ var singleFileComponentRecorder = this.ComponentRecorder.CreateSingleFileComponentRecorder(projectPath);
+ LockFileUtilities.ProcessLockFile(lockFile, singleFileComponentRecorder, this.Logger);
+
+ // Register DotNet components (SDK version, target framework, project type)
+ // This matches DotNetComponentDetector's behavior for the fallback path
+ await this.projectInfoProvider.RegisterDotNetComponentsAsync(lockFile, location, this.ComponentRecorder, cancellationToken);
+ }
+}
diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/MSBuildProjectInfo.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/MSBuildProjectInfo.cs
new file mode 100644
index 000000000..e4cc89379
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Detectors/nuget/MSBuildProjectInfo.cs
@@ -0,0 +1,299 @@
+namespace Microsoft.ComponentDetection.Detectors.NuGet;
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Build.Framework;
+
+///
+/// Represents project information extracted from an MSBuild binlog file.
+/// Contains properties of interest for component classification.
+///
+internal class MSBuildProjectInfo
+{
+ ///
+ /// Maps MSBuild property names to their metadata.
+ ///
+ private static readonly Dictionary Properties = new(StringComparer.OrdinalIgnoreCase)
+ {
+ [nameof(IsDevelopment)] = new((info, value) => info.IsDevelopment = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)),
+ [nameof(IsPackable)] = new((info, value) => info.IsPackable = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)),
+ [nameof(IsShipping)] = new((info, value) => info.IsShipping = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)),
+ [nameof(IsTestProject)] = new((info, value) => info.IsTestProject = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)),
+ [nameof(NETCoreSdkVersion)] = new((info, value) => info.NETCoreSdkVersion = value),
+ [nameof(OutputType)] = new((info, value) => info.OutputType = value),
+ [nameof(PublishAot)] = new((info, value) => info.PublishAot = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)),
+ [nameof(ProjectAssetsFile)] = new(
+ (info, value) =>
+ {
+ if (!string.IsNullOrEmpty(value))
+ {
+ info.ProjectAssetsFile = value;
+ }
+ },
+ IsPath: true),
+ [nameof(SelfContained)] = new((info, value) => info.SelfContained = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)),
+ [nameof(TargetFramework)] = new((info, value) => info.TargetFramework = value),
+ [nameof(TargetFrameworks)] = new((info, value) => info.TargetFrameworks = value),
+ };
+
+ ///
+ /// Maps MSBuild item type names to their metadata.
+ ///
+ private static readonly Dictionary Items = new(StringComparer.OrdinalIgnoreCase)
+ {
+ [nameof(PackageReference)] = new(info => info.PackageReference),
+ [nameof(PackageDownload)] = new(info => info.PackageDownload),
+ };
+
+ ///
+ /// Gets or sets the full path to the project file.
+ ///
+ public string? ProjectPath { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this is a development-only project.
+ /// Corresponds to the MSBuild IsDevelopment property.
+ ///
+ public bool? IsDevelopment { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the project is packable.
+ /// Corresponds to the MSBuild IsPackable property.
+ ///
+ public bool? IsPackable { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this project produces shipping artifacts.
+ /// Corresponds to the MSBuild IsShipping property.
+ ///
+ public bool? IsShipping { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this is a test project.
+ /// Corresponds to the MSBuild IsTestProject property.
+ /// When true, all dependencies of this project should be classified as development dependencies.
+ ///
+ public bool? IsTestProject { get; set; }
+
+ ///
+ /// Gets or sets the output type of the project (e.g., "Exe", "Library", "WinExe").
+ /// Corresponds to the MSBuild OutputType property.
+ ///
+ public string? OutputType { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this project uses native AOT compilation.
+ /// Corresponds to the MSBuild PublishAot property.
+ ///
+ public bool? PublishAot { get; set; }
+
+ ///
+ /// Gets or sets the .NET Core SDK version used to build the project.
+ /// Corresponds to the MSBuild NETCoreSdkVersion property.
+ ///
+ public string? NETCoreSdkVersion { get; set; }
+
+ ///
+ /// Gets or sets the path to the project.assets.json file.
+ /// Corresponds to the MSBuild ProjectAssetsFile property.
+ ///
+ public string? ProjectAssetsFile { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the project is self-contained.
+ /// Corresponds to the MSBuild SelfContained property.
+ ///
+ public bool? SelfContained { get; set; }
+
+ ///
+ /// Gets or sets the target framework for single-targeted projects.
+ /// Corresponds to the MSBuild TargetFramework property.
+ ///
+ public string? TargetFramework { get; set; }
+
+ ///
+ /// Gets or sets the target frameworks for multi-targeted projects.
+ /// Corresponds to the MSBuild TargetFrameworks property.
+ ///
+ public string? TargetFrameworks { get; set; }
+
+ ///
+ /// Gets the PackageReference items captured from the project.
+ /// Keyed by package name (ItemSpec).
+ ///
+ public IDictionary PackageReference { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Gets the PackageDownload items captured from the project.
+ /// Keyed by package name (ItemSpec).
+ ///
+ public IDictionary PackageDownload { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Gets the inner builds for multi-targeted projects.
+ /// For multi-targeted projects, the outer build has TargetFrameworks set and dispatches to inner builds.
+ /// Each inner build has a specific TargetFramework and its own set of properties and items.
+ ///
+ public IList InnerBuilds { get; } = [];
+
+ ///
+ /// Gets a value indicating whether this is an outer build of a multi-targeted project.
+ /// The outer build has TargetFrameworks set but TargetFramework is empty (it dispatches to inner builds).
+ /// Inner builds have both TargetFrameworks and TargetFramework set.
+ ///
+ public bool IsOuterBuild => !string.IsNullOrEmpty(this.TargetFrameworks) && string.IsNullOrEmpty(this.TargetFramework);
+
+ ///
+ /// Determines whether the specified item type is one that this class captures.
+ ///
+ /// The MSBuild item type.
+ /// When true, the item's ItemSpec is a filesystem path that may need rebasing.
+ /// True if the item type is of interest; otherwise, false.
+ public static bool IsItemTypeOfInterest(string itemType, out bool isPath)
+ {
+ if (Items.TryGetValue(itemType, out var info))
+ {
+ isPath = info.IsPath;
+ return true;
+ }
+
+ isPath = false;
+ return false;
+ }
+
+ ///
+ /// Determines whether the specified property name is one that this class captures.
+ ///
+ /// The MSBuild property name.
+ /// When true, the property value is a filesystem path that may need rebasing.
+ /// True if the property is of interest; otherwise, false.
+ public static bool IsPropertyOfInterest(string propertyName, out bool isPath)
+ {
+ if (Properties.TryGetValue(propertyName, out var info))
+ {
+ isPath = info.IsPath;
+ return true;
+ }
+
+ isPath = false;
+ return false;
+ }
+
+ ///
+ /// Sets a property value if it is one of the properties of interest.
+ ///
+ /// The MSBuild property name.
+ /// The property value.
+ /// True if the property was set; otherwise, false.
+ public bool TrySetProperty(string propertyName, string value)
+ {
+ if (Properties.TryGetValue(propertyName, out var info))
+ {
+ info.Setter(this, value);
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Adds or updates an item if it is one of the item types of interest.
+ ///
+ /// The item type (e.g., "PackageReference").
+ /// The item to add or update.
+ /// True if the item was added or updated; otherwise, false.
+ public bool TryAddOrUpdateItem(string itemType, ITaskItem item)
+ {
+ if (item == null || !Items.TryGetValue(itemType, out var itemInfo))
+ {
+ return false;
+ }
+
+ var dictionary = itemInfo.GetDictionary(this);
+ dictionary[item.ItemSpec] = item;
+ return true;
+ }
+
+ ///
+ /// Removes an item if it exists.
+ ///
+ /// The item type (e.g., "PackageReference").
+ /// The item spec (e.g., package name).
+ /// True if the item was removed; otherwise, false.
+ public bool TryRemoveItem(string itemType, string itemSpec)
+ {
+ if (!Items.TryGetValue(itemType, out var info))
+ {
+ return false;
+ }
+
+ var dictionary = info.GetDictionary(this);
+ return dictionary.Remove(itemSpec);
+ }
+
+ ///
+ /// Merges another project info into this one, forming a superset.
+ /// This is used when the same project is seen multiple times (e.g., build + publish passes).
+ /// In practice, property values are not expected to differ across passes for the same project
+ /// and target framework — the merge fills in any values that were not set rather than overriding.
+ /// Boolean properties use logical OR (any true value is sufficient to classify the project).
+ /// Items from are added if not already present.
+ ///
+ /// The other project info to merge from.
+ /// This instance for fluent chaining.
+ public MSBuildProjectInfo MergeWith(MSBuildProjectInfo other)
+ {
+ // Merge boolean properties: true wins. For all classification booleans (IsTestProject,
+ // IsDevelopment, IsShipping, etc.), if any pass reports true it is sufficient to classify
+ // the project accordingly. These values are not expected to differ across passes.
+ this.IsDevelopment = MergeBool(this.IsDevelopment, other.IsDevelopment);
+ this.IsPackable = MergeBool(this.IsPackable, other.IsPackable);
+ this.IsShipping = MergeBool(this.IsShipping, other.IsShipping);
+ this.IsTestProject = MergeBool(this.IsTestProject, other.IsTestProject);
+ this.PublishAot = MergeBool(this.PublishAot, other.PublishAot);
+ this.SelfContained = MergeBool(this.SelfContained, other.SelfContained);
+
+ // Merge string properties: fill in unset values only.
+ // These are not expected to differ across passes for the same project/TFM.
+ this.OutputType ??= other.OutputType;
+ this.NETCoreSdkVersion ??= other.NETCoreSdkVersion;
+ this.ProjectAssetsFile ??= other.ProjectAssetsFile;
+ this.TargetFramework ??= other.TargetFramework;
+ this.TargetFrameworks ??= other.TargetFrameworks;
+
+ // Merge items: add items from other that are not already present
+ MergeItems(this.PackageReference, other.PackageReference);
+ MergeItems(this.PackageDownload, other.PackageDownload);
+
+ return this;
+ }
+
+ private static bool? MergeBool(bool? existing, bool? incoming)
+ {
+ if (existing == true || incoming == true)
+ {
+ return true;
+ }
+
+ return existing ?? incoming;
+ }
+
+ private static void MergeItems(IDictionary target, IDictionary source)
+ {
+ foreach (var kvp in source)
+ {
+ // TryAdd: only add if not already present (existing items win)
+ target.TryAdd(kvp.Key, kvp.Value);
+ }
+ }
+
+ ///
+ /// Metadata for a tracked MSBuild property: its setter and whether its value is a filesystem path.
+ ///
+ private record PropertyInfo(Action Setter, bool IsPath = false);
+
+ ///
+ /// Metadata for a tracked MSBuild item type: its dictionary accessor and whether its ItemSpec is a filesystem path.
+ ///
+ private record ItemInfo(Func> GetDictionary, bool IsPath = false);
+}
diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetProjectModelProjectCentricComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetProjectModelProjectCentricComponentDetector.cs
index 068283dd4..0d4e507b7 100644
--- a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetProjectModelProjectCentricComponentDetector.cs
+++ b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetProjectModelProjectCentricComponentDetector.cs
@@ -3,13 +3,9 @@ namespace Microsoft.ComponentDetection.Detectors.NuGet;
using System;
using System.Collections.Generic;
-using System.IO;
-using System.Linq;
using System.Threading;
using System.Threading.Tasks;
-using global::NuGet.Packaging.Core;
using global::NuGet.ProjectModel;
-using global::NuGet.Versioning;
using Microsoft.ComponentDetection.Contracts;
using Microsoft.ComponentDetection.Contracts.Internal;
using Microsoft.ComponentDetection.Contracts.TypedComponent;
@@ -17,8 +13,6 @@ namespace Microsoft.ComponentDetection.Detectors.NuGet;
public class NuGetProjectModelProjectCentricComponentDetector : FileComponentDetector
{
- public const string ProjectDependencyType = "project";
-
private readonly IFileUtilityService fileUtilityService;
public NuGetProjectModelProjectCentricComponentDetector(
@@ -43,46 +37,6 @@ public NuGetProjectModelProjectCentricComponentDetector(
public override int Version { get; } = 2;
- private static string[] GetFrameworkReferences(LockFile lockFile, LockFileTarget target)
- {
- var frameworkInformation = lockFile.PackageSpec.TargetFrameworks.FirstOrDefault(x => x.FrameworkName.Equals(target.TargetFramework));
-
- if (frameworkInformation == null)
- {
- return [];
- }
-
- // add directly referenced frameworks
- var results = frameworkInformation.FrameworkReferences.Select(x => x.Name);
-
- // add transitive framework references
- results = results.Concat(target.Libraries.SelectMany(l => l.FrameworkReferences));
-
- return results.Distinct().ToArray();
- }
-
- private static bool IsADevelopmentDependency(LockFileTargetLibrary library, LockFile lockFile)
- {
- // a placeholder item is an empty file that doesn't exist with name _._ meant to indicate an empty folder in a nuget package, but also used by NuGet when a package's assets are excluded.
- static bool IsAPlaceholderItem(LockFileItem item) => Path.GetFileName(item.Path).Equals(PackagingCoreConstants.EmptyFolder, StringComparison.OrdinalIgnoreCase);
-
- // All(IsAPlaceholderItem) checks if the collection is empty or all items are placeholders.
- return library.RuntimeAssemblies.All(IsAPlaceholderItem) &&
- library.RuntimeTargets.All(IsAPlaceholderItem) &&
- library.ResourceAssemblies.All(IsAPlaceholderItem) &&
- library.NativeLibraries.All(IsAPlaceholderItem) &&
- library.ContentFiles.All(IsAPlaceholderItem) &&
- library.Build.All(IsAPlaceholderItem) &&
- library.BuildMultiTargeting.All(IsAPlaceholderItem) &&
-
- // The SDK looks at the library for analyzers using the following hueristic:
- // https://github.com/dotnet/sdk/blob/d7fe6e66d8f67dc93c5c294a75f42a2924889196/src/Tasks/Microsoft.NET.Build.Tasks/NuGetUtils.NuGet.cs#L43
- (!lockFile.GetLibrary(library.Name, library.Version)?.Files
- .Any(file => file.StartsWith("analyzers", StringComparison.Ordinal)
- && file.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
- && !file.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)) ?? false);
- }
-
protected override Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default)
{
try
@@ -97,34 +51,9 @@ protected override Task OnFileFoundAsync(ProcessRequest processRequest, IDiction
return Task.CompletedTask;
}
- var explicitReferencedDependencies = this.GetTopLevelLibraries(lockFile)
- .Select(x => this.GetLibraryComponentWithDependencyLookup(lockFile.Libraries, x.Name, x.Version, x.VersionRange))
- .ToList();
- var explicitlyReferencedComponentIds =
- explicitReferencedDependencies
- .Select(x => new NuGetComponent(x.Name, x.Version.ToNormalizedString()).Id)
- .ToHashSet();
-
// Since we report projects as the location, we ignore the passed in single file recorder.
var singleFileComponentRecorder = this.ComponentRecorder.CreateSingleFileComponentRecorder(lockFile.PackageSpec.RestoreMetadata.ProjectPath);
- foreach (var target in lockFile.Targets)
- {
- var frameworkReferences = GetFrameworkReferences(lockFile, target);
- var frameworkPackages = FrameworkPackages.GetFrameworkPackages(target.TargetFramework, frameworkReferences, target);
- bool IsFrameworkOrDevelopmentDependency(LockFileTargetLibrary library) =>
- frameworkPackages.Any(fp => fp.IsAFrameworkComponent(library.Name, library.Version)) ||
- IsADevelopmentDependency(library, lockFile);
-
- // This call to GetTargetLibrary is not guarded, because if this can't be resolved then something is fundamentally broken (e.g. an explicit dependency reference not being in the list of libraries)
- // issue: we treat top level dependencies for all targets as top level for each target, but some may not be top level for other targets, or may not even be present for other targets.
- foreach (var library in explicitReferencedDependencies.Select(x => target.GetTargetLibrary(x.Name)).Where(x => x != null))
- {
- this.NavigateAndRegister(target, explicitlyReferencedComponentIds, singleFileComponentRecorder, library, null, IsFrameworkOrDevelopmentDependency);
- }
- }
-
- // Register PackageDownload
- this.RegisterPackageDownloads(singleFileComponentRecorder, lockFile);
+ LockFileUtilities.ProcessLockFile(lockFile, singleFileComponentRecorder, this.Logger);
}
catch (Exception e)
{
@@ -134,148 +63,4 @@ bool IsFrameworkOrDevelopmentDependency(LockFileTargetLibrary library) =>
return Task.CompletedTask;
}
-
- private void NavigateAndRegister(
- LockFileTarget target,
- HashSet explicitlyReferencedComponentIds,
- ISingleFileComponentRecorder singleFileComponentRecorder,
- LockFileTargetLibrary library,
- string parentComponentId,
- Func isDevelopmentDependency,
- HashSet visited = null)
- {
- if (library.Type == ProjectDependencyType)
- {
- return;
- }
-
- visited ??= [];
-
- var libraryComponent = new DetectedComponent(new NuGetComponent(library.Name, library.Version.ToNormalizedString()));
-
- // Possibly adding target framework to single file recorder
- singleFileComponentRecorder.RegisterUsage(
- libraryComponent,
- explicitlyReferencedComponentIds.Contains(libraryComponent.Component.Id),
- parentComponentId,
- isDevelopmentDependency: isDevelopmentDependency(library),
- targetFramework: target.TargetFramework?.GetShortFolderName());
-
- foreach (var dependency in library.Dependencies)
- {
- if (visited.Contains(dependency.Id))
- {
- continue;
- }
-
- var targetLibrary = target.GetTargetLibrary(dependency.Id);
-
- // There are project.assets.json files that don't have a dependency library in the libraries set.
- if (targetLibrary != null)
- {
- visited.Add(dependency.Id);
- this.NavigateAndRegister(target, explicitlyReferencedComponentIds, singleFileComponentRecorder, targetLibrary, libraryComponent.Component.Id, isDevelopmentDependency, visited);
- }
- }
- }
-
- private void RegisterPackageDownloads(ISingleFileComponentRecorder singleFileComponentRecorder, LockFile lockFile)
- {
- foreach (var framework in lockFile.PackageSpec.TargetFrameworks)
- {
- foreach (var packageDownload in framework.DownloadDependencies)
- {
- if (packageDownload?.Name is null || packageDownload?.VersionRange?.MinVersion is null)
- {
- continue;
- }
-
- var libraryComponent = new DetectedComponent(new NuGetComponent(packageDownload.Name, packageDownload.VersionRange.MinVersion.ToNormalizedString()));
-
- // PackageDownload is always a development dependency since it's usage does not make it part of the application
- singleFileComponentRecorder.RegisterUsage(
- libraryComponent,
- isExplicitReferencedDependency: true,
- parentComponentId: null,
- isDevelopmentDependency: true,
- targetFramework: framework.FrameworkName?.GetShortFolderName());
- }
- }
- }
-
- private List<(string Name, Version Version, VersionRange VersionRange)> GetTopLevelLibraries(LockFile lockFile)
- {
- // First, populate libraries from the TargetFrameworks section -- This is the base level authoritative list of nuget packages a project has dependencies on.
- var toBeFilled = new List<(string Name, Version Version, VersionRange VersionRange)>();
-
- foreach (var framework in lockFile.PackageSpec.TargetFrameworks)
- {
- foreach (var dependency in framework.Dependencies)
- {
- toBeFilled.Add((dependency.Name, Version: null, dependency.LibraryRange.VersionRange));
- }
- }
-
- // Next, we need to resolve project references -- This is a little funky, because project references are only stored via path in
- // project.assets.json, so we first build a list of all paths and then compare what is top level to them to resolve their
- // associated library.
- var projectDirectory = Path.GetDirectoryName(lockFile.PackageSpec.RestoreMetadata.ProjectPath);
- var librariesWithAbsolutePath =
- lockFile.Libraries.Where(x => x.Type == ProjectDependencyType)
- .Select(x => (library: x, absoluteProjectPath: Path.GetFullPath(Path.Combine(projectDirectory, x.Path))))
- .ToDictionary(x => x.absoluteProjectPath, x => x.library);
-
- foreach (var restoreMetadataTargetFramework in lockFile.PackageSpec.RestoreMetadata.TargetFrameworks)
- {
- foreach (var projectReference in restoreMetadataTargetFramework.ProjectReferences)
- {
- if (librariesWithAbsolutePath.TryGetValue(Path.GetFullPath(projectReference.ProjectPath), out var library))
- {
- toBeFilled.Add((library.Name, library.Version.Version, null));
- }
- }
- }
-
- return toBeFilled;
- }
-
- // Looks up a library in project.assets.json given a version (preferred) or version range (have to in some cases due to how project.assets.json stores things)
- private LockFileLibrary GetLibraryComponentWithDependencyLookup(IList libraries, string dependencyId, Version version, VersionRange versionRange)
- {
- if ((version == null && versionRange == null) || (version != null && versionRange != null))
- {
- throw new ArgumentException($"Either {nameof(version)} or {nameof(versionRange)} must be specified, but not both.");
- }
-
- var matchingLibraryNames = libraries.Where(x => string.Equals(x.Name, dependencyId, StringComparison.OrdinalIgnoreCase)).ToList();
-
- if (matchingLibraryNames.Count == 0)
- {
- throw new InvalidOperationException("Project.assets.json is malformed, no library could be found matching: " + dependencyId);
- }
-
- LockFileLibrary matchingLibrary;
- if (version != null)
- {
- // .Version.Version ensures we get to a nuget normalized 4 part version
- matchingLibrary = matchingLibraryNames.FirstOrDefault(x => x.Version.Version.Equals(version));
- }
- else
- {
- matchingLibrary = matchingLibraryNames.FirstOrDefault(x => versionRange.Satisfies(x.Version));
- }
-
- if (matchingLibrary == null)
- {
- matchingLibrary = matchingLibraryNames.First();
- var versionString = versionRange != null ? versionRange.ToNormalizedString() : version.ToString();
- this.Logger.LogWarning(
- "Couldn't satisfy lookup for {Version}. Falling back to first found component for {MatchingLibraryName}, resolving to version {MatchingLibraryVersion}.",
- versionString,
- matchingLibrary.Name,
- matchingLibrary.Version);
- }
-
- return matchingLibrary;
- }
}
diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Configs/MSBuildBinaryLogExperiment.cs b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Configs/MSBuildBinaryLogExperiment.cs
new file mode 100644
index 000000000..e5f19ba4e
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Configs/MSBuildBinaryLogExperiment.cs
@@ -0,0 +1,27 @@
+namespace Microsoft.ComponentDetection.Orchestrator.Experiments.Configs;
+
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Detectors.DotNet;
+using Microsoft.ComponentDetection.Detectors.NuGet;
+
+///
+/// Experiment configuration for validating the
+/// against the existing and
+/// .
+///
+public class MSBuildBinaryLogExperiment : IExperimentConfiguration
+{
+ ///
+ public string Name => "MSBuildBinaryLogDetector";
+
+ ///
+ public bool IsInControlGroup(IComponentDetector componentDetector) =>
+ componentDetector is NuGetProjectModelProjectCentricComponentDetector or DotNetComponentDetector;
+
+ ///
+ public bool IsInExperimentGroup(IComponentDetector componentDetector) =>
+ componentDetector is MSBuildBinaryLogComponentDetector;
+
+ ///
+ public bool ShouldRecord(IComponentDetector componentDetector, int numComponents) => true;
+}
diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
index ab86692c6..837964bfc 100644
--- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
@@ -73,6 +73,7 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// Detectors
// CocoaPods
@@ -131,6 +132,7 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// PIP
services.AddSingleton();
diff --git a/test/Directory.Build.props b/test/Directory.Build.props
index 584193311..641d03916 100644
--- a/test/Directory.Build.props
+++ b/test/Directory.Build.props
@@ -6,6 +6,8 @@
true
true
+
+ $(TestingPlatformCommandLineArguments) --filter TestCategory!=Integration
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs
index e6483b995..20eac5430 100644
--- a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs
@@ -862,7 +862,12 @@ public async Task TestDotNetDetectorRebasePaths(string additionalPathSegment)
discoveredComponents.Where(component => component.Component.Id == "4.5.6 netstandard2.0 library - DotNet").Should().ContainSingle();
}
+ // The following tests call RestoreProjectAndGetAssetsPathAsync which spawns a real
+ // 'dotnet restore' process, making them slow and environment-dependent. They are
+ // tagged as Integration so they are excluded from default local test runs
+ // (see test/Directory.Build.props).
[TestMethod]
+ [TestCategory("Integration")]
public async Task TestDotNetDetectorSelfContainedWithSelfContainedProperty()
{
// Emit a self-contained .csproj, restore it, and use the real project.assets.json.
@@ -934,6 +939,7 @@ public async Task TestDotNetDetectorSelfContainedWithSelfContainedProperty()
}
[TestMethod]
+ [TestCategory("Integration")]
public async Task TestDotNetDetectorSelfContainedLibrary()
{
// A library can also be self-contained when it sets SelfContained + RuntimeIdentifier.
@@ -990,6 +996,7 @@ public async Task TestDotNetDetectorSelfContainedLibrary()
}
[TestMethod]
+ [TestCategory("Integration")]
public async Task TestDotNetDetectorSelfContainedWithPublishAot()
{
// PublishAot implies native AOT compilation (self-contained).
@@ -1050,6 +1057,7 @@ public async Task TestDotNetDetectorSelfContainedWithPublishAot()
}
[TestMethod]
+ [TestCategory("Integration")]
public async Task TestDotNetDetectorNotSelfContained()
{
// Framework-dependent app — no RuntimeIdentifier, no SelfContained.
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/Microsoft.ComponentDetection.Detectors.Tests.csproj b/test/Microsoft.ComponentDetection.Detectors.Tests/Microsoft.ComponentDetection.Detectors.Tests.csproj
index 082707904..45a66cfd5 100644
--- a/test/Microsoft.ComponentDetection.Detectors.Tests/Microsoft.ComponentDetection.Detectors.Tests.csproj
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/Microsoft.ComponentDetection.Detectors.Tests.csproj
@@ -8,6 +8,7 @@
+
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/dotnet/PathRebasingUtilityTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/dotnet/PathRebasingUtilityTests.cs
new file mode 100644
index 000000000..106c0bf09
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/dotnet/PathRebasingUtilityTests.cs
@@ -0,0 +1,318 @@
+namespace Microsoft.ComponentDetection.Detectors.Tests.DotNet;
+
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using AwesomeAssertions;
+using Microsoft.ComponentDetection.Detectors.DotNet;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+[TestClass]
+public class PathRebasingUtilityTests
+{
+ private static readonly string RootDir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "C:" : string.Empty;
+
+ // A second root to simulate the build machine having a different drive or prefix.
+ private static readonly string AltRootDir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "D:" : "/alt";
+
+ [TestMethod]
+ public void NormalizePath_ReplacesBackslashesWithForwardSlashes()
+ {
+ PathRebasingUtility.NormalizePath(@"C:\path\to\file").Should().Be("C:/path/to/file");
+ }
+
+ [TestMethod]
+ public void NormalizePath_ForwardSlashesUnchanged()
+ {
+ PathRebasingUtility.NormalizePath("C:/path/to/file").Should().Be("C:/path/to/file");
+ }
+
+ [TestMethod]
+ public void NormalizePath_MixedSlashes()
+ {
+ PathRebasingUtility.NormalizePath(@"C:\path/to\file").Should().Be("C:/path/to/file");
+ }
+
+ [TestMethod]
+ public void NormalizeDirectory_NullReturnsNull()
+ {
+ PathRebasingUtility.NormalizeDirectory(null).Should().BeNull();
+ }
+
+ [TestMethod]
+ public void NormalizeDirectory_EmptyReturnsEmpty()
+ {
+ PathRebasingUtility.NormalizeDirectory(string.Empty).Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void NormalizeDirectory_TrimsTrailingSeparators()
+ {
+ PathRebasingUtility.NormalizeDirectory(@"C:\path\to\dir\").Should().Be("C:/path/to/dir");
+ }
+
+ [TestMethod]
+ public void NormalizeDirectory_TrimsMultipleTrailingSeparators()
+ {
+ PathRebasingUtility.NormalizeDirectory(@"C:\path\to\dir\/\/").Should().Be("C:/path/to/dir");
+ }
+
+ [TestMethod]
+ public void NormalizeDirectory_NoTrailingSeparator()
+ {
+ PathRebasingUtility.NormalizeDirectory(@"C:\path\to\dir").Should().Be("C:/path/to/dir");
+ }
+
+ [TestMethod]
+ public void GetRebaseRoot_BasicRebase_ReturnsBuildMachineRoot()
+ {
+ // Scan machine: C:/src/repo/path/to/project
+ // Build machine: D:/a/_work/1/s/path/to/project
+ var sourceDir = $"{RootDir}/src/repo";
+ var sourceBasedPath = $"{RootDir}/src/repo/path/to/project";
+ var artifactPath = $"{AltRootDir}/a/_work/1/s/path/to/project";
+
+ var result = PathRebasingUtility.GetRebaseRoot(sourceDir, sourceBasedPath, artifactPath);
+
+ result.Should().Be($"{AltRootDir}/a/_work/1/s/");
+ }
+
+ [TestMethod]
+ public void GetRebaseRoot_SamePaths_ReturnsNull()
+ {
+ var path = $"{RootDir}/src/repo/path/to/project";
+
+ var result = PathRebasingUtility.GetRebaseRoot($"{RootDir}/src/repo", path, path);
+
+ result.Should().BeNull();
+ }
+
+ [TestMethod]
+ public void GetRebaseRoot_NoCommonSuffix_ReturnsNull()
+ {
+ // Artifact path doesn't share any relative suffix with the source-based path.
+ var sourceDir = $"{RootDir}/src/repo";
+ var sourceBasedPath = $"{RootDir}/src/repo/path/to/project";
+ var artifactPath = $"{AltRootDir}/completely/different/layout";
+
+ var result = PathRebasingUtility.GetRebaseRoot(sourceDir, sourceBasedPath, artifactPath);
+
+ result.Should().BeNull();
+ }
+
+ [TestMethod]
+ public void GetRebaseRoot_NullSourceDirectory_ReturnsNull()
+ {
+ PathRebasingUtility.GetRebaseRoot(null, "/some/path", "/other/path").Should().BeNull();
+ }
+
+ [TestMethod]
+ public void GetRebaseRoot_EmptySourceDirectory_ReturnsNull()
+ {
+ PathRebasingUtility.GetRebaseRoot(string.Empty, "/some/path", "/other/path").Should().BeNull();
+ }
+
+ [TestMethod]
+ public void GetRebaseRoot_NullArtifactPath_ReturnsNull()
+ {
+ PathRebasingUtility.GetRebaseRoot("/src", "/src/path", null).Should().BeNull();
+ }
+
+ [TestMethod]
+ public void GetRebaseRoot_EmptyArtifactPath_ReturnsNull()
+ {
+ PathRebasingUtility.GetRebaseRoot("/src", "/src/path", string.Empty).Should().BeNull();
+ }
+
+ [TestMethod]
+ public void GetRebaseRoot_BackslashPaths_NormalizedBeforeComparison()
+ {
+ var sourceDir = $"{RootDir}/src/repo";
+ var sourceBasedPath = $@"{RootDir}\src\repo\path\to\project";
+ var artifactPath = $@"{AltRootDir}\a\_work\1\s\path\to\project";
+
+ var result = PathRebasingUtility.GetRebaseRoot(sourceDir, sourceBasedPath, artifactPath);
+
+ result.Should().Be($"{AltRootDir}/a/_work/1/s/");
+ }
+
+ [TestMethod]
+ public void GetRebaseRoot_TrailingSeparatorsAreNormalized()
+ {
+ var sourceDir = $"{RootDir}/src/repo";
+ var sourceBasedPath = $"{RootDir}/src/repo/path/to/project/";
+ var artifactPath = $"{AltRootDir}/agent/path/to/project/";
+
+ var result = PathRebasingUtility.GetRebaseRoot(sourceDir, sourceBasedPath, artifactPath);
+
+ result.Should().Be($"{AltRootDir}/agent/");
+ }
+
+ [TestMethod]
+ public void RebasePath_BasicRebase()
+ {
+ var originalRoot = $"{AltRootDir}/a/_work/1/s/";
+ var newRoot = $"{RootDir}/src/repo";
+ var path = $"{AltRootDir}/a/_work/1/s/path/to/project/obj";
+
+ var result = PathRebasingUtility.RebasePath(path, originalRoot, newRoot);
+
+ result.Should().Be($"{RootDir}/src/repo/path/to/project/obj");
+ }
+
+ [TestMethod]
+ public void RebasePath_BackslashInput_NormalizedOutput()
+ {
+ var originalRoot = $@"{AltRootDir}\a\_work\1\s\";
+ var newRoot = $@"{RootDir}\src\repo";
+ var path = $@"{AltRootDir}\a\_work\1\s\path\to\file.csproj";
+
+ var result = PathRebasingUtility.RebasePath(path, originalRoot, newRoot);
+
+ result.Should().Be($"{RootDir}/src/repo/path/to/file.csproj");
+ }
+
+ [TestMethod]
+ public void RebasePath_RootOnlyPath_ReturnsNewRoot()
+ {
+ var originalRoot = $"{AltRootDir}/build/";
+ var newRoot = $"{RootDir}/scan";
+ var path = $"{AltRootDir}/build";
+
+ var result = PathRebasingUtility.RebasePath(path, originalRoot, newRoot);
+
+ // Path equals originalRoot → relative is "." → Path.Combine preserves it.
+ result.Should().Be($"{RootDir}/scan/.");
+ }
+
+ [TestMethod]
+ public void RebasePath_RoundTripsWithGetRebaseRoot()
+ {
+ var sourceDir = $"{RootDir}/src/repo";
+ var sourceBasedPath = $"{RootDir}/src/repo/subdir/project";
+ var artifactPath = $"{AltRootDir}/agent/s/subdir/project";
+
+ var rebaseRoot = PathRebasingUtility.GetRebaseRoot(sourceDir, sourceBasedPath, artifactPath);
+ rebaseRoot.Should().NotBeNull();
+
+ // Given a different path on the build machine, rebase it to the scan machine.
+ var buildMachinePath = $"{AltRootDir}/agent/s/subdir/project/obj/project.assets.json";
+ var result = PathRebasingUtility.RebasePath(buildMachinePath, rebaseRoot!, sourceDir);
+
+ result.Should().Be($"{RootDir}/src/repo/subdir/project/obj/project.assets.json");
+ }
+
+ [TestMethod]
+ public void RebasePath_RelativePath_ReturnedUnchanged()
+ {
+ var originalRoot = $"{RootDir}/build/root";
+ var newRoot = $"{RootDir}/scan/root";
+ var relativePath = "relative/path/file.csproj";
+
+ var result = PathRebasingUtility.RebasePath(relativePath, originalRoot, newRoot);
+
+ // A non-rooted path cannot be rebased — returned unchanged.
+ result.Should().Be("relative/path/file.csproj");
+ }
+
+ [TestMethod]
+ public void RebasePath_PathOutsideOriginalRoot_ReturnedUnchanged()
+ {
+ // Path is on a completely different root from originalRoot.
+ var originalRoot = $"{AltRootDir}/a/_work/1/s";
+ var newRoot = $"{RootDir}/src/repo";
+ var outsidePath = $"{RootDir}/completely/different/file.csproj";
+
+ var result = PathRebasingUtility.RebasePath(outsidePath, originalRoot, newRoot);
+
+ // Cannot be rebased — returned unchanged (normalized).
+ result.Should().Be($"{RootDir}/completely/different/file.csproj");
+ }
+
+ [TestMethod]
+ public void FindByRelativePath_MatchesBySuffix()
+ {
+ var sourceDir = $"{RootDir}/src/repo";
+ var scanPath = $"{RootDir}/src/repo/path/to/project/obj/project.assets.json";
+
+ var dictionary = new Dictionary
+ {
+ { $"{AltRootDir}/agent/s/path/to/project/obj/project.assets.json", "matched-value" },
+ };
+
+ var result = PathRebasingUtility.FindByRelativePath(
+ dictionary, sourceDir, scanPath, out var rebaseRoot);
+
+ result.Should().Be("matched-value");
+ rebaseRoot.Should().Be($"{AltRootDir}/agent/s/");
+ }
+
+ [TestMethod]
+ public void FindByRelativePath_NoMatch_ReturnsDefault()
+ {
+ var sourceDir = $"{RootDir}/src/repo";
+ var scanPath = $"{RootDir}/src/repo/path/to/project/obj/project.assets.json";
+
+ var dictionary = new Dictionary
+ {
+ { $"{AltRootDir}/completely/different/layout.json", "value" },
+ };
+
+ var result = PathRebasingUtility.FindByRelativePath(
+ dictionary, sourceDir, scanPath, out var rebaseRoot);
+
+ result.Should().BeNull();
+ rebaseRoot.Should().BeNull();
+ }
+
+ [TestMethod]
+ public void FindByRelativePath_PathOutsideSourceDir_ReturnsDefault()
+ {
+ // scanMachinePath is NOT under sourceDirectory (relative path starts with "..")
+ var sourceDir = $"{RootDir}/src/repo";
+ var scanPath = $"{RootDir}/somewhere/else/file.json";
+
+ var dictionary = new Dictionary
+ {
+ { $"{AltRootDir}/agent/s/somewhere/else/file.json", "value" },
+ };
+
+ var result = PathRebasingUtility.FindByRelativePath(
+ dictionary, sourceDir, scanPath, out var rebaseRoot);
+
+ result.Should().BeNull();
+ rebaseRoot.Should().BeNull();
+ }
+
+ [TestMethod]
+ public void FindByRelativePath_EmptyDictionary_ReturnsDefault()
+ {
+ var sourceDir = $"{RootDir}/src/repo";
+ var scanPath = $"{RootDir}/src/repo/path/file.json";
+
+ var result = PathRebasingUtility.FindByRelativePath(
+ [], sourceDir, scanPath, out var rebaseRoot);
+
+ result.Should().BeNull();
+ rebaseRoot.Should().BeNull();
+ }
+
+ [TestMethod]
+ public void FindByRelativePath_MultipleEntries_MatchesCorrectOne()
+ {
+ var sourceDir = $"{RootDir}/src/repo";
+ var scanPath = $"{RootDir}/src/repo/path/B/file.json";
+
+ var dictionary = new Dictionary
+ {
+ { $"{AltRootDir}/agent/path/A/file.json", "wrong" },
+ { $"{AltRootDir}/agent/path/B/file.json", "correct" },
+ { $"{AltRootDir}/agent/path/C/file.json", "wrong" },
+ };
+
+ var result = PathRebasingUtility.FindByRelativePath(
+ dictionary, sourceDir, scanPath, out var rebaseRoot);
+
+ result.Should().Be("correct");
+ rebaseRoot.Should().Be($"{AltRootDir}/agent/");
+ }
+}
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/nuget/BinLogProcessorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/nuget/BinLogProcessorTests.cs
new file mode 100644
index 000000000..dc1f202d5
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/nuget/BinLogProcessorTests.cs
@@ -0,0 +1,1672 @@
+namespace Microsoft.ComponentDetection.Detectors.Tests.NuGet;
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using AwesomeAssertions;
+using Microsoft.ComponentDetection.Detectors.NuGet;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Integration tests for that build real MSBuild projects
+/// to produce binlog files, then parse them to verify extracted project information.
+/// Excluded from default local test runs via TestCategory("Integration");
+/// see test/Directory.Build.props.
+///
+[TestClass]
+[TestCategory("Integration")]
+public class BinLogProcessorTests
+{
+ private readonly BinLogProcessor processor;
+ private string testDir = null!;
+
+ public BinLogProcessorTests() => this.processor = new BinLogProcessor(NullLogger.Instance);
+
+ [TestInitialize]
+ public void TestInitialize()
+ {
+ this.testDir = Path.Combine(Path.GetTempPath(), "BinLogProcessorTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(this.testDir);
+
+ // Copy the workspace global.json so temp projects use the same SDK as the repo.
+ // This avoids hardcoding a version that may not be installed in CI/dev machines.
+ var workspaceGlobalJson = FindWorkspaceGlobalJsonPath();
+ if (workspaceGlobalJson != null)
+ {
+ File.Copy(workspaceGlobalJson, Path.Combine(this.testDir, "global.json"));
+ }
+ }
+
+ private static string? FindWorkspaceGlobalJsonPath()
+ {
+ var currentDirectory = Directory.GetCurrentDirectory();
+
+ while (!string.IsNullOrEmpty(currentDirectory))
+ {
+ var candidate = Path.Combine(currentDirectory, "global.json");
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+
+ var parent = Directory.GetParent(currentDirectory)?.FullName;
+ if (parent == currentDirectory)
+ {
+ break;
+ }
+
+ currentDirectory = parent;
+ }
+
+ return null;
+ }
+
+ [TestCleanup]
+ public void TestCleanup()
+ {
+ try
+ {
+ if (Directory.Exists(this.testDir))
+ {
+ Directory.Delete(this.testDir, recursive: true);
+ }
+ }
+ catch
+ {
+ // Best effort cleanup
+ }
+ }
+
+ [TestMethod]
+ public async Task SingleTargetProject_ExtractsBasicProperties()
+ {
+ var projectDir = Path.Combine(this.testDir, "SingleTarget");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+ Exe
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "SingleTarget.csproj", content);
+ WriteMinimalProgram(projectDir);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "SingleTarget.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ results.Should().NotBeEmpty();
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("SingleTarget.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.TargetFramework.Should().Be("net8.0");
+ projectInfo.OutputType.Should().Be("Exe");
+ projectInfo.NETCoreSdkVersion.Should().NotBeNullOrEmpty();
+ projectInfo.ProjectAssetsFile.Should().NotBeNullOrEmpty();
+ projectInfo.IsOuterBuild.Should().BeFalse();
+
+ // PackageReference should be captured
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ }
+
+ [TestMethod]
+ public async Task MultiTargetProject_ExtractsOuterAndInnerBuilds()
+ {
+ var projectDir = Path.Combine(this.testDir, "MultiTarget");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0;net7.0
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "MultiTarget.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "MultiTarget.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("MultiTarget.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // The outer build should have TargetFrameworks set
+ projectInfo.IsOuterBuild.Should().BeTrue();
+ projectInfo.TargetFrameworks.Should().Contain("net8.0");
+ projectInfo.TargetFrameworks.Should().Contain("net7.0");
+
+ // Inner builds should be captured
+ projectInfo.InnerBuilds.Should().HaveCountGreaterThanOrEqualTo(2);
+
+ var net8Inner = projectInfo.InnerBuilds.FirstOrDefault(
+ ib => ib.TargetFramework != null && ib.TargetFramework.Contains("net8.0"));
+ var net7Inner = projectInfo.InnerBuilds.FirstOrDefault(
+ ib => ib.TargetFramework != null && ib.TargetFramework.Contains("net7.0"));
+
+ net8Inner.Should().NotBeNull();
+ net7Inner.Should().NotBeNull();
+
+ // Each inner build should have its own PackageReference
+ net8Inner!.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ net7Inner!.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ }
+
+ [TestMethod]
+ public async Task TestProject_ExtractsIsTestProject()
+ {
+ var projectDir = Path.Combine(this.testDir, "TestProject");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+ true
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "TestProject.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "TestProject.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("TestProject.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.IsTestProject.Should().Be(true);
+ }
+
+ [TestMethod]
+ public async Task ProjectWithIsShippingFalse_ExtractsIsShipping()
+ {
+ var projectDir = Path.Combine(this.testDir, "NonShipping");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+ false
+
+
+ """;
+ WriteFile(projectDir, "NonShipping.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "NonShipping.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("NonShipping.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.IsShipping.Should().Be(false);
+ }
+
+ [TestMethod]
+ public async Task MultipleProjectsInSameBuild_ExtractsAll()
+ {
+ // Create a solution with two projects
+ var solutionDir = Path.Combine(this.testDir, "MultiProject");
+ Directory.CreateDirectory(solutionDir);
+
+ var projectADir = Path.Combine(solutionDir, "ProjectA");
+ var projectBDir = Path.Combine(solutionDir, "ProjectB");
+ Directory.CreateDirectory(projectADir);
+ Directory.CreateDirectory(projectBDir);
+
+ var projectAContent = """
+
+
+ net8.0
+ Exe
+
+
+
+
+
+ """;
+ WriteFile(projectADir, "ProjectA.csproj", projectAContent);
+ WriteMinimalProgram(projectADir);
+
+ var projectBContent = """
+
+
+ net8.0
+ Library
+
+
+
+
+
+ """;
+ WriteFile(projectBDir, "ProjectB.csproj", projectBContent);
+
+ // Create a solution to build both projects in a single binlog
+ await RunDotNetAsync(solutionDir, "new sln --name MultiProject");
+ await RunDotNetAsync(solutionDir, $"sln add \"{Path.Combine(projectADir, "ProjectA.csproj")}\"");
+ await RunDotNetAsync(solutionDir, $"sln add \"{Path.Combine(projectBDir, "ProjectB.csproj")}\"");
+ var binlogPath = Path.Combine(solutionDir, "build.binlog");
+ await RunDotNetAsync(solutionDir, $"build \"MultiProject.sln\" -bl:\"{binlogPath}\" /p:UseAppHost=false");
+
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectA = results.FirstOrDefault(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("ProjectA.csproj", StringComparison.OrdinalIgnoreCase));
+ var projectB = results.FirstOrDefault(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("ProjectB.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectA.Should().NotBeNull();
+ projectB.Should().NotBeNull();
+
+ projectA!.OutputType.Should().Be("Exe");
+ projectA.PackageReference.Should().ContainKey("Newtonsoft.Json");
+
+ projectB!.OutputType.Should().Be("Library");
+ projectB.PackageReference.Should().ContainKey("System.Text.Json");
+ }
+
+ [TestMethod]
+ public async Task ProjectToProjectReference_ExtractsBothProjects()
+ {
+ var solutionDir = Path.Combine(this.testDir, "P2P");
+ Directory.CreateDirectory(solutionDir);
+
+ var libDir = Path.Combine(solutionDir, "MyLib");
+ var appDir = Path.Combine(solutionDir, "MyApp");
+ Directory.CreateDirectory(libDir);
+ Directory.CreateDirectory(appDir);
+
+ var libContent = """
+
+
+ net8.0
+
+
+
+
+
+ """;
+ WriteFile(libDir, "MyLib.csproj", libContent);
+
+ var appContent = $"""
+
+
+ net8.0
+ Exe
+
+
+
+
+
+
+ """;
+ WriteFile(appDir, "MyApp.csproj", appContent);
+ WriteMinimalProgram(appDir);
+
+ // Build the app (which also builds the lib)
+ var binlogPath = await BuildProjectAsync(appDir, "MyApp.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var appInfo = results.FirstOrDefault(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("MyApp.csproj", StringComparison.OrdinalIgnoreCase));
+ var libInfo = results.FirstOrDefault(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("MyLib.csproj", StringComparison.OrdinalIgnoreCase));
+
+ appInfo.Should().NotBeNull();
+ libInfo.Should().NotBeNull();
+
+ appInfo!.OutputType.Should().Be("Exe");
+ appInfo.PackageReference.Should().ContainKey("System.Text.Json");
+
+ libInfo!.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ }
+
+ [TestMethod]
+ public async Task CustomTargetModifiesProperties_PropertyIsOverridden()
+ {
+ var projectDir = Path.Combine(this.testDir, "CustomTarget");
+ Directory.CreateDirectory(projectDir);
+
+ // A custom target that runs before Restore and changes a property
+ var content = """
+
+
+ net8.0
+ false
+
+
+
+ true
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "CustomTarget.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "CustomTarget.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("CustomTarget.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // The custom target's property override should be captured
+ // depending on when the binlog captures it.
+ // At minimum the evaluation-time value should be captured.
+ projectInfo.IsTestProject.Should().NotBeNull();
+ }
+
+ [TestMethod]
+ public async Task CustomTargetAddsItems_ItemsAreCaptured()
+ {
+ var projectDir = Path.Combine(this.testDir, "CustomItems");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "CustomItems.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "CustomItems.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("CustomItems.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+
+ // The PackageDownload added by the custom target should be captured
+ // (depends on whether the task runs and binlog captures TaskParameter events)
+ // This tests that task-parameter-level item tracking works
+ projectInfo.PackageDownload.Should().ContainKey("System.Memory");
+ }
+
+ [TestMethod]
+ public async Task PackageDownloadItems_AreCaptured()
+ {
+ var projectDir = Path.Combine(this.testDir, "PkgDownload");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "PkgDownload.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "PkgDownload.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("PkgDownload.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ projectInfo.PackageDownload.Should().ContainKey("System.Memory");
+ }
+
+ [TestMethod]
+ public async Task MultiTargetWithDifferentPackagesPerTfm_InnerBuildsHaveDifferentItems()
+ {
+ var projectDir = Path.Combine(this.testDir, "PerTfmPackages");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0;net7.0
+
+
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "PerTfmPackages.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "PerTfmPackages.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("PerTfmPackages.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.IsOuterBuild.Should().BeTrue();
+
+ var net8Inner = projectInfo.InnerBuilds.FirstOrDefault(
+ ib => ib.TargetFramework != null && ib.TargetFramework.Contains("net8.0"));
+ var net7Inner = projectInfo.InnerBuilds.FirstOrDefault(
+ ib => ib.TargetFramework != null && ib.TargetFramework.Contains("net7.0"));
+
+ net8Inner.Should().NotBeNull();
+ net7Inner.Should().NotBeNull();
+
+ // net8.0 inner build should have both packages
+ net8Inner!.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ net8Inner.PackageReference.Should().ContainKey("System.Text.Json");
+
+ // net7.0 inner build should only have Newtonsoft.Json
+ net7Inner!.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ net7Inner.PackageReference.Should().NotContainKey("System.Text.Json");
+ }
+
+ [TestMethod]
+ public async Task SelfContainedProject_ExtractsSelfContained()
+ {
+ var projectDir = Path.Combine(this.testDir, "SelfContained");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+ Exe
+ true
+ win-x64
+
+
+ """;
+ WriteFile(projectDir, "SelfContained.csproj", content);
+ WriteMinimalProgram(projectDir);
+
+ // Use restore-only build: evaluation captures properties in the binlog
+ // without needing a full build (which conflicts UseAppHost=false + SelfContained=true)
+ var binlogPath = Path.Combine(projectDir, "build.binlog");
+ await RunDotNetAsync(projectDir, $"msbuild \"{Path.Combine(projectDir, "SelfContained.csproj")}\" -t:Restore -bl:\"{binlogPath}\"");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("SelfContained.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.SelfContained.Should().Be(true);
+ projectInfo.OutputType.Should().Be("Exe");
+ }
+
+ [TestMethod]
+ public async Task ItemWithMetadata_MetadataIsCaptured()
+ {
+ var projectDir = Path.Combine(this.testDir, "ItemMetadata");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+ all
+ true
+
+
+
+ """;
+ WriteFile(projectDir, "ItemMetadata.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "ItemMetadata.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("ItemMetadata.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.PackageReference.Should().ContainKey("StyleCop.Analyzers");
+
+ var styleCopItem = projectInfo.PackageReference["StyleCop.Analyzers"];
+ styleCopItem.GetMetadata("IsDevelopmentDependency").Should().Be("true");
+ }
+
+ [TestMethod]
+ public void EmptyBinlogPath_ReturnsEmptyList()
+ {
+ // Test with a non-existent file
+ var results = this.processor.ExtractProjectInfo(Path.Combine(this.testDir, "nonexistent.binlog"));
+ results.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task EvaluationPropertyReassignment_LaterDefinitionWins()
+ {
+ // An imported .targets file overrides a property set in the project file.
+ // This tests that PropertyReassignment events during evaluation are captured.
+ var projectDir = Path.Combine(this.testDir, "PropReassign");
+ Directory.CreateDirectory(projectDir);
+
+ // Create a .targets file that overrides OutputType
+ var targetsContent = """
+
+
+ Exe
+
+
+ """;
+ WriteFile(projectDir, "override.targets", targetsContent);
+
+ var content = """
+
+
+ net8.0
+ Library
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "PropReassign.csproj", content);
+ WriteMinimalProgram(projectDir);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "PropReassign.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("PropReassign.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // The imported .targets overrides OutputType from Library to Exe during evaluation
+ projectInfo.OutputType.Should().Be("Exe");
+ }
+
+ [TestMethod]
+ public async Task TargetSetsProperty_OnlyCapturedAtEvaluationTime()
+ {
+ // Verifies that target-level changes are NOT captured in binlog
+ // property events. This documents a known limitation: only evaluation-time
+ // property values are tracked.
+ var projectDir = Path.Combine(this.testDir, "TargetPropLimit");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+ Library
+
+
+
+ Exe
+
+
+
+ """;
+ WriteFile(projectDir, "TargetPropLimit.csproj", content);
+
+ // Use restore-only to avoid compile errors (the target changes OutputType to Exe
+ // which expects a Main method that doesn't exist). Evaluation properties are still captured.
+ var binlogPath = Path.Combine(projectDir, "build.binlog");
+ await RunDotNetAsync(projectDir, $"msbuild TargetPropLimit.csproj -t:Restore -bl:\"{binlogPath}\"");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("TargetPropLimit.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // Target-level property changes don't emit PropertyReassignment events in binlog.
+ // We only capture the evaluation-time value.
+ projectInfo.OutputType.Should().Be(
+ "Library",
+ "target-level property changes are not visible in binlog events");
+ }
+
+ [TestMethod]
+ public async Task EvaluationBoolPropertyReassignment_LaterDefinitionWins()
+ {
+ // An imported .targets file overrides IsTestProject from false to true.
+ // This tests that boolean property reassignment during evaluation is captured.
+ var projectDir = Path.Combine(this.testDir, "BoolReassign");
+ Directory.CreateDirectory(projectDir);
+
+ var targetsContent = """
+
+
+ true
+
+
+ """;
+ WriteFile(projectDir, "override.targets", targetsContent);
+
+ var content = """
+
+
+ net8.0
+ false
+
+
+
+ """;
+ WriteFile(projectDir, "BoolReassign.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "BoolReassign.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("BoolReassign.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // The imported .targets overrides IsTestProject to true during evaluation
+ projectInfo.IsTestProject.Should().Be(true);
+ }
+
+ [TestMethod]
+ public async Task TargetBeforeRestoreAddsPackageReference_ItemIsCaptured()
+ {
+ // A target running before Restore adds a PackageReference
+ var projectDir = Path.Combine(this.testDir, "TargetAddsRef");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "TargetAddsRef.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "TargetAddsRef.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("TargetAddsRef.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // The static PackageReference from evaluation
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+
+ // The dynamically-added PackageReference from the target
+ projectInfo.PackageReference.Should().ContainKey("System.Memory");
+ }
+
+ [TestMethod]
+ public async Task TargetBeforeRestoreAddsPackageDownload_ItemIsCaptured()
+ {
+ // A target running before Restore adds a PackageDownload
+ var projectDir = Path.Combine(this.testDir, "TargetAddsDownload");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "TargetAddsDownload.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "TargetAddsDownload.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("TargetAddsDownload.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ projectInfo.PackageDownload.Should().ContainKey("System.Memory");
+ }
+
+ [TestMethod]
+ public async Task TraversalBuildAndPublish_MergesProperties()
+ {
+ // An orchestrator project builds a child project, then restores it with SelfContained.
+ // This simulates a traversal or CI script that does build + publish in one binlog.
+ var solutionDir = Path.Combine(this.testDir, "TraversalMerge");
+ Directory.CreateDirectory(solutionDir);
+
+ var appDir = Path.Combine(solutionDir, "App");
+ Directory.CreateDirectory(appDir);
+
+ var appContent = """
+
+
+ net8.0
+ Exe
+
+
+
+
+
+ """;
+ WriteFile(appDir, "App.csproj", appContent);
+ WriteMinimalProgram(appDir);
+
+ // Orchestrator: restore, build, then restore with SelfContained (all in one binlog)
+ var orchestratorContent = """
+
+
+
+
+
+
+
+ """;
+ WriteFile(solutionDir, "Orchestrator.proj", orchestratorContent);
+
+ var binlogPath = Path.Combine(solutionDir, "build.binlog");
+ await RunDotNetAsync(solutionDir, $"msbuild Orchestrator.proj -t:BuildAndPublish -bl:\"{binlogPath}\"");
+
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var appInfo = results.FirstOrDefault(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("App.csproj", StringComparison.OrdinalIgnoreCase));
+
+ appInfo.Should().NotBeNull();
+
+ // After merge, SelfContained should be true (from the second restore pass)
+ appInfo!.SelfContained.Should().Be(true);
+
+ // The original PackageReference should still be present
+ appInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ }
+
+ [TestMethod]
+ public async Task BuildThenPublishSelfContained_MergesSelfContained()
+ {
+ // Simulates a common CI pattern: build first (non-self-contained), then publish (self-contained).
+ // Both produce entries in the same binlog. The merge should yield SelfContained=true.
+ var projectDir = Path.Combine(this.testDir, "BuildPublishMerge");
+ Directory.CreateDirectory(projectDir);
+
+ var appContent = """
+
+
+ net8.0
+ Exe
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "App.csproj", appContent);
+ WriteMinimalProgram(projectDir);
+
+ // First build (not self-contained), then publish (self-contained), both into same binlog
+ // We use an orchestrator project that invokes MSBuild twice
+ var orchestratorContent = """
+
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "Orchestrator.proj", orchestratorContent);
+
+ var binlogPath = Path.Combine(projectDir, "build.binlog");
+ await RunDotNetAsync(projectDir, $"msbuild Orchestrator.proj -t:BuildAndPublish -bl:\"{binlogPath}\"");
+
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var appInfo = results.FirstOrDefault(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("App.csproj", StringComparison.OrdinalIgnoreCase));
+
+ appInfo.Should().NotBeNull();
+ appInfo!.SelfContained.Should().Be(true, "the publish pass sets SelfContained=true, which should be merged");
+ appInfo.OutputType.Should().Be("Exe");
+ appInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ }
+
+ [TestMethod]
+ public async Task GlobalPropertyFromCommandLine_CapturedInBinlog()
+ {
+ // Pass IsTestProject=true as a global property via /p: on the command line
+ var projectDir = Path.Combine(this.testDir, "GlobalProp");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "GlobalProp.csproj", content);
+
+ var binlogPath = Path.Combine(projectDir, "build.binlog");
+ await RunDotNetAsync(projectDir, $"build GlobalProp.csproj -bl:\"{binlogPath}\" /p:UseAppHost=false /p:IsTestProject=true");
+
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("GlobalProp.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // Global property should be captured from evaluation
+ projectInfo.IsTestProject.Should().Be(true);
+ }
+
+ [TestMethod]
+ public async Task GlobalPropertyOverridesProjectFile_CommandLineWins()
+ {
+ // Project file says OutputType=Library, command line says OutputType=Exe
+ var projectDir = Path.Combine(this.testDir, "GlobalOverride");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+ Library
+
+
+ """;
+ WriteFile(projectDir, "GlobalOverride.csproj", content);
+ WriteMinimalProgram(projectDir);
+
+ var binlogPath = Path.Combine(projectDir, "build.binlog");
+ await RunDotNetAsync(projectDir, $"build GlobalOverride.csproj -bl:\"{binlogPath}\" /p:UseAppHost=false /p:OutputType=Exe");
+
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("GlobalOverride.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // Command-line global property should override the project file value
+ projectInfo.OutputType.Should().Be("Exe");
+ }
+
+ [TestMethod]
+ public async Task EnvironmentVariableProperty_CapturedInBinlog()
+ {
+ // MSBuild automatically promotes environment variables to properties.
+ // Setting IsTestProject as an env var (without referencing it in the project)
+ // should still be captured in the binlog evaluation.
+ var projectDir = Path.Combine(this.testDir, "EnvVar");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "EnvVar.csproj", content);
+
+ var binlogPath = Path.Combine(projectDir, "build.binlog");
+ await RunProcessWithEnvAsync(
+ projectDir,
+ "dotnet",
+ $"build EnvVar.csproj -bl:\"{binlogPath}\" /p:UseAppHost=false",
+ ("IsTestProject", "true"));
+
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("EnvVar.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // The env var is automatically promoted to an MSBuild property during evaluation
+ projectInfo.IsTestProject.Should().Be(true);
+ }
+
+ [TestMethod]
+ public async Task TargetRemovesPackageReference_ItemIsRemoved()
+ {
+ // A PackageReference is defined in the project, but a target removes it before restore
+ var projectDir = Path.Combine(this.testDir, "RemoveItem");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "RemoveItem.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "RemoveItem.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("RemoveItem.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // The target removes System.Memory before restore
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ projectInfo.PackageReference.Should().NotContainKey(
+ "System.Memory",
+ "the target removes System.Memory before restore");
+ }
+
+ [TestMethod]
+ public async Task ItemUpdateTag_MetadataIsUpdated()
+ {
+ // Uses to change metadata
+ var projectDir = Path.Combine(this.testDir, "ItemUpdate");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "ItemUpdate.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "ItemUpdate.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("ItemUpdate.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+
+ // After the Update, PrivateAssets metadata should be set
+ var item = projectInfo.PackageReference["Newtonsoft.Json"];
+ item.GetMetadata("PrivateAssets").Should().Be("all");
+ }
+
+ [TestMethod]
+ public async Task ItemDefinitionGroup_DefaultMetadataApplied()
+ {
+ // ItemDefinitionGroup sets default PrivateAssets for all PackageReference items
+ var projectDir = Path.Combine(this.testDir, "ItemDefGroup");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+ all
+
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "ItemDefGroup.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "ItemDefGroup.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("ItemDefGroup.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+ projectInfo.PackageReference.Should().ContainKey("System.Memory");
+
+ // ItemDefinitionGroup should apply PrivateAssets=all to both items
+ var newtonsoftItem = projectInfo.PackageReference["Newtonsoft.Json"];
+ newtonsoftItem.GetMetadata("PrivateAssets").Should().Be("all");
+
+ var memoryItem = projectInfo.PackageReference["System.Memory"];
+ memoryItem.GetMetadata("PrivateAssets").Should().Be("all");
+ }
+
+ [TestMethod]
+ public async Task TargetRemovesAndReAddsWithMetadata_MetadataReflectsChange()
+ {
+ // A target runs and modifies metadata via Remove+Include pattern.
+ // This is necessary because Item Update inside targets does not emit
+ // TaskParameter events in binlog, but Remove+Include does.
+ var projectDir = Path.Combine(this.testDir, "TargetMetadata");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+
+
+
+ all
+
+
+
+
+ """;
+ WriteFile(projectDir, "TargetMetadata.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "TargetMetadata.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("TargetMetadata.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+
+ // The Remove+Include in target produces TaskParameter events
+ var item = projectInfo.PackageReference["Newtonsoft.Json"];
+ item.GetMetadata("PrivateAssets").Should().Be("all");
+ }
+
+ [TestMethod]
+ public async Task TargetItemUpdateLimitation_UpdateNotVisibleInBinlog()
+ {
+ // Documents a known limitation: inside a
+ // does NOT emit TaskParameter events in the binlog. The metadata
+ // change is invisible to the BinLogProcessor.
+ var projectDir = Path.Combine(this.testDir, "TargetUpdateLimit");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "TargetUpdateLimit.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "TargetUpdateLimit.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("TargetUpdateLimit.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+
+ // Item Update inside targets doesn't emit TaskParameter events.
+ // PrivateAssets remains at the evaluation-time value (not set).
+ var item = projectInfo.PackageReference["Newtonsoft.Json"];
+ item.GetMetadata("PrivateAssets").Should().BeNullOrEmpty(
+ "item Update inside targets is not visible in binlog TaskParameter events");
+ }
+
+ [TestMethod]
+ public async Task TargetAddsItemButPropertyInvisible_ItemCapturedPropertyNot()
+ {
+ // A single target modifies both a property and adds an item.
+ // Property changes in targets are invisible, but item additions are visible.
+ var projectDir = Path.Combine(this.testDir, "ComboPropsItems");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+ false
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "ComboPropsItems.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "ComboPropsItems.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("ComboPropsItems.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // Property change in target is invisible (stays at evaluation value)
+ projectInfo.IsTestProject.Should().Be(
+ false,
+ "target-level property changes are not visible in binlog events");
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+
+ // Item addition in target IS captured via TaskParameter events
+ projectInfo.PackageDownload.Should().ContainKey("System.Memory");
+ }
+
+ [TestMethod]
+ public async Task MultiTargetWithTargetConditionalOnTfm_PerInnerBuildChanges()
+ {
+ // A multi-targeted project where a target conditionally adds a PackageDownload
+ // only for the net8.0 TFM
+ var projectDir = Path.Combine(this.testDir, "MultiTargetConditional");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0;net7.0
+
+
+
+
+
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "MultiTargetConditional.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "MultiTargetConditional.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("MultiTargetConditional.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.IsOuterBuild.Should().BeTrue();
+
+ var net8Inner = projectInfo.InnerBuilds.First(
+ ib => ib.TargetFramework != null && ib.TargetFramework.Contains("net8.0"));
+ var net7Inner = projectInfo.InnerBuilds.First(
+ ib => ib.TargetFramework != null && ib.TargetFramework.Contains("net7.0"));
+
+ // net8.0 should have the target-added PackageDownload
+ net8Inner.PackageDownload.Should().ContainKey("System.Memory");
+
+ // net7.0 should NOT have it (condition not met)
+ net7Inner.PackageDownload.Should().NotContainKey("System.Memory");
+ }
+
+ [TestMethod]
+ public async Task GlobalPropertyAndTargetOverride_TargetWins()
+ {
+ // Global property sets SelfContained=false, but a target changes it to true
+ var projectDir = Path.Combine(this.testDir, "GlobalAndTarget");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+ Exe
+
+
+
+
+
+
+ true
+
+
+
+ """;
+ WriteFile(projectDir, "GlobalAndTarget.csproj", content);
+ WriteMinimalProgram(projectDir);
+
+ // Pass SelfContained=false on command line, but target overrides to true
+ var binlogPath = Path.Combine(projectDir, "build.binlog");
+ await RunDotNetAsync(projectDir, $"msbuild GlobalAndTarget.csproj -t:Restore -bl:\"{binlogPath}\" /p:SelfContained=false");
+
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("GlobalAndTarget.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // Note: Global properties cannot be overridden by targets in MSBuild.
+ // The global property wins. This tests that we correctly capture this MSBuild behavior.
+ projectInfo.SelfContained.Should().Be(
+ false,
+ "global properties cannot be overridden by targets in MSBuild");
+ }
+
+ [TestMethod]
+ public async Task ImportedPropsFile_PropertyFromImportCaptured()
+ {
+ // A project imports a .props file that sets a property
+ var projectDir = Path.Combine(this.testDir, "ImportedProps");
+ Directory.CreateDirectory(projectDir);
+
+ var propsContent = """
+
+
+ true
+ false
+
+
+ """;
+ WriteFile(projectDir, "Directory.Build.props", propsContent);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "ImportedProps.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "ImportedProps.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("ImportedProps.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // Properties from the imported file should be captured during evaluation
+ projectInfo.IsTestProject.Should().Be(true);
+ projectInfo.IsShipping.Should().Be(false);
+ }
+
+ [TestMethod]
+ public async Task TargetRemovesAndReAddsItem_FinalStateReflectsReAdd()
+ {
+ // A target removes a PackageReference and then re-adds it with different metadata
+ var projectDir = Path.Combine(this.testDir, "RemoveReAdd");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+
+
+
+
+
+ all
+
+
+
+
+ """;
+ WriteFile(projectDir, "RemoveReAdd.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "RemoveReAdd.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("RemoveReAdd.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.PackageReference.Should().ContainKey("Newtonsoft.Json");
+
+ // The re-added item should have the updated version and metadata
+ var item = projectInfo.PackageReference["Newtonsoft.Json"];
+ item.GetMetadata("Version").Should().Be("13.0.3");
+ item.GetMetadata("PrivateAssets").Should().Be("all");
+ }
+
+ [TestMethod]
+ public async Task MultiTarget_BuildAndPublishSelfContained_MergesInnerBuilds()
+ {
+ // Multi-targeted project where a second pass restores with SelfContained=true.
+ // The merge should mark the inner builds as self-contained.
+ var solutionDir = Path.Combine(this.testDir, "MultiTargetPublish");
+ Directory.CreateDirectory(solutionDir);
+
+ var appContent = """
+
+
+ net8.0;net7.0
+ Exe
+
+
+
+
+
+ """;
+ var appDir = Path.Combine(solutionDir, "App");
+ Directory.CreateDirectory(appDir);
+ WriteFile(appDir, "App.csproj", appContent);
+ WriteMinimalProgram(appDir);
+
+ // Orchestrator: restore, build, then restore with SelfContained (all in one binlog)
+ var orchestratorContent = """
+
+
+
+
+
+
+
+ """;
+ WriteFile(solutionDir, "Orchestrator.proj", orchestratorContent);
+
+ var binlogPath = Path.Combine(solutionDir, "build.binlog");
+ await RunDotNetAsync(solutionDir, $"msbuild Orchestrator.proj -t:BuildThenRestore -bl:\"{binlogPath}\"");
+
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var appInfo = results.FirstOrDefault(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("App.csproj", StringComparison.OrdinalIgnoreCase));
+
+ appInfo.Should().NotBeNull();
+
+ // The first build creates inner builds; the second restore adds SelfContained.
+ // Verify at least the project is found and contains expected packages.
+ appInfo!.PackageReference.Should().ContainKey("Newtonsoft.Json");
+
+ // After merging, some representation should have SelfContained=true
+ // (either on the project directly, or in inner builds)
+ var allInfos = new[] { appInfo }
+ .Concat(appInfo.InnerBuilds)
+ .ToList();
+ allInfos.Should().Contain(
+ p => p.SelfContained == true,
+ "the second pass sets SelfContained=true which should be merged");
+ }
+
+ [TestMethod]
+ public async Task ItemDefinitionGroupWithPerItemOverride_OverrideWins()
+ {
+ // ItemDefinitionGroup sets PrivateAssets=all for all PackageReferences,
+ // but one specific PackageReference overrides it
+ var projectDir = Path.Combine(this.testDir, "DefGroupOverride");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+
+
+
+ all
+
+
+
+
+
+ none
+
+
+
+ """;
+ WriteFile(projectDir, "DefGroupOverride.csproj", content);
+
+ var binlogPath = await BuildProjectAsync(projectDir, "DefGroupOverride.csproj");
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("DefGroupOverride.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // Newtonsoft.Json inherits PrivateAssets=all from ItemDefinitionGroup
+ var newtonsoftItem = projectInfo.PackageReference["Newtonsoft.Json"];
+ newtonsoftItem.GetMetadata("PrivateAssets").Should().Be("all");
+
+ // System.Memory overrides to none
+ var memoryItem = projectInfo.PackageReference["System.Memory"];
+ memoryItem.GetMetadata("PrivateAssets").Should().Be("none");
+ }
+
+ [TestMethod]
+ public async Task PropertyPrecedence_EnvVarOverriddenByProjectFile()
+ {
+ // Environment sets OutputType=Library, project file sets OutputType=Exe.
+ // MSBuild precedence: project file wins over env vars.
+ var projectDir = Path.Combine(this.testDir, "EnvVarPrecedence");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+ Exe
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "EnvVarPrecedence.csproj", content);
+ WriteMinimalProgram(projectDir);
+
+ var binlogPath = Path.Combine(projectDir, "build.binlog");
+ await RunProcessWithEnvAsync(
+ projectDir,
+ "dotnet",
+ $"build EnvVarPrecedence.csproj -bl:\"{binlogPath}\" /p:UseAppHost=false",
+ ("OutputType", "Library"));
+
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("EnvVarPrecedence.csproj", StringComparison.OrdinalIgnoreCase));
+
+ // Project file property wins over environment variable
+ projectInfo.OutputType.Should().Be("Exe");
+ }
+
+ [TestMethod]
+ public async Task PublishAotWithGlobalSelfContained_BothCaptured()
+ {
+ // Project has PublishAot=true, global property adds SelfContained=true
+ var projectDir = Path.Combine(this.testDir, "AotAndSC");
+ Directory.CreateDirectory(projectDir);
+
+ var content = """
+
+
+ net8.0
+ Exe
+ true
+
+
+
+
+
+ """;
+ WriteFile(projectDir, "AotAndSC.csproj", content);
+ WriteMinimalProgram(projectDir);
+
+ var binlogPath = Path.Combine(projectDir, "build.binlog");
+ await RunDotNetAsync(projectDir, $"msbuild AotAndSC.csproj -t:Restore -bl:\"{binlogPath}\" /p:SelfContained=true /p:RuntimeIdentifier=win-x64");
+
+ var results = this.processor.ExtractProjectInfo(binlogPath);
+
+ var projectInfo = results.First(p =>
+ p.ProjectPath != null &&
+ p.ProjectPath.EndsWith("AotAndSC.csproj", StringComparison.OrdinalIgnoreCase));
+
+ projectInfo.PublishAot.Should().Be(true);
+ projectInfo.SelfContained.Should().Be(true);
+ }
+
+ private static void WriteFile(string directory, string fileName, string content)
+ {
+ File.WriteAllText(Path.Combine(directory, fileName), content);
+ }
+
+ private static void WriteMinimalProgram(string directory)
+ {
+ WriteFile(directory, "Program.cs", "System.Console.WriteLine();");
+ }
+
+ private static async Task BuildProjectAsync(string projectDir, string projectFile)
+ {
+ var binlogPath = Path.Combine(projectDir, "build.binlog");
+ await RunDotNetAsync(projectDir, $"build \"{projectFile}\" -bl:\"{binlogPath}\" /p:UseAppHost=false");
+
+ if (!File.Exists(binlogPath))
+ {
+ throw new InvalidOperationException($"Build did not produce binlog at: {binlogPath}");
+ }
+
+ return binlogPath;
+ }
+
+ private static async Task RunDotNetAsync(string workingDirectory, string arguments)
+ {
+ // dotnet build already includes restore by default, no need for separate restore
+ await RunProcessAsync(workingDirectory, "dotnet", arguments);
+ }
+
+ private static async Task RunProcessAsync(string workingDirectory, string fileName, string arguments)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ using var process = Process.Start(psi)
+ ?? throw new InvalidOperationException($"Failed to start: {fileName} {arguments}");
+
+ var stdout = await process.StandardOutput.ReadToEndAsync();
+ var stderr = await process.StandardError.ReadToEndAsync();
+
+ await process.WaitForExitAsync();
+
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException(
+ $"Process exited with code {process.ExitCode}.\nCommand: {fileName} {arguments}\nStdout:\n{stdout}\nStderr:\n{stderr}");
+ }
+ }
+
+ private static async Task RunProcessWithEnvAsync(
+ string workingDirectory,
+ string fileName,
+ string arguments,
+ params (string Key, string Value)[] envVars)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ foreach (var (key, value) in envVars)
+ {
+ psi.Environment[key] = value;
+ }
+
+ using var process = Process.Start(psi)
+ ?? throw new InvalidOperationException($"Failed to start: {fileName} {arguments}");
+
+ var stdout = await process.StandardOutput.ReadToEndAsync();
+ var stderr = await process.StandardError.ReadToEndAsync();
+
+ await process.WaitForExitAsync();
+
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException(
+ $"Process exited with code {process.ExitCode}.\nCommand: {fileName} {arguments}\nStdout:\n{stdout}\nStderr:\n{stderr}");
+ }
+ }
+}
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/nuget/MSBuildBinaryLogComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/nuget/MSBuildBinaryLogComponentDetectorTests.cs
new file mode 100644
index 000000000..9ac2d2a29
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/nuget/MSBuildBinaryLogComponentDetectorTests.cs
@@ -0,0 +1,1030 @@
+namespace Microsoft.ComponentDetection.Detectors.Tests.NuGet;
+
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reactive.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using AwesomeAssertions;
+using Microsoft.Build.Framework;
+using Microsoft.ComponentDetection.Common.DependencyGraph;
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.Internal;
+using Microsoft.ComponentDetection.Contracts.TypedComponent;
+using Microsoft.ComponentDetection.Detectors.NuGet;
+using Microsoft.ComponentDetection.Detectors.Tests.Utilities;
+using Microsoft.ComponentDetection.TestsUtilities;
+using Microsoft.Extensions.Logging;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+///
+/// Tests for .
+/// Fallback tests use (public constructor, no binlog mock).
+/// Binlog-enhanced tests construct the detector manually via the internal constructor with a mocked
+/// .
+///
+[TestClass]
+public class MSBuildBinaryLogComponentDetectorTests : BaseDetectorTest
+{
+ private const string ProjectPath = @"C:\test\TestProject.csproj";
+ private const string AssetsFilePath = @"C:\test\obj\project.assets.json";
+ private const string BinlogFilePath = @"C:\test\build.binlog";
+
+ private readonly Mock commandLineInvocationServiceMock;
+ private readonly Mock fileUtilityServiceMock;
+ private readonly Mock pathUtilityServiceMock;
+
+ public MSBuildBinaryLogComponentDetectorTests()
+ {
+ this.commandLineInvocationServiceMock = new Mock();
+ this.commandLineInvocationServiceMock
+ .Setup(x => x.ExecuteCommandAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 1 });
+
+ this.fileUtilityServiceMock = new Mock();
+ this.fileUtilityServiceMock.Setup(x => x.Exists(It.IsAny())).Returns(true);
+ this.fileUtilityServiceMock.Setup(x => x.Exists(It.Is(p => p.EndsWith("global.json")))).Returns(false);
+
+ this.pathUtilityServiceMock = new Mock();
+ this.pathUtilityServiceMock.Setup(x => x.NormalizePath(It.IsAny())).Returns(p => p);
+ this.pathUtilityServiceMock.Setup(x => x.GetParentDirectory(It.IsAny())).Returns(p => Path.GetDirectoryName(p) ?? string.Empty);
+
+ this.DetectorTestUtility
+ .AddServiceMock(this.commandLineInvocationServiceMock)
+ .AddServiceMock(this.fileUtilityServiceMock)
+ .AddServiceMock(this.pathUtilityServiceMock);
+ }
+
+ // ================================================================
+ // Fallback tests – no binlog info available.
+ // Uses DetectorTestUtility / BaseDetectorTest (public constructor).
+ // ================================================================
+ [TestMethod]
+ public async Task Fallback_SimpleAssetsFile_DetectsComponents()
+ {
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await this.DetectorTestUtility
+ .WithFile("project.assets.json", assetsJson)
+ .ExecuteDetectorAsync();
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = recorder.GetDetectedComponents().Where(c => c.Component is NuGetComponent).ToList();
+ components.Should().HaveCount(1);
+
+ var nuget = (NuGetComponent)components.Single().Component;
+ nuget.Name.Should().Be("Newtonsoft.Json");
+ nuget.Version.Should().Be("13.0.1");
+ }
+
+ [TestMethod]
+ public async Task Fallback_TransitiveDependencies_BuildsDependencyGraph()
+ {
+ var assetsJson = TransitiveAssetsJson();
+
+ var (result, recorder) = await this.DetectorTestUtility
+ .WithFile("project.assets.json", assetsJson)
+ .ExecuteDetectorAsync();
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = recorder.GetDetectedComponents().Where(c => c.Component is NuGetComponent).ToList();
+ components.Should().HaveCount(2);
+
+ var graphs = recorder.GetDependencyGraphsByLocation();
+ graphs.Should().NotBeEmpty();
+ var graph = graphs.Values.First();
+
+ var logging = components.First(c => ((NuGetComponent)c.Component).Name == "Microsoft.Extensions.Logging");
+ var abstractions = components.First(c => ((NuGetComponent)c.Component).Name == "Microsoft.Extensions.Logging.Abstractions");
+
+ graph.IsComponentExplicitlyReferenced(logging.Component.Id).Should().BeTrue();
+ graph.IsComponentExplicitlyReferenced(abstractions.Component.Id).Should().BeFalse();
+ graph.GetDependenciesForComponent(logging.Component.Id).Should().Contain(abstractions.Component.Id);
+ }
+
+ [TestMethod]
+ public async Task Fallback_NoPackageSpec_HandlesGracefully()
+ {
+ var assetsJson = @"{ ""version"": 3, ""targets"": { ""net8.0"": {} }, ""libraries"": {}, ""packageFolders"": {} }";
+
+ var (result, recorder) = await this.DetectorTestUtility
+ .WithFile("project.assets.json", assetsJson)
+ .ExecuteDetectorAsync();
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ recorder.GetDetectedComponents().Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task Fallback_ProjectReference_ExcludesProjectDependencies()
+ {
+ var assetsJson = ProjectReferenceAssetsJson();
+
+ var (result, recorder) = await this.DetectorTestUtility
+ .WithFile("project.assets.json", assetsJson)
+ .ExecuteDetectorAsync();
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = recorder.GetDetectedComponents().Where(c => c.Component is NuGetComponent).ToList();
+ components.Should().HaveCount(1);
+ ((NuGetComponent)components.Single().Component).Name.Should().Be("Newtonsoft.Json");
+ }
+
+ [TestMethod]
+ public async Task Fallback_PackageDownload_RegisteredAsDevDependency()
+ {
+ var assetsJson = PackageDownloadAssetsJson();
+
+ var (result, recorder) = await this.DetectorTestUtility
+ .WithFile("project.assets.json", assetsJson)
+ .ExecuteDetectorAsync();
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var download = recorder.GetDetectedComponents()
+ .Where(c => c.Component is NuGetComponent)
+ .Single(c => ((NuGetComponent)c.Component).Name == "Microsoft.Net.Compilers.Toolset");
+ recorder.GetEffectiveDevDependencyValue(download.Component.Id).Should().BeTrue();
+ }
+
+ // ================================================================
+ // Binlog-enhanced tests – mock IBinLogProcessor injected via
+ // the internal constructor (accessible through InternalsVisibleTo).
+ // ================================================================
+ [TestMethod]
+ public async Task WithBinlog_NormalProject_DetectsNuGetComponents()
+ {
+ var info = CreateProjectInfo();
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var nuget = recorder.GetDetectedComponents()
+ .Where(c => c.Component is NuGetComponent)
+ .ToList();
+ nuget.Should().HaveCount(1);
+ ((NuGetComponent)nuget[0].Component).Name.Should().Be("Newtonsoft.Json");
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_TestProject_AllDependenciesMarkedAsDev()
+ {
+ var info = CreateProjectInfo();
+ info.IsTestProject = true;
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var component = recorder.GetDetectedComponents().Single(c => c.Component is NuGetComponent);
+ recorder.GetEffectiveDevDependencyValue(component.Component.Id).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_IsShippingFalse_AllDependenciesMarkedAsDev()
+ {
+ var info = CreateProjectInfo();
+ info.IsShipping = false;
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var component = recorder.GetDetectedComponents().Single(c => c.Component is NuGetComponent);
+ recorder.GetEffectiveDevDependencyValue(component.Component.Id).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_IsDevelopmentTrue_AllDependenciesMarkedAsDev()
+ {
+ var info = CreateProjectInfo();
+ info.IsDevelopment = true;
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var component = recorder.GetDetectedComponents().Single(c => c.Component is NuGetComponent);
+ recorder.GetEffectiveDevDependencyValue(component.Component.Id).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_ShippingProject_DependenciesNotMarkedAsDev()
+ {
+ // All dev-only flags are null or positive, so this is a normal shipping project
+ var info = CreateProjectInfo();
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var component = recorder.GetDetectedComponents().Single(c => c.Component is NuGetComponent);
+ recorder.GetEffectiveDevDependencyValue(component.Component.Id).Should().BeFalse();
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_ExplicitDevDependencyTrue_OverridesPackageToDev()
+ {
+ var info = CreateProjectInfo();
+ AddPackageReference(info, "Newtonsoft.Json", isDevelopmentDependency: true);
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var component = recorder.GetDetectedComponents().Single(c => c.Component is NuGetComponent);
+ recorder.GetEffectiveDevDependencyValue(component.Component.Id).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_ExplicitDevDependencyFalse_OverridesPackageToNotDev()
+ {
+ // On a normal project, IsDevelopmentDependency=false means "not dev", even when heuristics
+ // (framework component / autoReferenced) would otherwise classify it as dev.
+ var info = CreateProjectInfo();
+ AddPackageReference(info, "Newtonsoft.Json", isDevelopmentDependency: false);
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var component = recorder.GetDetectedComponents().Single(c => c.Component is NuGetComponent);
+ recorder.GetEffectiveDevDependencyValue(component.Component.Id).Should().BeFalse();
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_TestProject_OverridesPerPackageFalse()
+ {
+ // Project-level classification (IsTestProject) always wins.
+ // IsDevelopmentDependency=false on a package is ignored because the project IS a test project.
+ var info = CreateProjectInfo();
+ info.IsTestProject = true;
+ AddPackageReference(info, "Newtonsoft.Json", isDevelopmentDependency: false);
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var component = recorder.GetDetectedComponents().Single(c => c.Component is NuGetComponent);
+ recorder.GetEffectiveDevDependencyValue(component.Component.Id).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_TransitiveDepsOfDevPackage_InheritDevStatus()
+ {
+ // When a top-level package has IsDevelopmentDependency=true, the override callback
+ // (_ => true) applies transitively to the entire sub-graph.
+ var info = CreateProjectInfo();
+ AddPackageReference(info, "Microsoft.Extensions.Logging", isDevelopmentDependency: true);
+ var assetsJson = TransitiveAssetsJson();
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = recorder.GetDetectedComponents().Where(c => c.Component is NuGetComponent).ToList();
+ components.Should().HaveCount(2);
+
+ var abstractions = components.First(c => ((NuGetComponent)c.Component).Name == "Microsoft.Extensions.Logging.Abstractions");
+ recorder.GetEffectiveDevDependencyValue(abstractions.Component.Id).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_PackageDownload_DefaultIsDevDependency()
+ {
+ var info = CreateProjectInfo();
+ var assetsJson = PackageDownloadAssetsJson();
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var download = recorder.GetDetectedComponents()
+ .First(c => ((NuGetComponent)c.Component).Name == "Microsoft.Net.Compilers.Toolset");
+ recorder.GetEffectiveDevDependencyValue(download.Component.Id).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_PackageDownload_ExplicitFalse_NotDevDependency()
+ {
+ var info = CreateProjectInfo();
+ AddPackageDownload(info, "Microsoft.Net.Compilers.Toolset", isDevelopmentDependency: false);
+ var assetsJson = PackageDownloadAssetsJson();
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var download = recorder.GetDetectedComponents()
+ .First(c => ((NuGetComponent)c.Component).Name == "Microsoft.Net.Compilers.Toolset");
+ recorder.GetEffectiveDevDependencyValue(download.Component.Id).Should().BeFalse();
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_RegistersDotNetComponent()
+ {
+ var info = CreateProjectInfo();
+ info.NETCoreSdkVersion = "8.0.100";
+ info.OutputType = "Library";
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var dotNetComponents = recorder.GetDetectedComponents()
+ .Where(c => c.Component is DotNetComponent)
+ .ToList();
+ dotNetComponents.Should().HaveCount(1);
+
+ var dotNet = (DotNetComponent)dotNetComponents[0].Component;
+ dotNet.SdkVersion.Should().Be("8.0.100");
+ dotNet.TargetFramework.Should().Be("net8.0");
+ dotNet.ProjectType.Should().Be("library");
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_SelfContained_RegistersApplicationSelfContained()
+ {
+ var info = CreateProjectInfo();
+ info.NETCoreSdkVersion = "8.0.100";
+ info.OutputType = "Exe";
+ info.SelfContained = true;
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var dotNet = recorder.GetDetectedComponents()
+ .Where(c => c.Component is DotNetComponent)
+ .Select(c => (DotNetComponent)c.Component)
+ .Single();
+ dotNet.ProjectType.Should().Be("application-selfcontained");
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_PublishAot_RegistersApplicationSelfContained()
+ {
+ var info = CreateProjectInfo();
+ info.NETCoreSdkVersion = "8.0.100";
+ info.OutputType = "Exe";
+ info.PublishAot = true;
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var dotNet = recorder.GetDetectedComponents()
+ .Where(c => c.Component is DotNetComponent)
+ .Select(c => (DotNetComponent)c.Component)
+ .Single();
+ dotNet.ProjectType.Should().Be("application-selfcontained");
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_LibrarySelfContained_RegistersLibrarySelfContained()
+ {
+ var info = CreateProjectInfo();
+ info.NETCoreSdkVersion = "8.0.100";
+ info.OutputType = "Library";
+ info.SelfContained = true;
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var dotNet = recorder.GetDetectedComponents()
+ .Where(c => c.Component is DotNetComponent)
+ .Select(c => (DotNetComponent)c.Component)
+ .Single();
+ dotNet.ProjectType.Should().Be("library-selfcontained");
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_NotSelfContained_RegistersPlainApplication()
+ {
+ var info = CreateProjectInfo();
+ info.NETCoreSdkVersion = "8.0.100";
+ info.OutputType = "Exe";
+ info.SelfContained = false;
+ info.PublishAot = false;
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([info], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var dotNet = recorder.GetDetectedComponents()
+ .Where(c => c.Component is DotNetComponent)
+ .Select(c => (DotNetComponent)c.Component)
+ .Single();
+ dotNet.ProjectType.Should().Be("application");
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_MultiTarget_PerTfmDevDependency()
+ {
+ // Multi-target project: net8.0 inner build marks package as dev, net6.0 does not.
+ var outer = CreateProjectInfo(targetFramework: null);
+ outer.TrySetProperty("TargetFrameworks", "net8.0;net6.0");
+
+ var innerNet8 = new MSBuildProjectInfo { ProjectPath = ProjectPath, ProjectAssetsFile = AssetsFilePath, TargetFramework = "net8.0" };
+ AddPackageReference(innerNet8, "Newtonsoft.Json", isDevelopmentDependency: true);
+
+ var innerNet6 = new MSBuildProjectInfo { ProjectPath = ProjectPath, ProjectAssetsFile = AssetsFilePath, TargetFramework = "net6.0" };
+
+ outer.InnerBuilds.Add(innerNet8);
+ outer.InnerBuilds.Add(innerNet6);
+
+ var assetsJson = MultiTargetAssetsJson();
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([outer], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+
+ // Package appears in both TFMs.
+ // net8.0: devDependencyOverride = true → registered as dev
+ // net6.0: devDependencyOverride = null → uses heuristic → normal package → NOT dev
+ // GetEffectiveDevDependencyValue ANDs across registrations → false wins
+ var component = recorder.GetDetectedComponents()
+ .Single(c => c.Component is NuGetComponent && ((NuGetComponent)c.Component).Name == "Newtonsoft.Json");
+ recorder.GetEffectiveDevDependencyValue(component.Component.Id).Should().BeFalse();
+ }
+
+ [TestMethod]
+ public async Task WithBinlog_NoBinlogMatch_FallsBackToStandardProcessing()
+ {
+ // Binlog contains info for a DIFFERENT project; assets file project path doesn't match.
+ var otherInfo = CreateProjectInfo(
+ projectPath: @"C:\other\OtherProject.csproj",
+ assetsFilePath: @"C:\other\obj\project.assets.json");
+ otherInfo.IsTestProject = true;
+
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var (result, recorder) = await ExecuteWithBinlogAsync([otherInfo], assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var component = recorder.GetDetectedComponents().Single(c => c.Component is NuGetComponent);
+
+ // Falls back to standard processing – no dev-dependency override
+ recorder.GetEffectiveDevDependencyValue(component.Component.Id).Should().BeFalse();
+ }
+
+ // ================================================================
+ // Multi-binlog merge tests – verify that project info from multiple
+ // binlogs (e.g., build + publish) is merged before assets processing.
+ // This is intentional: a repo may produce separate binlogs for build
+ // and publish passes. The detector merges them via AddOrUpdate+MergeWith
+ // so that properties like SelfContained (only set during publish) are
+ // available when processing the shared project.assets.json.
+ // ================================================================
+ [TestMethod]
+ public async Task MultipleBinlogs_BuildThenPublishSelfContained_MergedAsSelfContained()
+ {
+ // First binlog: normal build — SelfContained is not set
+ var buildInfo = CreateProjectInfo();
+ buildInfo.NETCoreSdkVersion = "8.0.100";
+ buildInfo.OutputType = "Exe";
+
+ // Second binlog: publish self-contained — SelfContained = true
+ var publishInfo = CreateProjectInfo();
+ publishInfo.NETCoreSdkVersion = "8.0.100";
+ publishInfo.OutputType = "Exe";
+ publishInfo.SelfContained = true;
+
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var binlogs = new Dictionary>
+ {
+ [@"C:\test\build.binlog"] = [buildInfo],
+ [@"C:\test\publish.binlog"] = [publishInfo],
+ };
+
+ var (result, recorder) = await ExecuteWithMultipleBinlogsAsync(binlogs, assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+
+ // The merged project info should have SelfContained=true from the publish binlog
+ var dotNet = recorder.GetDetectedComponents()
+ .Where(c => c.Component is DotNetComponent)
+ .Select(c => (DotNetComponent)c.Component)
+ .Single();
+ dotNet.ProjectType.Should().Be("application-selfcontained");
+ }
+
+ [TestMethod]
+ public async Task MultipleBinlogs_BuildThenPublishNotSelfContained_MergedAsNotSelfContained()
+ {
+ // Both binlogs: normal build — SelfContained is not set in either
+ var buildInfo = CreateProjectInfo();
+ buildInfo.NETCoreSdkVersion = "8.0.100";
+ buildInfo.OutputType = "Exe";
+
+ var publishInfo = CreateProjectInfo();
+ publishInfo.NETCoreSdkVersion = "8.0.100";
+ publishInfo.OutputType = "Exe";
+
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var binlogs = new Dictionary>
+ {
+ [@"C:\test\build.binlog"] = [buildInfo],
+ [@"C:\test\publish.binlog"] = [publishInfo],
+ };
+
+ var (result, recorder) = await ExecuteWithMultipleBinlogsAsync(binlogs, assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+
+ var dotNet = recorder.GetDetectedComponents()
+ .Where(c => c.Component is DotNetComponent)
+ .Select(c => (DotNetComponent)c.Component)
+ .Single();
+ dotNet.ProjectType.Should().Be("application");
+ }
+
+ [TestMethod]
+ public async Task MultipleBinlogs_BuildThenPublishAot_MergedAsSelfContained()
+ {
+ // First binlog: normal build
+ var buildInfo = CreateProjectInfo();
+ buildInfo.NETCoreSdkVersion = "8.0.100";
+ buildInfo.OutputType = "Exe";
+
+ // Second binlog: publish with AOT
+ var publishInfo = CreateProjectInfo();
+ publishInfo.NETCoreSdkVersion = "8.0.100";
+ publishInfo.OutputType = "Exe";
+ publishInfo.PublishAot = true;
+
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var binlogs = new Dictionary>
+ {
+ [@"C:\test\build.binlog"] = [buildInfo],
+ [@"C:\test\publish.binlog"] = [publishInfo],
+ };
+
+ var (result, recorder) = await ExecuteWithMultipleBinlogsAsync(binlogs, assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+
+ var dotNet = recorder.GetDetectedComponents()
+ .Where(c => c.Component is DotNetComponent)
+ .Select(c => (DotNetComponent)c.Component)
+ .Single();
+ dotNet.ProjectType.Should().Be("application-selfcontained");
+ }
+
+ [TestMethod]
+ public async Task MultipleBinlogs_MergesTestProjectFlag_AllDepsAreDev()
+ {
+ // First binlog: normal build — IsTestProject not set
+ var buildInfo = CreateProjectInfo();
+
+ // Second binlog: test invocation — IsTestProject = true
+ var testInfo = CreateProjectInfo();
+ testInfo.IsTestProject = true;
+
+ var assetsJson = SimpleAssetsJson("Newtonsoft.Json", "13.0.1");
+
+ var binlogs = new Dictionary>
+ {
+ [@"C:\test\build.binlog"] = [buildInfo],
+ [@"C:\test\test.binlog"] = [testInfo],
+ };
+
+ var (result, recorder) = await ExecuteWithMultipleBinlogsAsync(binlogs, assetsJson);
+
+ result.ResultCode.Should().Be(ProcessingResultCode.Success);
+
+ var component = recorder.GetDetectedComponents().Single(c => c.Component is NuGetComponent);
+ recorder.GetEffectiveDevDependencyValue(component.Component.Id).Should().BeTrue();
+ }
+
+ // ================================================================
+ // Helpers – project info construction
+ // ================================================================
+ private static MSBuildProjectInfo CreateProjectInfo(
+ string projectPath = ProjectPath,
+ string assetsFilePath = AssetsFilePath,
+ string? targetFramework = "net8.0")
+ {
+ return new MSBuildProjectInfo
+ {
+ ProjectPath = projectPath,
+ ProjectAssetsFile = assetsFilePath,
+ TargetFramework = targetFramework,
+ };
+ }
+
+ private static void AddPackageReference(MSBuildProjectInfo info, string packageName, bool isDevelopmentDependency)
+ {
+ var item = CreateTaskItemMock(packageName, isDevelopmentDependency);
+ info.PackageReference[packageName] = item;
+ }
+
+ private static void AddPackageDownload(MSBuildProjectInfo info, string packageName, bool isDevelopmentDependency)
+ {
+ var item = CreateTaskItemMock(packageName, isDevelopmentDependency);
+ info.PackageDownload[packageName] = item;
+ }
+
+ private static ITaskItem CreateTaskItemMock(string itemSpec, bool isDevelopmentDependency)
+ {
+ var mock = new Mock();
+ mock.SetupGet(x => x.ItemSpec).Returns(itemSpec);
+ mock.Setup(x => x.GetMetadata("IsDevelopmentDependency"))
+ .Returns(isDevelopmentDependency ? "true" : "false");
+ return mock.Object;
+ }
+
+ // ================================================================
+ // Helpers – detector execution with mocked IBinLogProcessor
+ // ================================================================
+ private static async Task<(IndividualDetectorScanResult Result, IComponentRecorder Recorder)> ExecuteWithBinlogAsync(
+ IReadOnlyList projectInfos,
+ string assetsJson,
+ string binlogPath = BinlogFilePath,
+ string assetsLocation = AssetsFilePath)
+ {
+ var binLogProcessorMock = new Mock();
+ binLogProcessorMock
+ .Setup(x => x.ExtractProjectInfo(binlogPath, It.IsAny()))
+ .Returns(projectInfos);
+
+ var walkerMock = new Mock();
+ var streamFactoryMock = new Mock();
+ var commandLineInvocationMock = new Mock();
+ var directoryUtilityMock = new Mock();
+ var fileUtilityMock = new Mock();
+ fileUtilityMock.Setup(x => x.Exists(It.IsAny())).Returns(true);
+ var pathUtilityMock = new Mock();
+ pathUtilityMock.Setup(x => x.NormalizePath(It.IsAny())).Returns(p => p);
+ pathUtilityMock.Setup(x => x.GetParentDirectory(It.IsAny())).Returns(p => Path.GetDirectoryName(p) ?? string.Empty);
+ var loggerMock = new Mock>();
+
+ var detector = new MSBuildBinaryLogComponentDetector(
+ streamFactoryMock.Object,
+ walkerMock.Object,
+ commandLineInvocationMock.Object,
+ directoryUtilityMock.Object,
+ fileUtilityMock.Object,
+ pathUtilityMock.Object,
+ binLogProcessorMock.Object,
+ loggerMock.Object);
+
+ var recorder = new ComponentRecorder();
+
+ var requests = new[]
+ {
+ CreateProcessRequest(recorder, binlogPath, "fake-binlog-content"),
+ CreateProcessRequest(recorder, assetsLocation, assetsJson),
+ };
+
+ walkerMock
+ .Setup(x => x.GetFilteredComponentStreamObservable(
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns(requests.ToObservable());
+
+ var scanRequest = new ScanRequest(
+ new DirectoryInfo(Path.GetTempPath()),
+ null,
+ null,
+ new Dictionary(),
+ null,
+ recorder,
+ sourceFileRoot: new DirectoryInfo(Path.GetTempPath()));
+
+ var result = await detector.ExecuteDetectorAsync(scanRequest);
+ return (result, recorder);
+ }
+
+ ///
+ /// Executes the detector with multiple binlog files, each returning its own set of project infos.
+ /// This exercises the merge path: when two binlogs reference the same project (same assets file),
+ /// their MSBuildProjectInfo instances are merged via AddOrUpdate+MergeWith before the assets file
+ /// is processed. For example, a normal build binlog + a publish-self-contained binlog should
+ /// produce a merged MSBuildProjectInfo with SelfContained=true.
+ ///
+ private static async Task<(IndividualDetectorScanResult Result, IComponentRecorder Recorder)> ExecuteWithMultipleBinlogsAsync(
+ Dictionary> binlogProjectInfos,
+ string assetsJson,
+ string assetsLocation = AssetsFilePath)
+ {
+ var binLogProcessorMock = new Mock();
+ foreach (var (binlogPath, projectInfos) in binlogProjectInfos)
+ {
+ binLogProcessorMock
+ .Setup(x => x.ExtractProjectInfo(binlogPath, It.IsAny()))
+ .Returns(projectInfos);
+ }
+
+ var walkerMock = new Mock();
+ var streamFactoryMock = new Mock();
+ var commandLineInvocationMock = new Mock();
+ var directoryUtilityMock = new Mock();
+ var fileUtilityMock = new Mock();
+ fileUtilityMock.Setup(x => x.Exists(It.IsAny())).Returns(true);
+ var pathUtilityMock = new Mock();
+ pathUtilityMock.Setup(x => x.NormalizePath(It.IsAny())).Returns(p => p);
+ pathUtilityMock.Setup(x => x.GetParentDirectory(It.IsAny())).Returns(p => Path.GetDirectoryName(p) ?? string.Empty);
+ var loggerMock = new Mock>();
+
+ var detector = new MSBuildBinaryLogComponentDetector(
+ streamFactoryMock.Object,
+ walkerMock.Object,
+ commandLineInvocationMock.Object,
+ directoryUtilityMock.Object,
+ fileUtilityMock.Object,
+ pathUtilityMock.Object,
+ binLogProcessorMock.Object,
+ loggerMock.Object);
+
+ var recorder = new ComponentRecorder();
+
+ // Build process requests: one per binlog file, then the assets file
+ var requests = binlogProjectInfos.Keys
+ .Select(binlogPath => CreateProcessRequest(recorder, binlogPath, "fake-binlog-content"))
+ .Append(CreateProcessRequest(recorder, assetsLocation, assetsJson))
+ .ToArray();
+
+ walkerMock
+ .Setup(x => x.GetFilteredComponentStreamObservable(
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns(requests.ToObservable());
+
+ var scanRequest = new ScanRequest(
+ new DirectoryInfo(Path.GetTempPath()),
+ null,
+ null,
+ new Dictionary(),
+ null,
+ recorder,
+ sourceFileRoot: new DirectoryInfo(Path.GetTempPath()));
+
+ var result = await detector.ExecuteDetectorAsync(scanRequest);
+ return (result, recorder);
+ }
+
+ private static ProcessRequest CreateProcessRequest(IComponentRecorder recorder, string location, string content)
+ {
+ var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
+ var mockStream = new Mock();
+ mockStream.SetupGet(x => x.Stream).Returns(stream);
+ mockStream.SetupGet(x => x.Location).Returns(location);
+ mockStream.SetupGet(x => x.Pattern).Returns(Path.GetFileName(location));
+
+ return new ProcessRequest
+ {
+ SingleFileComponentRecorder = recorder.CreateSingleFileComponentRecorder(location),
+ ComponentStream = mockStream.Object,
+ };
+ }
+
+ ///
+ /// Creates a minimal valid project.assets.json with a single package.
+ ///
+ private static string SimpleAssetsJson(string packageName, string version) => $$"""
+ {
+ "version": 3,
+ "targets": {
+ "net8.0": {
+ "{{packageName}}/{{version}}": {
+ "type": "package",
+ "compile": { "lib/net8.0/{{packageName}}.dll": {} },
+ "runtime": { "lib/net8.0/{{packageName}}.dll": {} }
+ }
+ }
+ },
+ "libraries": {
+ "{{packageName}}/{{version}}": {
+ "sha512": "fakehash",
+ "type": "package",
+ "path": "{{packageName.ToUpperInvariant()}}/{{version}}",
+ "files": [ "lib/net8.0/{{packageName}}.dll" ]
+ }
+ },
+ "projectFileDependencyGroups": {
+ "net8.0": [ "{{packageName}} >= {{version}}" ]
+ },
+ "packageFolders": { "C:\\Users\\test\\.nuget\\packages\\": {} },
+ "project": {
+ "version": "1.0.0",
+ "restore": {
+ "projectName": "TestProject",
+ "projectPath": "C:\\test\\TestProject.csproj",
+ "outputPath": "C:\\test\\obj"
+ },
+ "frameworks": {
+ "net8.0": {
+ "targetAlias": "net8.0",
+ "dependencies": {
+ "{{packageName}}": { "target": "Package", "version": "[{{version}}, )" }
+ }
+ }
+ }
+ }
+ }
+ """;
+
+ ///
+ /// Assets JSON with a top-level package that has a transitive dependency.
+ /// Microsoft.Extensions.Logging → Microsoft.Extensions.Logging.Abstractions.
+ ///
+ private static string TransitiveAssetsJson() => """
+ {
+ "version": 3,
+ "targets": {
+ "net8.0": {
+ "Microsoft.Extensions.Logging/8.0.0": {
+ "type": "package",
+ "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "8.0.0" },
+ "compile": { "lib/net8.0/Microsoft.Extensions.Logging.dll": {} },
+ "runtime": { "lib/net8.0/Microsoft.Extensions.Logging.dll": {} }
+ },
+ "Microsoft.Extensions.Logging.Abstractions/8.0.0": {
+ "type": "package",
+ "compile": { "lib/net8.0/Microsoft.Extensions.Logging.Abstractions.dll": {} },
+ "runtime": { "lib/net8.0/Microsoft.Extensions.Logging.Abstractions.dll": {} }
+ }
+ }
+ },
+ "libraries": {
+ "Microsoft.Extensions.Logging/8.0.0": {
+ "sha512": "fakehash", "type": "package",
+ "path": "microsoft.extensions.logging/8.0.0",
+ "files": [ "lib/net8.0/Microsoft.Extensions.Logging.dll" ]
+ },
+ "Microsoft.Extensions.Logging.Abstractions/8.0.0": {
+ "sha512": "fakehash", "type": "package",
+ "path": "microsoft.extensions.logging.abstractions/8.0.0",
+ "files": [ "lib/net8.0/Microsoft.Extensions.Logging.Abstractions.dll" ]
+ }
+ },
+ "projectFileDependencyGroups": {
+ "net8.0": [ "Microsoft.Extensions.Logging >= 8.0.0" ]
+ },
+ "packageFolders": { "C:\\Users\\test\\.nuget\\packages\\": {} },
+ "project": {
+ "version": "1.0.0",
+ "restore": {
+ "projectName": "TestProject",
+ "projectPath": "C:\\test\\TestProject.csproj",
+ "outputPath": "C:\\test\\obj"
+ },
+ "frameworks": {
+ "net8.0": {
+ "targetAlias": "net8.0",
+ "dependencies": {
+ "Microsoft.Extensions.Logging": { "target": "Package", "version": "[8.0.0, )" }
+ }
+ }
+ }
+ }
+ }
+ """;
+
+ ///
+ /// Assets JSON with a NuGet package and a project reference.
+ ///
+ private static string ProjectReferenceAssetsJson() => """
+ {
+ "version": 3,
+ "targets": {
+ "net8.0": {
+ "Newtonsoft.Json/13.0.1": {
+ "type": "package",
+ "compile": { "lib/net8.0/Newtonsoft.Json.dll": {} },
+ "runtime": { "lib/net8.0/Newtonsoft.Json.dll": {} }
+ },
+ "MyOtherProject/1.0.0": { "type": "project" }
+ }
+ },
+ "libraries": {
+ "Newtonsoft.Json/13.0.1": {
+ "sha512": "fakehash", "type": "package",
+ "path": "newtonsoft.json/13.0.1",
+ "files": [ "lib/net8.0/Newtonsoft.Json.dll" ]
+ },
+ "MyOtherProject/1.0.0": {
+ "type": "project",
+ "path": "../MyOtherProject/MyOtherProject.csproj",
+ "msbuildProject": "../MyOtherProject/MyOtherProject.csproj"
+ }
+ },
+ "projectFileDependencyGroups": {
+ "net8.0": [ "Newtonsoft.Json >= 13.0.1" ]
+ },
+ "packageFolders": { "C:\\Users\\test\\.nuget\\packages\\": {} },
+ "project": {
+ "version": "1.0.0",
+ "restore": {
+ "projectName": "TestProject",
+ "projectPath": "C:\\test\\TestProject.csproj",
+ "outputPath": "C:\\test\\obj"
+ },
+ "frameworks": {
+ "net8.0": {
+ "targetAlias": "net8.0",
+ "dependencies": {
+ "Newtonsoft.Json": { "target": "Package", "version": "[13.0.1, )" }
+ }
+ }
+ }
+ }
+ }
+ """;
+
+ ///
+ /// Assets JSON with a PackageDownload dependency.
+ ///
+ private static string PackageDownloadAssetsJson() => """
+ {
+ "version": 3,
+ "targets": { "net8.0": {} },
+ "libraries": {},
+ "projectFileDependencyGroups": { "net8.0": [] },
+ "packageFolders": { "C:\\Users\\test\\.nuget\\packages\\": {} },
+ "project": {
+ "version": "1.0.0",
+ "restore": {
+ "projectName": "TestProject",
+ "projectPath": "C:\\test\\TestProject.csproj",
+ "outputPath": "C:\\test\\obj"
+ },
+ "frameworks": {
+ "net8.0": {
+ "targetAlias": "net8.0",
+ "dependencies": {},
+ "downloadDependencies": [
+ { "name": "Microsoft.Net.Compilers.Toolset", "version": "[4.8.0, 4.8.0]" }
+ ]
+ }
+ }
+ }
+ }
+ """;
+
+ ///
+ /// Assets JSON with two target frameworks (net8.0 and net6.0), each containing the same package.
+ ///
+ private static string MultiTargetAssetsJson() => """
+ {
+ "version": 3,
+ "targets": {
+ "net8.0": {
+ "Newtonsoft.Json/13.0.1": {
+ "type": "package",
+ "compile": { "lib/net8.0/Newtonsoft.Json.dll": {} },
+ "runtime": { "lib/net8.0/Newtonsoft.Json.dll": {} }
+ }
+ },
+ "net6.0": {
+ "Newtonsoft.Json/13.0.1": {
+ "type": "package",
+ "compile": { "lib/net6.0/Newtonsoft.Json.dll": {} },
+ "runtime": { "lib/net6.0/Newtonsoft.Json.dll": {} }
+ }
+ }
+ },
+ "libraries": {
+ "Newtonsoft.Json/13.0.1": {
+ "sha512": "fakehash", "type": "package",
+ "path": "newtonsoft.json/13.0.1",
+ "files": [ "lib/net8.0/Newtonsoft.Json.dll", "lib/net6.0/Newtonsoft.Json.dll" ]
+ }
+ },
+ "projectFileDependencyGroups": {
+ "net8.0": [ "Newtonsoft.Json >= 13.0.1" ],
+ "net6.0": [ "Newtonsoft.Json >= 13.0.1" ]
+ },
+ "packageFolders": { "C:\\Users\\test\\.nuget\\packages\\": {} },
+ "project": {
+ "version": "1.0.0",
+ "restore": {
+ "projectName": "TestProject",
+ "projectPath": "C:\\test\\TestProject.csproj",
+ "outputPath": "C:\\test\\obj"
+ },
+ "frameworks": {
+ "net8.0": {
+ "targetAlias": "net8.0",
+ "dependencies": {
+ "Newtonsoft.Json": { "target": "Package", "version": "[13.0.1, )" }
+ }
+ },
+ "net6.0": {
+ "targetAlias": "net6.0",
+ "dependencies": {
+ "Newtonsoft.Json": { "target": "Package", "version": "[13.0.1, )" }
+ }
+ }
+ }
+ }
+ }
+ """;
+}