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();
+ }
}