diff --git a/docs/detectors/dotnet.md b/docs/detectors/dotnet.md index fbeccef5f..fbdcdf199 100644 --- a/docs/detectors/dotnet.md +++ b/docs/detectors/dotnet.md @@ -24,6 +24,18 @@ and have unreported vulnerabilities. `TargetFramework` is determined from the ` the type of the project is determined by locating the project's output assembly in a subdirectory of the output path and reading the PE COFF header's characteristics for `IMAGE_FILE_EXECUTABLE_IMAGE`[2]. +The `ProjectType` value is further qualified with a `-selfcontained` suffix (e.g. `application-selfcontained` +or `library-selfcontained`) when the project is detected as self-contained. A project is considered +self-contained when either: +- Its `project.assets.json` indicates that a framework reference (e.g. `Microsoft.NETCore.App`) has a + corresponding runtime package download (e.g. `Microsoft.NETCore.App.Runtime.*`) listed in the target + framework's `downloadDependencies`. This covers `SelfContained=true` scenarios. +- The target references `Microsoft.DotNet.ILCompiler`, which indicates native AOT compilation + (`PublishAot=true`) and therefore an implicitly self-contained deployment. + +Self-contained applications bundle the .NET runtime and are responsible for servicing it, so this +distinction is important for vulnerability tracking. + [1]: https://learn.microsoft.com/en-us/dotnet/core/tools/global-json [2]: https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#characteristics @@ -33,4 +45,4 @@ If the `dotnet` executable is not on the path the detector may fail to locate th project. The detector will fallback to parsing the `global.json` in this case if it is present. Detection of the output type is done by locating the output assembly under the output path specified in `project.assets.json`. Some build systems may place project intermediates in a different location. In this -case the project type will be reported as `unknown`. \ No newline at end of file +case the project type will be reported as `unknown` and the `-selfcontained` suffix will not be appended. \ No newline at end of file diff --git a/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs b/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs index ee0e3c7de..cb69b8c71 100644 --- a/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs +++ b/src/Microsoft.ComponentDetection.Contracts/TypedComponent/DotNetComponent.cs @@ -38,7 +38,11 @@ public DotNetComponent(string sdkVersion, string targetFramework = null, string public string TargetFramework { get; set; } /// - /// Project type: application, library. Null in the case of global.json or if no project output could be discovered. + /// Project type: application, library, application-selfcontained, library-selfcontained, or unknown. + /// Set to "unknown" when the project output could not be discovered (e.g. global.json or missing output assembly). + /// The "-selfcontained" suffix is appended when the project bundles the .NET runtime + /// (i.e. the target framework has a runtime package download matching a framework reference, + /// or the target references Microsoft.DotNet.ILCompiler indicating native AOT). /// [JsonPropertyName("projectType")] public string ProjectType { get; set; } diff --git a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs index 4e425531d..51fecf322 100644 --- a/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/dotnet/DotNetComponentDetector.cs @@ -10,6 +10,7 @@ namespace Microsoft.ComponentDetection.Detectors.DotNet; 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; @@ -201,9 +202,11 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID var componentReporter = this.ComponentRecorder.CreateSingleFileComponentRecorder(projectPath); foreach (var target in lockFile.Targets ?? []) { - var targetFramework = target.TargetFramework?.GetShortFolderName(); + 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, targetType))); + componentReporter.RegisterUsage(new DetectedComponent(new DotNetComponent(sdkVersion, targetFramework?.GetShortFolderName(), targetTypeWithSelfContained))); } } @@ -247,6 +250,59 @@ private bool IsApplication(string assemblyPath) 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. /// diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs index c1f165771..e6483b995 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DotNetComponentDetectorTests.cs @@ -14,7 +14,10 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using System.Threading.Tasks; using AwesomeAssertions; using global::NuGet.Frameworks; +using global::NuGet.LibraryModel; using global::NuGet.ProjectModel; +using global::NuGet.Versioning; +using Microsoft.ComponentDetection.Common; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.DotNet; @@ -30,6 +33,14 @@ public class DotNetComponentDetectorTests { private static readonly string RootDir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "C:" : @"/"; + /// + /// The short NuGet TFM (e.g. "net8.0") that the test assembly itself targets. + /// Used by real-restore tests so they don't break when the SDK drops an older framework. + /// + private static readonly string CurrentTfm = NuGetFramework.Parse( + Assembly.GetExecutingAssembly().GetCustomAttribute().FrameworkName) + .GetShortFolderName(); + private readonly DetectorTestUtilityBuilder detectorTestUtility = new(); private readonly Mock> mockLogger = new(); @@ -38,6 +49,8 @@ public class DotNetComponentDetectorTests private readonly Mock mockCommandLineInvocationService = new(); private readonly CommandLineExecutionResult commandLineExecutionResult = new(); + private readonly ICommandLineInvocationService realCommandLineService = new CommandLineInvocationService(); + // uses Exists, EnumerateFiles private readonly Mock mockDirectoryUtilityService = new(); @@ -183,6 +196,20 @@ public void ClearMocks() } private static string ProjectAssets(string projectName, string outputPath, string projectPath, params string[] targetFrameworks) + { + return ProjectAssetsWithSelfContained(projectName, outputPath, projectPath, selfContainedTargetFrameworks: null, aotTargetFrameworks: null, targetFrameworks); + } + + /// + /// Creates a project assets JSON string for testing, with optional self-contained configuration. + /// + /// Name of the project. + /// Output path for the project. + /// Path to the project file. + /// Set of target frameworks that should be configured as self-contained (via runtime download dependencies). If null, none are self-contained. + /// Set of target frameworks that should include a Microsoft.DotNet.ILCompiler reference (AOT). If null, none are AOT. + /// Target frameworks for the project. + private static string ProjectAssetsWithSelfContained(string projectName, string outputPath, string projectPath, ISet selfContainedTargetFrameworks, ISet aotTargetFrameworks = null, params string[] targetFrameworks) { LockFileFormat format = new(); LockFile lockFile = new(); @@ -194,7 +221,31 @@ private static string ProjectAssets(string projectName, string outputPath, strin outputPath += Path.DirectorySeparatorChar; } - lockFile.Targets = targetFrameworks.Select(tfm => new LockFileTarget() { TargetFramework = NuGetFramework.Parse(tfm) }).ToList(); + var targets = new List(); + foreach (var tfm in targetFrameworks) + { + var framework = NuGetFramework.Parse(tfm); + var isSelfContained = selfContainedTargetFrameworks != null && selfContainedTargetFrameworks.Contains(tfm); + var isAot = aotTargetFrameworks != null && aotTargetFrameworks.Contains(tfm); + + var target = new LockFileTarget { TargetFramework = framework }; + + // AOT projects have a Microsoft.DotNet.ILCompiler library in targets + if (isAot) + { + target.Libraries.Add(new LockFileTargetLibrary { Name = "Microsoft.DotNet.ILCompiler", Version = new NuGetVersion("8.0.0"), Type = "package" }); + } + + targets.Add(target); + + // Self-contained projects have an additional RID-qualified target in their assets file + if (isSelfContained) + { + targets.Add(new LockFileTarget { TargetFramework = framework, RuntimeIdentifier = "win-x64" }); + } + } + + lockFile.Targets = targets; lockFile.PackageSpec = new() { RestoreMetadata = new() @@ -205,6 +256,25 @@ private static string ProjectAssets(string projectName, string outputPath, strin }, }; + foreach (var tfm in targetFrameworks) + { + var isSelfContained = selfContainedTargetFrameworks != null && selfContainedTargetFrameworks.Contains(tfm); + + var tfi = new TargetFrameworkInformation + { + FrameworkName = NuGetFramework.Parse(tfm), + FrameworkReferences = + [ + new FrameworkDependency("Microsoft.NETCore.App", FrameworkDependencyFlags.All), + ], + DownloadDependencies = isSelfContained + ? [new DownloadDependency("Microsoft.NETCore.App.Ref", new VersionRange(new NuGetVersion("8.0.0"))), new DownloadDependency("Microsoft.NETCore.App.Runtime.win-x64", new VersionRange(new NuGetVersion("8.0.0")))] + : [], + }; + + lockFile.PackageSpec.TargetFrameworks.Add(tfi); + } + format.Write(textWriter, lockFile); return textWriter.ToString(); } @@ -239,6 +309,50 @@ private static Stream StreamFromString(string content) return stream; } + /// + /// Writes a .csproj (with isolation files so the repo's build config doesn't interfere), + /// runs dotnet restore, and returns the path to the generated project.assets.json. + /// The caller is responsible for cleaning up if desired. + /// + private async Task RestoreProjectAndGetAssetsPathAsync(string projectDir, string csproj) + { + Directory.CreateDirectory(projectDir); + + // Isolation files so the test project is not affected by the repo's + // Directory.Build.props / .targets / Directory.Packages.props / global.json. + await File.WriteAllTextAsync(Path.Combine(projectDir, "Directory.Build.props"), ""); + await File.WriteAllTextAsync(Path.Combine(projectDir, "Directory.Build.targets"), ""); + await File.WriteAllTextAsync(Path.Combine(projectDir, "Directory.Packages.props"), ""); + await File.WriteAllTextAsync(Path.Combine(projectDir, "global.json"), "{}"); + + // Minimal source file so restore doesn't complain. + await File.WriteAllTextAsync(Path.Combine(projectDir, "Program.cs"), "return;"); + + // The project definition supplied by the test. + var csprojPath = Path.Combine(projectDir, "test.csproj"); + await File.WriteAllTextAsync(csprojPath, csproj); + + var result = await this.realCommandLineService.ExecuteCommandAsync( + "dotnet", + default, + new DirectoryInfo(projectDir), + $"restore \"{csprojPath}\""); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"dotnet restore failed (exit {result.ExitCode}).\nstdout:\n{result.StdOut}\nstderr:\n{result.StdErr}"); + } + + var assetsPath = Path.Combine(projectDir, "obj", "project.assets.json"); + if (!File.Exists(assetsPath)) + { + throw new FileNotFoundException("project.assets.json was not generated by dotnet restore.", assetsPath); + } + + return assetsPath; + } + [TestMethod] public async Task TestDotNetDetectorWithNoFiles_ReturnsSuccessfullyAsync() { @@ -747,4 +861,402 @@ public async Task TestDotNetDetectorRebasePaths(string additionalPathSegment) discoveredComponents.Where(component => component.Component.Id == "4.5.6 net6.0 library - DotNet").Should().ContainSingle(); discoveredComponents.Where(component => component.Component.Id == "4.5.6 netstandard2.0 library - DotNet").Should().ContainSingle(); } + + [TestMethod] + public async Task TestDotNetDetectorSelfContainedWithSelfContainedProperty() + { + // Emit a self-contained .csproj, restore it, and use the real project.assets.json. + var projectDir = Path.Combine(Path.GetTempPath(), "cd-test-selfcontained-" + Guid.NewGuid().ToString("N")); + try + { + var csproj = $""" + + + {CurrentTfm} + Exe + win-x64 + true + + + """; + + var assetsPath = await this.RestoreProjectAndGetAssetsPathAsync(projectDir, csproj); + + // Parse the restored assets file to extract paths the detector will use. + var lockFileFormat = new LockFileFormat(); + var lockFile = lockFileFormat.Read(assetsPath); + var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath; + + // Trim trailing separator so mock filesystem paths are consistent. + // The detector will fall back to projectAssetsDirectory (derived from the + // stream location, which has no trailing sep) for EnumerateFiles. + var outputPath = Path.TrimEndingDirectorySeparator(lockFile.PackageSpec.RestoreMetadata.OutputPath); + + // Verify the restored assets have a RID-qualified target (e.g. net8.0/win-x64). + lockFile.Targets.Should().Contain(t => t.RuntimeIdentifier != null, "self-contained restore should produce a RID-qualified target"); + + var globalJson = GlobalJson("4.5.6"); + this.AddFile(Path.Combine(Path.GetDirectoryName(projectDir), "global.json"), globalJson); + this.SetCommandResult(0, "4.5.6"); + + this.AddFile(projectPath, null); + + using (var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location)) + { + var memoryStream = new MemoryStream(); + await applicationAssemblyStream.CopyToAsync(memoryStream); + memoryStream.Position = 0; + + this.AddFile(Path.Combine(outputPath, "Release", CurrentTfm, "test.dll"), memoryStream); + } + + var assetsContent = await File.ReadAllTextAsync(assetsPath); + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(assetsPath, assetsContent) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + + // Both the plain TFM and RID-qualified targets map to the same framework. + // The detector should report application-selfcontained for this framework. + discoveredComponents.Where(component => component.Component.Id == $"4.5.6 {CurrentTfm} application-selfcontained - DotNet").Should().ContainSingle(); + } + finally + { + if (Directory.Exists(projectDir)) + { + Directory.Delete(projectDir, recursive: true); + } + } + } + + [TestMethod] + public async Task TestDotNetDetectorSelfContainedLibrary() + { + // A library can also be self-contained when it sets SelfContained + RuntimeIdentifier. + var projectDir = Path.Combine(Path.GetTempPath(), "cd-test-selfcontained-lib-" + Guid.NewGuid().ToString("N")); + try + { + var csproj = $""" + + + {CurrentTfm} + win-x64 + true + + + """; + + var assetsPath = await this.RestoreProjectAndGetAssetsPathAsync(projectDir, csproj); + + var lockFileFormat = new LockFileFormat(); + var lockFile = lockFileFormat.Read(assetsPath); + var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath; + var outputPath = Path.TrimEndingDirectorySeparator(lockFile.PackageSpec.RestoreMetadata.OutputPath); + + // Self-contained library should also have a RID-qualified target. + lockFile.Targets.Should().Contain(t => t.RuntimeIdentifier != null, "self-contained restore should produce a RID-qualified target"); + + var globalJson = GlobalJson("4.5.6"); + this.AddFile(Path.Combine(Path.GetDirectoryName(projectDir), "global.json"), globalJson); + this.SetCommandResult(0, "4.5.6"); + + this.AddFile(projectPath, null); + + var libraryAssemblyStream = File.OpenRead(typeof(DotNetComponent).Assembly.Location); + this.AddFile(Path.Combine(outputPath, "Release", CurrentTfm, "test.dll"), libraryAssemblyStream); + + var assetsContent = await File.ReadAllTextAsync(assetsPath); + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(assetsPath, assetsContent) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == $"4.5.6 {CurrentTfm} library-selfcontained - DotNet").Should().ContainSingle(); + } + finally + { + if (Directory.Exists(projectDir)) + { + Directory.Delete(projectDir, recursive: true); + } + } + } + + [TestMethod] + public async Task TestDotNetDetectorSelfContainedWithPublishAot() + { + // PublishAot implies native AOT compilation (self-contained). + // The detector recognises this via the Microsoft.DotNet.ILCompiler reference + // that the SDK injects at restore time, regardless of RuntimeIdentifier. + var projectDir = Path.Combine(Path.GetTempPath(), "cd-test-aot-" + Guid.NewGuid().ToString("N")); + try + { + var csproj = $""" + + + {CurrentTfm} + Exe + true + + + """; + + var assetsPath = await this.RestoreProjectAndGetAssetsPathAsync(projectDir, csproj); + + var lockFileFormat = new LockFileFormat(); + var lockFile = lockFileFormat.Read(assetsPath); + var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath; + var outputPath = Path.TrimEndingDirectorySeparator(lockFile.PackageSpec.RestoreMetadata.OutputPath); + + // PublishAot projects should have Microsoft.DotNet.ILCompiler in targets. + lockFile.Targets.Should().Contain( + t => t.Libraries.Any(lib => lib.Name.Equals("Microsoft.DotNet.ILCompiler", StringComparison.OrdinalIgnoreCase)), + "PublishAot should produce an ILCompiler reference in the targets"); + + var globalJson = GlobalJson("4.5.6"); + this.AddFile(Path.Combine(Path.GetDirectoryName(projectDir), "global.json"), globalJson); + this.SetCommandResult(0, "4.5.6"); + + this.AddFile(projectPath, null); + + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(outputPath, "Release", CurrentTfm, "test.dll"), applicationAssemblyStream); + + var assetsContent = await File.ReadAllTextAsync(assetsPath); + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(assetsPath, assetsContent) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == $"4.5.6 {CurrentTfm} application-selfcontained - DotNet").Should().ContainSingle(); + } + finally + { + if (Directory.Exists(projectDir)) + { + Directory.Delete(projectDir, recursive: true); + } + } + } + + [TestMethod] + public async Task TestDotNetDetectorNotSelfContained() + { + // Framework-dependent app — no RuntimeIdentifier, no SelfContained. + var projectDir = Path.Combine(Path.GetTempPath(), "cd-test-fdd-" + Guid.NewGuid().ToString("N")); + try + { + var csproj = $""" + + + {CurrentTfm} + Exe + + + """; + + var assetsPath = await this.RestoreProjectAndGetAssetsPathAsync(projectDir, csproj); + + var lockFileFormat = new LockFileFormat(); + var lockFile = lockFileFormat.Read(assetsPath); + var projectPath = lockFile.PackageSpec.RestoreMetadata.ProjectPath; + var outputPath = Path.TrimEndingDirectorySeparator(lockFile.PackageSpec.RestoreMetadata.OutputPath); + + // Framework-dependent should NOT have RID-qualified targets. + lockFile.Targets.Should().NotContain( + t => t.RuntimeIdentifier != null, + "framework-dependent restore should not produce RID-qualified targets"); + + var globalJson = GlobalJson("4.5.6"); + this.AddFile(Path.Combine(Path.GetDirectoryName(projectDir), "global.json"), globalJson); + this.SetCommandResult(0, "4.5.6"); + + this.AddFile(projectPath, null); + + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(outputPath, "Release", CurrentTfm, "test.dll"), applicationAssemblyStream); + + var assetsContent = await File.ReadAllTextAsync(assetsPath); + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(assetsPath, assetsContent) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == $"4.5.6 {CurrentTfm} application - DotNet").Should().ContainSingle(); + } + finally + { + if (Directory.Exists(projectDir)) + { + Directory.Delete(projectDir, recursive: true); + } + } + } + + [TestMethod] + public async Task TestDotNetDetectorSyntheticSelfContainedApplication() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + + var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, new HashSet { "net8.0" }, aotTargetFrameworks: null, "net8.0"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application-selfcontained - DotNet").Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDotNetDetectorSyntheticSelfContainedLibrary() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var libraryProjectName = "library"; + var libraryProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{libraryProjectName}.csproj"); + this.AddFile(libraryProjectPath, null); + var libraryOutputPath = Path.Combine(Path.GetDirectoryName(libraryProjectPath), "obj"); + var libraryAssetsPath = Path.Combine(libraryOutputPath, "project.assets.json"); + + var libraryAssets = ProjectAssetsWithSelfContained("library", libraryOutputPath, libraryProjectPath, new HashSet { "net8.0" }, aotTargetFrameworks: null, "net8.0"); + var libraryAssemblyStream = File.OpenRead(typeof(DotNetComponent).Assembly.Location); + this.AddFile(Path.Combine(libraryOutputPath, "Release", "net8.0", "library.dll"), libraryAssemblyStream); + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(libraryAssetsPath, libraryAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 library-selfcontained - DotNet").Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDotNetDetectorSyntheticAotApplication() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + + // AOT: ILCompiler in targets, no framework reference + download dependency needed + var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, selfContainedTargetFrameworks: null, new HashSet { "net8.0" }, "net8.0"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application-selfcontained - DotNet").Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDotNetDetectorSyntheticNotSelfContained() + { + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + + // Framework-dependent: no self-contained, no AOT + var applicationAssets = ProjectAssets("application", applicationOutputPath, applicationProjectPath, "net8.0"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application - DotNet").Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDotNetDetectorMultiTargetWithMixedSelfContained() + { + // NuGet restore applies RuntimeIdentifier globally in cross-targeting, so a real + // restore can't produce per-TFM mixed self-contained/framework-dependent targets. + // Use the synthetic helper which correctly models per-TFM download dependencies + // and RID-qualified targets. + var globalJson = GlobalJson("4.5.6"); + var globalJsonDir = Path.Combine(RootDir, "path"); + this.AddFile(Path.Combine(globalJsonDir, "global.json"), globalJson); + + this.SetCommandResult(0, "4.5.6"); + + var applicationProjectName = "application"; + var applicationProjectPath = Path.Combine(RootDir, "path", "to", "project", $"{applicationProjectName}.csproj"); + this.AddFile(applicationProjectPath, null); + var applicationOutputPath = Path.Combine(Path.GetDirectoryName(applicationProjectPath), "obj"); + var applicationAssetsPath = Path.Combine(applicationOutputPath, "project.assets.json"); + + // Multi-target: net8.0 is self-contained (has runtime downloads + RID target), net6.0 is not + var applicationAssets = ProjectAssetsWithSelfContained("application", applicationOutputPath, applicationProjectPath, new HashSet { "net8.0" }, aotTargetFrameworks: null, "net8.0", "net6.0"); + var applicationAssemblyStream = File.OpenRead(Assembly.GetEntryAssembly().Location); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net8.0", "application.dll"), applicationAssemblyStream); + this.AddFile(Path.Combine(applicationOutputPath, "Release", "net6.0", "application.dll"), applicationAssemblyStream); + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(applicationAssetsPath, applicationAssets) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var discoveredComponents = componentRecorder.GetDetectedComponents().ToArray(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net8.0 application-selfcontained - DotNet").Should().ContainSingle(); + discoveredComponents.Where(component => component.Component.Id == "4.5.6 net6.0 application - DotNet").Should().ContainSingle(); + } }