diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8beeef793..adc0944eb 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -21,9 +21,12 @@
+
+
+
diff --git a/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj b/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj
index f50084046..8398c1710 100644
--- a/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj
+++ b/src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj
@@ -9,7 +9,10 @@
+
+
+
@@ -33,4 +36,8 @@
+
+
+
+
diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetMSBuildBinaryLogComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetMSBuildBinaryLogComponentDetector.cs
new file mode 100644
index 000000000..1f49a11be
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetMSBuildBinaryLogComponentDetector.cs
@@ -0,0 +1,353 @@
+namespace Microsoft.ComponentDetection.Detectors.NuGet;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using Microsoft.Build.Locator;
+using Microsoft.Build.Logging.StructuredLogger;
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.Internal;
+using Microsoft.ComponentDetection.Contracts.TypedComponent;
+using Microsoft.Extensions.Logging;
+
+using Task = System.Threading.Tasks.Task;
+
+public class NuGetMSBuildBinaryLogComponentDetector : FileComponentDetector
+{
+ private static readonly HashSet TopLevelPackageItemNames = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "PackageReference",
+ };
+
+ // the items listed below represent collection names that NuGet will resolve a package into, along with the metadata value names to get the package name and version
+ private static readonly Dictionary ResolvedPackageItemNames = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ // regular restore operations
+ ["NativeCopyLocalItems"] = ("NuGetPackageId", "NuGetPackageVersion"),
+ ["ResourceCopyLocalItems"] = ("NuGetPackageId", "NuGetPackageVersion"),
+ ["RuntimeCopyLocalItems"] = ("NuGetPackageId", "NuGetPackageVersion"),
+ ["ResolvedAnalyzers"] = ("NuGetPackageId", "NuGetPackageVersion"),
+ ["_PackageDependenciesDesignTime"] = ("Name", "Version"),
+
+ // implicitly added by the SDK during a publish operation
+ ["ResolvedAppHostPack"] = ("NuGetPackageId", "NuGetPackageVersion"),
+ ["ResolvedSingleFileHostPack"] = ("NuGetPackageId", "NuGetPackageVersion"),
+ ["ResolvedComHostPack"] = ("NuGetPackageId", "NuGetPackageVersion"),
+ ["ResolvedIjwHostPack"] = ("NuGetPackageId", "NuGetPackageVersion"),
+ };
+
+ // the items listed below represent top-level property names that correspond to well-known components
+ private static readonly Dictionary ComponentPropertyNames = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["NETCoreSdkVersion"] = ".NET SDK",
+ };
+
+ private static readonly object MSBuildRegistrationGate = new();
+
+ public NuGetMSBuildBinaryLogComponentDetector(
+ IObservableDirectoryWalkerFactory walkerFactory,
+ ILogger logger)
+ {
+ this.Scanner = walkerFactory;
+ this.Logger = logger;
+ }
+
+ public override string Id { get; } = "NuGetMSBuildBinaryLog";
+
+ public override IEnumerable Categories => new[] { Enum.GetName(typeof(DetectorClass), DetectorClass.NuGet) };
+
+ public override IList SearchPatterns { get; } = new List { "*.binlog", "*.buildlog" };
+
+ public override IEnumerable SupportedComponentTypes { get; } = new[] { ComponentType.NuGet };
+
+ public override int Version { get; } = 1;
+
+ private static void ProcessResolvedComponentReference(ProjectPathToTopLevelComponents topLevelComponents, ProjectPathToComponents projectResolvedComponents, NamedNode node)
+ {
+ var doRemoveOperation = node is RemoveItem;
+ var doAddOperation = node is AddItem;
+ if (TopLevelPackageItemNames.Contains(node.Name))
+ {
+ var projectEvaluation = node.GetNearestParent();
+ if (projectEvaluation is not null)
+ {
+ foreach (var child in node.Children.OfType- ())
+ {
+ var componentName = child.Name;
+ var topLevel = topLevelComponents.GetComponentNames(projectEvaluation.ProjectFile);
+
+ if (doRemoveOperation)
+ {
+ topLevel.Remove(componentName);
+ }
+
+ if (doAddOperation)
+ {
+ topLevel.Add(componentName);
+ }
+ }
+ }
+ }
+ else if (ResolvedPackageItemNames.TryGetValue(node.Name, out var metadataNames))
+ {
+ var nameMetadata = metadataNames.NameMetadata;
+ var versionMetadata = metadataNames.VersionMetadata;
+ var originalProject = node.GetNearestParent();
+ if (originalProject is not null)
+ {
+ foreach (var child in node.Children.OfType
- ())
+ {
+ var componentName = GetChildMetadataValue(child, nameMetadata);
+ var componentVersion = GetChildMetadataValue(child, versionMetadata);
+ if (componentName is not null && componentVersion is not null)
+ {
+ var project = originalProject;
+ while (project is not null)
+ {
+ var components = projectResolvedComponents.GetComponents(project.ProjectFile);
+ var evaluatedVersions = components.GetEvaluatedVersions(componentName);
+ var componentVersions = evaluatedVersions.GetComponentVersions(project.EvaluationId);
+
+ if (doRemoveOperation)
+ {
+ componentVersions.Remove(componentVersion);
+ }
+
+ if (doAddOperation)
+ {
+ componentVersions.Add(componentVersion);
+ }
+
+ project = project.GetNearestParent();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private static void ProcessProjectProperty(ProjectPathToComponents projectResolvedComponents, Property node)
+ {
+ if (ComponentPropertyNames.TryGetValue(node.Name, out var packageName))
+ {
+ string projectFile;
+ int evaluationId;
+ var projectEvaluation = node.GetNearestParent();
+ if (projectEvaluation is not null)
+ {
+ // `.binlog` files store properties in a `ProjectEvaluation`
+ projectFile = projectEvaluation.ProjectFile;
+ evaluationId = projectEvaluation.Id;
+ }
+ else
+ {
+ // `.buildlog` files store proeprties in `Project`
+ var project = node.GetNearestParent();
+ projectFile = project?.ProjectFile;
+ evaluationId = project?.EvaluationId ?? 0;
+ }
+
+ if (projectFile is not null)
+ {
+ var componentVersion = node.Value;
+ var components = projectResolvedComponents.GetComponents(projectFile);
+ var evaluatedVersions = components.GetEvaluatedVersions(packageName);
+ var componentVersions = evaluatedVersions.GetComponentVersions(evaluationId);
+
+ componentVersions.Add(componentVersion);
+ }
+ }
+ }
+
+ private static string GetChildMetadataValue(TreeNode node, string metadataItemName)
+ {
+ var metadata = node.Children.OfType();
+ var metadataValue = metadata.FirstOrDefault(m => m.Name.Equals(metadataItemName, StringComparison.OrdinalIgnoreCase))?.Value;
+ return metadataValue;
+ }
+
+ protected override Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ EnsureMSBuildIsRegistered();
+
+ var singleFileComponentRecorder = this.ComponentRecorder.CreateSingleFileComponentRecorder(processRequest.ComponentStream.Location);
+ var extension = Path.GetExtension(processRequest.ComponentStream.Location);
+ var buildRoot = extension.ToLower() switch
+ {
+ ".binlog" => BinaryLog.ReadBuild(processRequest.ComponentStream.Stream),
+ ".buildlog" => BuildLogReader.Read(processRequest.ComponentStream.Stream),
+ _ => throw new NotSupportedException($"Unexpected log file extension: {extension}"),
+ };
+ this.RecordLockfileVersion(buildRoot.FileFormatVersion);
+ this.ProcessBinLog(buildRoot, singleFileComponentRecorder);
+ }
+ catch (Exception e)
+ {
+ // If something went wrong, just ignore the package
+ this.Logger.LogError(e, "Failed to process MSBuild binary log {BinLogFile}", processRequest.ComponentStream.Location);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ internal static void EnsureMSBuildIsRegistered()
+ {
+ lock (MSBuildRegistrationGate)
+ {
+ if (!MSBuildLocator.IsRegistered)
+ {
+ // this must happen once per process, and never again
+ var defaultInstance = MSBuildLocator.QueryVisualStudioInstances().First();
+ MSBuildLocator.RegisterInstance(defaultInstance);
+ }
+ }
+ }
+
+ protected override Task OnDetectionFinishedAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ private void ProcessBinLog(Build buildRoot, ISingleFileComponentRecorder componentRecorder)
+ {
+ // maps a project path to a set of resolved dependencies
+ var projectTopLevelComponents = new ProjectPathToTopLevelComponents();
+ var projectResolvedComponents = new ProjectPathToComponents();
+ buildRoot.VisitAllChildren(node =>
+ {
+ switch (node)
+ {
+ case NamedNode namedNode when namedNode is AddItem or RemoveItem:
+ ProcessResolvedComponentReference(projectTopLevelComponents, projectResolvedComponents, namedNode);
+ break;
+ case Property property when property.Parent is Folder folder && folder.Name == "Properties":
+ ProcessProjectProperty(projectResolvedComponents, property);
+ break;
+ default:
+ break;
+ }
+ });
+
+ // dependencies were resolved per project, we need to re-arrange them to be per package/version
+ var projectsPerComponent = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ foreach (var projectPath in projectResolvedComponents.Keys.OrderBy(p => p))
+ {
+ if (Path.GetExtension(projectPath).Equals(".sln", StringComparison.OrdinalIgnoreCase))
+ {
+ // don't report solution files
+ continue;
+ }
+
+ var projectComponents = projectResolvedComponents[projectPath];
+ foreach (var (componentName, componentVersionsPerEvaluationid) in projectComponents.OrderBy(p => p.Key))
+ {
+ foreach (var componentVersions in componentVersionsPerEvaluationid.OrderBy(p => p.Key).Select(kvp => kvp.Value))
+ {
+ foreach (var componentVersion in componentVersions.OrderBy(v => v))
+ {
+ var key = $"{componentName}/{componentVersion}";
+ if (!projectsPerComponent.TryGetValue(key, out var projectPaths))
+ {
+ projectPaths = new HashSet(StringComparer.OrdinalIgnoreCase);
+ projectsPerComponent[key] = projectPaths;
+ }
+
+ projectPaths.Add(projectPath);
+ }
+ }
+ }
+ }
+
+ // report it all
+ foreach (var componentNameAndVersion in projectsPerComponent.Keys.OrderBy(p => p))
+ {
+ var projectPaths = projectsPerComponent[componentNameAndVersion];
+ var parts = componentNameAndVersion.Split('/', 2);
+ var componentName = parts[0];
+ var componentVersion = parts[1];
+ var component = new NuGetComponent(componentName, componentVersion);
+ var libraryComponent = new DetectedComponent(component);
+ foreach (var projectPath in projectPaths)
+ {
+ libraryComponent.FilePaths.Add(projectPath);
+ }
+
+ componentRecorder.RegisterUsage(libraryComponent);
+ }
+ }
+
+ // To make the above code easier to read, some helper types are added here. Without these, the code above would contain a type of:
+ // Dictionary>>>
+ // which isn't very descriptive.
+ private abstract class KeyedCollection : Dictionary
+ where TKey : notnull
+ {
+ protected KeyedCollection()
+ : base()
+ {
+ }
+
+ protected KeyedCollection(IEqualityComparer comparer)
+ : base(comparer)
+ {
+ }
+
+ protected TValue GetOrAdd(TKey key, Func valueFactory)
+ {
+ if (!this.TryGetValue(key, out var value))
+ {
+ value = valueFactory();
+ this[key] = value;
+ }
+
+ return value;
+ }
+ }
+
+ // Represents a collection of top-level components for a given project path.
+ private class ProjectPathToTopLevelComponents : KeyedCollection>
+ {
+ public HashSet GetComponentNames(string projectPath) => this.GetOrAdd(projectPath, () => new(StringComparer.OrdinalIgnoreCase));
+ }
+
+ // Represents a collection of evaluated components for a given project path.
+ private class ProjectPathToComponents : KeyedCollection
+ {
+ public ProjectPathToComponents()
+ : base(StringComparer.OrdinalIgnoreCase)
+ {
+ }
+
+ public ComponentNameToEvaluatedVersions GetComponents(string projectPath) => this.GetOrAdd(projectPath, () => new());
+ }
+
+ // Represents a collection of evaluated components for a given component name.
+ private class ComponentNameToEvaluatedVersions : KeyedCollection
+ {
+ public ComponentNameToEvaluatedVersions()
+ : base(StringComparer.OrdinalIgnoreCase)
+ {
+ }
+
+ public EvaluationIdToComponentVersions GetEvaluatedVersions(string componentName) => this.GetOrAdd(componentName, () => new());
+ }
+
+ // Represents a collection of component versions for a given evaluation id.
+ private class EvaluationIdToComponentVersions : KeyedCollection
+ {
+ public ComponentVersions GetComponentVersions(int evaluationId) => this.GetOrAdd(evaluationId, () => new());
+ }
+
+ // Represents a collection of version strings.
+ private class ComponentVersions : HashSet
+ {
+ public ComponentVersions()
+ : base(StringComparer.OrdinalIgnoreCase)
+ {
+ }
+ }
+}
diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
index 41d16ccc5..4da100fe3 100644
--- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
@@ -105,6 +105,7 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// PIP
services.AddSingleton();
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/MSBuildTestUtilities.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/MSBuildTestUtilities.cs
new file mode 100644
index 000000000..2ca0553ac
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/MSBuildTestUtilities.cs
@@ -0,0 +1,338 @@
+namespace Microsoft.ComponentDetection.Detectors.Tests;
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using System.Xml.XPath;
+using FluentAssertions;
+
+public static class MSBuildTestUtilities
+{
+ public const int TestTargetFrameworkVersion = 6;
+ public static readonly string TestTargetFramework = $"net{TestTargetFrameworkVersion}.0";
+
+ // we need to find the file `Microsoft.NETCoreSdk.BundledVersions.props` in the SDK directory
+ private static readonly Lazy BundledVersionsPropsPath = new(static () =>
+ {
+ // get the sdk version
+ using var tempDir = new TemporaryProjectDirectory();
+ var projectContents = @"
+
+
+
+
+
+ ";
+ var projectPath = Path.Combine(tempDir.DirectoryPath, "project.csproj");
+ File.WriteAllText(projectPath, projectContents);
+ var (exitCode, stdout, stderr) = RunProcessAsync("dotnet", $"msbuild {projectPath} /t:_ReportCurrentSdkVersion").Result;
+ if (exitCode != 0)
+ {
+ throw new NotSupportedException($"Failed to report the current SDK version:\n{stdout}\n{stderr}");
+ }
+
+ var matches = Regex.Matches(stdout, "_CurrentSdkVersion=(?.*)$", RegexOptions.Multiline);
+ if (matches.Count == 0)
+ {
+ throw new NotSupportedException($"Failed to find the current SDK version in the output:\n{stdout}");
+ }
+
+ var sdkVersionString = matches.First().Groups["SdkVersion"].Value.Trim();
+
+ // find the actual SDK directory
+ var privateCoreLibPath = typeof(object).Assembly.Location; // e.g., C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.4\System.Private.CoreLib.dll
+ var sdkDirectory = Path.Combine(Path.GetDirectoryName(privateCoreLibPath), "..", "..", "..", "sdk", sdkVersionString); // e.g., C:\Program Files\dotnet\sdk\8.0.204
+ var bundledVersionsPropsPath = Path.Combine(sdkDirectory, "Microsoft.NETCoreSdk.BundledVersions.props");
+ var normalizedPath = new FileInfo(bundledVersionsPropsPath);
+ return normalizedPath.FullName;
+ });
+
+ public static async Task GetBinLogStreamFromFileContentsAsync(
+ string defaultFilePath,
+ string defaultFileContents,
+ string targetName = null,
+ (string FileName, string Contents)[] additionalFiles = null,
+ (string Name, string Version, string TargetFramework, string AdditionalMetadataXml)[] mockedPackages = null)
+ {
+ // write all files
+ using var tempDir = new TemporaryProjectDirectory();
+ var fullDefaultFilePath = Path.Combine(tempDir.DirectoryPath, defaultFilePath);
+ await File.WriteAllTextAsync(fullDefaultFilePath, defaultFileContents);
+ if (additionalFiles is not null)
+ {
+ foreach (var (fileName, contents) in additionalFiles)
+ {
+ var fullFilePath = Path.Combine(tempDir.DirectoryPath, fileName);
+ var fullFileDirectory = Path.GetDirectoryName(fullFilePath);
+ Directory.CreateDirectory(fullFileDirectory);
+ await File.WriteAllTextAsync(fullFilePath, contents);
+ }
+ }
+
+ await MockNuGetPackagesInDirectoryAsync(tempDir, mockedPackages);
+
+ // generate the binlog
+ targetName ??= "GenerateBuildDependencyFile";
+ var (exitCode, stdOut, stdErr) = await RunProcessAsync("dotnet", $"build \"{fullDefaultFilePath}\" /t:{targetName} /bl:msbuild.binlog", workingDirectory: tempDir.DirectoryPath);
+ exitCode.Should().Be(0, $"STDOUT:\n{stdOut}\n\nSTDERR:\n{stdErr}");
+
+ // copy it to memory so the temporary directory can be cleaned up
+ var fullBinLogPath = Path.Combine(tempDir.DirectoryPath, "msbuild.binlog");
+ using var binLogStream = File.OpenRead(fullBinLogPath);
+ var inMemoryStream = new MemoryStream();
+ await binLogStream.CopyToAsync(inMemoryStream);
+ inMemoryStream.Position = 0;
+ return inMemoryStream;
+ }
+
+ private static async Task MockNuGetPackagesInDirectoryAsync(
+ TemporaryProjectDirectory tempDir,
+ (string Name, string Version, string TargetFramework, string AdditionalMetadataXml)[] mockedPackages)
+ {
+ if (mockedPackages is not null)
+ {
+ var nugetConfig = @"
+
+
+
+
+
+
+ ";
+ await File.WriteAllTextAsync(Path.Combine(tempDir.DirectoryPath, "NuGet.Config"), nugetConfig);
+ var packagesPath = Path.Combine(tempDir.DirectoryPath, "local-packages");
+ Directory.CreateDirectory(packagesPath);
+
+ var mockedPackagesWithFiles = mockedPackages.Select(p =>
+ {
+ return (
+ p.Name,
+ p.Version,
+ p.TargetFramework,
+ p.AdditionalMetadataXml,
+ Files: new[] { ($"lib/{p.TargetFramework}/{p.Name}.dll", Array.Empty()) });
+ });
+
+ var allPackages = mockedPackagesWithFiles.Concat(GetCommonPackages());
+
+ using var sha512 = SHA512.Create(); // this is used to compute the hash of each package below
+ foreach (var package in allPackages)
+ {
+ var nuspec = NugetTestUtilities.GetValidNuspec(package.Name, package.Version, Array.Empty());
+ if (package.AdditionalMetadataXml is not null)
+ {
+ // augment the nuspec
+ var doc = XDocument.Parse(nuspec);
+ var additionalMetadata = XElement.Parse(package.AdditionalMetadataXml);
+ additionalMetadata = WithNamespace(additionalMetadata, doc.Root.Name.Namespace);
+
+ var metadataElement = doc.Root.Descendants().First(e => e.Name.LocalName == "metadata");
+ metadataElement.Add(additionalMetadata);
+ nuspec = doc.ToString();
+ }
+
+ var nupkg = await NugetTestUtilities.ZipNupkgComponentAsync(package.Name, nuspec, additionalFiles: package.Files);
+
+ // to create a local nuget package source, we need a directory structure like this:
+ // local-packages///
+ var packagePath = Path.Combine(packagesPath, package.Name.ToLower(), package.Version.ToLower());
+ Directory.CreateDirectory(packagePath);
+
+ // and we need the following files:
+ // 1. the package
+ var nupkgPath = Path.Combine(packagePath, $"{package.Name}.{package.Version}.nupkg".ToLower());
+ using (var nupkgFileStream = File.OpenWrite(nupkgPath))
+ {
+ await nupkg.CopyToAsync(nupkgFileStream);
+ }
+
+ // 2. the nuspec
+ var nuspecPath = Path.Combine(packagePath, $"{package.Name}.nuspec".ToLower());
+ await File.WriteAllTextAsync(nuspecPath, nuspec);
+
+ // 3. SHA512 hash of the package
+ var hash = sha512.ComputeHash(File.ReadAllBytes(nupkgPath));
+ var hashString = Convert.ToBase64String(hash);
+ var hashPath = $"{nupkgPath}.sha512";
+ await File.WriteAllTextAsync(hashPath, hashString);
+
+ // 4. a JSON metadata file
+ var metadata = $@"{{""version"": 2, ""contentHash"": ""{hashString}"", ""source"": null}}";
+ var metadataPath = Path.Combine(packagePath, ".nupkg.metadata");
+ await File.WriteAllTextAsync(metadataPath, metadata);
+ }
+ }
+ }
+
+ private static XElement WithNamespace(XElement element, XNamespace ns)
+ {
+ return new XElement(
+ ns + element.Name.LocalName,
+ element.Attributes(),
+ element.Elements().Select(e => WithNamespace(e, ns)),
+ element.Value);
+ }
+
+ private static IEnumerable<(string Name, string Version, string TargetFramework, string AdditionalMetadataXml, (string Path, byte[] Content)[] Files)> GetCommonPackages()
+ {
+ // to allow the tests to not require the network, we need to mock some common packages
+ yield return MakeWellKnownReferencePackage("Microsoft.AspNetCore.App", null);
+ yield return MakeWellKnownReferencePackage("Microsoft.WindowsDesktop.App", null);
+
+ var frameworksXml = $@"
+
+
+ ";
+ yield return MakeWellKnownReferencePackage("Microsoft.NETCore.App", new[] { ("data/FrameworkList.xml", Encoding.UTF8.GetBytes(frameworksXml)) });
+ }
+
+ private static (string Name, string Version, string TargetFramework, string AdditionalMetadataXml, (string Path, byte[] Content)[] Files) MakeWellKnownReferencePackage(string packageName, (string Path, byte[] Content)[] files)
+ {
+ var propsDocument = XDocument.Load(BundledVersionsPropsPath.Value);
+ var xpathQuery = $@"
+ /Project/ItemGroup/KnownFrameworkReference
+ [
+ @Include='{packageName}' and
+ @TargetingPackName='{packageName}.Ref' and
+ @TargetFramework='{TestTargetFramework}'
+ ]
+ ";
+ var matchingFrameworkElement = propsDocument.XPathSelectElement(xpathQuery);
+ if (matchingFrameworkElement is null)
+ {
+ throw new NotSupportedException($"Unable to find {packageName}.Ref");
+ }
+
+ var expectedVersion = matchingFrameworkElement.Attribute("TargetingPackVersion").Value;
+ return (
+ $"{packageName}.Ref",
+ expectedVersion,
+ TestTargetFramework,
+ "",
+ files);
+ }
+
+ public static Task<(int ExitCode, string Output, string Error)> RunProcessAsync(string fileName, string arguments = "", string workingDirectory = null)
+ {
+ var tcs = new TaskCompletionSource<(int, string, string)>();
+
+ var redirectInitiated = new ManualResetEventSlim();
+ var process = new Process
+ {
+ StartInfo =
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ UseShellExecute = false, // required to redirect output
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ },
+ EnableRaisingEvents = true,
+ };
+
+ if (workingDirectory is not null)
+ {
+ process.StartInfo.WorkingDirectory = workingDirectory;
+ }
+
+ var stdout = new StringBuilder();
+ var stderr = new StringBuilder();
+
+ process.OutputDataReceived += (_, e) => stdout.AppendLine(e.Data);
+ process.ErrorDataReceived += (_, e) => stderr.AppendLine(e.Data);
+
+ process.Exited += (sender, args) =>
+ {
+ // It is necessary to wait until we have invoked 'BeginXReadLine' for our redirected IO. Then,
+ // we must call WaitForExit to make sure we've received all OutputDataReceived/ErrorDataReceived calls
+ // or else we'll be returning a list we're still modifying. For paranoia, we'll start a task here rather
+ // than enter right back into the Process type and start a wait which isn't guaranteed to be safe.
+ var unused = Task.Run(() =>
+ {
+ redirectInitiated.Wait();
+ redirectInitiated.Dispose();
+ redirectInitiated = null;
+
+ process.WaitForExit();
+
+ tcs.TrySetResult((process.ExitCode, stdout.ToString(), stderr.ToString()));
+ process.Dispose();
+ });
+ };
+
+ if (!process.Start())
+ {
+ throw new InvalidOperationException("Process failed to start");
+ }
+
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ redirectInitiated.Set();
+
+ return tcs.Task;
+ }
+
+ private class TemporaryProjectDirectory : IDisposable
+ {
+ private const string DirectoryBuildPropsContents = @"
+
+
+ false
+
+
+ ";
+
+ private readonly Dictionary originalEnvironment = new();
+
+ public TemporaryProjectDirectory()
+ {
+ var testDataPath = Path.Combine(Path.GetDirectoryName(this.GetType().Assembly.Location), "test-data");
+ Directory.CreateDirectory(testDataPath);
+
+ // ensure tests don't crawl the directory tree
+ File.WriteAllText(Path.Combine(testDataPath, "Directory.Build.props"), DirectoryBuildPropsContents);
+ File.WriteAllText(Path.Combine(testDataPath, "Directory.Build.targets"), "");
+ File.WriteAllText(Path.Combine(testDataPath, "Directory.Packages.props"), "");
+
+ // create temporary project directory
+ this.DirectoryPath = Path.Combine(testDataPath, Guid.NewGuid().ToString("d"));
+ Directory.CreateDirectory(this.DirectoryPath);
+
+ // ensure each project gets a fresh package cache
+ foreach (var envName in new[] { "NUGET_PACKAGES", "NUGET_HTTP_CACHE_PATH", "NUGET_SCRATCH", "NUGET_PLUGINS_CACHE_PATH" })
+ {
+ this.originalEnvironment[envName] = Environment.GetEnvironmentVariable(envName);
+ var dir = Path.Join(this.DirectoryPath, envName);
+ Directory.CreateDirectory(dir);
+ Environment.SetEnvironmentVariable(envName, dir);
+ }
+ }
+
+ public string DirectoryPath { get; }
+
+ public void Dispose()
+ {
+ foreach (var (key, value) in this.originalEnvironment)
+ {
+ Environment.SetEnvironmentVariable(key, value);
+ }
+
+ try
+ {
+ Directory.Delete(this.DirectoryPath, recursive: true);
+ }
+ catch
+ {
+ }
+ }
+ }
+}
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 efcd77335..470a9f9af 100644
--- a/test/Microsoft.ComponentDetection.Detectors.Tests/Microsoft.ComponentDetection.Detectors.Tests.csproj
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/Microsoft.ComponentDetection.Detectors.Tests.csproj
@@ -7,7 +7,10 @@
+
+
+
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetMSBuildBinaryLogComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetMSBuildBinaryLogComponentDetectorTests.cs
new file mode 100644
index 000000000..00696f9f4
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetMSBuildBinaryLogComponentDetectorTests.cs
@@ -0,0 +1,604 @@
+namespace Microsoft.ComponentDetection.Detectors.Tests;
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Build.Logging.StructuredLogger;
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.TypedComponent;
+using Microsoft.ComponentDetection.Detectors.NuGet;
+using Microsoft.ComponentDetection.Detectors.Tests.Utilities;
+using Microsoft.ComponentDetection.TestsUtilities;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using MSBuildTask = Microsoft.Build.Logging.StructuredLogger.Task;
+using Task = System.Threading.Tasks.Task;
+
+[TestClass]
+[TestCategory("Governance/All")]
+[TestCategory("Governance/ComponentDetection")]
+public class NuGetMSBuildBinaryLogComponentDetectorTests : BaseDetectorTest
+{
+ static NuGetMSBuildBinaryLogComponentDetectorTests() => NuGetMSBuildBinaryLogComponentDetector.EnsureMSBuildIsRegistered();
+
+ [TestMethod]
+ public async Task DependenciesAreReportedForEachProjectFile()
+ {
+ // the contents of `projectContents` are the root entrypoint to the detector, but MSBuild will crawl to the other project file
+ var (scanResult, componentRecorder) = await this.ExecuteDetectorAndGetBinLogAsync(
+ projectContents: $@"
+
+
+ {MSBuildTestUtilities.TestTargetFramework}
+
+
+
+
+
+ ",
+ additionalFiles: new[]
+ {
+ ("other-project/other-project.csproj", $@"
+
+
+ {MSBuildTestUtilities.TestTargetFramework}
+
+
+
+
+
+ "),
+ },
+ mockedPackages: new[]
+ {
+ ("Some.Package", "1.2.3", MSBuildTestUtilities.TestTargetFramework, ""),
+ ("Transitive.Dependency", "4.5.6", MSBuildTestUtilities.TestTargetFramework, null),
+ });
+
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ // components are reported for each project file
+ var originalFileComponents = detectedComponents
+ .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/project.csproj")))
+ .Select(d => d.Component)
+ .Cast()
+ .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly
+ .OrderBy(c => c.Name)
+ .Select(c => $"{c.Name}/{c.Version}");
+ originalFileComponents.Should().Equal("Some.Package/1.2.3", "Transitive.Dependency/4.5.6");
+
+ var referencedFileComponents = detectedComponents
+ .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/other-project/other-project.csproj")))
+ .Select(d => d.Component)
+ .Cast()
+ .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly
+ .OrderBy(c => c.Name)
+ .Select(c => $"{c.Name}/{c.Version}");
+ referencedFileComponents.Should().Equal("Some.Package/1.2.3", "Transitive.Dependency/4.5.6");
+ }
+
+ [TestMethod]
+ public async Task RemovedPackagesAreNotReported()
+ {
+ // This is a very specific scenario that should be tested, but we don't want any changes to this repo's SDK to
+ // change the outcome of the test, so we're doing it manually. The scenario is the SDK knowingly replacing an
+ // assembly from an outdated transitive package. One example common in the wild is the package
+ // `Microsoft.Extensions.Configuration.Json/6.0.0` which contains a transitive dependency on
+ // `System.Text.Json/6.0.0`, but the SDK version 6.0.424 or later pulls this reference out of the dependency
+ // set and replaces the .dll with a local updated copy. The end result is the `package.assets.json` file
+ // reports that `System.Text.Json/6.0.0` is referenced by a project, but after build and at runtime, this isn't
+ // the case and can lead to false positives when reporting dependencies. The way the SDK accomplishes this is
+ // by removing `System.Text.Json.dll` from the group `@(RuntimeCopyLocalItems)`. To accomplish this in the
+ // test, we're inserting a custom target that does this same action.
+ var (scanResult, componentRecorder) = await this.ExecuteDetectorAndGetBinLogAsync(
+ projectContents: $@"
+
+
+ {MSBuildTestUtilities.TestTargetFramework}
+
+
+
+
+
+
+
+
+
+
+
+ ",
+ mockedPackages: new[]
+ {
+ ("Some.Package", "1.2.3", MSBuildTestUtilities.TestTargetFramework, ""),
+ ("Transitive.Dependency", "4.5.6", MSBuildTestUtilities.TestTargetFramework, null),
+ });
+
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var packages = detectedComponents
+ .Select(d => d.Component)
+ .Cast()
+ .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly
+ .OrderBy(c => c.Name)
+ .Select(c => $"{c.Name}/{c.Version}");
+ packages.Should().Equal("Some.Package/1.2.3");
+ }
+
+ [TestMethod]
+ public async Task PackagesReportedFromSeparateProjectsDoNotOverlap()
+ {
+ // In this test, a top-level solution file references two projects which have no relationship between them.
+ // The result should be that each project only reports its own dependencies.
+ var slnContents = @"
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio 17
+VisualStudioVersion = 17.0.31808.319
+MinimumVisualStudioVersion = 15.0.26124.0
+Project(""{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"") = ""project1"", ""project1\project1.csproj"", ""{782E0C0A-10D3-444D-9640-263D03D2B20C}""
+EndProject
+Project(""{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"") = ""project2"", ""project2\project2.csproj"", ""{CBA73BF8-C922-4DD7-A41D-88CD22914356}""
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {782E0C0A-10D3-444D-9640-263D03D2B20C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {782E0C0A-10D3-444D-9640-263D03D2B20C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {782E0C0A-10D3-444D-9640-263D03D2B20C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {782E0C0A-10D3-444D-9640-263D03D2B20C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CBA73BF8-C922-4DD7-A41D-88CD22914356}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CBA73BF8-C922-4DD7-A41D-88CD22914356}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CBA73BF8-C922-4DD7-A41D-88CD22914356}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CBA73BF8-C922-4DD7-A41D-88CD22914356}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
+";
+ using var binLogStream = await MSBuildTestUtilities.GetBinLogStreamFromFileContentsAsync(
+ defaultFilePath: "solution.sln",
+ defaultFileContents: slnContents,
+ additionalFiles: new[]
+ {
+ ("project1/project1.csproj", $@"
+
+
+ {MSBuildTestUtilities.TestTargetFramework}
+
+
+
+
+
+ "),
+ ("project2/project2.csproj", $@"
+
+
+ {MSBuildTestUtilities.TestTargetFramework}
+
+
+
+
+
+ "),
+ },
+ mockedPackages: new[]
+ {
+ ("Package.A", "1.2.3", MSBuildTestUtilities.TestTargetFramework, ""),
+ ("Package.B", "4.5.6", MSBuildTestUtilities.TestTargetFramework, ""),
+ });
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("msbuild.binlog", binLogStream)
+ .ExecuteDetectorAsync();
+
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var project1Components = detectedComponents
+ .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/project1.csproj")))
+ .Select(d => d.Component)
+ .Cast()
+ .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly
+ .OrderBy(c => c.Name)
+ .Select(c => $"{c.Name}/{c.Version}");
+ project1Components.Should().Equal("Package.A/1.2.3");
+
+ var project2Components = detectedComponents
+ .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/project2.csproj")))
+ .Select(d => d.Component)
+ .Cast()
+ .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly
+ .OrderBy(c => c.Name)
+ .Select(c => $"{c.Name}/{c.Version}");
+ project2Components.Should().Equal("Package.B/4.5.6");
+
+ var solutionComponents = detectedComponents
+ .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/solution.sln")))
+ .Select(d => d.Component)
+ .Cast()
+ .OrderBy(c => c.Name)
+ .Select(c => $"{c.Name}/{c.Version}");
+ solutionComponents.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task PackagesAreReportedWhenConditionedOnTargetFramework()
+ {
+ // This test simulates a project with multiple TFMs where the same package is imported in each, but with a
+ // different version. To avoid building the entire project, we can fake what MSBuild does by resolving
+ // packages with each TFM. I manually verified that a "real" invocation of MSBuild with the "Build" target
+ // produces the same shape of the binlog as this test generates.
+
+ // The end result is that _all_ packages are reported, regardless of the TFM invokation they came from, and in
+ // this case that is good, because we really only care about what packages were used in the build and what
+ // project file they came from.
+ var (scanResult, componentRecorder) = await this.ExecuteDetectorAndGetBinLogAsync(
+ projectContents: $@"
+
+
+ netstandard2.0;{MSBuildTestUtilities.TestTargetFramework}
+
+
+
+
+
+
+
+
+
+
+ ",
+ targetName: "TEST_GenerateBuildDependencyFileForTargetFrameworks",
+ mockedPackages: new[]
+ {
+ ("NETStandard.Library", "2.0.3", "netstandard2.0", ""),
+ ("Some.Package", "1.2.3", "netstandard2.0", null),
+ ("Some.Package", "4.5.6", MSBuildTestUtilities.TestTargetFramework, null),
+ });
+
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var packages = detectedComponents
+ .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/project.csproj")))
+ .Select(d => d.Component)
+ .Cast()
+ .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly
+ .OrderBy(c => c.Name).ThenBy(c => c.Version)
+ .Select(c => $"{c.Name}/{c.Version}");
+ packages.Should().Equal("NETStandard.Library/2.0.3", "Some.Package/1.2.3", "Some.Package/4.5.6");
+ }
+
+ [TestMethod]
+ public async Task PackagesAreReportedWhenConflictResolutionRemovesPackages()
+ {
+ // This comes from a real-world scenario where the project file looks like this:
+ //
+ //
+ //
+ // net472;net8.0
+ //
+ //
+ //
+ //
+ //
+ //
+ // During a build, the project is evantually evaluated twice: once for `net472` and once for `net8.0`. In the
+ // `net472` scenario, `System.Text.Json/6.0.0` is added to the output, but in the `net8.0` scenario, that
+ // package is added but then removed by the conflict resolution logic in the SDK. We need to ensure that the
+ // `RemoveItem` from the `net8.0` evaluation doesn't affect the `net472` evaluation. The end result is that we
+ // _should_ report the package because it was used in at least one project evaluation.
+ //
+ // To make this easy to test, custom targets have been added to the test project below that do exactly what the
+ // SDK does, just without an expernsive build invocation; remove an item from the item group, but only for one
+ // of the target frameworks. The end result is a binary log that mimics the shape of the real-world example
+ // given above.
+ var (scanResult, componentRecorder) = await this.ExecuteDetectorAndGetBinLogAsync(
+ projectContents: $@"
+
+
+ netstandard2.0;{MSBuildTestUtilities.TestTargetFramework}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ",
+ targetName: "TEST_GenerateBuildDependencyFileForTargetFrameworks",
+ mockedPackages: new[]
+ {
+ ("NETStandard.Library", "2.0.3", "netstandard2.0", ""),
+ ("Some.Package", "1.2.3", "netstandard2.0", null),
+ });
+
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var packages = detectedComponents
+ .Where(d => d.FilePaths.Any(p => p.Replace("\\", "/").EndsWith("/project.csproj")))
+ .Select(d => d.Component)
+ .Cast()
+ .Where(c => c.Name != ".NET SDK") // dealt with in another test because the SDK version will change regularly
+ .OrderBy(c => c.Name).ThenBy(c => c.Version)
+ .Select(c => $"{c.Name}/{c.Version}");
+ packages.Should().Equal("NETStandard.Library/2.0.3", "Some.Package/1.2.3");
+ }
+
+ [TestMethod]
+ public async Task PackagesImplicitlyAddedBySdkDuringPublishAreAdded()
+ {
+ // When a project is published, the SDK will add references to some AppHost specific packages. Doing an actual
+ // publish operation here would be too slow, so a mock in-memory binary log is used that has the same shape
+ // (although trimmed down) of a real publish log.
+ var binlog = new Build()
+ {
+ Succeeded = true,
+ Children =
+ {
+ new Project()
+ {
+ ProjectFile = "project.csproj",
+ Children =
+ {
+ new Target()
+ {
+ Name = "ResolveFrameworkReferences",
+ Children =
+ {
+ // ResolvedAppHostPack
+ new MSBuildTask()
+ {
+ Name = "GetPackageDirectory",
+ Children =
+ {
+ new Folder()
+ {
+ Name = "OutputItems",
+ Children =
+ {
+ new AddItem()
+ {
+ Name = "ResolvedAppHostPack",
+ Children =
+ {
+ new Item()
+ {
+ Name = "AppHost",
+ Children =
+ {
+ new Metadata()
+ {
+ Name = "NuGetPackageId",
+ Value = "Microsoft.NETCore.App.Host.win-x64",
+ },
+ new Metadata()
+ {
+ Name = "NuGetPackageVersion",
+ Value = "6.0.33",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+
+ // ResolvedSingleFileHostPack
+ new MSBuildTask()
+ {
+ Name = "GetPackageDirectory",
+ Children =
+ {
+ new Folder()
+ {
+ Name = "OutputItems",
+ Children =
+ {
+ new AddItem()
+ {
+ Name = "ResolvedSingleFileHostPack",
+ Children =
+ {
+ new Item()
+ {
+ Name = "SingleFileHost",
+ Children =
+ {
+ new Metadata()
+ {
+ Name = "NuGetPackageId",
+ Value = "Microsoft.NETCore.App.Host.win-x64",
+ },
+ new Metadata()
+ {
+ Name = "NuGetPackageVersion",
+ Value = "6.0.33",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+
+ // ResolvedComHostPack
+ new MSBuildTask()
+ {
+ Name = "GetPackageDirectory",
+ Children =
+ {
+ new Folder()
+ {
+ Name = "OutputItems",
+ Children =
+ {
+ new AddItem()
+ {
+ Name = "ResolvedComHostPack",
+ Children =
+ {
+ new Item()
+ {
+ Name = "ComHost",
+ Children =
+ {
+ new Metadata()
+ {
+ Name = "NuGetPackageId",
+ Value = "Microsoft.NETCore.App.Host.win-x64",
+ },
+ new Metadata()
+ {
+ Name = "NuGetPackageVersion",
+ Value = "6.0.33",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+
+ // ResolvedIjwHostPack
+ new MSBuildTask()
+ {
+ Name = "GetPackageDirectory",
+ Children =
+ {
+ new Folder()
+ {
+ Name = "OutputItems",
+ Children =
+ {
+ new AddItem()
+ {
+ Name = "ResolvedIjwHostPack",
+ Children =
+ {
+ new Item()
+ {
+ Name = "IjwHost",
+ Children =
+ {
+ new Metadata()
+ {
+ Name = "NuGetPackageId",
+ Value = "Microsoft.NETCore.App.Host.win-x64",
+ },
+ new Metadata()
+ {
+ Name = "NuGetPackageVersion",
+ Value = "6.0.33",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ // in-memory logs need to be `.buildlog`
+ using var tempFile = new TemporaryFile(".buildlog");
+ Serialization.Write(binlog, tempFile.FilePath);
+ using var binLogStream = File.OpenRead(tempFile.FilePath);
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile(tempFile.FilePath, binLogStream)
+ .ExecuteDetectorAsync();
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var components = detectedComponents
+ .Select(d => d.Component)
+ .Cast()
+ .OrderBy(c => c.Name)
+ .Select(c => $"{c.Name}/{c.Version}");
+ components.Should().Equal("Microsoft.NETCore.App.Host.win-x64/6.0.33");
+ }
+
+ [TestMethod]
+ public async Task DotNetSDKVersionIsReported()
+ {
+ var binlog = new Build()
+ {
+ Succeeded = true,
+ };
+
+ binlog.EvaluationFolder.Children.Add(
+ new Project()
+ {
+ ProjectFile = "project.csproj",
+ Children =
+ {
+ new Folder()
+ {
+ Name = "Properties",
+ Children =
+ {
+ new Property()
+ {
+ Name = "NETCoreSdkVersion",
+ Value = "6.0.789",
+ },
+ },
+ },
+ },
+ });
+
+ // in-memory logs need to be `.buildlog`
+ using var tempFile = new TemporaryFile(".buildlog");
+ Serialization.Write(binlog, tempFile.FilePath);
+ using var binLogStream = File.OpenRead(tempFile.FilePath);
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile(tempFile.FilePath, binLogStream)
+ .ExecuteDetectorAsync();
+ var detectedComponents = componentRecorder.GetDetectedComponents();
+
+ var components = detectedComponents
+ .Select(d => d.Component)
+ .Cast()
+ .OrderBy(c => c.Name)
+ .Select(c => $"{c.Name}/{c.Version}");
+ components.Should().Equal(".NET SDK/6.0.789");
+ }
+
+ private async Task<(IndividualDetectorScanResult ScanResult, IComponentRecorder ComponentRecorder)> ExecuteDetectorAndGetBinLogAsync(
+ string projectContents,
+ string targetName = null,
+ (string FileName, string Content)[] additionalFiles = null,
+ (string Name, string Version, string TargetFramework, string DependenciesXml)[] mockedPackages = null)
+ {
+ using var binLogStream = await MSBuildTestUtilities.GetBinLogStreamFromFileContentsAsync("project.csproj", projectContents, targetName: targetName, additionalFiles: additionalFiles, mockedPackages: mockedPackages);
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("msbuild.binlog", binLogStream)
+ .ExecuteDetectorAsync();
+ return (scanResult, componentRecorder);
+ }
+}
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/NugetTestUtilities.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/NugetTestUtilities.cs
index 37fa45ce4..ea576713c 100644
--- a/test/Microsoft.ComponentDetection.Detectors.Tests/NugetTestUtilities.cs
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/NugetTestUtilities.cs
@@ -3,6 +3,7 @@
using System.IO;
using System.IO.Compression;
using System.Text;
+using System.Threading;
using System.Threading.Tasks;
using Microsoft.ComponentDetection.Contracts;
using Microsoft.ComponentDetection.TestsUtilities;
@@ -60,7 +61,7 @@ public static string GetValidNuspec(string componentName, string version, string
return GetTemplatedNuspec(componentName, version, authors);
}
- public static async Task ZipNupkgComponentAsync(string filename, string content)
+ public static async Task ZipNupkgComponentAsync(string filename, string content, (string Path, byte[] Contents)[] additionalFiles = null)
{
var stream = new MemoryStream();
@@ -68,10 +69,21 @@ public static async Task ZipNupkgComponentAsync(string filename, string
{
var entry = archive.CreateEntry($"{filename}.nuspec");
- using var entryStream = entry.Open();
-
- var templateBytes = Encoding.UTF8.GetBytes(content);
- await entryStream.WriteAsync(templateBytes);
+ using (var entryStream = entry.Open())
+ {
+ var templateBytes = Encoding.UTF8.GetBytes(content);
+ await entryStream.WriteAsync(templateBytes);
+ }
+
+ if (additionalFiles is not null)
+ {
+ foreach (var file in additionalFiles)
+ {
+ var additionalEntry = archive.CreateEntry(file.Path);
+ using var additionalEntryStream = additionalEntry.Open();
+ await additionalEntryStream.WriteAsync(file.Contents, CancellationToken.None);
+ }
+ }
}
stream.Seek(0, SeekOrigin.Begin);
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/Utilities/TemporaryFile.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/Utilities/TemporaryFile.cs
new file mode 100644
index 000000000..f19776cba
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/Utilities/TemporaryFile.cs
@@ -0,0 +1,41 @@
+namespace Microsoft.ComponentDetection.Detectors.Tests.Utilities;
+
+using System;
+using System.IO;
+
+public sealed class TemporaryFile : IDisposable
+{
+ static TemporaryFile()
+ {
+ TemporaryDirectory = Path.Combine(Path.GetDirectoryName(typeof(TemporaryFile).Assembly.Location), "temporary-files");
+ Directory.CreateDirectory(TemporaryDirectory);
+ }
+
+ // Creates a temporary file in the test directory with the optional given file extension. The test/debug directory
+ // is used to avoid polluting the user's temp directory and so that a `git clean` operation will remove any
+ // remaining files.
+ public TemporaryFile(string extension = null)
+ {
+ if (extension is not null && !extension.StartsWith("."))
+ {
+ throw new ArgumentException("Extension must start with a period.", nameof(extension));
+ }
+
+ this.FilePath = Path.Combine(TemporaryDirectory, $"{Guid.NewGuid():d}{extension}");
+ }
+
+ private static string TemporaryDirectory { get; }
+
+ public string FilePath { get; }
+
+ public void Dispose()
+ {
+ try
+ {
+ File.Delete(this.FilePath);
+ }
+ catch
+ {
+ }
+ }
+}