From 3c41a28e9ee179882c5b678cef3a53767075c71a Mon Sep 17 00:00:00 2001 From: SimaTian Date: Fri, 17 Apr 2026 11:23:47 +0200 Subject: [PATCH 01/10] Migrate GenerateDepsFile to IMultiThreadableTask Implements IMultiThreadableTask to enable safe multi-threaded execution: - Added [MSBuildMultiThreadableTask] attribute - Added TaskEnvironment property with NETFRAMEWORK polyfill - Absolutize ProjectPath at task level before passing to DependencyContextBuilder - Moved AssetsFilePath and RuntimeGraphPath absolutization into WriteDepsFile - Use AbsolutePath.OriginalValue for FilesWritten output to preserve relativity Addresses all 3 AR-May findings from PR #52936: 1. ProjectPath absolutization: Now absolutized at ExecuteCore level via TaskEnvironment.GetAbsolutePath, passes .Value (absolute) to DependencyContextBuilder 2. Cleanliness: AssetsFilePath and RuntimeGraphPath absolutization moved into WriteDepsFile where consumed 3. Output fidelity: FilesWritten uses AbsolutePath.OriginalValue to preserve user-supplied relative paths Added comprehensive behavioral tests in GivenAGenerateDepsFileMultiThreading.cs: - OutputRelativityIsPreserved: Verifies FilesWritten preserves relative paths - ProjectPathIsAbsolutized: Ensures ProjectPath is absolutized before use - DecoyCwdDoesNotAffectPathResolution: Validates CWD independence - CrossThreadEnvVarIsolation: Tests environment variable isolation - MultiProcessParity: Confirms identical output with/without TaskEnvironment - ConcurrentExecutionProducesCorrectResults: Parallel execution test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GenerateDepsFile.cs | 37 ++- .../GivenAGenerateDepsFileMultiThreading.cs | 273 ++++++++++++++++++ 2 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs index bdc14a94ac97..1480eb249b99 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs @@ -16,8 +16,20 @@ namespace Microsoft.NET.Build.Tasks /// /// Generates the $(project).deps.json file. /// - public class GenerateDepsFile : TaskBase + [MSBuildMultiThreadableTask] + public class GenerateDepsFile : TaskBase, IMultiThreadableTask { +#if NETFRAMEWORK + private TaskEnvironment _taskEnvironment; + public TaskEnvironment TaskEnvironment + { + get => _taskEnvironment ??= new TaskEnvironment(new ProcessTaskEnvironmentDriver(Directory.GetCurrentDirectory())); + set => _taskEnvironment = value; + } +#else + public TaskEnvironment TaskEnvironment { get; set; } = null!; +#endif + [Required] public string ProjectPath { get; set; } @@ -133,13 +145,14 @@ private Dictionary GetFilteredPackages() return filteredPackages; } - private void WriteDepsFile(string depsFilePath) + private void WriteDepsFile(AbsolutePath projectPath, AbsolutePath depsFilePath, TaskEnvironment taskEnvironment) { ProjectContext projectContext = null; LockFileLookup lockFileLookup = null; if (AssetsFilePath != null) { - LockFile lockFile = new LockFileCache(this).GetLockFile(AssetsFilePath); + AbsolutePath absoluteAssetsFilePath = taskEnvironment?.GetAbsolutePath(AssetsFilePath) ?? new AbsolutePath(Path.GetFullPath(AssetsFilePath)); + LockFile lockFile = new LockFileCache(this).GetLockFile(absoluteAssetsFilePath); projectContext = lockFile.CreateProjectContext( TargetFramework, EffectiveRuntimeIdentifier, @@ -153,7 +166,7 @@ private void WriteDepsFile(string depsFilePath) CompilationOptions compilationOptions = CompilationOptionsConverter.ConvertFrom(CompilerOptions); SingleProjectInfo mainProject = SingleProjectInfo.Create( - ProjectPath, + projectPath, AssemblyName, AssemblyExtension, AssemblyVersion, @@ -218,8 +231,12 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) // If a RID-graph is provided to the DependencyContextBuilder, it generates a RID-fallback // graph with respect to the target RuntimeIdentifier. - RuntimeGraph runtimeGraph = - IsSelfContained ? new RuntimeGraphCache(this).GetRuntimeGraph(RuntimeGraphPath) : null; + RuntimeGraph runtimeGraph = null; + if (IsSelfContained) + { + AbsolutePath absoluteRuntimeGraphPath = taskEnvironment?.GetAbsolutePath(RuntimeGraphPath) ?? new AbsolutePath(Path.GetFullPath(RuntimeGraphPath)); + runtimeGraph = new RuntimeGraphCache(this).GetRuntimeGraph(absoluteRuntimeGraphPath); + } builder = new DependencyContextBuilder(mainProject, IncludeRuntimeFileVersions, runtimeGraph, projectContext, lockFileLookup); } @@ -265,11 +282,11 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) DependencyContext dependencyContext = builder.Build(userRuntimeAssemblies); var writer = new DependencyContextWriter(); - using (var fileStream = File.Create(depsFilePath)) + using (var fileStream = File.Create(depsFilePath.Value)) { writer.Write(dependencyContext, fileStream); } - _filesWritten.Add(new TaskItem(depsFilePath)); + _filesWritten.Add(new TaskItem(depsFilePath.OriginalValue)); if (ValidRuntimeIdentifierPlatformsForAssets != null) { @@ -310,7 +327,9 @@ private bool ShouldWarnOnRuntimeIdentifer(string runtimeIdentifier) protected override void ExecuteCore() { - WriteDepsFile(DepsFilePath); + AbsolutePath absoluteProjectPath = TaskEnvironment?.GetAbsolutePath(ProjectPath) ?? new AbsolutePath(Path.GetFullPath(ProjectPath)); + AbsolutePath absoluteDepsFilePath = TaskEnvironment?.GetAbsolutePath(DepsFilePath) ?? new AbsolutePath(Path.GetFullPath(DepsFilePath)); + WriteDepsFile(absoluteProjectPath, absoluteDepsFilePath, TaskEnvironment); } } } diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs new file mode 100644 index 000000000000..a78bcce9bb8b --- /dev/null +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs @@ -0,0 +1,273 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; +using Task = System.Threading.Tasks.Task; + +namespace Microsoft.NET.Build.Tasks.UnitTests +{ + [Collection("CwdSensitive")] + public class GivenAGenerateDepsFileMultiThreading : IDisposable + { + private readonly List _tempDirs = new(); + private readonly string _originalCwd = Directory.GetCurrentDirectory(); + + [Fact] + public void OutputRelativityIsPreserved() + { + string projectDir = CreateTestProjectDirectory(); + string relativeDepsPath = Path.Combine("bin", "Debug", "net8.0", "TestApp.deps.json"); + string absoluteProjectPath = Path.Combine(projectDir, "TestApp.csproj"); + + var task = CreateMinimalTask(absoluteProjectPath, relativeDepsPath); + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + bool result = task.Execute(); + + result.Should().BeTrue("task should succeed"); + task.FilesWritten.Should().HaveCount(1, "should write one file"); + task.FilesWritten[0].ItemSpec.Should().Be(relativeDepsPath, + "FilesWritten should preserve the original relative path, not absolutize it"); + } + + [Fact] + public void ProjectPathIsAbsolutized() + { + string projectDir = CreateTestProjectDirectory(); + string relativeProjectPath = "TestApp.csproj"; + string absoluteDepsPath = Path.Combine(projectDir, "bin", "Debug", "net8.0", "TestApp.deps.json"); + + var originalCwd = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(projectDir); + + var task = CreateMinimalTask(relativeProjectPath, absoluteDepsPath); + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + bool result = task.Execute(); + + result.Should().BeTrue("task should succeed"); + + // Verify the deps.json was written to the correct absolute path + File.Exists(absoluteDepsPath).Should().BeTrue("deps file should be created"); + + // Parse and check that mainProject has absolute path + var depsJson = JObject.Parse(File.ReadAllText(absoluteDepsPath)); + string projectPath = depsJson.SelectToken("targets..TestApp/1.0.0")?.Parent?.Parent?.Parent?.Parent? + .SelectToken("libraries['TestApp/1.0.0'].path")?.ToString(); + + // The path stored should be absolute because we absolutized ProjectPath + if (!string.IsNullOrEmpty(projectPath)) + { + Path.IsPathRooted(projectPath).Should().BeTrue( + "ProjectPath should be absolutized internally even when passed as relative"); + } + } + finally + { + Directory.SetCurrentDirectory(originalCwd); + } + } + + [Fact] + public void DecoyCwdDoesNotAffectPathResolution() + { + string projectDir = CreateTestProjectDirectory(); + string decoyCwd = CreateTestProjectDirectory("DecoyDir"); + string relativeDepsPath = Path.Combine("bin", "Debug", "net8.0", "TestApp.deps.json"); + string absoluteProjectPath = Path.Combine(projectDir, "TestApp.csproj"); + + var originalCwd = Directory.GetCurrentDirectory(); + try + { + // Set CWD to a decoy directory - task should still resolve relative to projectDir + Directory.SetCurrentDirectory(decoyCwd); + + var task = CreateMinimalTask(absoluteProjectPath, relativeDepsPath); + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + bool result = task.Execute(); + + result.Should().BeTrue("task should succeed"); + + // File should be written relative to project directory, not decoy CWD + string expectedPath = Path.Combine(projectDir, relativeDepsPath); + File.Exists(expectedPath).Should().BeTrue( + "deps file should be written relative to TaskEnvironment.ProjectDirectory, not process CWD"); + + // Verify it was NOT written to the decoy directory + string decoyPath = Path.Combine(decoyCwd, relativeDepsPath); + File.Exists(decoyPath).Should().BeFalse( + "deps file should NOT be written to the decoy CWD"); + } + finally + { + Directory.SetCurrentDirectory(originalCwd); + } + } + + [Fact] + public async Task CrossThreadEnvVarIsolation() + { + string projectDir1 = CreateTestProjectDirectory("Project1"); + string projectDir2 = CreateTestProjectDirectory("Project2"); + + var env1 = TaskEnvironmentHelper.CreateForTest(projectDir1); + env1.SetEnvironmentVariable("TEST_GENERATE_DEPS_VAR", "value1"); + + var env2 = TaskEnvironmentHelper.CreateForTest(projectDir2); + env2.SetEnvironmentVariable("TEST_GENERATE_DEPS_VAR", "value2"); + + var task1 = CreateMinimalTask( + Path.Combine(projectDir1, "TestApp.csproj"), + Path.Combine(projectDir1, "TestApp.deps.json")); + task1.TaskEnvironment = env1; + + var task2 = CreateMinimalTask( + Path.Combine(projectDir2, "TestApp.csproj"), + Path.Combine(projectDir2, "TestApp.deps.json")); + task2.TaskEnvironment = env2; + + var task1Execution = Task.Run(() => task1.Execute()); + var task2Execution = Task.Run(() => task2.Execute()); + + await Task.WhenAll(task1Execution, task2Execution); + + (await task1Execution).Should().BeTrue("task1 should succeed"); + (await task2Execution).Should().BeTrue("task2 should succeed"); + + // Verify each task operated in its own environment + env1.GetEnvironmentVariable("TEST_GENERATE_DEPS_VAR").Should().Be("value1"); + env2.GetEnvironmentVariable("TEST_GENERATE_DEPS_VAR").Should().Be("value2"); + } + + [Fact] + public void MultiProcessParity() + { + string projectDir = CreateTestProjectDirectory(); + string projectPath = Path.Combine(projectDir, "TestApp.csproj"); + string depsPath = Path.Combine(projectDir, "TestApp.deps.json"); + + // Run without TaskEnvironment (legacy) + var task1 = CreateMinimalTask(projectPath, depsPath); + bool result1 = task1.Execute(); + result1.Should().BeTrue("legacy task should succeed"); + string deps1 = File.ReadAllText(depsPath); + File.Delete(depsPath); + + // Run with TaskEnvironment + var task2 = CreateMinimalTask(projectPath, depsPath); + task2.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + bool result2 = task2.Execute(); + result2.Should().BeTrue("multi-threadable task should succeed"); + string deps2 = File.ReadAllText(depsPath); + + // Normalize JSON for comparison (whitespace/formatting may differ) + var json1 = JsonConvert.DeserializeObject(deps1); + var json2 = JsonConvert.DeserializeObject(deps2); + + JToken.DeepEquals(json1, json2).Should().BeTrue( + "deps.json content should be identical whether using TaskEnvironment or not"); + } + + [Fact] + public async Task ConcurrentExecutionProducesCorrectResults() + { + const int concurrency = 4; + var projectDirs = new string[concurrency]; + var tasks = new GenerateDepsFile[concurrency]; + var executionTasks = new Task[concurrency]; + + for (int i = 0; i < concurrency; i++) + { + projectDirs[i] = CreateTestProjectDirectory($"ConcurrentProject{i}"); + string projectPath = Path.Combine(projectDirs[i], "TestApp.csproj"); + string depsPath = Path.Combine(projectDirs[i], "TestApp.deps.json"); + + tasks[i] = CreateMinimalTask(projectPath, depsPath); + tasks[i].TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDirs[i]); + } + + for (int i = 0; i < concurrency; i++) + { + var t = tasks[i]; + executionTasks[i] = Task.Run(() => t.Execute()); + } + + await Task.WhenAll(executionTasks); + + for (int i = 0; i < concurrency; i++) + { + (await executionTasks[i]).Should().BeTrue($"task {i} should succeed"); + string depsPath = Path.Combine(projectDirs[i], "TestApp.deps.json"); + File.Exists(depsPath).Should().BeTrue($"task {i} should create deps file"); + } + + // Verify all created valid JSON + for (int i = 0; i < concurrency; i++) + { + string depsPath = Path.Combine(projectDirs[i], "TestApp.deps.json"); + var json = JObject.Parse(File.ReadAllText(depsPath)); + json.Should().NotBeNull($"task {i} should produce valid JSON"); + } + } + + private string CreateTestProjectDirectory([CallerMemberName] string testName = null) + { + string testDir = Path.Combine(Path.GetTempPath(), $"GenerateDepsFileTest_{testName}_{Guid.NewGuid():N}"); + Directory.CreateDirectory(testDir); + Directory.CreateDirectory(Path.Combine(testDir, "bin", "Debug", "net8.0")); + _tempDirs.Add(testDir); + + // Create a minimal runtime.json + string runtimeJsonPath = Path.Combine(testDir, "runtime.json"); + File.WriteAllText(runtimeJsonPath, @"{ + ""runtimes"": { + ""win"": {}, + ""win-x64"": { ""#import"": [ ""win"" ] } + } +}"); + + return testDir; + } + + private GenerateDepsFile CreateMinimalTask(string projectPath, string depsFilePath) + { + string projectDir = Path.GetDirectoryName(projectPath); + return new GenerateDepsFile + { + BuildEngine = new MockBuildEngine(), + ProjectPath = projectPath, + DepsFilePath = depsFilePath, + TargetFramework = "net8.0", + AssemblyName = "TestApp", + AssemblyExtension = ".dll", + AssemblyVersion = "1.0.0.0", + IncludeMainProject = true, + CompileReferences = Array.Empty(), + ResolvedNuGetFiles = Array.Empty(), + ResolvedRuntimeTargetsFiles = Array.Empty(), + RuntimeGraphPath = Path.Combine(projectDir, "runtime.json"), + }; + } + + public void Dispose() + { + Directory.SetCurrentDirectory(_originalCwd); + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + } +} From 5dc9a2b8d480f91526dd2e228d6f238360026bcc Mon Sep 17 00:00:00 2001 From: SimaTian Date: Wed, 29 Apr 2026 13:25:46 +0200 Subject: [PATCH 02/10] Fix GenerateDepsFile task environment resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GenerateDepsFile.cs | 24 +- .../GivenAGenerateDepsFileMultiThreading.cs | 432 +++++++++++++----- 2 files changed, 325 insertions(+), 131 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs index 1480eb249b99..9333f9f9e300 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs @@ -19,16 +19,7 @@ namespace Microsoft.NET.Build.Tasks [MSBuildMultiThreadableTask] public class GenerateDepsFile : TaskBase, IMultiThreadableTask { -#if NETFRAMEWORK - private TaskEnvironment _taskEnvironment; - public TaskEnvironment TaskEnvironment - { - get => _taskEnvironment ??= new TaskEnvironment(new ProcessTaskEnvironmentDriver(Directory.GetCurrentDirectory())); - set => _taskEnvironment = value; - } -#else - public TaskEnvironment TaskEnvironment { get; set; } = null!; -#endif + public TaskEnvironment TaskEnvironment { get; set; } [Required] public string ProjectPath { get; set; } @@ -151,7 +142,7 @@ private void WriteDepsFile(AbsolutePath projectPath, AbsolutePath depsFilePath, LockFileLookup lockFileLookup = null; if (AssetsFilePath != null) { - AbsolutePath absoluteAssetsFilePath = taskEnvironment?.GetAbsolutePath(AssetsFilePath) ?? new AbsolutePath(Path.GetFullPath(AssetsFilePath)); + AbsolutePath absoluteAssetsFilePath = taskEnvironment.GetAbsolutePath(AssetsFilePath); LockFile lockFile = new LockFileCache(this).GetLockFile(absoluteAssetsFilePath); projectContext = lockFile.CreateProjectContext( TargetFramework, @@ -234,7 +225,7 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) RuntimeGraph runtimeGraph = null; if (IsSelfContained) { - AbsolutePath absoluteRuntimeGraphPath = taskEnvironment?.GetAbsolutePath(RuntimeGraphPath) ?? new AbsolutePath(Path.GetFullPath(RuntimeGraphPath)); + AbsolutePath absoluteRuntimeGraphPath = taskEnvironment.GetAbsolutePath(RuntimeGraphPath); runtimeGraph = new RuntimeGraphCache(this).GetRuntimeGraph(absoluteRuntimeGraphPath); } @@ -327,9 +318,12 @@ private bool ShouldWarnOnRuntimeIdentifer(string runtimeIdentifier) protected override void ExecuteCore() { - AbsolutePath absoluteProjectPath = TaskEnvironment?.GetAbsolutePath(ProjectPath) ?? new AbsolutePath(Path.GetFullPath(ProjectPath)); - AbsolutePath absoluteDepsFilePath = TaskEnvironment?.GetAbsolutePath(DepsFilePath) ?? new AbsolutePath(Path.GetFullPath(DepsFilePath)); - WriteDepsFile(absoluteProjectPath, absoluteDepsFilePath, TaskEnvironment); + TaskEnvironment taskEnvironment = TaskEnvironment + ?? throw new InvalidOperationException($"{nameof(GenerateDepsFile)} requires MSBuild to provide a {nameof(TaskEnvironment)}."); + + AbsolutePath absoluteProjectPath = taskEnvironment.GetAbsolutePath(ProjectPath); + AbsolutePath absoluteDepsFilePath = taskEnvironment.GetAbsolutePath(DepsFilePath); + WriteDepsFile(absoluteProjectPath, absoluteDepsFilePath, taskEnvironment); } } } diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs index a78bcce9bb8b..4cd15cbfef52 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs @@ -3,7 +3,9 @@ #nullable disable +using System.Collections.Concurrent; using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Build.Framework; @@ -15,6 +17,11 @@ namespace Microsoft.NET.Build.Tasks.UnitTests { + [CollectionDefinition("CwdSensitive", DisableParallelization = true)] + public sealed class CwdSensitiveCollection + { + } + [Collection("CwdSensitive")] public class GivenAGenerateDepsFileMultiThreading : IDisposable { @@ -40,38 +47,45 @@ public void OutputRelativityIsPreserved() } [Fact] - public void ProjectPathIsAbsolutized() + public void TaskEnvironmentIsRequired() { string projectDir = CreateTestProjectDirectory(); - string relativeProjectPath = "TestApp.csproj"; - string absoluteDepsPath = Path.Combine(projectDir, "bin", "Debug", "net8.0", "TestApp.deps.json"); + string relativeDepsPath = Path.Combine("bin", "Debug", "net8.0", "TestApp.deps.json"); + + var task = CreateMinimalTask(Path.Combine(projectDir, "TestApp.csproj"), relativeDepsPath, projectDir); + + Action act = () => task.Execute(); + + act.Should().Throw() + .WithMessage($"*{nameof(TaskEnvironment)}*"); + } + + [Fact] + public void AssetsFilePathIsResolvedThroughTaskEnvironment() + { + string projectDir = CreateTestProjectDirectory(); + string decoyCwd = CreateTestProjectDirectory("AssetsDecoy"); + string relativeAssetsPath = Path.Combine("obj", "project.assets.json"); + CreateAssetsFile(projectDir, relativeAssetsPath); var originalCwd = Directory.GetCurrentDirectory(); try { - Directory.SetCurrentDirectory(projectDir); + Directory.SetCurrentDirectory(decoyCwd); - var task = CreateMinimalTask(relativeProjectPath, absoluteDepsPath); + var task = CreateMinimalTask("TestApp.csproj", "assets.deps.json", projectDir); + task.AssetsFilePath = relativeAssetsPath; task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); bool result = task.Execute(); - result.Should().BeTrue("task should succeed"); - - // Verify the deps.json was written to the correct absolute path - File.Exists(absoluteDepsPath).Should().BeTrue("deps file should be created"); - - // Parse and check that mainProject has absolute path - var depsJson = JObject.Parse(File.ReadAllText(absoluteDepsPath)); - string projectPath = depsJson.SelectToken("targets..TestApp/1.0.0")?.Parent?.Parent?.Parent?.Parent? - .SelectToken("libraries['TestApp/1.0.0'].path")?.ToString(); - - // The path stored should be absolute because we absolutized ProjectPath - if (!string.IsNullOrEmpty(projectPath)) - { - Path.IsPathRooted(projectPath).Should().BeTrue( - "ProjectPath should be absolutized internally even when passed as relative"); - } + result.Should().BeTrue( + "AssetsFilePath should be resolved from TaskEnvironment.ProjectDirectory, not process CWD. Errors: {0}", + GetLoggedErrors(task)); + File.Exists(Path.Combine(projectDir, "assets.deps.json")).Should().BeTrue( + "deps file should be written under the task environment project directory"); + File.Exists(Path.Combine(decoyCwd, "assets.deps.json")).Should().BeFalse( + "deps file should not be written under the process CWD"); } finally { @@ -80,35 +94,37 @@ public void ProjectPathIsAbsolutized() } [Fact] - public void DecoyCwdDoesNotAffectPathResolution() + public void RuntimeGraphPathIsResolvedThroughTaskEnvironment() { string projectDir = CreateTestProjectDirectory(); - string decoyCwd = CreateTestProjectDirectory("DecoyDir"); - string relativeDepsPath = Path.Combine("bin", "Debug", "net8.0", "TestApp.deps.json"); - string absoluteProjectPath = Path.Combine(projectDir, "TestApp.csproj"); + string decoyCwd = CreateTestProjectDirectory("RuntimeGraphDecoy"); + string decoyRuntimeGraphPath = Path.Combine(decoyCwd, "runtime.json"); + File.Delete(decoyRuntimeGraphPath); + string relativeAssetsPath = Path.Combine("obj", "project.assets.json"); + CreateAssetsFile(projectDir, relativeAssetsPath); var originalCwd = Directory.GetCurrentDirectory(); try { - // Set CWD to a decoy directory - task should still resolve relative to projectDir Directory.SetCurrentDirectory(decoyCwd); + File.Exists(decoyRuntimeGraphPath).Should().BeFalse( + "the process CWD must not contain a runtime graph that could satisfy RuntimeGraphPath"); - var task = CreateMinimalTask(absoluteProjectPath, relativeDepsPath); + var task = CreateMinimalTask("TestApp.csproj", "selfcontained.deps.json", projectDir); + task.AssetsFilePath = relativeAssetsPath; + task.RuntimeGraphPath = "runtime.json"; + task.IsSelfContained = true; task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); bool result = task.Execute(); - result.Should().BeTrue("task should succeed"); - - // File should be written relative to project directory, not decoy CWD - string expectedPath = Path.Combine(projectDir, relativeDepsPath); - File.Exists(expectedPath).Should().BeTrue( - "deps file should be written relative to TaskEnvironment.ProjectDirectory, not process CWD"); - - // Verify it was NOT written to the decoy directory - string decoyPath = Path.Combine(decoyCwd, relativeDepsPath); - File.Exists(decoyPath).Should().BeFalse( - "deps file should NOT be written to the decoy CWD"); + result.Should().BeTrue( + "RuntimeGraphPath should be resolved from TaskEnvironment.ProjectDirectory, not process CWD. Errors: {0}", + GetLoggedErrors(task)); + File.Exists(Path.Combine(projectDir, "selfcontained.deps.json")).Should().BeTrue( + "the relative runtime graph should be read from the task environment project directory"); + File.Exists(Path.Combine(decoyCwd, "selfcontained.deps.json")).Should().BeFalse( + "deps file should not be written under the process CWD"); } finally { @@ -117,108 +133,154 @@ public void DecoyCwdDoesNotAffectPathResolution() } [Fact] - public async Task CrossThreadEnvVarIsolation() + public void GenerateDepsFileHasMSBuildMultiThreadableMarkers() { - string projectDir1 = CreateTestProjectDirectory("Project1"); - string projectDir2 = CreateTestProjectDirectory("Project2"); - - var env1 = TaskEnvironmentHelper.CreateForTest(projectDir1); - env1.SetEnvironmentVariable("TEST_GENERATE_DEPS_VAR", "value1"); + typeof(GenerateDepsFile).Should().BeAssignableTo(); + typeof(GenerateDepsFile) + .GetCustomAttributes(typeof(MSBuildMultiThreadableTaskAttribute), inherit: false) + .Should() + .ContainSingle("MSBuild should recognize GenerateDepsFile as multi-threadable through the marker attribute"); + } - var env2 = TaskEnvironmentHelper.CreateForTest(projectDir2); - env2.SetEnvironmentVariable("TEST_GENERATE_DEPS_VAR", "value2"); + [Fact] + public void ProjectDirectoryControlsRelativePathResolution() + { + string projectDir = CreateTestProjectDirectory(); + string decoyCwd = CreateTestProjectDirectory("DecoyDir"); + string relativeProjectPath = "TestApp.csproj"; + string relativeDepsPath = Path.Combine("bin", "Debug", "net8.0", "TestApp.deps.json"); - var task1 = CreateMinimalTask( - Path.Combine(projectDir1, "TestApp.csproj"), - Path.Combine(projectDir1, "TestApp.deps.json")); - task1.TaskEnvironment = env1; + var originalCwd = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(decoyCwd); - var task2 = CreateMinimalTask( - Path.Combine(projectDir2, "TestApp.csproj"), - Path.Combine(projectDir2, "TestApp.deps.json")); - task2.TaskEnvironment = env2; + var task = CreateMinimalTask(relativeProjectPath, relativeDepsPath, projectDir); + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); - var task1Execution = Task.Run(() => task1.Execute()); - var task2Execution = Task.Run(() => task2.Execute()); + bool result = task.Execute(); - await Task.WhenAll(task1Execution, task2Execution); + result.Should().BeTrue("task should resolve relative paths from TaskEnvironment.ProjectDirectory"); - (await task1Execution).Should().BeTrue("task1 should succeed"); - (await task2Execution).Should().BeTrue("task2 should succeed"); + string expectedPath = Path.Combine(projectDir, relativeDepsPath); + File.Exists(expectedPath).Should().BeTrue( + "deps file should be written relative to TaskEnvironment.ProjectDirectory, not process CWD"); - // Verify each task operated in its own environment - env1.GetEnvironmentVariable("TEST_GENERATE_DEPS_VAR").Should().Be("value1"); - env2.GetEnvironmentVariable("TEST_GENERATE_DEPS_VAR").Should().Be("value2"); + string decoyPath = Path.Combine(decoyCwd, relativeDepsPath); + File.Exists(decoyPath).Should().BeFalse( + "deps file should not be written to the process CWD"); + } + finally + { + Directory.SetCurrentDirectory(originalCwd); + } } [Fact] - public void MultiProcessParity() + public void InjectedTaskEnvironmentProducesSameDepsJsonForAbsoluteAndRelativeProjectPathInputs() { string projectDir = CreateTestProjectDirectory(); - string projectPath = Path.Combine(projectDir, "TestApp.csproj"); - string depsPath = Path.Combine(projectDir, "TestApp.deps.json"); - - // Run without TaskEnvironment (legacy) - var task1 = CreateMinimalTask(projectPath, depsPath); - bool result1 = task1.Execute(); - result1.Should().BeTrue("legacy task should succeed"); - string deps1 = File.ReadAllText(depsPath); - File.Delete(depsPath); - - // Run with TaskEnvironment - var task2 = CreateMinimalTask(projectPath, depsPath); - task2.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); - bool result2 = task2.Execute(); - result2.Should().BeTrue("multi-threadable task should succeed"); - string deps2 = File.ReadAllText(depsPath); - - // Normalize JSON for comparison (whitespace/formatting may differ) - var json1 = JsonConvert.DeserializeObject(deps1); - var json2 = JsonConvert.DeserializeObject(deps2); - - JToken.DeepEquals(json1, json2).Should().BeTrue( - "deps.json content should be identical whether using TaskEnvironment or not"); + string decoyCwd = CreateTestProjectDirectory("ParityDecoy"); + string absoluteDepsPath = Path.Combine(projectDir, "absolute.deps.json"); + string relativeDepsPath = "relative.deps.json"; + string assetsPath = CreateAssetsFile(projectDir, Path.Combine("obj", "project.assets.json"), includeProjectReference: true); + ITaskItem projectReference = CreateProjectReference(projectDir); + + var originalCwd = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(decoyCwd); + + var absoluteTask = CreateMinimalTask( + Path.Combine(projectDir, "TestApp.csproj"), + absoluteDepsPath, + projectDir); + absoluteTask.AssetsFilePath = assetsPath; + absoluteTask.ReferencePaths = [projectReference]; + absoluteTask.UserRuntimeAssemblies = [projectReference]; + absoluteTask.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + absoluteTask.Execute().Should().BeTrue("absolute input task should succeed"); + + var relativeTask = CreateMinimalTask("TestApp.csproj", relativeDepsPath, projectDir); + relativeTask.AssetsFilePath = assetsPath; + relativeTask.ReferencePaths = [projectReference]; + relativeTask.UserRuntimeAssemblies = [projectReference]; + relativeTask.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + relativeTask.Execute().Should().BeTrue("relative input task should succeed"); + + var absoluteJson = JsonConvert.DeserializeObject(File.ReadAllText(absoluteDepsPath)); + var relativeJson = JsonConvert.DeserializeObject(File.ReadAllText(Path.Combine(projectDir, relativeDepsPath))); + + FindTargetLibrary(relativeJson, "ReferenceProject/1.0.0").Should().NotBeNull( + "relative ProjectPath should be absolutized before project-reference lookup"); + JToken.DeepEquals(absoluteJson, relativeJson).Should().BeTrue( + "relative paths should be absolutized through TaskEnvironment before deps generation"); + relativeTask.FilesWritten[0].ItemSpec.Should().Be(relativeDepsPath, + "FilesWritten should preserve the caller's original relative output path"); + } + finally + { + Directory.SetCurrentDirectory(originalCwd); + } } [Fact] - public async Task ConcurrentExecutionProducesCorrectResults() + public async Task ConcurrentExecutionWithRelativePathsKeepsCwdStable() { - const int concurrency = 4; + const int concurrency = 64; + string decoyCwd = CreateTestProjectDirectory("StressDecoy"); var projectDirs = new string[concurrency]; - var tasks = new GenerateDepsFile[concurrency]; - var executionTasks = new Task[concurrency]; for (int i = 0; i < concurrency; i++) { - projectDirs[i] = CreateTestProjectDirectory($"ConcurrentProject{i}"); - string projectPath = Path.Combine(projectDirs[i], "TestApp.csproj"); - string depsPath = Path.Combine(projectDirs[i], "TestApp.deps.json"); - - tasks[i] = CreateMinimalTask(projectPath, depsPath); - tasks[i].TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDirs[i]); + projectDirs[i] = CreateTestProjectDirectory($"StressProject{i}"); } - for (int i = 0; i < concurrency; i++) + using var startBarrier = new Barrier(concurrency); + var failures = new ConcurrentBag(); + var executionTasks = new Task[concurrency]; + var originalCwd = Directory.GetCurrentDirectory(); + + try { - var t = tasks[i]; - executionTasks[i] = Task.Run(() => t.Execute()); - } + Directory.SetCurrentDirectory(decoyCwd); - await Task.WhenAll(executionTasks); + for (int i = 0; i < concurrency; i++) + { + int taskIndex = i; + executionTasks[i] = Task.Factory.StartNew(() => + { + string relativeDepsPath = Path.Combine("bin", "Debug", "net8.0", $"TestApp{taskIndex}.deps.json"); + try + { + startBarrier.SignalAndWait(TimeSpan.FromSeconds(30)); + + var task = CreateMinimalTask("TestApp.csproj", relativeDepsPath, projectDirs[taskIndex]); + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDirs[taskIndex]); + + task.Execute().Should().BeTrue($"task {taskIndex} should succeed"); + File.Exists(Path.Combine(projectDirs[taskIndex], relativeDepsPath)).Should().BeTrue( + $"task {taskIndex} should write under its project directory"); + File.Exists(Path.Combine(decoyCwd, relativeDepsPath)).Should().BeFalse( + $"task {taskIndex} should not write under the process CWD"); + task.FilesWritten[0].ItemSpec.Should().Be(relativeDepsPath); + } + catch (Exception ex) + { + failures.Add(ex); + } + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } - for (int i = 0; i < concurrency; i++) - { - (await executionTasks[i]).Should().BeTrue($"task {i} should succeed"); - string depsPath = Path.Combine(projectDirs[i], "TestApp.deps.json"); - File.Exists(depsPath).Should().BeTrue($"task {i} should create deps file"); - } + await Task.WhenAll(executionTasks); - // Verify all created valid JSON - for (int i = 0; i < concurrency; i++) + failures.Should().BeEmpty(); + Directory.GetCurrentDirectory().Should().Be(decoyCwd, + "GenerateDepsFile must not mutate process CWD during concurrent execution"); + } + finally { - string depsPath = Path.Combine(projectDirs[i], "TestApp.deps.json"); - var json = JObject.Parse(File.ReadAllText(depsPath)); - json.Should().NotBeNull($"task {i} should produce valid JSON"); + Directory.SetCurrentDirectory(originalCwd); } } @@ -228,7 +290,7 @@ private string CreateTestProjectDirectory([CallerMemberName] string testName = n Directory.CreateDirectory(testDir); Directory.CreateDirectory(Path.Combine(testDir, "bin", "Debug", "net8.0")); _tempDirs.Add(testDir); - + // Create a minimal runtime.json string runtimeJsonPath = Path.Combine(testDir, "runtime.json"); File.WriteAllText(runtimeJsonPath, @"{ @@ -237,13 +299,15 @@ private string CreateTestProjectDirectory([CallerMemberName] string testName = n ""win-x64"": { ""#import"": [ ""win"" ] } } }"); - + return testDir; } - private GenerateDepsFile CreateMinimalTask(string projectPath, string depsFilePath) + private GenerateDepsFile CreateMinimalTask(string projectPath, string depsFilePath, string projectDirectory = null) { - string projectDir = Path.GetDirectoryName(projectPath); + string projectDir = projectDirectory ?? Path.GetDirectoryName(projectPath); + projectDir.Should().NotBeNull("relative project paths require the test project directory to be provided"); + return new GenerateDepsFile { BuildEngine = new MockBuildEngine(), @@ -261,6 +325,142 @@ private GenerateDepsFile CreateMinimalTask(string projectPath, string depsFilePa }; } + private static ITaskItem CreateProjectReference(string projectDir) + { + string referenceProjectDir = Path.Combine(projectDir, "ReferenceProject"); + string referenceProjectPath = Path.Combine(referenceProjectDir, "ReferenceProject.csproj"); + string referenceAssemblyPath = Path.Combine(referenceProjectDir, "bin", "Debug", "net8.0", "ReferenceProject.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(referenceAssemblyPath)); + File.WriteAllText(referenceProjectPath, ""); + File.WriteAllText(referenceAssemblyPath, string.Empty); + + var reference = new TaskItem(referenceAssemblyPath); + reference.SetMetadata(MetadataKeys.ReferenceSourceTarget, "ProjectReference"); + reference.SetMetadata(MetadataKeys.MSBuildSourceProjectFile, referenceProjectPath); + reference.SetMetadata("Version", "1.0.0"); + return reference; + } + + private static string CreateAssetsFile( + string projectDir, + string relativeAssetsPath, + string targetName = "net8.0", + bool includeProjectReference = false) + { + string assetsPath = Path.Combine(projectDir, relativeAssetsPath); + Directory.CreateDirectory(Path.GetDirectoryName(assetsPath)); + + var targetLibraries = new JObject(); + var libraries = new JObject(); + var projectFileDependencies = new JArray(); + var frameworkDependencies = new JObject(); + var restoreProjectReferences = new JObject(); + + if (includeProjectReference) + { + string relativeProjectReferencePath = Path.Combine("ReferenceProject", "ReferenceProject.csproj"); + string projectReferenceName = "ReferenceProject/1.0.0"; + + targetLibraries[projectReferenceName] = new JObject + { + ["type"] = "project", + ["compile"] = new JObject + { + ["bin/ReferenceProject.dll"] = new JObject() + }, + ["runtime"] = new JObject + { + ["bin/ReferenceProject.dll"] = new JObject() + } + }; + libraries[projectReferenceName] = new JObject + { + ["type"] = "project", + ["path"] = relativeProjectReferencePath, + ["msbuildProject"] = relativeProjectReferencePath + }; + projectFileDependencies.Add("ReferenceProject >= 1.0.0"); + frameworkDependencies["ReferenceProject"] = new JObject + { + ["target"] = "Project", + ["version"] = "[1.0.0, )" + }; + restoreProjectReferences[relativeProjectReferencePath] = new JObject + { + ["projectPath"] = Path.Combine(projectDir, relativeProjectReferencePath) + }; + } + + var assets = new JObject + { + ["version"] = 3, + ["targets"] = new JObject + { + [targetName] = targetLibraries + }, + ["libraries"] = libraries, + ["projectFileDependencyGroups"] = new JObject + { + ["net8.0"] = projectFileDependencies + }, + ["packageFolders"] = new JObject(), + ["project"] = new JObject + { + ["version"] = "1.0.0", + ["restore"] = new JObject + { + ["projectUniqueName"] = Path.Combine(projectDir, "TestApp.csproj"), + ["projectName"] = "TestApp", + ["projectPath"] = Path.Combine(projectDir, "TestApp.csproj"), + ["packagesPath"] = Path.Combine(projectDir, ".nuget", "packages"), + ["outputPath"] = Path.Combine(projectDir, "obj"), + ["projectStyle"] = "PackageReference", + ["fallbackFolders"] = new JArray(), + ["configFilePaths"] = new JArray(), + ["originalTargetFrameworks"] = new JArray("net8.0"), + ["sources"] = new JObject(), + ["frameworks"] = new JObject + { + ["net8.0"] = new JObject + { + ["targetAlias"] = "net8.0", + ["projectReferences"] = restoreProjectReferences + } + }, + ["warningProperties"] = new JObject + { + ["warnAsError"] = new JArray("NU1605") + } + }, + ["frameworks"] = new JObject + { + ["net8.0"] = new JObject + { + ["targetAlias"] = "net8.0", + ["dependencies"] = frameworkDependencies, + ["runtimeIdentifierGraphPath"] = Path.Combine(projectDir, "runtime.json") + } + } + } + }; + + File.WriteAllText(assetsPath, assets.ToString(Formatting.Indented)); + return assetsPath; + } + + private static JToken FindTargetLibrary(JObject depsJson, string libraryName) + { + return ((JObject)depsJson["targets"]) + .Properties() + .Select(target => target.Value[libraryName]) + .FirstOrDefault(library => library != null); + } + + private static string GetLoggedErrors(GenerateDepsFile task) + { + return string.Join(Environment.NewLine, ((MockBuildEngine)task.BuildEngine).Errors.Select(e => e.Message)); + } + public void Dispose() { Directory.SetCurrentDirectory(_originalCwd); From 2dff72fe083b34c9d3f4f99a700a0c5aa7866245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Tue, 19 May 2026 16:29:58 +0200 Subject: [PATCH 03/10] removed redundant tests, right TaskEnvironment --- .../GenerateDepsFile.cs | 17 +- .../GivenAGenerateDepsFileMultiThreading.cs | 417 +++--------------- 2 files changed, 67 insertions(+), 367 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs index 9333f9f9e300..0aca2ae804eb 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs @@ -19,7 +19,7 @@ namespace Microsoft.NET.Build.Tasks [MSBuildMultiThreadableTask] public class GenerateDepsFile : TaskBase, IMultiThreadableTask { - public TaskEnvironment TaskEnvironment { get; set; } + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; [Required] public string ProjectPath { get; set; } @@ -136,13 +136,13 @@ private Dictionary GetFilteredPackages() return filteredPackages; } - private void WriteDepsFile(AbsolutePath projectPath, AbsolutePath depsFilePath, TaskEnvironment taskEnvironment) + private void WriteDepsFile(AbsolutePath projectPath, AbsolutePath depsFilePath) { ProjectContext projectContext = null; LockFileLookup lockFileLookup = null; if (AssetsFilePath != null) { - AbsolutePath absoluteAssetsFilePath = taskEnvironment.GetAbsolutePath(AssetsFilePath); + AbsolutePath absoluteAssetsFilePath = TaskEnvironment.GetAbsolutePath(AssetsFilePath); LockFile lockFile = new LockFileCache(this).GetLockFile(absoluteAssetsFilePath); projectContext = lockFile.CreateProjectContext( TargetFramework, @@ -225,7 +225,7 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) RuntimeGraph runtimeGraph = null; if (IsSelfContained) { - AbsolutePath absoluteRuntimeGraphPath = taskEnvironment.GetAbsolutePath(RuntimeGraphPath); + AbsolutePath absoluteRuntimeGraphPath = TaskEnvironment.GetAbsolutePath(RuntimeGraphPath); runtimeGraph = new RuntimeGraphCache(this).GetRuntimeGraph(absoluteRuntimeGraphPath); } @@ -318,12 +318,9 @@ private bool ShouldWarnOnRuntimeIdentifer(string runtimeIdentifier) protected override void ExecuteCore() { - TaskEnvironment taskEnvironment = TaskEnvironment - ?? throw new InvalidOperationException($"{nameof(GenerateDepsFile)} requires MSBuild to provide a {nameof(TaskEnvironment)}."); - - AbsolutePath absoluteProjectPath = taskEnvironment.GetAbsolutePath(ProjectPath); - AbsolutePath absoluteDepsFilePath = taskEnvironment.GetAbsolutePath(DepsFilePath); - WriteDepsFile(absoluteProjectPath, absoluteDepsFilePath, taskEnvironment); + AbsolutePath absoluteProjectPath = TaskEnvironment.GetAbsolutePath(ProjectPath); + AbsolutePath absoluteDepsFilePath = TaskEnvironment.GetAbsolutePath(DepsFilePath); + WriteDepsFile(absoluteProjectPath, absoluteDepsFilePath); } } } diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs index 4cd15cbfef52..03d790758adf 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs @@ -3,17 +3,12 @@ #nullable disable -using System.Collections.Concurrent; using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; using FluentAssertions; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using Xunit; -using Task = System.Threading.Tasks.Task; namespace Microsoft.NET.Build.Tasks.UnitTests { @@ -23,78 +18,36 @@ public sealed class CwdSensitiveCollection } [Collection("CwdSensitive")] - public class GivenAGenerateDepsFileMultiThreading : IDisposable + public class GivenAGenerateDepsFilePathResolution : IDisposable { private readonly List _tempDirs = new(); private readonly string _originalCwd = Directory.GetCurrentDirectory(); [Fact] - public void OutputRelativityIsPreserved() + public void ProjectAndDepsFilePathsAreResolvedThroughTaskEnvironment() { string projectDir = CreateTestProjectDirectory(); + string decoyCwd = CreateTestProjectDirectory("DecoyDir"); string relativeDepsPath = Path.Combine("bin", "Debug", "net8.0", "TestApp.deps.json"); - string absoluteProjectPath = Path.Combine(projectDir, "TestApp.csproj"); - var task = CreateMinimalTask(absoluteProjectPath, relativeDepsPath); + Directory.SetCurrentDirectory(decoyCwd); + + var task = CreateMinimalTask("TestApp.csproj", relativeDepsPath, projectDir); task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); bool result = task.Execute(); - result.Should().BeTrue("task should succeed"); - task.FilesWritten.Should().HaveCount(1, "should write one file"); - task.FilesWritten[0].ItemSpec.Should().Be(relativeDepsPath, - "FilesWritten should preserve the original relative path, not absolutize it"); - } - - [Fact] - public void TaskEnvironmentIsRequired() - { - string projectDir = CreateTestProjectDirectory(); - string relativeDepsPath = Path.Combine("bin", "Debug", "net8.0", "TestApp.deps.json"); - - var task = CreateMinimalTask(Path.Combine(projectDir, "TestApp.csproj"), relativeDepsPath, projectDir); - - Action act = () => task.Execute(); - - act.Should().Throw() - .WithMessage($"*{nameof(TaskEnvironment)}*"); - } - - [Fact] - public void AssetsFilePathIsResolvedThroughTaskEnvironment() - { - string projectDir = CreateTestProjectDirectory(); - string decoyCwd = CreateTestProjectDirectory("AssetsDecoy"); - string relativeAssetsPath = Path.Combine("obj", "project.assets.json"); - CreateAssetsFile(projectDir, relativeAssetsPath); - - var originalCwd = Directory.GetCurrentDirectory(); - try - { - Directory.SetCurrentDirectory(decoyCwd); - - var task = CreateMinimalTask("TestApp.csproj", "assets.deps.json", projectDir); - task.AssetsFilePath = relativeAssetsPath; - task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); - - bool result = task.Execute(); - - result.Should().BeTrue( - "AssetsFilePath should be resolved from TaskEnvironment.ProjectDirectory, not process CWD. Errors: {0}", - GetLoggedErrors(task)); - File.Exists(Path.Combine(projectDir, "assets.deps.json")).Should().BeTrue( - "deps file should be written under the task environment project directory"); - File.Exists(Path.Combine(decoyCwd, "assets.deps.json")).Should().BeFalse( - "deps file should not be written under the process CWD"); - } - finally - { - Directory.SetCurrentDirectory(originalCwd); - } + result.Should().BeTrue("task should resolve relative paths from TaskEnvironment.ProjectDirectory"); + File.Exists(Path.Combine(projectDir, relativeDepsPath)).Should().BeTrue( + "deps file should be written relative to TaskEnvironment.ProjectDirectory, not process CWD"); + File.Exists(Path.Combine(decoyCwd, relativeDepsPath)).Should().BeFalse( + "deps file should not be written to the process CWD"); + task.FilesWritten.Should().ContainSingle().Which.ItemSpec.Should().Be(relativeDepsPath, + "FilesWritten should preserve the caller's original relative output path"); } [Fact] - public void RuntimeGraphPathIsResolvedThroughTaskEnvironment() + public void AssetsAndRuntimeGraphPathsAreResolvedThroughTaskEnvironment() { string projectDir = CreateTestProjectDirectory(); string decoyCwd = CreateTestProjectDirectory("RuntimeGraphDecoy"); @@ -103,185 +56,25 @@ public void RuntimeGraphPathIsResolvedThroughTaskEnvironment() string relativeAssetsPath = Path.Combine("obj", "project.assets.json"); CreateAssetsFile(projectDir, relativeAssetsPath); - var originalCwd = Directory.GetCurrentDirectory(); - try - { - Directory.SetCurrentDirectory(decoyCwd); - File.Exists(decoyRuntimeGraphPath).Should().BeFalse( - "the process CWD must not contain a runtime graph that could satisfy RuntimeGraphPath"); - - var task = CreateMinimalTask("TestApp.csproj", "selfcontained.deps.json", projectDir); - task.AssetsFilePath = relativeAssetsPath; - task.RuntimeGraphPath = "runtime.json"; - task.IsSelfContained = true; - task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); - - bool result = task.Execute(); - - result.Should().BeTrue( - "RuntimeGraphPath should be resolved from TaskEnvironment.ProjectDirectory, not process CWD. Errors: {0}", - GetLoggedErrors(task)); - File.Exists(Path.Combine(projectDir, "selfcontained.deps.json")).Should().BeTrue( - "the relative runtime graph should be read from the task environment project directory"); - File.Exists(Path.Combine(decoyCwd, "selfcontained.deps.json")).Should().BeFalse( - "deps file should not be written under the process CWD"); - } - finally - { - Directory.SetCurrentDirectory(originalCwd); - } - } - - [Fact] - public void GenerateDepsFileHasMSBuildMultiThreadableMarkers() - { - typeof(GenerateDepsFile).Should().BeAssignableTo(); - typeof(GenerateDepsFile) - .GetCustomAttributes(typeof(MSBuildMultiThreadableTaskAttribute), inherit: false) - .Should() - .ContainSingle("MSBuild should recognize GenerateDepsFile as multi-threadable through the marker attribute"); - } - - [Fact] - public void ProjectDirectoryControlsRelativePathResolution() - { - string projectDir = CreateTestProjectDirectory(); - string decoyCwd = CreateTestProjectDirectory("DecoyDir"); - string relativeProjectPath = "TestApp.csproj"; - string relativeDepsPath = Path.Combine("bin", "Debug", "net8.0", "TestApp.deps.json"); - - var originalCwd = Directory.GetCurrentDirectory(); - try - { - Directory.SetCurrentDirectory(decoyCwd); - - var task = CreateMinimalTask(relativeProjectPath, relativeDepsPath, projectDir); - task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); - - bool result = task.Execute(); - - result.Should().BeTrue("task should resolve relative paths from TaskEnvironment.ProjectDirectory"); - - string expectedPath = Path.Combine(projectDir, relativeDepsPath); - File.Exists(expectedPath).Should().BeTrue( - "deps file should be written relative to TaskEnvironment.ProjectDirectory, not process CWD"); - - string decoyPath = Path.Combine(decoyCwd, relativeDepsPath); - File.Exists(decoyPath).Should().BeFalse( - "deps file should not be written to the process CWD"); - } - finally - { - Directory.SetCurrentDirectory(originalCwd); - } - } - - [Fact] - public void InjectedTaskEnvironmentProducesSameDepsJsonForAbsoluteAndRelativeProjectPathInputs() - { - string projectDir = CreateTestProjectDirectory(); - string decoyCwd = CreateTestProjectDirectory("ParityDecoy"); - string absoluteDepsPath = Path.Combine(projectDir, "absolute.deps.json"); - string relativeDepsPath = "relative.deps.json"; - string assetsPath = CreateAssetsFile(projectDir, Path.Combine("obj", "project.assets.json"), includeProjectReference: true); - ITaskItem projectReference = CreateProjectReference(projectDir); - - var originalCwd = Directory.GetCurrentDirectory(); - try - { - Directory.SetCurrentDirectory(decoyCwd); - - var absoluteTask = CreateMinimalTask( - Path.Combine(projectDir, "TestApp.csproj"), - absoluteDepsPath, - projectDir); - absoluteTask.AssetsFilePath = assetsPath; - absoluteTask.ReferencePaths = [projectReference]; - absoluteTask.UserRuntimeAssemblies = [projectReference]; - absoluteTask.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); - absoluteTask.Execute().Should().BeTrue("absolute input task should succeed"); - - var relativeTask = CreateMinimalTask("TestApp.csproj", relativeDepsPath, projectDir); - relativeTask.AssetsFilePath = assetsPath; - relativeTask.ReferencePaths = [projectReference]; - relativeTask.UserRuntimeAssemblies = [projectReference]; - relativeTask.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); - relativeTask.Execute().Should().BeTrue("relative input task should succeed"); - - var absoluteJson = JsonConvert.DeserializeObject(File.ReadAllText(absoluteDepsPath)); - var relativeJson = JsonConvert.DeserializeObject(File.ReadAllText(Path.Combine(projectDir, relativeDepsPath))); - - FindTargetLibrary(relativeJson, "ReferenceProject/1.0.0").Should().NotBeNull( - "relative ProjectPath should be absolutized before project-reference lookup"); - JToken.DeepEquals(absoluteJson, relativeJson).Should().BeTrue( - "relative paths should be absolutized through TaskEnvironment before deps generation"); - relativeTask.FilesWritten[0].ItemSpec.Should().Be(relativeDepsPath, - "FilesWritten should preserve the caller's original relative output path"); - } - finally - { - Directory.SetCurrentDirectory(originalCwd); - } - } - - [Fact] - public async Task ConcurrentExecutionWithRelativePathsKeepsCwdStable() - { - const int concurrency = 64; - string decoyCwd = CreateTestProjectDirectory("StressDecoy"); - var projectDirs = new string[concurrency]; - - for (int i = 0; i < concurrency; i++) - { - projectDirs[i] = CreateTestProjectDirectory($"StressProject{i}"); - } - - using var startBarrier = new Barrier(concurrency); - var failures = new ConcurrentBag(); - var executionTasks = new Task[concurrency]; - var originalCwd = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(decoyCwd); + File.Exists(decoyRuntimeGraphPath).Should().BeFalse( + "the process CWD must not contain a runtime graph that could satisfy RuntimeGraphPath"); - try - { - Directory.SetCurrentDirectory(decoyCwd); - - for (int i = 0; i < concurrency; i++) - { - int taskIndex = i; - executionTasks[i] = Task.Factory.StartNew(() => - { - string relativeDepsPath = Path.Combine("bin", "Debug", "net8.0", $"TestApp{taskIndex}.deps.json"); - try - { - startBarrier.SignalAndWait(TimeSpan.FromSeconds(30)); - - var task = CreateMinimalTask("TestApp.csproj", relativeDepsPath, projectDirs[taskIndex]); - task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDirs[taskIndex]); - - task.Execute().Should().BeTrue($"task {taskIndex} should succeed"); - File.Exists(Path.Combine(projectDirs[taskIndex], relativeDepsPath)).Should().BeTrue( - $"task {taskIndex} should write under its project directory"); - File.Exists(Path.Combine(decoyCwd, relativeDepsPath)).Should().BeFalse( - $"task {taskIndex} should not write under the process CWD"); - task.FilesWritten[0].ItemSpec.Should().Be(relativeDepsPath); - } - catch (Exception ex) - { - failures.Add(ex); - } - }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); - } + var task = CreateMinimalTask("TestApp.csproj", "selfcontained.deps.json", projectDir); + task.AssetsFilePath = relativeAssetsPath; + task.RuntimeGraphPath = "runtime.json"; + task.IsSelfContained = true; + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); - await Task.WhenAll(executionTasks); + bool result = task.Execute(); - failures.Should().BeEmpty(); - Directory.GetCurrentDirectory().Should().Be(decoyCwd, - "GenerateDepsFile must not mutate process CWD during concurrent execution"); - } - finally - { - Directory.SetCurrentDirectory(originalCwd); - } + result.Should().BeTrue( + "AssetsFilePath and RuntimeGraphPath should be resolved from TaskEnvironment.ProjectDirectory, not process CWD. Errors: {0}", + GetLoggedErrors(task)); + File.Exists(Path.Combine(projectDir, "selfcontained.deps.json")).Should().BeTrue( + "the relative runtime graph should be read from the task environment project directory"); + File.Exists(Path.Combine(decoyCwd, "selfcontained.deps.json")).Should().BeFalse( + "deps file should not be written under the process CWD"); } private string CreateTestProjectDirectory([CallerMemberName] string testName = null) @@ -325,137 +118,47 @@ private GenerateDepsFile CreateMinimalTask(string projectPath, string depsFilePa }; } - private static ITaskItem CreateProjectReference(string projectDir) - { - string referenceProjectDir = Path.Combine(projectDir, "ReferenceProject"); - string referenceProjectPath = Path.Combine(referenceProjectDir, "ReferenceProject.csproj"); - string referenceAssemblyPath = Path.Combine(referenceProjectDir, "bin", "Debug", "net8.0", "ReferenceProject.dll"); - Directory.CreateDirectory(Path.GetDirectoryName(referenceAssemblyPath)); - File.WriteAllText(referenceProjectPath, ""); - File.WriteAllText(referenceAssemblyPath, string.Empty); - - var reference = new TaskItem(referenceAssemblyPath); - reference.SetMetadata(MetadataKeys.ReferenceSourceTarget, "ProjectReference"); - reference.SetMetadata(MetadataKeys.MSBuildSourceProjectFile, referenceProjectPath); - reference.SetMetadata("Version", "1.0.0"); - return reference; - } - - private static string CreateAssetsFile( - string projectDir, - string relativeAssetsPath, - string targetName = "net8.0", - bool includeProjectReference = false) + private static string CreateAssetsFile(string projectDir, string relativeAssetsPath) { string assetsPath = Path.Combine(projectDir, relativeAssetsPath); Directory.CreateDirectory(Path.GetDirectoryName(assetsPath)); + string projectPath = Path.Combine(projectDir, "TestApp.csproj"); - var targetLibraries = new JObject(); - var libraries = new JObject(); - var projectFileDependencies = new JArray(); - var frameworkDependencies = new JObject(); - var restoreProjectReferences = new JObject(); - - if (includeProjectReference) - { - string relativeProjectReferencePath = Path.Combine("ReferenceProject", "ReferenceProject.csproj"); - string projectReferenceName = "ReferenceProject/1.0.0"; - - targetLibraries[projectReferenceName] = new JObject + File.WriteAllText(assetsPath, $$""" { - ["type"] = "project", - ["compile"] = new JObject - { - ["bin/ReferenceProject.dll"] = new JObject() + "version": 3, + "targets": { "net8.0": {} }, + "libraries": {}, + "projectFileDependencyGroups": { "net8.0": [] }, + "packageFolders": {}, + "project": { + "version": "1.0.0", + "restore": { + "projectUniqueName": {{JsonConvert.ToString(projectPath)}}, + "projectName": "TestApp", + "projectPath": {{JsonConvert.ToString(projectPath)}}, + "packagesPath": {{JsonConvert.ToString(Path.Combine(projectDir, ".nuget", "packages"))}}, + "outputPath": {{JsonConvert.ToString(Path.Combine(projectDir, "obj"))}}, + "projectStyle": "PackageReference", + "fallbackFolders": [], + "configFilePaths": [], + "originalTargetFrameworks": [ "net8.0" ], + "sources": {}, + "frameworks": { "net8.0": { "targetAlias": "net8.0" } }, + "warningProperties": { "warnAsError": [ "NU1605" ] } }, - ["runtime"] = new JObject - { - ["bin/ReferenceProject.dll"] = new JObject() - } - }; - libraries[projectReferenceName] = new JObject - { - ["type"] = "project", - ["path"] = relativeProjectReferencePath, - ["msbuildProject"] = relativeProjectReferencePath - }; - projectFileDependencies.Add("ReferenceProject >= 1.0.0"); - frameworkDependencies["ReferenceProject"] = new JObject - { - ["target"] = "Project", - ["version"] = "[1.0.0, )" - }; - restoreProjectReferences[relativeProjectReferencePath] = new JObject - { - ["projectPath"] = Path.Combine(projectDir, relativeProjectReferencePath) - }; - } - - var assets = new JObject - { - ["version"] = 3, - ["targets"] = new JObject - { - [targetName] = targetLibraries - }, - ["libraries"] = libraries, - ["projectFileDependencyGroups"] = new JObject - { - ["net8.0"] = projectFileDependencies - }, - ["packageFolders"] = new JObject(), - ["project"] = new JObject - { - ["version"] = "1.0.0", - ["restore"] = new JObject - { - ["projectUniqueName"] = Path.Combine(projectDir, "TestApp.csproj"), - ["projectName"] = "TestApp", - ["projectPath"] = Path.Combine(projectDir, "TestApp.csproj"), - ["packagesPath"] = Path.Combine(projectDir, ".nuget", "packages"), - ["outputPath"] = Path.Combine(projectDir, "obj"), - ["projectStyle"] = "PackageReference", - ["fallbackFolders"] = new JArray(), - ["configFilePaths"] = new JArray(), - ["originalTargetFrameworks"] = new JArray("net8.0"), - ["sources"] = new JObject(), - ["frameworks"] = new JObject - { - ["net8.0"] = new JObject - { - ["targetAlias"] = "net8.0", - ["projectReferences"] = restoreProjectReferences - } - }, - ["warningProperties"] = new JObject - { - ["warnAsError"] = new JArray("NU1605") - } - }, - ["frameworks"] = new JObject - { - ["net8.0"] = new JObject - { - ["targetAlias"] = "net8.0", - ["dependencies"] = frameworkDependencies, - ["runtimeIdentifierGraphPath"] = Path.Combine(projectDir, "runtime.json") - } + "frameworks": { + "net8.0": { + "targetAlias": "net8.0", + "runtimeIdentifierGraphPath": {{JsonConvert.ToString(Path.Combine(projectDir, "runtime.json"))}} + } } + } } - }; - - File.WriteAllText(assetsPath, assets.ToString(Formatting.Indented)); + """); return assetsPath; } - private static JToken FindTargetLibrary(JObject depsJson, string libraryName) - { - return ((JObject)depsJson["targets"]) - .Properties() - .Select(target => target.Value[libraryName]) - .FirstOrDefault(library => library != null); - } - private static string GetLoggedErrors(GenerateDepsFile task) { return string.Join(Environment.NewLine, ((MockBuildEngine)task.BuildEngine).Errors.Select(e => e.Message)); From 48ce3deace356d1983e1355f8f043c99e2562f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Thu, 21 May 2026 11:37:11 +0200 Subject: [PATCH 04/10] fixed unit tests --- .../GivenAGenerateDepsFileMultiThreading.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs index 03d790758adf..4e48fc6f93ce 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs @@ -12,12 +12,12 @@ namespace Microsoft.NET.Build.Tasks.UnitTests { - [CollectionDefinition("CwdSensitive", DisableParallelization = true)] - public sealed class CwdSensitiveCollection + [CollectionDefinition(nameof(GenerateDepsFileCwdSensitiveCollection), DisableParallelization = true)] + public sealed class GenerateDepsFileCwdSensitiveCollection { } - [Collection("CwdSensitive")] + [Collection(nameof(GenerateDepsFileCwdSensitiveCollection))] public class GivenAGenerateDepsFilePathResolution : IDisposable { private readonly List _tempDirs = new(); From 2482ee92f5c5122ce73ec3982ed8dfb0338da9a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Tue, 26 May 2026 10:10:36 +0200 Subject: [PATCH 05/10] addressed comments --- .../GivenAGenerateDepsFileMultiThreading.cs | 77 +++++++++++++++++-- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs index 4e48fc6f93ce..a42bb1a13ea8 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs @@ -8,6 +8,7 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Xunit; namespace Microsoft.NET.Build.Tasks.UnitTests @@ -50,15 +51,14 @@ public void ProjectAndDepsFilePathsAreResolvedThroughTaskEnvironment() public void AssetsAndRuntimeGraphPathsAreResolvedThroughTaskEnvironment() { string projectDir = CreateTestProjectDirectory(); - string decoyCwd = CreateTestProjectDirectory("RuntimeGraphDecoy"); - string decoyRuntimeGraphPath = Path.Combine(decoyCwd, "runtime.json"); - File.Delete(decoyRuntimeGraphPath); + // Decoy CWD is intentionally empty (no runtime.json) so that resolving + // RuntimeGraphPath against the process CWD would fail; only resolution + // through TaskEnvironment.ProjectDirectory can succeed. + string decoyCwd = CreateEmptyDecoyDirectory(); string relativeAssetsPath = Path.Combine("obj", "project.assets.json"); CreateAssetsFile(projectDir, relativeAssetsPath); Directory.SetCurrentDirectory(decoyCwd); - File.Exists(decoyRuntimeGraphPath).Should().BeFalse( - "the process CWD must not contain a runtime graph that could satisfy RuntimeGraphPath"); var task = CreateMinimalTask("TestApp.csproj", "selfcontained.deps.json", projectDir); task.AssetsFilePath = relativeAssetsPath; @@ -77,6 +77,73 @@ public void AssetsAndRuntimeGraphPathsAreResolvedThroughTaskEnvironment() "deps file should not be written under the process CWD"); } + [Fact] + public void SatelliteAssemblyTargetPathFlowsVerbatimToDepsJsonResources() + { + // Encodes the contract that AssemblySatelliteAssemblies metadata is passed through + // to deps.json verbatim. The task reads only Culture and TargetPath; TargetPath is + // relative by design (deps.json format requirement) and must NOT be absolutized + // against TaskEnvironment.ProjectDirectory or the process CWD. + string projectDir = CreateTestProjectDirectory(); + string decoyCwd = CreateEmptyDecoyDirectory(); + string relativeDepsPath = Path.Combine("bin", "Debug", "net8.0", "TestApp.deps.json"); + string satelliteRelativeTargetPath = Path.Combine("de", "TestApp.resources.dll"); + + Directory.SetCurrentDirectory(decoyCwd); + + var task = CreateMinimalTask("TestApp.csproj", relativeDepsPath, projectDir); + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + task.AssemblySatelliteAssemblies = new ITaskItem[] + { + // ItemSpec is intentionally not a real on-disk path: the task only reads + // metadata from satellite items, never opens the underlying file. + new MockTaskItem( + itemSpec: @"de\TestApp.resources.dll", + metadata: new Dictionary + { + { "Culture", "de" }, + { "TargetPath", satelliteRelativeTargetPath }, + }), + }; + + bool result = task.Execute(); + + result.Should().BeTrue("task should succeed: {0}", GetLoggedErrors(task)); + string depsPath = Path.Combine(projectDir, relativeDepsPath); + File.Exists(depsPath).Should().BeTrue("deps file should be produced"); + + JObject depsJson = JObject.Parse(File.ReadAllText(depsPath)); + JObject targets = (JObject)depsJson["targets"]; + targets.Should().NotBeNull("deps.json must have a targets section"); + JObject targetFramework = (JObject)targets.Properties().First().Value; + JObject mainLibrary = (JObject)targetFramework["TestApp/1.0.0.0"]; + mainLibrary.Should().NotBeNull("the main project library should be present in deps.json"); + + JObject resources = (JObject)mainLibrary["resources"]; + resources.Should().NotBeNull("the satellite assembly should produce a resources section"); + + // The deps.json writer normalizes path separators to '/'; the path itself is + // otherwise the TargetPath metadata byte-for-byte. Critically it is NOT: + // - prefixed with TaskEnvironment.ProjectDirectory, + // - prefixed with the process CWD, + // - resolved or absolutized in any way. + string expectedRelativePath = satelliteRelativeTargetPath.Replace('\\', '/'); + resources.Properties().Should().ContainSingle( + "exactly one satellite was supplied") + .Which.Name.Should().Be(expectedRelativePath, + "TargetPath metadata flows verbatim into deps.json (slashes normalized only)"); + resources[expectedRelativePath]["locale"].Value().Should().Be("de", + "Culture metadata is preserved as the locale"); + } + + private string CreateEmptyDecoyDirectory([CallerMemberName] string testName = null) + { + string decoyDir = Path.Combine(Path.GetTempPath(), $"GenerateDepsFileTest_{testName}_decoy_{Guid.NewGuid():N}"); + Directory.CreateDirectory(decoyDir); + _tempDirs.Add(decoyDir); + return decoyDir; + } + private string CreateTestProjectDirectory([CallerMemberName] string testName = null) { string testDir = Path.Combine(Path.GetTempPath(), $"GenerateDepsFileTest_{testName}_{Guid.NewGuid():N}"); From 7c8f3682fbc30688aec6fdb2f4ab14cb41467b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Wed, 27 May 2026 12:06:36 +0200 Subject: [PATCH 06/10] analyzed unsafe api calls and fixed them --- .../Common/FileUtilities.MetadataReader.cs | 47 +++++++++++++++++++ src/Tasks/Common/FileUtilities.cs | 20 ++++++++ .../DependencyContextBuilder.cs | 29 ++++++++---- .../FrameworkReferenceResolver.cs | 11 +---- .../GenerateDepsFile.cs | 27 ++++++----- 5 files changed, 103 insertions(+), 31 deletions(-) diff --git a/src/Tasks/Common/FileUtilities.MetadataReader.cs b/src/Tasks/Common/FileUtilities.MetadataReader.cs index d8708f775008..848942249626 100644 --- a/src/Tasks/Common/FileUtilities.MetadataReader.cs +++ b/src/Tasks/Common/FileUtilities.MetadataReader.cs @@ -13,6 +13,7 @@ #if NETCOREAPP || !EXTENSIONS using System.Collections.Concurrent; +using Microsoft.Build.Framework; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; @@ -73,6 +74,52 @@ static partial class FileUtilities } } } + + private static Version? GetAssemblyVersion(AbsolutePath sourcePath) + { + DateTime lastWriteTimeUtc = File.GetLastWriteTimeUtc(sourcePath); + + string sourcePathValue = sourcePath.Value; + if (s_versionCache.TryGetValue(sourcePathValue, out var cacheEntry) + && lastWriteTimeUtc == cacheEntry.LastKnownWriteTimeUtc) + { + return cacheEntry.Version; + } + + Version? version = GetAssemblyVersionFromFile(sourcePath); + + s_versionCache[sourcePathValue] = (lastWriteTimeUtc, version); + + return version; + + static Version? GetAssemblyVersionFromFile(AbsolutePath sourcePath) + { + using (var assemblyStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Delete | FileShare.Read)) + { + Version? result = null; + try + { + using (PEReader peReader = new(assemblyStream, PEStreamOptions.LeaveOpen)) + { + if (peReader.HasMetadata) + { + MetadataReader reader = peReader.GetMetadataReader(); + if (reader.IsAssembly) + { + result = reader.GetAssemblyDefinition().Version; + } + } + } + } + catch (BadImageFormatException) + { + // not a PE + } + + return result; + } + } + } } } diff --git a/src/Tasks/Common/FileUtilities.cs b/src/Tasks/Common/FileUtilities.cs index 59c92abbcaf8..253c7984eaf5 100644 --- a/src/Tasks/Common/FileUtilities.cs +++ b/src/Tasks/Common/FileUtilities.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Microsoft.Build.Framework; namespace Microsoft.NET.Build.Tasks { @@ -22,6 +23,18 @@ static partial class FileUtilities return null; } + public static Version? GetFileVersion(AbsolutePath sourcePath) + { + var fvi = FileVersionInfo.GetVersionInfo(sourcePath); + + if (fvi != null) + { + return new Version(fvi.FileMajorPart, fvi.FileMinorPart, fvi.FileBuildPart, fvi.FilePrivatePart); + } + + return null; + } + static readonly HashSet s_assemblyExtensions = new(new[] { ".dll", ".exe", ".winmd" }, StringComparer.OrdinalIgnoreCase); public static Version? TryGetAssemblyVersion(string sourcePath) { @@ -29,5 +42,12 @@ static partial class FileUtilities return s_assemblyExtensions.Contains(extension) ? GetAssemblyVersion(sourcePath) : null; } + + public static Version? TryGetAssemblyVersion(AbsolutePath sourcePath) + { + var extension = Path.GetExtension(sourcePath.Value); + + return s_assemblyExtensions.Contains(extension) ? GetAssemblyVersion(sourcePath) : null; + } } } diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs b/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs index 1395a5900ba5..a167289e4ee8 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs @@ -41,6 +41,7 @@ internal class DependencyContextBuilder private readonly bool _isPortable; private readonly HashSet _usedLibraryNames; private readonly RuntimeGraph _runtimeGraph; + private readonly Func _getAbsolutePath; private Dictionary _referenceLibraryNames; @@ -49,11 +50,12 @@ internal class DependencyContextBuilder private const string NetCorePlatformLibrary = "Microsoft.NETCore.App"; - public DependencyContextBuilder(SingleProjectInfo mainProjectInfo, bool includeRuntimeFileVersions, RuntimeGraph runtimeGraph, ProjectContext projectContext, LockFileLookup libraryLookup) + public DependencyContextBuilder(SingleProjectInfo mainProjectInfo, bool includeRuntimeFileVersions, RuntimeGraph runtimeGraph, ProjectContext projectContext, LockFileLookup libraryLookup, Func getAbsolutePath = null) { _mainProjectInfo = mainProjectInfo; _includeRuntimeFileVersions = includeRuntimeFileVersions; _runtimeGraph = runtimeGraph; + _getAbsolutePath = getAbsolutePath ?? TaskEnvironment.Fallback.GetAbsolutePath; _dependencyLibraries = projectContext.LockFileTarget.Libraries .Select(lockFileTargetLibrary => @@ -101,10 +103,12 @@ public DependencyContextBuilder( string runtimeIdentifier, bool isSelfContained, string platformLibraryName, - string targetFramework) + string targetFramework, + Func getAbsolutePath = null) { _mainProjectInfo = mainProjectInfo; _includeRuntimeFileVersions = includeRuntimeFileVersions; + _getAbsolutePath = getAbsolutePath ?? TaskEnvironment.Fallback.GetAbsolutePath; _isFrameworkDependent = LockFileExtensions.IsFrameworkDependent( runtimeFrameworks, @@ -299,7 +303,7 @@ public DependencyContext Build((string Path, string DestinationSubPath)[] userRu name: GetReferenceLibraryName(directReference), version: directReference.Version, hash: string.Empty, - runtimeAssemblyGroups: [new RuntimeAssetGroup(string.Empty, [CreateRuntimeFile(directReference.FileName, directReference.FullPath, directReference.DestinationSubPath)])], + runtimeAssemblyGroups: [new RuntimeAssetGroup(string.Empty, [CreateRuntimeFile(directReference.FileName, GetAbsolutePath(directReference.FullPath), directReference.DestinationSubPath)])], nativeLibraryGroups: [], resourceAssemblies: CreateResourceAssemblies(directReference.ResourceAssemblies), dependencies: [], @@ -607,11 +611,11 @@ private IEnumerable GetRuntimePackLibraries() { var runtimeAssemblyGroup = new RuntimeAssetGroup(string.Empty, runtimePack.Value.Where(asset => asset.AssetType == AssetType.Runtime) - .Select(asset => CreateRuntimeFile(asset.DestinationSubPath, asset.SourcePath))); + .Select(asset => CreateRuntimeFile(asset.DestinationSubPath, GetAbsolutePath(asset.SourcePath)))); var nativeLibraryGroup = new RuntimeAssetGroup(string.Empty, runtimePack.Value.Where(asset => asset.AssetType == AssetType.Native) - .Select(asset => CreateRuntimeFile(asset.DestinationSubPath, asset.SourcePath))); + .Select(asset => CreateRuntimeFile(asset.DestinationSubPath, GetAbsolutePath(asset.SourcePath)))); return new ModifiableRuntimeLibrary(new RuntimeLibrary( type: "runtimepack", @@ -652,8 +656,10 @@ private ModifiableRuntimeLibrary GetRuntimeLibrary(DependencyLibrary library, (s (string Path, string DestinationSubPath) assembly = userRuntimeAssemblies is not null ? userRuntimeAssemblies.FirstOrDefault(p => Path.GetFileNameWithoutExtension(p.Path).Equals(fileName)) : default; - var runtimeFile = !string.IsNullOrWhiteSpace(assembly.Path) && File.Exists(assembly.Path) ? CreateRuntimeFile(referenceProjectInfo.OutputName, assembly.Path, assembly.DestinationSubPath) : - !string.IsNullOrWhiteSpace(library.Path) && File.Exists(library.Path) ? CreateRuntimeFile(referenceProjectInfo.OutputName, library.Path) : + AbsolutePath assemblyPath = !string.IsNullOrWhiteSpace(assembly.Path) ? GetAbsolutePath(assembly.Path) : default; + AbsolutePath libraryPath = !string.IsNullOrWhiteSpace(library.Path) ? GetAbsolutePath(library.Path) : default; + var runtimeFile = !string.IsNullOrWhiteSpace(assembly.Path) && File.Exists(assemblyPath) ? CreateRuntimeFile(referenceProjectInfo.OutputName, assemblyPath, assembly.DestinationSubPath) : + !string.IsNullOrWhiteSpace(library.Path) && File.Exists(libraryPath) ? CreateRuntimeFile(referenceProjectInfo.OutputName, libraryPath) : new RuntimeFile(referenceProjectInfo.OutputName, string.Empty, string.Empty); runtimeAssemblyGroups.Add(new RuntimeAssetGroup(string.Empty, [runtimeFile])); @@ -819,10 +825,10 @@ private void GetCommonLibraryProperties(DependencyLibrary library, private RuntimeFile CreateRuntimeFile(ResolvedFile resolvedFile) { string relativePath = string.IsNullOrEmpty(resolvedFile.PathInPackage) ? resolvedFile.DestinationSubPath : resolvedFile.PathInPackage; - return CreateRuntimeFile(relativePath, resolvedFile.SourcePath, resolvedFile.DestinationSubPath); + return CreateRuntimeFile(relativePath, GetAbsolutePath(resolvedFile.SourcePath), resolvedFile.DestinationSubPath); } - private RuntimeFile CreateRuntimeFile(string path, string fullPath, string localPath = null) + private RuntimeFile CreateRuntimeFile(string path, AbsolutePath fullPath, string localPath = null) { if (_includeRuntimeFileVersions) { @@ -836,6 +842,11 @@ private RuntimeFile CreateRuntimeFile(string path, string fullPath, string local } } + private AbsolutePath GetAbsolutePath(string path) + { + return _getAbsolutePath(path); + } + private static IEnumerable CreateResourceAssemblies(IEnumerable resourceAssemblyInfos) { return resourceAssemblyInfos diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/FrameworkReferenceResolver.cs b/src/Tasks/Microsoft.NET.Build.Tasks/FrameworkReferenceResolver.cs index d0e3c58f2fab..033285eb15be 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/FrameworkReferenceResolver.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/FrameworkReferenceResolver.cs @@ -11,17 +11,10 @@ internal class FrameworkReferenceResolver { private readonly Func _getEnvironmentVariable; - /// - /// Creates an instance that reads environment variables from the process environment. - /// - public FrameworkReferenceResolver() - : this(Environment.GetEnvironmentVariable) - { - } - /// /// Creates an instance that reads environment variables via the supplied delegate. - /// Use this from MSBuild tasks to route reads through TaskEnvironment. + /// Use this from MSBuild tasks to route reads through TaskEnvironment so that + /// per-task isolated environment state (multithreaded mode) is honored. /// public FrameworkReferenceResolver(Func getEnvironmentVariable) { diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs index 0aca2ae804eb..734756c75f4e 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs @@ -142,8 +142,8 @@ private void WriteDepsFile(AbsolutePath projectPath, AbsolutePath depsFilePath) LockFileLookup lockFileLookup = null; if (AssetsFilePath != null) { - AbsolutePath absoluteAssetsFilePath = TaskEnvironment.GetAbsolutePath(AssetsFilePath); - LockFile lockFile = new LockFileCache(this).GetLockFile(absoluteAssetsFilePath); + LockFile lockFile = new LockFileCache(this).GetLockFile( + string.IsNullOrEmpty(AssetsFilePath) ? AssetsFilePath : TaskEnvironment.GetAbsolutePath(AssetsFilePath)); projectContext = lockFile.CreateProjectContext( TargetFramework, EffectiveRuntimeIdentifier, @@ -225,11 +225,11 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) RuntimeGraph runtimeGraph = null; if (IsSelfContained) { - AbsolutePath absoluteRuntimeGraphPath = TaskEnvironment.GetAbsolutePath(RuntimeGraphPath); - runtimeGraph = new RuntimeGraphCache(this).GetRuntimeGraph(absoluteRuntimeGraphPath); + runtimeGraph = new RuntimeGraphCache(this).GetRuntimeGraph( + string.IsNullOrEmpty(RuntimeGraphPath) ? RuntimeGraphPath : TaskEnvironment.GetAbsolutePath(RuntimeGraphPath)); } - builder = new DependencyContextBuilder(mainProject, IncludeRuntimeFileVersions, runtimeGraph, projectContext, lockFileLookup); + builder = new DependencyContextBuilder(mainProject, IncludeRuntimeFileVersions, runtimeGraph, projectContext, lockFileLookup, TaskEnvironment.GetAbsolutePath); } else { @@ -237,10 +237,11 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) mainProject, IncludeRuntimeFileVersions, RuntimeFrameworks, - isSelfContained: IsSelfContained, - platformLibraryName: PlatformLibraryName, - runtimeIdentifier: EffectiveRuntimeIdentifier, - targetFramework: TargetFramework); + EffectiveRuntimeIdentifier, + IsSelfContained, + PlatformLibraryName, + TargetFramework, + TaskEnvironment.GetAbsolutePath); } builder = builder @@ -252,7 +253,7 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) .WithReferenceProjectInfos(referenceProjects) .WithRuntimePackAssets(runtimePackAssets) .WithCompilationOptions(compilationOptions) - .WithReferenceAssembliesPath(new FrameworkReferenceResolver().GetDefaultReferenceAssembliesPath()) + .WithReferenceAssembliesPath(new FrameworkReferenceResolver(TaskEnvironment.GetEnvironmentVariable).GetDefaultReferenceAssembliesPath()) .WithPackagesThatWereFiltered(GetFilteredPackages()); if (CompileReferences.Length > 0) @@ -260,9 +261,9 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) builder = builder.WithCompileReferences(ReferenceInfo.CreateReferenceInfos(CompileReferences)); } - var resolvedNuGetFiles = ResolvedNuGetFiles.Select(f => new ResolvedFile(f, false)) + var resolvedNuGetFileInfos = ResolvedNuGetFiles.Select(f => new ResolvedFile(f, false)) .Concat(ResolvedRuntimeTargetsFiles.Select(f => new ResolvedFile(f, true))); - builder = builder.WithResolvedNuGetFiles(resolvedNuGetFiles); + builder = builder.WithResolvedNuGetFiles(resolvedNuGetFileInfos); var userRuntimeAssemblies = UserRuntimeAssemblies.Select(i => { @@ -273,7 +274,7 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) DependencyContext dependencyContext = builder.Build(userRuntimeAssemblies); var writer = new DependencyContextWriter(); - using (var fileStream = File.Create(depsFilePath.Value)) + using (var fileStream = File.Create(depsFilePath)) { writer.Write(dependencyContext, fileStream); } From 9f95ea91f0eddc35b5d0bc648d70f034199a0655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Fri, 29 May 2026 12:15:49 +0200 Subject: [PATCH 07/10] better absolutization handling --- .../DependencyContextBuilder.cs | 32 +++++++------------ .../GenerateDepsFile.cs | 7 ++-- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs b/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs index a167289e4ee8..49edb9eb909b 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs @@ -41,7 +41,6 @@ internal class DependencyContextBuilder private readonly bool _isPortable; private readonly HashSet _usedLibraryNames; private readonly RuntimeGraph _runtimeGraph; - private readonly Func _getAbsolutePath; private Dictionary _referenceLibraryNames; @@ -50,12 +49,11 @@ internal class DependencyContextBuilder private const string NetCorePlatformLibrary = "Microsoft.NETCore.App"; - public DependencyContextBuilder(SingleProjectInfo mainProjectInfo, bool includeRuntimeFileVersions, RuntimeGraph runtimeGraph, ProjectContext projectContext, LockFileLookup libraryLookup, Func getAbsolutePath = null) + public DependencyContextBuilder(SingleProjectInfo mainProjectInfo, bool includeRuntimeFileVersions, RuntimeGraph runtimeGraph, ProjectContext projectContext, LockFileLookup libraryLookup) { _mainProjectInfo = mainProjectInfo; _includeRuntimeFileVersions = includeRuntimeFileVersions; _runtimeGraph = runtimeGraph; - _getAbsolutePath = getAbsolutePath ?? TaskEnvironment.Fallback.GetAbsolutePath; _dependencyLibraries = projectContext.LockFileTarget.Libraries .Select(lockFileTargetLibrary => @@ -103,12 +101,10 @@ public DependencyContextBuilder( string runtimeIdentifier, bool isSelfContained, string platformLibraryName, - string targetFramework, - Func getAbsolutePath = null) + string targetFramework) { _mainProjectInfo = mainProjectInfo; _includeRuntimeFileVersions = includeRuntimeFileVersions; - _getAbsolutePath = getAbsolutePath ?? TaskEnvironment.Fallback.GetAbsolutePath; _isFrameworkDependent = LockFileExtensions.IsFrameworkDependent( runtimeFrameworks, @@ -303,7 +299,7 @@ public DependencyContext Build((string Path, string DestinationSubPath)[] userRu name: GetReferenceLibraryName(directReference), version: directReference.Version, hash: string.Empty, - runtimeAssemblyGroups: [new RuntimeAssetGroup(string.Empty, [CreateRuntimeFile(directReference.FileName, GetAbsolutePath(directReference.FullPath), directReference.DestinationSubPath)])], + runtimeAssemblyGroups: [new RuntimeAssetGroup(string.Empty, [CreateRuntimeFile(directReference.FileName, directReference.FullPath, directReference.DestinationSubPath)])], nativeLibraryGroups: [], resourceAssemblies: CreateResourceAssemblies(directReference.ResourceAssemblies), dependencies: [], @@ -611,11 +607,11 @@ private IEnumerable GetRuntimePackLibraries() { var runtimeAssemblyGroup = new RuntimeAssetGroup(string.Empty, runtimePack.Value.Where(asset => asset.AssetType == AssetType.Runtime) - .Select(asset => CreateRuntimeFile(asset.DestinationSubPath, GetAbsolutePath(asset.SourcePath)))); + .Select(asset => CreateRuntimeFile(asset.DestinationSubPath, asset.SourcePath))); var nativeLibraryGroup = new RuntimeAssetGroup(string.Empty, runtimePack.Value.Where(asset => asset.AssetType == AssetType.Native) - .Select(asset => CreateRuntimeFile(asset.DestinationSubPath, GetAbsolutePath(asset.SourcePath)))); + .Select(asset => CreateRuntimeFile(asset.DestinationSubPath, asset.SourcePath))); return new ModifiableRuntimeLibrary(new RuntimeLibrary( type: "runtimepack", @@ -656,10 +652,11 @@ private ModifiableRuntimeLibrary GetRuntimeLibrary(DependencyLibrary library, (s (string Path, string DestinationSubPath) assembly = userRuntimeAssemblies is not null ? userRuntimeAssemblies.FirstOrDefault(p => Path.GetFileNameWithoutExtension(p.Path).Equals(fileName)) : default; - AbsolutePath assemblyPath = !string.IsNullOrWhiteSpace(assembly.Path) ? GetAbsolutePath(assembly.Path) : default; - AbsolutePath libraryPath = !string.IsNullOrWhiteSpace(library.Path) ? GetAbsolutePath(library.Path) : default; - var runtimeFile = !string.IsNullOrWhiteSpace(assembly.Path) && File.Exists(assemblyPath) ? CreateRuntimeFile(referenceProjectInfo.OutputName, assemblyPath, assembly.DestinationSubPath) : - !string.IsNullOrWhiteSpace(library.Path) && File.Exists(libraryPath) ? CreateRuntimeFile(referenceProjectInfo.OutputName, libraryPath) : + string mainProjectDirectory = Path.GetDirectoryName(_mainProjectInfo.ProjectPath); + string assemblyPath = !string.IsNullOrWhiteSpace(assembly.Path) ? assembly.Path : null; + string libraryPath = !string.IsNullOrWhiteSpace(library.Path) ? Path.Combine(mainProjectDirectory, library.Path) : null; + var runtimeFile = assemblyPath != null && File.Exists(assemblyPath) ? CreateRuntimeFile(referenceProjectInfo.OutputName, assemblyPath, assembly.DestinationSubPath) : + libraryPath != null && File.Exists(libraryPath) ? CreateRuntimeFile(referenceProjectInfo.OutputName, libraryPath) : new RuntimeFile(referenceProjectInfo.OutputName, string.Empty, string.Empty); runtimeAssemblyGroups.Add(new RuntimeAssetGroup(string.Empty, [runtimeFile])); @@ -825,10 +822,10 @@ private void GetCommonLibraryProperties(DependencyLibrary library, private RuntimeFile CreateRuntimeFile(ResolvedFile resolvedFile) { string relativePath = string.IsNullOrEmpty(resolvedFile.PathInPackage) ? resolvedFile.DestinationSubPath : resolvedFile.PathInPackage; - return CreateRuntimeFile(relativePath, GetAbsolutePath(resolvedFile.SourcePath), resolvedFile.DestinationSubPath); + return CreateRuntimeFile(relativePath, resolvedFile.SourcePath, resolvedFile.DestinationSubPath); } - private RuntimeFile CreateRuntimeFile(string path, AbsolutePath fullPath, string localPath = null) + private RuntimeFile CreateRuntimeFile(string path, string fullPath, string localPath = null) { if (_includeRuntimeFileVersions) { @@ -842,11 +839,6 @@ private RuntimeFile CreateRuntimeFile(string path, AbsolutePath fullPath, string } } - private AbsolutePath GetAbsolutePath(string path) - { - return _getAbsolutePath(path); - } - private static IEnumerable CreateResourceAssemblies(IEnumerable resourceAssemblyInfos) { return resourceAssemblyInfos diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs index 734756c75f4e..b3fceab62d87 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs @@ -229,7 +229,7 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) string.IsNullOrEmpty(RuntimeGraphPath) ? RuntimeGraphPath : TaskEnvironment.GetAbsolutePath(RuntimeGraphPath)); } - builder = new DependencyContextBuilder(mainProject, IncludeRuntimeFileVersions, runtimeGraph, projectContext, lockFileLookup, TaskEnvironment.GetAbsolutePath); + builder = new DependencyContextBuilder(mainProject, IncludeRuntimeFileVersions, runtimeGraph, projectContext, lockFileLookup); } else { @@ -240,8 +240,7 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) EffectiveRuntimeIdentifier, IsSelfContained, PlatformLibraryName, - TargetFramework, - TaskEnvironment.GetAbsolutePath); + TargetFramework); } builder = builder @@ -269,7 +268,7 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) { string destinationSubDir = i.GetMetadata(MetadataKeys.DestinationSubDirectory); string destinationSubPath = string.IsNullOrEmpty(destinationSubDir) ? null : Path.Combine(destinationSubDir, Path.GetFileName(i.ItemSpec)); - return (i.ItemSpec, destinationSubPath); + return (TaskEnvironment.GetAbsolutePath(i.ItemSpec).Value, destinationSubPath); }).ToArray(); DependencyContext dependencyContext = builder.Build(userRuntimeAssemblies); From 756a48955deb5a177395883a39cf9fbb2d658c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Fri, 29 May 2026 13:33:53 +0200 Subject: [PATCH 08/10] removed redundant overloads --- .../Common/FileUtilities.MetadataReader.cs | 47 ------------------- src/Tasks/Common/FileUtilities.cs | 20 -------- 2 files changed, 67 deletions(-) diff --git a/src/Tasks/Common/FileUtilities.MetadataReader.cs b/src/Tasks/Common/FileUtilities.MetadataReader.cs index 848942249626..d8708f775008 100644 --- a/src/Tasks/Common/FileUtilities.MetadataReader.cs +++ b/src/Tasks/Common/FileUtilities.MetadataReader.cs @@ -13,7 +13,6 @@ #if NETCOREAPP || !EXTENSIONS using System.Collections.Concurrent; -using Microsoft.Build.Framework; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; @@ -74,52 +73,6 @@ static partial class FileUtilities } } } - - private static Version? GetAssemblyVersion(AbsolutePath sourcePath) - { - DateTime lastWriteTimeUtc = File.GetLastWriteTimeUtc(sourcePath); - - string sourcePathValue = sourcePath.Value; - if (s_versionCache.TryGetValue(sourcePathValue, out var cacheEntry) - && lastWriteTimeUtc == cacheEntry.LastKnownWriteTimeUtc) - { - return cacheEntry.Version; - } - - Version? version = GetAssemblyVersionFromFile(sourcePath); - - s_versionCache[sourcePathValue] = (lastWriteTimeUtc, version); - - return version; - - static Version? GetAssemblyVersionFromFile(AbsolutePath sourcePath) - { - using (var assemblyStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Delete | FileShare.Read)) - { - Version? result = null; - try - { - using (PEReader peReader = new(assemblyStream, PEStreamOptions.LeaveOpen)) - { - if (peReader.HasMetadata) - { - MetadataReader reader = peReader.GetMetadataReader(); - if (reader.IsAssembly) - { - result = reader.GetAssemblyDefinition().Version; - } - } - } - } - catch (BadImageFormatException) - { - // not a PE - } - - return result; - } - } - } } } diff --git a/src/Tasks/Common/FileUtilities.cs b/src/Tasks/Common/FileUtilities.cs index 253c7984eaf5..59c92abbcaf8 100644 --- a/src/Tasks/Common/FileUtilities.cs +++ b/src/Tasks/Common/FileUtilities.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using Microsoft.Build.Framework; namespace Microsoft.NET.Build.Tasks { @@ -23,18 +22,6 @@ static partial class FileUtilities return null; } - public static Version? GetFileVersion(AbsolutePath sourcePath) - { - var fvi = FileVersionInfo.GetVersionInfo(sourcePath); - - if (fvi != null) - { - return new Version(fvi.FileMajorPart, fvi.FileMinorPart, fvi.FileBuildPart, fvi.FilePrivatePart); - } - - return null; - } - static readonly HashSet s_assemblyExtensions = new(new[] { ".dll", ".exe", ".winmd" }, StringComparer.OrdinalIgnoreCase); public static Version? TryGetAssemblyVersion(string sourcePath) { @@ -42,12 +29,5 @@ static partial class FileUtilities return s_assemblyExtensions.Contains(extension) ? GetAssemblyVersion(sourcePath) : null; } - - public static Version? TryGetAssemblyVersion(AbsolutePath sourcePath) - { - var extension = Path.GetExtension(sourcePath.Value); - - return s_assemblyExtensions.Contains(extension) ? GetAssemblyVersion(sourcePath) : null; - } } } From 0d500e32434d4c1755c9a6e06b1c835e224a0cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Fri, 29 May 2026 14:49:01 +0200 Subject: [PATCH 09/10] addressed comments --- src/Tasks/Common/FileUtilities.cs | 5 +++++ .../DependencyContextBuilder.cs | 12 +++++++----- .../Microsoft.NET.Build.Tasks/GenerateDepsFile.cs | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Tasks/Common/FileUtilities.cs b/src/Tasks/Common/FileUtilities.cs index 59c92abbcaf8..ae2afcff381f 100644 --- a/src/Tasks/Common/FileUtilities.cs +++ b/src/Tasks/Common/FileUtilities.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Microsoft.Build.Framework; namespace Microsoft.NET.Build.Tasks { @@ -22,6 +23,8 @@ static partial class FileUtilities return null; } + public static Version? GetFileVersion(AbsolutePath sourcePath) => GetFileVersion(sourcePath.Value); + static readonly HashSet s_assemblyExtensions = new(new[] { ".dll", ".exe", ".winmd" }, StringComparer.OrdinalIgnoreCase); public static Version? TryGetAssemblyVersion(string sourcePath) { @@ -29,5 +32,7 @@ static partial class FileUtilities return s_assemblyExtensions.Contains(extension) ? GetAssemblyVersion(sourcePath) : null; } + + public static Version? TryGetAssemblyVersion(AbsolutePath sourcePath) => TryGetAssemblyVersion(sourcePath.Value); } } diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs b/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs index 49edb9eb909b..a4adde143879 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/DependencyContextBuilder.cs @@ -263,7 +263,7 @@ public DependencyContextBuilder WithPackagesThatWereFiltered(Dictionary GetRuntimePackLibraries() }); } - private ModifiableRuntimeLibrary GetRuntimeLibrary(DependencyLibrary library, (string Path, string DestinationSubPath)[] userRuntimeAssemblies) + private ModifiableRuntimeLibrary GetRuntimeLibrary(DependencyLibrary library, (AbsolutePath Path, string DestinationSubPath)[] userRuntimeAssemblies) { GetCommonLibraryProperties(library, out string hash, @@ -649,13 +649,12 @@ private ModifiableRuntimeLibrary GetRuntimeLibrary(DependencyLibrary library, (s if (library.Type == "project" && !(referenceProjectInfo is UnreferencedProjectInfo)) { var fileName = Path.GetFileNameWithoutExtension(library.Path); - (string Path, string DestinationSubPath) assembly = userRuntimeAssemblies is not null + (AbsolutePath Path, string DestinationSubPath) assembly = userRuntimeAssemblies is not null ? userRuntimeAssemblies.FirstOrDefault(p => Path.GetFileNameWithoutExtension(p.Path).Equals(fileName)) : default; string mainProjectDirectory = Path.GetDirectoryName(_mainProjectInfo.ProjectPath); - string assemblyPath = !string.IsNullOrWhiteSpace(assembly.Path) ? assembly.Path : null; string libraryPath = !string.IsNullOrWhiteSpace(library.Path) ? Path.Combine(mainProjectDirectory, library.Path) : null; - var runtimeFile = assemblyPath != null && File.Exists(assemblyPath) ? CreateRuntimeFile(referenceProjectInfo.OutputName, assemblyPath, assembly.DestinationSubPath) : + var runtimeFile = !string.IsNullOrWhiteSpace(assembly.Path) && File.Exists(assembly.Path) ? CreateRuntimeFile(referenceProjectInfo.OutputName, assembly.Path, assembly.DestinationSubPath) : libraryPath != null && File.Exists(libraryPath) ? CreateRuntimeFile(referenceProjectInfo.OutputName, libraryPath) : new RuntimeFile(referenceProjectInfo.OutputName, string.Empty, string.Empty); runtimeAssemblyGroups.Add(new RuntimeAssetGroup(string.Empty, [runtimeFile])); @@ -839,6 +838,9 @@ private RuntimeFile CreateRuntimeFile(string path, string fullPath, string local } } + private RuntimeFile CreateRuntimeFile(string path, AbsolutePath fullPath, string localPath = null) + => CreateRuntimeFile(path, fullPath.Value, localPath); + private static IEnumerable CreateResourceAssemblies(IEnumerable resourceAssemblyInfos) { return resourceAssemblyInfos diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs index b3fceab62d87..9ccd3a20d538 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs @@ -268,7 +268,7 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) { string destinationSubDir = i.GetMetadata(MetadataKeys.DestinationSubDirectory); string destinationSubPath = string.IsNullOrEmpty(destinationSubDir) ? null : Path.Combine(destinationSubDir, Path.GetFileName(i.ItemSpec)); - return (TaskEnvironment.GetAbsolutePath(i.ItemSpec).Value, destinationSubPath); + return (TaskEnvironment.GetAbsolutePath(i.ItemSpec), destinationSubPath); }).ToArray(); DependencyContext dependencyContext = builder.Build(userRuntimeAssemblies); From 93faa0dddf47c4f8d8b578c79057f74d83333c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Fri, 29 May 2026 16:18:33 +0200 Subject: [PATCH 10/10] added comments for folder paths --- src/Tasks/Microsoft.NET.Build.Tasks/GetPackageDirectory.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GetPackageDirectory.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GetPackageDirectory.cs index f430b0d93384..ecfad6f4ca6c 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GetPackageDirectory.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GetPackageDirectory.cs @@ -30,6 +30,10 @@ protected override void ExecuteCore() return; } + // The package folder paths (PackageFolders and those read from the assets file) are already + // absolute, as they originate from NuGet restore. Likewise, the package directory resolved + // below is an absolute on-disk path. These paths are intentionally not wrapped in AbsolutePath: + // doing so would not change behavior and would only complicate this code. if (!string.IsNullOrEmpty(AssetsFileWithAdditionalPackageFolders)) { var lockFileCache = new LockFileCache(this);