diff --git a/src/Tasks.UnitTests/Al_Tests.cs b/src/Tasks.UnitTests/Al_Tests.cs index b1a777002cc..72960a64e59 100644 --- a/src/Tasks.UnitTests/Al_Tests.cs +++ b/src/Tasks.UnitTests/Al_Tests.cs @@ -1,10 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; +using System.IO; using Microsoft.Build.Framework; using Microsoft.Build.Tasks; using Microsoft.Build.Utilities; +using Shouldly; using Xunit; +using Xunit.Abstractions; #nullable disable @@ -18,13 +22,19 @@ namespace Microsoft.Build.UnitTests */ public sealed class AlTests { + private readonly ITestOutputHelper _output; + + public AlTests(ITestOutputHelper output) + { + _output = output; + } /// /// Tests the AlgorithmId parameter /// [Fact] public void AlgorithmId() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.AlgorithmId); // "Default value" t.AlgorithmId = "whatisthis"; @@ -40,7 +50,7 @@ public void AlgorithmId() [Fact] public void BaseAddress() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.BaseAddress); // "Default value" t.BaseAddress = "12345678"; @@ -56,7 +66,7 @@ public void BaseAddress() [Fact] public void CompanyName() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.CompanyName); // "Default value" t.CompanyName = "Google"; @@ -72,7 +82,7 @@ public void CompanyName() [Fact] public void Configuration() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.Configuration); // "Default value" t.Configuration = "debug"; @@ -88,7 +98,7 @@ public void Configuration() [Fact] public void Copyright() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.Copyright); // "Default value" t.Copyright = "(C) 2005"; @@ -104,7 +114,7 @@ public void Copyright() [Fact] public void Culture() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.Culture); // "Default value" t.Culture = "aussie"; @@ -120,7 +130,7 @@ public void Culture() [Fact] public void DelaySign() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.False(t.DelaySign); // "Default value" t.DelaySign = true; @@ -136,7 +146,7 @@ public void DelaySign() [Fact] public void Description() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.Description); // "Default value" t.Description = "whatever"; @@ -152,7 +162,7 @@ public void Description() [Fact] public void EmbedResourcesWithPrivateAccess() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.EmbedResources); // "Default value" @@ -177,7 +187,7 @@ public void EmbedResourcesWithPrivateAccess() [Fact] public void EvidenceFile() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.EvidenceFile); // "Default value" t.EvidenceFile = "MyEvidenceFile"; @@ -193,7 +203,7 @@ public void EvidenceFile() [Fact] public void FileVersion() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.FileVersion); // "Default value" t.FileVersion = "1.2.3.4"; @@ -209,7 +219,7 @@ public void FileVersion() [Fact] public void Flags() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.Flags); // "Default value" t.Flags = "0x8421"; @@ -225,7 +235,7 @@ public void Flags() [Fact] public void GenerateFullPaths() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.False(t.GenerateFullPaths); // "Default value" t.GenerateFullPaths = true; @@ -241,7 +251,7 @@ public void GenerateFullPaths() [Fact] public void KeyFile() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.KeyFile); // "Default value" t.KeyFile = "mykey.snk"; @@ -257,7 +267,7 @@ public void KeyFile() [Fact] public void KeyContainer() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.KeyContainer); // "Default value" t.KeyContainer = "MyKeyContainer"; @@ -273,7 +283,7 @@ public void KeyContainer() [Fact] public void LinkResourcesWithPrivateAccessAndTargetFile() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.LinkResources); // "Default value" @@ -299,7 +309,7 @@ public void LinkResourcesWithPrivateAccessAndTargetFile() [Fact] public void LinkResourcesWithTwoItems() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.LinkResources); // "Default value" @@ -332,7 +342,7 @@ public void LinkResourcesWithTwoItems() [Fact] public void MainEntryPoint() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.MainEntryPoint); // "Default value" t.MainEntryPoint = "Class1.Main"; @@ -348,7 +358,7 @@ public void MainEntryPoint() [Fact] public void OutputAssembly() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.OutputAssembly); // "Default value" t.OutputAssembly = new TaskItem("foo.dll"); @@ -364,7 +374,7 @@ public void OutputAssembly() [Fact] public void Platform() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.Platform); // "Default value" t.Platform = "x86"; @@ -380,26 +390,26 @@ public void Platform() public void PlatformAndPrefer32Bit() { // Implicit "anycpu" - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; CommandLine.ValidateNoParameterStartsWith(t, @"/platform:"); - t = new AL(); + t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; t.Prefer32Bit = false; CommandLine.ValidateNoParameterStartsWith(t, @"/platform:"); - t = new AL(); + t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; t.Prefer32Bit = true; CommandLine.ValidateHasParameter( t, @"/platform:anycpu32bitpreferred"); // Explicit "anycpu" - t = new AL(); + t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; t.Platform = "anycpu"; CommandLine.ValidateHasParameter(t, @"/platform:anycpu"); - t = new AL(); + t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; t.Platform = "anycpu"; t.Prefer32Bit = false; CommandLine.ValidateHasParameter(t, @"/platform:anycpu"); - t = new AL(); + t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; t.Platform = "anycpu"; t.Prefer32Bit = true; CommandLine.ValidateHasParameter( @@ -407,14 +417,14 @@ public void PlatformAndPrefer32Bit() @"/platform:anycpu32bitpreferred"); // Explicit "x86" - t = new AL(); + t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; t.Platform = "x86"; CommandLine.ValidateHasParameter(t, @"/platform:x86"); - t = new AL(); + t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; t.Platform = "x86"; t.Prefer32Bit = false; CommandLine.ValidateHasParameter(t, @"/platform:x86"); - t = new AL(); + t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; t.Platform = "x86"; t.Prefer32Bit = true; CommandLine.ValidateHasParameter(t, @"/platform:x86"); @@ -426,7 +436,7 @@ public void PlatformAndPrefer32Bit() [Fact] public void ProductName() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.ProductName); // "Default value" t.ProductName = "VisualStudio"; @@ -442,7 +452,7 @@ public void ProductName() [Fact] public void ProductVersion() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.ProductVersion); // "Default value" t.ProductVersion = "8.0"; @@ -458,7 +468,7 @@ public void ProductVersion() [Fact] public void ResponseFiles() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.ResponseFiles); // "Default value" t.ResponseFiles = new string[2] { "one.rsp", "two.rsp" }; @@ -475,7 +485,7 @@ public void ResponseFiles() [Fact] public void SourceModules() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.SourceModules); // "Default value" @@ -500,7 +510,7 @@ public void SourceModules() [Fact] public void TargetType() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.TargetType); // "Default value" t.TargetType = "winexe"; @@ -516,7 +526,7 @@ public void TargetType() [Fact] public void TemplateFile() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.TemplateFile); // "Default value" t.TemplateFile = "mymainassembly.dll"; @@ -534,7 +544,7 @@ public void TemplateFile() [Fact] public void Title() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.Title); // "Default value" t.Title = "WarAndPeace"; @@ -550,7 +560,7 @@ public void Title() [Fact] public void Trademark() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.Trademark); // "Default value" t.Trademark = "MyTrademark"; @@ -566,7 +576,7 @@ public void Trademark() [Fact] public void Version() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.Version); // "Default value" t.Version = "WowHowManyKindsOfVersionsAreThere"; @@ -584,7 +594,7 @@ public void Version() [Fact] public void Win32Icon() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.Win32Icon); // "Default value" t.Win32Icon = "foo.ico"; @@ -600,7 +610,7 @@ public void Win32Icon() [Fact] public void Win32Resource() { - AL t = new AL(); + AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; Assert.Null(t.Win32Resource); // "Default value" t.Win32Resource = "foo.res"; @@ -609,5 +619,62 @@ public void Win32Resource() // Check the parameters. CommandLine.ValidateHasParameter(t, @"/win32res:foo.res"); } + + /// + /// Verifies that GenerateFullPathToTool returns an absolute path (or null) + /// when called with a multithreaded TaskEnvironment, validating the + /// TaskEnvironment.GetAbsolutePath() integration. + /// + [WindowsFullFrameworkOnlyFact] + public void GenerateFullPathToTool_ReturnsAbsolutePathOrNull() + { + string projectDir = Path.GetTempPath(); + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); + var taskEnv = new TaskEnvironment(driver); + + TestableAL t = new TestableAL(); + t.TaskEnvironment = taskEnv; + t.BuildEngine = new MockEngine(_output); + + string result = t.CallGenerateFullPathToTool(); + + if (result is not null) + { + Path.IsPathRooted(result).ShouldBeTrue( + $"GenerateFullPathToTool should return an absolute path, got: {result}"); + } + } + + /// + /// Verifies that the GetProcessStartInfo override routes through + /// GetProcessStartInfoMultiThreaded when TaskEnvironment is set, + /// and that the working directory comes from the TaskEnvironment. + /// + [WindowsFullFrameworkOnlyFact] + public void GetProcessStartInfo_UsesTaskEnvironmentWorkingDirectory() + { + string expectedWorkingDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + using var driver = new MultiThreadedTaskEnvironmentDriver(expectedWorkingDir); + var taskEnv = new TaskEnvironment(driver); + + TestableAL t = new TestableAL(); + t.TaskEnvironment = taskEnv; + t.BuildEngine = new MockEngine(_output); + + ProcessStartInfo startInfo = t.CallGetProcessStartInfo(@"C:\test\al.exe", "/nologo", null); + + startInfo.WorkingDirectory.ShouldBe(expectedWorkingDir); + } + + /// + /// Subclass that exposes protected methods for testing without reflection. + /// + private sealed class TestableAL : AL + { + public string CallGenerateFullPathToTool() => GenerateFullPathToTool(); + + public ProcessStartInfo CallGetProcessStartInfo(string pathToTool, string commandLineCommands, string responseFileSwitch) + => GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + } } } diff --git a/src/Tasks/Al.cs b/src/Tasks/Al.cs index d1872e4fb39..4ff00e13399 100644 --- a/src/Tasks/Al.cs +++ b/src/Tasks/Al.cs @@ -3,7 +3,6 @@ #if NETFRAMEWORK using System; - using Microsoft.Build.Shared.FileSystem; using Microsoft.Build.Utilities; #endif @@ -20,6 +19,7 @@ namespace Microsoft.Build.Tasks /// This class defines the "AL" XMake task, which enables using al.exe to link /// modules and resource files into assemblies. /// + [MSBuildMultiThreadableTask] public class AL : ToolTaskExtension, IALTaskContract { #region Properties @@ -305,12 +305,12 @@ protected override string GenerateFullPathToTool() // If COMPLUS_InstallRoot\COMPLUS_Version are set (the dogfood world), we want to find it there, instead of // the SDK, which may or may not be installed. The following will look there. - if (!String.IsNullOrEmpty(Environment.GetEnvironmentVariable("COMPLUS_InstallRoot")) || !String.IsNullOrEmpty(Environment.GetEnvironmentVariable("COMPLUS_Version"))) + if (!String.IsNullOrEmpty(TaskEnvironment.GetEnvironmentVariable("COMPLUS_InstallRoot")) || !String.IsNullOrEmpty(TaskEnvironment.GetEnvironmentVariable("COMPLUS_Version"))) { pathToTool = ToolLocationHelper.GetPathToDotNetFrameworkFile(ToolExe, TargetDotNetFrameworkVersion.Latest); } - if (String.IsNullOrEmpty(pathToTool) || !FileSystems.Default.FileExists(pathToTool)) + if (String.IsNullOrEmpty(pathToTool) || !FileSystems.Default.FileExists(TaskEnvironment.GetAbsolutePath(pathToTool))) { // The bitness of al.exe should match the platform being built // Yoda condition prevents null reference exception if Platform is null. @@ -318,10 +318,18 @@ protected override string GenerateFullPathToTool() "x64".Equals(Platform, StringComparison.OrdinalIgnoreCase) ? ProcessorArchitecture.AMD64 : // x64 maps to AMD64 in GeneratePathToTool ProcessorArchitecture.CurrentProcessArchitecture; - pathToTool = SdkToolsPathUtility.GeneratePathToTool(f => SdkToolsPathUtility.FileInfoExists(f), archToLookFor, SdkToolsPath, ToolExe, Log, true); + pathToTool = SdkToolsPathUtility.GeneratePathToTool( + f => !string.IsNullOrEmpty(f) + ? SdkToolsPathUtility.FileInfoExists(TaskEnvironment.GetAbsolutePath(f)) + : SdkToolsPathUtility.FileInfoExists(f), + archToLookFor, + SdkToolsPath, + ToolExe, + Log, + true); } - return pathToTool; + return string.IsNullOrEmpty(pathToTool) ? pathToTool : TaskEnvironment.GetAbsolutePath(pathToTool).Value; } /// @@ -400,6 +408,7 @@ public override bool Execute() /// /// Stub AL task for .NET Core. /// + [MSBuildMultiThreadableTask] public sealed class AL : TaskRequiresFramework, IALTaskContract { public AL() diff --git a/src/Utilities.UnitTests/ToolTask_Tests.cs b/src/Utilities.UnitTests/ToolTask_Tests.cs index 463bacfc033..77af6815973 100644 --- a/src/Utilities.UnitTests/ToolTask_Tests.cs +++ b/src/Utilities.UnitTests/ToolTask_Tests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Resources; @@ -754,6 +755,7 @@ public void ToolPathIsFoundWhenDirectoryExistsWithNameOfTool() [Fact] public void FindOnPathSucceeds() { + using MyTool tool = new MyTool(); string[] expectedCmdPath; string shellName; string cmdPath; @@ -761,13 +763,13 @@ public void FindOnPathSucceeds() { expectedCmdPath = new[] { Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe").ToUpperInvariant() }; shellName = "cmd.exe"; - cmdPath = ToolTask.FindOnPath(shellName).ToUpperInvariant(); + cmdPath = tool.FindOnPath(shellName).ToUpperInvariant(); } else { expectedCmdPath = new[] { "/bin/sh", "/usr/bin/sh" }; shellName = "sh"; - cmdPath = ToolTask.FindOnPath(shellName); + cmdPath = tool.FindOnPath(shellName); } cmdPath.ShouldBeOneOf(expectedCmdPath); @@ -1230,5 +1232,341 @@ public int TerminationTimeout /// public override bool Execute() => true; } + + /// + /// A ToolTask subclass for testing GetProcessStartInfo with TaskEnvironment. + /// + private sealed class MultiThreadedToolTask : ToolTask, IDisposable + { + private readonly string _fullToolName; + private readonly string _workingDirectory; + + public MultiThreadedToolTask(string fullToolName, string workingDirectory) + { + _fullToolName = fullToolName; + _workingDirectory = workingDirectory; + } + + public void Dispose() { } + + protected override string ToolName => Path.GetFileName(_fullToolName); + + protected override string GenerateFullPathToTool() => _fullToolName; + + protected override string GetWorkingDirectory() => _workingDirectory; + + /// + /// Exposes the protected GetProcessStartInfo for test verification. + /// + public ProcessStartInfo CallGetProcessStart(TaskEnvironment taskEnvironment) + { + TaskEnvironment = taskEnvironment; + return GetProcessStartInfo( + _fullToolName, + commandLineCommands: "/nologo", + responseFileSwitch: null); + } + + /// + /// Exposes the protected DeleteTempFile for test verification. + /// + public void CallDeleteTempFile(string fileName) => DeleteTempFile(fileName); + + /// + /// Exposes the protected GetProcessStartInfo for test verification. + /// + public ProcessStartInfo CallGetProcessStartInfo(string pathToTool, string commandLineCommands, string responseFileSwitch) + => GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + } + + [Fact] + public void GetProcessStartInfo_NoWorkingDirectoryOverride_UsesProjectDirectory() + { + // Arrange: no GetWorkingDirectory() override — WorkingDirectory should come from TaskEnvironment. + string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir"; + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); + var taskEnv = new TaskEnvironment(driver); + + string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(toolPath, null); + tool.BuildEngine = new MockEngine(_output); + + // Act + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); + + // Assert + result.WorkingDirectory.ShouldBe(projectDir, + "Without a GetWorkingDirectory() override, WorkingDirectory should fall back to taskEnvironment.ProjectDirectory"); + } + + [Fact] + public void GetProcessStartInfo_PropagatesSpecificEnvironmentVariable() + { + // Arrange: create a driver with a known env var and verify it appears in ProcessStartInfo. + string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir"; + var envVars = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["MY_CUSTOM_VAR"] = "custom_value" + }; + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir, envVars); + var taskEnv = new TaskEnvironment(driver); + + string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(toolPath, null); + tool.BuildEngine = new MockEngine(_output); + + // Act + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); + + // Assert + result.Environment["MY_CUSTOM_VAR"].ShouldBe("custom_value", + "Environment variables from TaskEnvironment should be propagated to ProcessStartInfo"); + } + + [Fact] + public void GetProcessStartInfo_RelativeWorkingDirectory_AbsolutizedAgainstProjectDir() + { + // Arrange: GetWorkingDirectory() returns a relative path — should be absolutized against project dir. + string projectDir = NativeMethodsShared.IsUnixLike ? "/projects/myapp" : @"C:\Projects\MyApp"; + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); + var taskEnv = new TaskEnvironment(driver); + + string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(toolPath, "subdir"); + tool.BuildEngine = new MockEngine(_output); + + // Act + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); + + // Assert: relative path should be combined with the project directory. + string expected = Path.Combine(projectDir, "subdir"); + result.WorkingDirectory.ShouldBe(expected, + "A relative GetWorkingDirectory() result should be absolutized against taskEnvironment.ProjectDirectory"); + } + + [Fact] + public void GetProcessStartInfo_AbsoluteWorkingDirectory_UsesOverridePath() + { + // Arrange: GetWorkingDirectory() returns an absolute path — should be used directly. + string projectDir = NativeMethodsShared.IsUnixLike ? "/projects/myapp" : @"C:\Projects\MyApp"; + string overrideDir = NativeMethodsShared.IsUnixLike ? "/custom/workdir" : @"D:\Custom\WorkDir"; + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); + var taskEnv = new TaskEnvironment(driver); + + string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(toolPath, overrideDir); + tool.BuildEngine = new MockEngine(_output); + + // Act + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); + + // Assert: absolute path should be used as-is (Path.Combine with absolute second arg returns it). + result.WorkingDirectory.ShouldBe(overrideDir, + "An absolute GetWorkingDirectory() result should be used directly, not combined with project directory"); + } + + [Fact] + public void GetProcessStartInfo_TaskEnvironmentVariablesOverride() + { + // Arrange: create a driver with a custom env var. + string expectedWorkingDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir"; + var envVars = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["MY_VAR"] = "from_driver", + ["PATH"] = "driver_path" + }; + using var driver = new MultiThreadedTaskEnvironmentDriver(expectedWorkingDir, envVars); + var taskEnv = new TaskEnvironment(driver); + + string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(toolPath, null); + tool.BuildEngine = new MockEngine(_output); + + // Set EnvironmentVariables on the task (should override the driver's value). + tool.EnvironmentVariables = ["MY_VAR=from_task_override"]; + + // Act + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); + + // Assert: task-level override should win. + result.Environment["MY_VAR"].ShouldBe("from_task_override", + "EnvironmentVariables property on the task should override TaskEnvironment values"); + } + + [Fact] + public void GetProcessStartInfo_MultiProcessDriver_BackwardCompat() + { + // Arrange: use the default MultiProcessTaskEnvironmentDriver (non-multithreaded mode). + // With the default driver, no working directory is set + // (the process inherits the parent's CWD), and process environment is inherited. + var taskEnv = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance); + + string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(toolPath, null); + tool.BuildEngine = new MockEngine(_output); + + // Act + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); + + // Assert: with MultiProcessTaskEnvironmentDriver, WorkingDirectory should be empty + // (process inherits parent CWD) — matching pre-migration behavior. + result.WorkingDirectory.ShouldBeEmpty( + "MultiProcessTaskEnvironmentDriver should not set WorkingDirectory, preserving old inherit-from-parent behavior"); + result.FileName.ShouldBe(toolPath); + result.Arguments.ShouldContain("/nologo"); + } + + [Fact] + public void GetProcessStartInfo_EmptyWorkingDirectory_KeepsProjectDirectory() + { + // Arrange: GetWorkingDirectory() returns empty string — should NOT override project dir. + // GetProcessStartInfo checks !string.IsNullOrEmpty, so empty string should leave + // the project directory from TaskEnvironment intact. + string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir"; + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); + var taskEnv = new TaskEnvironment(driver); + + string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(toolPath, string.Empty); + tool.BuildEngine = new MockEngine(_output); + + // Act + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); + + // Assert: empty-string GetWorkingDirectory() must not overwrite the project directory. + result.WorkingDirectory.ShouldBe(projectDir, + "Empty-string from GetWorkingDirectory() should not override the project directory from TaskEnvironment"); + } + + [Fact] + public void FindOnPath_UsesTaskEnvironmentPath() + { + // Arrange: create a temp dir with a dummy file, set TaskEnvironment PATH to that dir. + using var env = TestEnvironment.Create(_output); + string tempDir = env.CreateFolder().Path; + string toolName = NativeMethodsShared.IsWindows ? "mytesttool.exe" : "mytesttool"; + File.WriteAllText(Path.Combine(tempDir, toolName), "dummy"); + + string projectDir = tempDir; + var envVars = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["PATH"] = tempDir + }; + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir, envVars); + var taskEnv = new TaskEnvironment(driver); + + string fullToolName = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(fullToolName, null); + tool.TaskEnvironment = taskEnv; + tool.BuildEngine = new MockEngine(_output); + + // Act + string result = tool.FindOnPath(toolName); + + // Assert: should find the tool via TaskEnvironment's PATH. + result.ShouldNotBeNull("FindOnPath should find the tool via TaskEnvironment's PATH"); + result.ShouldBe(Path.Combine(tempDir, toolName)); + } + + [Fact] + public void DeleteTempFile_UsesTaskEnvironmentForAbsolutePath() + { + // Arrange: create a temp file in the project directory, use relative path for deletion. + using var env = TestEnvironment.Create(_output); + string projectDir = env.CreateFolder().Path; + string fileName = "tempfile.rsp"; + string fullPath = Path.Combine(projectDir, fileName); + File.WriteAllText(fullPath, "test content"); + + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); + var taskEnv = new TaskEnvironment(driver); + + string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(toolPath, null); + tool.TaskEnvironment = taskEnv; + tool.BuildEngine = new MockEngine(_output); + + // Act: delete using a relative path — TaskEnvironment should absolutize it. + tool.CallDeleteTempFile(fileName); + + // Assert + File.Exists(fullPath).ShouldBeFalse( + "DeleteTempFile should have deleted the file using TaskEnvironment-absolutized path"); + } + + [Fact] + public void GetProcessStartInfo_MultiThreadedDriver_SetsWorkingDirectoryAndEnvironment() + { + // Arrange: when TaskEnvironment uses MultiThreadedTaskEnvironmentDriver, + // GetProcessStartInfo should set WorkingDirectory from the driver's ProjectDirectory + // and propagate environment variables. + string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir"; + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); + var taskEnv = new TaskEnvironment(driver); + + string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(toolPath, null); + tool.TaskEnvironment = taskEnv; + tool.BuildEngine = new MockEngine(_output); + + // Act: call through the virtual GetProcessStartInfo (the normal entry point). + ProcessStartInfo result = tool.CallGetProcessStartInfo(toolPath, "/nologo", null); + + // Assert: WorkingDirectory should be set to project directory + // and environment variables should be propagated from the driver. + result.WorkingDirectory.ShouldBe(projectDir, + "MultiThreadedDriver should set WorkingDirectory to ProjectDirectory"); + result.Environment.Count.ShouldBeGreaterThan(0, + "MultiThreadedDriver should propagate environment variables"); + } + + [Fact] + public void GetProcessStartInfo_MultiProcessDriver_DoesNotSetWorkingDirectory() + { + // Arrange: when TaskEnvironment uses the default MultiProcessTaskEnvironmentDriver, + // WorkingDirectory should not be set (the process inherits the parent's CWD). + var taskEnv = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance); + + string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(toolPath, null); + tool.TaskEnvironment = taskEnv; + tool.BuildEngine = new MockEngine(_output); + + // Act + ProcessStartInfo result = tool.CallGetProcessStartInfo(toolPath, "/nologo", null); + + // Assert: WorkingDirectory should be empty (inherits from parent process). + result.WorkingDirectory.ShouldBeNullOrEmpty( + "MultiProcessDriver should not set WorkingDirectory, preserving pre-migration behavior"); + } + + [Fact] + public void ComputePathToTool_UsesTaskEnvironmentForFileExistence() + { + // Arrange: create a temp dir with a dummy tool, set up TaskEnvironment pointing there. + using var env = TestEnvironment.Create(_output); + string projectDir = env.CreateFolder().Path; + string toolDir = env.CreateFolder().Path; + string toolName = NativeMethodsShared.IsWindows ? "mytool.exe" : "mytool"; + string toolFullPath = Path.Combine(toolDir, toolName); + File.WriteAllText(toolFullPath, "dummy"); + + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); + var taskEnv = new TaskEnvironment(driver); + + // Use MyTool pointing to the actual tool location. + using var tool = new MyTool(); + tool.FullToolName = toolFullPath; + tool.TaskEnvironment = taskEnv; + tool.BuildEngine = new MockEngine(_output); + + // Act: Execute triggers ComputePathToTool which uses TaskEnvironment.GetAbsolutePath + // for file existence checks. The tool exists at an absolute path, so this should succeed. + bool result = tool.Execute(); + + // Assert: the tool should have been found and executed. + tool.ExecuteCalled.ShouldBeTrue( + "ComputePathToTool should find the tool using TaskEnvironment-absolutized path for existence check"); + } } } diff --git a/src/Utilities/ToolTask.cs b/src/Utilities/ToolTask.cs index 3d4d773bf07..05ed6aca646 100644 --- a/src/Utilities/ToolTask.cs +++ b/src/Utilities/ToolTask.cs @@ -58,7 +58,7 @@ public enum HostObjectInitializationStatus /// // INTERNAL WARNING: DO NOT USE the Log property in this class! Log points to resources in the task assembly itself, and // we want to use resources from Utilities. Use LogPrivate (for private Utilities resources) and LogShared (for shared MSBuild resources) - public abstract class ToolTask : Task, IIncrementalTask, ICancelableTask + public abstract class ToolTask : Task, IIncrementalTask, ICancelableTask, IMultiThreadableTask { private static readonly bool s_preserveTempFiles = string.Equals(Environment.GetEnvironmentVariable("MSBUILDPRESERVETOOLTEMPFILES"), "1", StringComparison.Ordinal); @@ -214,6 +214,8 @@ public virtual string ToolExe /// public string[] EnvironmentVariables { get; set; } + public virtual TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// Project visible property that allows the user to specify an amount of time after which the task executable /// is terminated. @@ -529,7 +531,7 @@ private string ComputePathToTool() pathToTool = Path.Combine(ToolPath, ToolExe); } - if (string.IsNullOrWhiteSpace(pathToTool) || (ToolPath == null && !FileSystems.Default.FileExists(pathToTool))) + if (string.IsNullOrWhiteSpace(pathToTool) || (ToolPath == null && !FileSystems.Default.FileExists(TaskEnvironment.GetAbsolutePath(pathToTool)))) { // Otherwise, try to find the tool ourselves. pathToTool = GenerateFullPathToTool(); @@ -550,7 +552,7 @@ private string ComputePathToTool() bool isOnlyFileName = Path.GetFileName(pathToTool).Length == pathToTool.Length; if (!isOnlyFileName) { - bool isExistingFile = FileSystems.Default.FileExists(pathToTool); + bool isExistingFile = FileSystems.Default.FileExists(TaskEnvironment.GetAbsolutePath(pathToTool)); if (!isExistingFile) { LogPrivate.LogErrorWithCodeFromResources("ToolTask.ToolExecutableNotFound", pathToTool); @@ -657,7 +659,9 @@ protected virtual ProcessStartInfo GetProcessStartInfo( LogPrivate.LogWarningWithCodeFromResources("ToolTask.CommandTooLong", GetType().Name); } - ProcessStartInfo startInfo = new ProcessStartInfo(pathToTool, commandLine); + ProcessStartInfo startInfo = TaskEnvironment.GetProcessStartInfo(); + startInfo.FileName = pathToTool; + startInfo.Arguments = commandLine; startInfo.CreateNoWindow = true; startInfo.UseShellExecute = false; startInfo.RedirectStandardError = true; @@ -676,11 +680,15 @@ protected virtual ProcessStartInfo GetProcessStartInfo( // Generally we won't set a working directory, and it will use the current directory string workingDirectory = GetWorkingDirectory(); - if (workingDirectory != null) + if (!string.IsNullOrEmpty(workingDirectory)) { - startInfo.WorkingDirectory = workingDirectory; + startInfo.WorkingDirectory = TaskEnvironment.GetAbsolutePath(workingDirectory); } + // Apply task-level environment variable overrides (both the obsolete EnvironmentOverride + // and the current EnvironmentVariables). Prefers the pre-parsed _environmentVariablePairs + // populated by Execute(), falling back to parsing EnvironmentVariables directly for + // callers outside the normal Execute() path. // Old style environment overrides #pragma warning disable 0618 // obsolete Dictionary envOverrides = EnvironmentOverride; @@ -701,6 +709,19 @@ protected virtual ProcessStartInfo GetProcessStartInfo( startInfo.Environment[variable.Key] = variable.Value; } } + else if (EnvironmentVariables != null) + { + // Fallback for callers outside the normal Execute() path + // where _environmentVariablePairs hasn't been populated yet. + foreach (string entry in EnvironmentVariables) + { + string[] nameValuePair = entry.Split(s_equalsSplitter, 2); + if (nameValuePair.Length == 2 && nameValuePair[0].Length > 0) + { + startInfo.Environment[nameValuePair[0]] = nameValuePair[1]; + } + } + } return startInfo; } @@ -868,24 +889,36 @@ protected virtual int ExecuteTool( /// /// File to delete protected void DeleteTempFile(string fileName) + { + AbsolutePath filePath = !string.IsNullOrEmpty(fileName) ? TaskEnvironment.GetAbsolutePath(fileName) : new AbsolutePath(fileName, ignoreRootedCheck: true); + DeleteTempFile(filePath); + } + + /// + /// Overload of that accepts an . + /// If the delete fails for some reason (e.g. file locked by anti-virus) then + /// the call will not throw an exception. Instead a warning will be logged, but the build will not fail. + /// + /// Absolute path to file to delete + protected void DeleteTempFile(AbsolutePath filePath) { if (s_preserveTempFiles) { - Log.LogMessageFromText($"Preserving temporary file '{fileName}'", MessageImportance.Low); + Log.LogMessageFromText($"Preserving temporary file '{filePath.OriginalValue}'", MessageImportance.Low); return; } try { - File.Delete(fileName); + File.Delete(filePath); } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { - string lockedFileMessage = LockCheck.GetLockedFileMessage(fileName); + string lockedFileMessage = LockCheck.GetLockedFileMessage(filePath); // Warn only -- occasionally temp files fail to delete because of virus checkers; we // don't want the build to fail in such cases - LogShared.LogWarningWithCodeFromResources("Shared.FailedDeletingTempFile", fileName, e.Message, lockedFileMessage); + LogShared.LogWarningWithCodeFromResources("Shared.FailedDeletingTempFile", filePath.OriginalValue, e.Message, lockedFileMessage); } } @@ -1027,7 +1060,7 @@ private void KillToolProcessOnTimeout(Process proc, bool isBeingCancelled) } int timeout = TaskProcessTerminationTimeout >= -1 ? TaskProcessTerminationTimeout : 5000; - string timeoutFromEnvironment = Environment.GetEnvironmentVariable("MSBUILDTOOLTASKCANCELPROCESSWAITTIMEOUT"); + string timeoutFromEnvironment = TaskEnvironment.GetEnvironmentVariable("MSBUILDTOOLTASKCANCELPROCESSWAITTIMEOUT"); if (timeoutFromEnvironment != null) { if (int.TryParse(timeoutFromEnvironment, out int result) && result >= 0) @@ -1380,17 +1413,22 @@ private bool AssignStandardStreamLoggingImportance() /// /// /// The location of the file, or null if file not found. - internal static string FindOnPath(string filename) + internal string FindOnPath(string filename) { // Get path from the environment and split path separator - return Environment.GetEnvironmentVariable("PATH")? + return TaskEnvironment.GetEnvironmentVariable("PATH")? .Split(MSBuildConstants.PathSeparatorChar)? .Where(path => { + if (string.IsNullOrEmpty(path)) + { + return false; + } + try { // The PATH can contain anything, including bad characters - return FileSystems.Default.DirectoryExists(path); + return FileSystems.Default.DirectoryExists(TaskEnvironment.GetAbsolutePath(path)); } catch (Exception) { @@ -1398,7 +1436,7 @@ internal static string FindOnPath(string filename) } }) .Select(folderPath => Path.Combine(folderPath, filename)) - .FirstOrDefault(fullPath => !string.IsNullOrEmpty(fullPath) && FileSystems.Default.FileExists(fullPath)); + .FirstOrDefault(fullPath => !string.IsNullOrEmpty(fullPath) && FileSystems.Default.FileExists(TaskEnvironment.GetAbsolutePath(fullPath))); } #endregion