From 22d83b353ee0acd6bb21da56711ff008eac0f57b Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:51:27 +0100 Subject: [PATCH 01/10] tried to transfer more tasks - 2 --- src/Tasks/AssignTargetPath.cs | 45 ++++++++++++------------ src/Tasks/ListOperators/FindUnderPath.cs | 14 +++++--- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/Tasks/AssignTargetPath.cs b/src/Tasks/AssignTargetPath.cs index 1c9ca3d5c38..be358e351b2 100644 --- a/src/Tasks/AssignTargetPath.cs +++ b/src/Tasks/AssignTargetPath.cs @@ -15,10 +15,17 @@ namespace Microsoft.Build.Tasks /// Create a new list of items that have <TargetPath> attributes if none was present in /// the input. /// - public class AssignTargetPath : TaskExtension + [MSBuildMultiThreadableTask] + public class AssignTargetPath : TaskExtension, IMultiThreadableTask { #region Properties + /// + /// Gets or sets the task execution environment for thread-safe path resolution. + /// + public TaskEnvironment TaskEnvironment { get; set; } + + /// /// The folder to make the links relative to. /// @@ -50,26 +57,18 @@ public override bool Execute() if (Files.Length > 0) { // Compose a file in the root folder. - // NOTE: at this point fullRootPath may or may not have a trailing - // slash because Path.GetFullPath() does not add or remove it - string fullRootPath = Path.GetFullPath(RootFolder); - + // NOTE: at this point fullRootPath may or may not have a trailing slash // Ensure trailing slash otherwise c:\bin appears to match part of c:\bin2\foo - fullRootPath = FileUtilities.EnsureTrailingSlash(fullRootPath); + // Also ensure that relative segments in the path are resolved. + AbsolutePath fullRootPath = FrameworkFileUtilities.RemoveRelativeSegments( + TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(RootFolder))); - string currentDirectory = Directory.GetCurrentDirectory(); + // Ensure trailing slash for comparison - AbsolutePath handles OS-aware case sensitivity + AbsolutePath currentDirectory = FrameworkFileUtilities.RemoveRelativeSegments( + FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory)); // check if the root folder is the same as the current directory - // NOTE: the path returned from Directory.GetCurrentDirectory() - // does not have a trailing slash, but fullRootPath does - bool isRootFolderSameAsCurrentDirectory = - ((fullRootPath.Length - 1 /* exclude trailing slash */) == currentDirectory.Length) - && - (String.Compare( - fullRootPath, 0, - currentDirectory, 0, - fullRootPath.Length - 1 /* don't compare trailing slash */, - StringComparison.OrdinalIgnoreCase) == 0); + bool isRootFolderSameAsCurrentDirectory = fullRootPath == currentDirectory; for (int i = 0; i < Files.Length; ++i) { @@ -95,21 +94,21 @@ public override bool Execute() isRootFolderSameAsCurrentDirectory) { // then just use the file path as-is - // PERF NOTE: we do this to avoid calling Path.GetFullPath() below, + // PERF NOTE: we do this to avoid calling Path.GetFullPath() below + // (which is called by RemoveRelativeSegments method), // because that method consumes a lot of memory, esp. when we have // a lot of items coming through this task targetPath = Files[i].ItemSpec; } else { - // PERF WARNING: Path.GetFullPath() is expensive in terms of memory; - // we should avoid calling it whenever possible - string itemSpecFullFileNamePath = Path.GetFullPath(Files[i].ItemSpec); + AbsolutePath itemSpecFullFileNamePath = FrameworkFileUtilities.RemoveRelativeSegments( + TaskEnvironment.GetAbsolutePath(Files[i].ItemSpec)); - if (String.Compare(fullRootPath, 0, itemSpecFullFileNamePath, 0, fullRootPath.Length, StringComparison.CurrentCultureIgnoreCase) == 0) + if (itemSpecFullFileNamePath.StartsWith(fullRootPath, StringComparison.OrdinalIgnoreCase)) { // The item spec file is in the "cone" of the RootFolder. Return the relative path from the cone root. - targetPath = itemSpecFullFileNamePath.Substring(fullRootPath.Length); + targetPath = itemSpecFullFileNamePath.Substring(fullRootPath.Value.Length); } else { diff --git a/src/Tasks/ListOperators/FindUnderPath.cs b/src/Tasks/ListOperators/FindUnderPath.cs index 836ec12b0f0..460f7e54a63 100644 --- a/src/Tasks/ListOperators/FindUnderPath.cs +++ b/src/Tasks/ListOperators/FindUnderPath.cs @@ -15,8 +15,14 @@ namespace Microsoft.Build.Tasks /// /// Given a list of items, determine which are in the cone of the folder passed in and which aren't. /// - public class FindUnderPath : TaskExtension + [MSBuildMultiThreadableTask] + public class FindUnderPath : TaskExtension, IMultiThreadableTask { + /// + /// Gets or sets the task execution environment for thread-safe path resolution. + /// + public TaskEnvironment TaskEnvironment { get; set; } + /// /// Filter based on whether items fall under this path or not. /// @@ -59,8 +65,8 @@ public override bool Execute() { conePath = Strings.WeakIntern( - System.IO.Path.GetFullPath(FileUtilities.FixFilePath(Path.ItemSpec))); - conePath = FileUtilities.EnsureTrailingSlash(conePath); + FrameworkFileUtilities.RemoveRelativeSegments(TaskEnvironment.GetAbsolutePath(FileUtilities.FixFilePath(Path.ItemSpec)))); + conePath = FrameworkFileUtilities.EnsureTrailingSlash(conePath); } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { @@ -80,7 +86,7 @@ public override bool Execute() { fullPath = Strings.WeakIntern( - System.IO.Path.GetFullPath(FileUtilities.FixFilePath(item.ItemSpec))); + FrameworkFileUtilities.RemoveRelativeSegments(TaskEnvironment.GetAbsolutePath(FileUtilities.FixFilePath(item.ItemSpec)))); } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { From f820318eba0e541b6d201be435c0e5ac54d4be30 Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:16:57 +0100 Subject: [PATCH 02/10] Fix merge errors --- src/Tasks/AssignTargetPath.cs | 4 ++-- src/Tasks/ListOperators/FindUnderPath.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Tasks/AssignTargetPath.cs b/src/Tasks/AssignTargetPath.cs index be358e351b2..cc0dcc73af9 100644 --- a/src/Tasks/AssignTargetPath.cs +++ b/src/Tasks/AssignTargetPath.cs @@ -105,10 +105,10 @@ public override bool Execute() AbsolutePath itemSpecFullFileNamePath = FrameworkFileUtilities.RemoveRelativeSegments( TaskEnvironment.GetAbsolutePath(Files[i].ItemSpec)); - if (itemSpecFullFileNamePath.StartsWith(fullRootPath, StringComparison.OrdinalIgnoreCase)) + if (itemSpecFullFileNamePath.Value.StartsWith(fullRootPath.Value, StringComparison.OrdinalIgnoreCase)) { // The item spec file is in the "cone" of the RootFolder. Return the relative path from the cone root. - targetPath = itemSpecFullFileNamePath.Substring(fullRootPath.Value.Length); + targetPath = itemSpecFullFileNamePath.Value.Substring(fullRootPath.Value.Length); } else { diff --git a/src/Tasks/ListOperators/FindUnderPath.cs b/src/Tasks/ListOperators/FindUnderPath.cs index 460f7e54a63..7e7004a90ed 100644 --- a/src/Tasks/ListOperators/FindUnderPath.cs +++ b/src/Tasks/ListOperators/FindUnderPath.cs @@ -65,7 +65,7 @@ public override bool Execute() { conePath = Strings.WeakIntern( - FrameworkFileUtilities.RemoveRelativeSegments(TaskEnvironment.GetAbsolutePath(FileUtilities.FixFilePath(Path.ItemSpec)))); + FrameworkFileUtilities.RemoveRelativeSegments(TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(Path.ItemSpec)))); conePath = FrameworkFileUtilities.EnsureTrailingSlash(conePath); } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) @@ -86,7 +86,7 @@ public override bool Execute() { fullPath = Strings.WeakIntern( - FrameworkFileUtilities.RemoveRelativeSegments(TaskEnvironment.GetAbsolutePath(FileUtilities.FixFilePath(item.ItemSpec)))); + FrameworkFileUtilities.RemoveRelativeSegments(TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(item.ItemSpec)))); } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { From 7c925ac9c7499c1e953bdc14239bcedbb0128c1f Mon Sep 17 00:00:00 2001 From: Alina Mayorova <67507805+AR-May@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:54:11 +0100 Subject: [PATCH 03/10] fix tests --- src/Tasks.UnitTests/AssignTargetPath_Tests.cs | 5 +++++ src/Tasks.UnitTests/FindUnderPath_Tests.cs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/Tasks.UnitTests/AssignTargetPath_Tests.cs b/src/Tasks.UnitTests/AssignTargetPath_Tests.cs index f2c08c59c6d..8678620f086 100644 --- a/src/Tasks.UnitTests/AssignTargetPath_Tests.cs +++ b/src/Tasks.UnitTests/AssignTargetPath_Tests.cs @@ -18,6 +18,7 @@ public sealed class AssignTargetPath_Tests public void Regress314791() { AssignTargetPath t = new AssignTargetPath(); + t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); t.Files = new ITaskItem[] { new TaskItem(NativeMethodsShared.IsWindows ? @"c:\bin2\abc.efg" : "/bin2/abc.efg") }; @@ -33,6 +34,7 @@ public void Regress314791() public void AtConeRoot() { AssignTargetPath t = new AssignTargetPath(); + t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); t.Files = new ITaskItem[] { new TaskItem(NativeMethodsShared.IsWindows ? @"c:\f1\f2\file.txt" : "/f1/f2/file.txt") }; @@ -47,6 +49,7 @@ public void AtConeRoot() public void OutOfCone() { AssignTargetPath t = new AssignTargetPath(); + t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); t.Files = new ITaskItem[] { @@ -68,6 +71,7 @@ public void OutOfCone() public void InConeButAbsolute() { AssignTargetPath t = new AssignTargetPath(); + t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); t.Files = new ITaskItem[] { @@ -90,6 +94,7 @@ public void InConeButAbsolute() public void TargetPathAlreadySet(string targetPath) { AssignTargetPath t = new AssignTargetPath(); + t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); Dictionary metaData = new Dictionary(); metaData.Add("TargetPath", targetPath); diff --git a/src/Tasks.UnitTests/FindUnderPath_Tests.cs b/src/Tasks.UnitTests/FindUnderPath_Tests.cs index 614a83b2202..d60e52be101 100644 --- a/src/Tasks.UnitTests/FindUnderPath_Tests.cs +++ b/src/Tasks.UnitTests/FindUnderPath_Tests.cs @@ -21,6 +21,7 @@ public sealed class FindUnderPath_Tests public void BasicFilter() { FindUnderPath t = new FindUnderPath(); + t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); t.Path = new TaskItem(@"C:\MyProject"); @@ -39,6 +40,7 @@ public void BasicFilter() public void InvalidFile() { FindUnderPath t = new FindUnderPath(); + t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); t.Path = new TaskItem(@"C:\MyProject"); @@ -55,6 +57,7 @@ public void InvalidFile() public void InvalidPath() { FindUnderPath t = new FindUnderPath(); + t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); t.Path = new TaskItem(@"||::||"); @@ -96,6 +99,7 @@ private static void RunTask(FindUnderPath t, out FileInfo testFile, out bool suc public void VerifyFullPath() { FindUnderPath t = new FindUnderPath(); + t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); t.UpdateToAbsolutePaths = true; @@ -118,6 +122,7 @@ public void VerifyFullPath() public void VerifyFullPathNegative() { FindUnderPath t = new FindUnderPath(); + t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); t.UpdateToAbsolutePaths = false; From 3c0c34653efd3c09d7d788eaf57a560515718e8e Mon Sep 17 00:00:00 2001 From: Alina Mayorova <67507805+AR-May@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:08:00 +0100 Subject: [PATCH 04/10] Use GetCanonicalForm --- src/Tasks/AssignTargetPath.cs | 12 ++++++------ src/Tasks/ListOperators/FindUnderPath.cs | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Tasks/AssignTargetPath.cs b/src/Tasks/AssignTargetPath.cs index cc0dcc73af9..799e936d6d6 100644 --- a/src/Tasks/AssignTargetPath.cs +++ b/src/Tasks/AssignTargetPath.cs @@ -60,12 +60,12 @@ public override bool Execute() // NOTE: at this point fullRootPath may or may not have a trailing slash // Ensure trailing slash otherwise c:\bin appears to match part of c:\bin2\foo // Also ensure that relative segments in the path are resolved. - AbsolutePath fullRootPath = FrameworkFileUtilities.RemoveRelativeSegments( - TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(RootFolder))); + AbsolutePath fullRootPath = + TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(RootFolder)).GetCanonicalForm(); // Ensure trailing slash for comparison - AbsolutePath handles OS-aware case sensitivity - AbsolutePath currentDirectory = FrameworkFileUtilities.RemoveRelativeSegments( - FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory)); + AbsolutePath currentDirectory = + new AbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory), ignoreRootedCheck: true).GetCanonicalForm(); // check if the root folder is the same as the current directory bool isRootFolderSameAsCurrentDirectory = fullRootPath == currentDirectory; @@ -102,8 +102,8 @@ public override bool Execute() } else { - AbsolutePath itemSpecFullFileNamePath = FrameworkFileUtilities.RemoveRelativeSegments( - TaskEnvironment.GetAbsolutePath(Files[i].ItemSpec)); + AbsolutePath itemSpecFullFileNamePath = + TaskEnvironment.GetAbsolutePath(Files[i].ItemSpec).GetCanonicalForm(); if (itemSpecFullFileNamePath.Value.StartsWith(fullRootPath.Value, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Tasks/ListOperators/FindUnderPath.cs b/src/Tasks/ListOperators/FindUnderPath.cs index 7e7004a90ed..7033832ae56 100644 --- a/src/Tasks/ListOperators/FindUnderPath.cs +++ b/src/Tasks/ListOperators/FindUnderPath.cs @@ -65,7 +65,7 @@ public override bool Execute() { conePath = Strings.WeakIntern( - FrameworkFileUtilities.RemoveRelativeSegments(TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(Path.ItemSpec)))); + TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(Path.ItemSpec)).GetCanonicalForm()); conePath = FrameworkFileUtilities.EnsureTrailingSlash(conePath); } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) @@ -86,7 +86,7 @@ public override bool Execute() { fullPath = Strings.WeakIntern( - FrameworkFileUtilities.RemoveRelativeSegments(TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(item.ItemSpec)))); + TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(item.ItemSpec)).GetCanonicalForm()); } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { From 95d2700894a931c0bc93bb7d7c402888e875393f Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:45:13 +0100 Subject: [PATCH 05/10] Add changewave for the behavioral chnages --- documentation/wiki/ChangeWaves.md | 4 + src/Framework/ChangeWaves.cs | 3 +- src/Tasks/AssignTargetPath.cs | 93 +++++++++++++++++------- src/Tasks/ListOperators/FindUnderPath.cs | 31 ++++++-- 4 files changed, 99 insertions(+), 32 deletions(-) diff --git a/documentation/wiki/ChangeWaves.md b/documentation/wiki/ChangeWaves.md index 66693a74667..d4007af5c8c 100644 --- a/documentation/wiki/ChangeWaves.md +++ b/documentation/wiki/ChangeWaves.md @@ -24,6 +24,10 @@ A wave of features is set to "rotate out" (i.e. become standard functionality) t ## Current Rotation of Change Waves +### 18.6 +- [FindUnderPath and AssignTargetPath tasks no longer throw on invalid path characters when using TaskEnvironment.GetAbsolutePath](https://github.com/dotnet/msbuild/pull/13069) +- [AssignTargetPath on Linux respects case sensitivity of the file system instead of always ignoring case](https://github.com/dotnet/msbuild/pull/13069) + ### 18.4 - [Start throwing on null or empty paths in MultiProcess and MultiThreaded Task Environment Drivers.](https://github.com/dotnet/msbuild/pull/12914) diff --git a/src/Framework/ChangeWaves.cs b/src/Framework/ChangeWaves.cs index 22049fdfc65..c575107d672 100644 --- a/src/Framework/ChangeWaves.cs +++ b/src/Framework/ChangeWaves.cs @@ -32,7 +32,8 @@ internal static class ChangeWaves internal static readonly Version Wave17_14 = new Version(17, 14); internal static readonly Version Wave18_3 = new Version(18, 3); internal static readonly Version Wave18_4 = new Version(18, 4); - internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4]; + internal static readonly Version Wave18_6 = new Version(18, 6); + internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4, Wave18_6]; /// /// Special value indicating that all features behind all Change Waves should be enabled. diff --git a/src/Tasks/AssignTargetPath.cs b/src/Tasks/AssignTargetPath.cs index 799e936d6d6..f7cabd02f5a 100644 --- a/src/Tasks/AssignTargetPath.cs +++ b/src/Tasks/AssignTargetPath.cs @@ -56,19 +56,44 @@ public override bool Execute() if (Files.Length > 0) { - // Compose a file in the root folder. - // NOTE: at this point fullRootPath may or may not have a trailing slash - // Ensure trailing slash otherwise c:\bin appears to match part of c:\bin2\foo - // Also ensure that relative segments in the path are resolved. - AbsolutePath fullRootPath = - TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(RootFolder)).GetCanonicalForm(); + AbsolutePath fullRootPath = default; + string fullRootPathString = null; + bool isRootFolderSameAsCurrentDirectory; - // Ensure trailing slash for comparison - AbsolutePath handles OS-aware case sensitivity - AbsolutePath currentDirectory = - new AbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory), ignoreRootedCheck: true).GetCanonicalForm(); - - // check if the root folder is the same as the current directory - bool isRootFolderSameAsCurrentDirectory = fullRootPath == currentDirectory; + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_6)) + { + // Compose a file in the root folder. + // NOTE: at this point fullRootPath may or may not have a trailing slash + // Ensure trailing slash otherwise c:\bin appears to match part of c:\bin2\foo + // Also ensure that relative segments in the path are resolved. + fullRootPath = + TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(RootFolder)).GetCanonicalForm(); + + // Ensure trailing slash for comparison - AbsolutePath handles OS-aware case sensitivity + AbsolutePath currentDirectory = + new AbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory), ignoreRootedCheck: true).GetCanonicalForm(); + + // Check if the root folder is the same as the current directory + isRootFolderSameAsCurrentDirectory = fullRootPath == currentDirectory; + } + else + { + // Compose a file in the root folder. + // NOTE: at this point fullRootPath may or may not have a trailing slash + // Ensure trailing slash otherwise c:\bin appears to match part of c:\bin2\foo + // Also ensure that relative segments in the path are resolved and throw on illegal characters in Path.GetFullPath to preserve pre-existing behavior. + fullRootPathString = + Path.GetFullPath(TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(RootFolder))); + + // Ensure trailing slash for easier comparison. + AbsolutePath currentDirectory = + new AbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory), ignoreRootedCheck: true).GetCanonicalForm(); + + // Check if the root folder is the same as the current directory. + // Perform a case-insensitive comparison to match Path.GetFullPath behavior on Windows, even on case-sensitive file systems, + // since that's what we're using to determine whether to preserve relative paths or not. + isRootFolderSameAsCurrentDirectory = String.Compare(fullRootPathString, currentDirectory, StringComparison.OrdinalIgnoreCase) == 0; + } for (int i = 0; i < Files.Length; ++i) { @@ -94,26 +119,44 @@ public override bool Execute() isRootFolderSameAsCurrentDirectory) { // then just use the file path as-is - // PERF NOTE: we do this to avoid calling Path.GetFullPath() below - // (which is called by RemoveRelativeSegments method), - // because that method consumes a lot of memory, esp. when we have - // a lot of items coming through this task + // PERF NOTE: we do this to avoid calling Path.GetFullPath() below (which is called by GetCanonicalForm method), + // because that method consumes a lot of memory, esp. when we have a lot of items coming through this task targetPath = Files[i].ItemSpec; } else { - AbsolutePath itemSpecFullFileNamePath = - TaskEnvironment.GetAbsolutePath(Files[i].ItemSpec).GetCanonicalForm(); - - if (itemSpecFullFileNamePath.Value.StartsWith(fullRootPath.Value, StringComparison.OrdinalIgnoreCase)) + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_6)) { - // The item spec file is in the "cone" of the RootFolder. Return the relative path from the cone root. - targetPath = itemSpecFullFileNamePath.Value.Substring(fullRootPath.Value.Length); + AbsolutePath itemSpecFullFileNamePath = + TaskEnvironment.GetAbsolutePath(Files[i].ItemSpec).GetCanonicalForm(); + + + if (itemSpecFullFileNamePath.Value.StartsWith(fullRootPath.Value, FileUtilities.PathComparison)) + { + // The item spec file is in the "cone" of the RootFolder. Return the relative path from the cone root. + targetPath = itemSpecFullFileNamePath.Value.Substring(fullRootPath.Value.Length); + } + else + { + // The item spec file is not in the "cone" of the RootFolder. Return the filename only. + targetPath = Path.GetFileName(Files[i].ItemSpec); + } } - else + else { - // The item spec file is not in the "cone" of the RootFolder. Return the filename only. - targetPath = Path.GetFileName(Files[i].ItemSpec); + string itemSpecFullFileNamePath = + Path.GetFullPath(TaskEnvironment.GetAbsolutePath(Files[i].ItemSpec)); + + if (String.Compare(fullRootPathString, 0, itemSpecFullFileNamePath, 0, fullRootPathString.Length, StringComparison.CurrentCultureIgnoreCase) == 0) + { + // The item spec file is in the "cone" of the RootFolder. Return the relative path from the cone root. + targetPath = itemSpecFullFileNamePath.Substring(fullRootPathString.Length); + } + else + { + // The item spec file is not in the "cone" of the RootFolder. Return the filename only. + targetPath = Path.GetFileName(Files[i].ItemSpec); + } } } } diff --git a/src/Tasks/ListOperators/FindUnderPath.cs b/src/Tasks/ListOperators/FindUnderPath.cs index 7033832ae56..24b36e5b119 100644 --- a/src/Tasks/ListOperators/FindUnderPath.cs +++ b/src/Tasks/ListOperators/FindUnderPath.cs @@ -63,9 +63,19 @@ public override bool Execute() try { - conePath = - Strings.WeakIntern( - TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(Path.ItemSpec)).GetCanonicalForm()); + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_6)) + { + conePath = + Strings.WeakIntern( + TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(Path.ItemSpec)).GetCanonicalForm()); + } + else + { + conePath = + Strings.WeakIntern( + System.IO.Path.GetFullPath(TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(Path.ItemSpec)))); + } + conePath = FrameworkFileUtilities.EnsureTrailingSlash(conePath); } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) @@ -84,9 +94,18 @@ public override bool Execute() string fullPath; try { - fullPath = - Strings.WeakIntern( - TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(item.ItemSpec)).GetCanonicalForm()); + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_6)) + { + fullPath = + Strings.WeakIntern( + TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(item.ItemSpec)).GetCanonicalForm()); + } + else + { + fullPath = + Strings.WeakIntern( + System.IO.Path.GetFullPath(TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.FixFilePath(item.ItemSpec)))); + } } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { From 4e11b61fd5fb964efdee6e092017c2c4fdebbdc9 Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:45:16 +0100 Subject: [PATCH 06/10] fix test --- src/Tasks.UnitTests/FindUnderPath_Tests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Tasks.UnitTests/FindUnderPath_Tests.cs b/src/Tasks.UnitTests/FindUnderPath_Tests.cs index d60e52be101..e5d85bf9a5c 100644 --- a/src/Tasks.UnitTests/FindUnderPath_Tests.cs +++ b/src/Tasks.UnitTests/FindUnderPath_Tests.cs @@ -6,6 +6,7 @@ using Microsoft.Build.Framework; using Microsoft.Build.Shared; using Microsoft.Build.Tasks; +using Microsoft.Build.UnitTests; using Microsoft.Build.Utilities; using Xunit; @@ -53,9 +54,19 @@ public void InvalidFile() // Don't crash } + /// + /// Tests that invalid path characters cause the task to fail. + /// This only applies when Wave18_6 is disabled, as the new behavior doesn't throw on invalid path characters. + /// [WindowsFullFrameworkOnlyFact(additionalMessage: ".NET Core 2.1+ no longer validates paths: https://github.com/dotnet/corefx/issues/27779#issuecomment-371253486. On Unix there is no invalid file name characters.")] public void InvalidPath() { + using TestEnvironment env = TestEnvironment.Create(); + + // TODO: Remove test when Wave18_6 rotates out - new behavior doesn't throw on invalid path characters + ChangeWaves.ResetStateForTests(); + env.SetEnvironmentVariable("MSBUILDDISABLEFEATURESFROMVERSION", ChangeWaves.Wave18_6.ToString()); + FindUnderPath t = new FindUnderPath(); t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); @@ -67,6 +78,8 @@ public void InvalidPath() Assert.False(success); + ChangeWaves.ResetStateForTests(); + // Don't crash } From cf14aa90491f0ce121fa72c85030f162793c773a Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:18:47 +0100 Subject: [PATCH 07/10] address pr comment. --- src/Tasks/AssignTargetPath.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tasks/AssignTargetPath.cs b/src/Tasks/AssignTargetPath.cs index f7cabd02f5a..13efceda59c 100644 --- a/src/Tasks/AssignTargetPath.cs +++ b/src/Tasks/AssignTargetPath.cs @@ -71,7 +71,7 @@ public override bool Execute() // Ensure trailing slash for comparison - AbsolutePath handles OS-aware case sensitivity AbsolutePath currentDirectory = - new AbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory), ignoreRootedCheck: true).GetCanonicalForm(); + FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory).GetCanonicalForm(); // Check if the root folder is the same as the current directory isRootFolderSameAsCurrentDirectory = fullRootPath == currentDirectory; @@ -87,7 +87,7 @@ public override bool Execute() // Ensure trailing slash for easier comparison. AbsolutePath currentDirectory = - new AbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory), ignoreRootedCheck: true).GetCanonicalForm(); + FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory).GetCanonicalForm(); // Check if the root folder is the same as the current directory. // Perform a case-insensitive comparison to match Path.GetFullPath behavior on Windows, even on case-sensitive file systems, From 6b424bb5b7892bfbb833e9ae4a4e5fb02fe4aad2 Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:21:19 +0100 Subject: [PATCH 08/10] Fix another test --- src/Tasks.UnitTests/FindUnderPath_Tests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Tasks.UnitTests/FindUnderPath_Tests.cs b/src/Tasks.UnitTests/FindUnderPath_Tests.cs index e5d85bf9a5c..378f0295a73 100644 --- a/src/Tasks.UnitTests/FindUnderPath_Tests.cs +++ b/src/Tasks.UnitTests/FindUnderPath_Tests.cs @@ -37,9 +37,19 @@ public void BasicFilter() Assert.Equal(FrameworkFileUtilities.FixFilePath(@"C:\SomeoneElsesProject\File2.txt"), t.OutOfPath[0].ItemSpec); } + /// + /// Tests that invalid file path characters cause the task to fail. + /// This only applies when Wave18_6 is disabled, as the new behavior doesn't throw on invalid path characters. + /// [WindowsFullFrameworkOnlyFact(additionalMessage: ".NET Core 2.1+ no longer validates paths: https://github.com/dotnet/corefx/issues/27779#issuecomment-371253486. On Unix there is no invalid file name characters.")] public void InvalidFile() { + using TestEnvironment env = TestEnvironment.Create(); + + // TODO: Remove test when Wave18_6 rotates out - new behavior doesn't throw on invalid path characters + ChangeWaves.ResetStateForTests(); + env.SetEnvironmentVariable("MSBUILDDISABLEFEATURESFROMVERSION", ChangeWaves.Wave18_6.ToString()); + FindUnderPath t = new FindUnderPath(); t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); t.BuildEngine = new MockEngine(); @@ -51,6 +61,8 @@ public void InvalidFile() Assert.False(success); + ChangeWaves.ResetStateForTests(); + // Don't crash } From 35748c5c955b51de190d96297d13dd9129e93ec1 Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:33:28 +0100 Subject: [PATCH 09/10] Do canonization of the ProjectDirectory on the setter. --- .../MultiThreadedTaskEnvironmentDriver.cs | 4 ++-- src/Tasks/AssignTargetPath.cs | 12 +++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs index cf54c430d0d..089389cf1f3 100644 --- a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs +++ b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs @@ -61,10 +61,10 @@ public AbsolutePath ProjectDirectory get => _currentDirectory; set { - _currentDirectory = value; + _currentDirectory = value.GetCanonicalForm(); // Keep the thread-static in sync for use by Expander and Modifiers during property/item expansion. // This allows Path.GetFullPath and %(FullPath) functions used in project files to resolve relative paths correctly in multithreaded mode. - FileUtilities.CurrentThreadWorkingDirectory = value.Value; + FileUtilities.CurrentThreadWorkingDirectory = _currentDirectory.Value; } } diff --git a/src/Tasks/AssignTargetPath.cs b/src/Tasks/AssignTargetPath.cs index 13efceda59c..5b71f207332 100644 --- a/src/Tasks/AssignTargetPath.cs +++ b/src/Tasks/AssignTargetPath.cs @@ -69,11 +69,10 @@ public override bool Execute() fullRootPath = TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(RootFolder)).GetCanonicalForm(); - // Ensure trailing slash for comparison - AbsolutePath handles OS-aware case sensitivity - AbsolutePath currentDirectory = - FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory).GetCanonicalForm(); + // Ensure trailing slash for comparison. Current directory is already canonical, so we don't need to call GetCanonicalForm on it. + AbsolutePath currentDirectory = FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory); - // Check if the root folder is the same as the current directory + // Check if the root folder is the same as the current directory - AbsolutePath handles OS-aware case sensitivity. isRootFolderSameAsCurrentDirectory = fullRootPath == currentDirectory; } else @@ -85,9 +84,8 @@ public override bool Execute() fullRootPathString = Path.GetFullPath(TaskEnvironment.GetAbsolutePath(FrameworkFileUtilities.EnsureTrailingSlash(RootFolder))); - // Ensure trailing slash for easier comparison. - AbsolutePath currentDirectory = - FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory).GetCanonicalForm(); + // Ensure trailing slash for comparison. Current directory is already canonical, so we don't need to call GetCanonicalForm on it. + AbsolutePath currentDirectory = FrameworkFileUtilities.EnsureTrailingSlash(TaskEnvironment.ProjectDirectory); // Check if the root folder is the same as the current directory. // Perform a case-insensitive comparison to match Path.GetFullPath behavior on Windows, even on case-sensitive file systems, From 0fcc4465b03c9b145218cad1e0af21da139ac797 Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:30:08 +0100 Subject: [PATCH 10/10] Change changewave from 18.6 to 18.5 --- documentation/wiki/ChangeWaves.md | 2 +- src/Framework/ChangeWaves.cs | 4 ++-- src/Tasks.UnitTests/FindUnderPath_Tests.cs | 12 ++++++------ src/Tasks/AssignTargetPath.cs | 4 ++-- src/Tasks/ListOperators/FindUnderPath.cs | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/documentation/wiki/ChangeWaves.md b/documentation/wiki/ChangeWaves.md index d4007af5c8c..b6daeef9d62 100644 --- a/documentation/wiki/ChangeWaves.md +++ b/documentation/wiki/ChangeWaves.md @@ -24,7 +24,7 @@ A wave of features is set to "rotate out" (i.e. become standard functionality) t ## Current Rotation of Change Waves -### 18.6 +### 18.5 - [FindUnderPath and AssignTargetPath tasks no longer throw on invalid path characters when using TaskEnvironment.GetAbsolutePath](https://github.com/dotnet/msbuild/pull/13069) - [AssignTargetPath on Linux respects case sensitivity of the file system instead of always ignoring case](https://github.com/dotnet/msbuild/pull/13069) diff --git a/src/Framework/ChangeWaves.cs b/src/Framework/ChangeWaves.cs index c575107d672..d40cd86c8f3 100644 --- a/src/Framework/ChangeWaves.cs +++ b/src/Framework/ChangeWaves.cs @@ -32,8 +32,8 @@ internal static class ChangeWaves internal static readonly Version Wave17_14 = new Version(17, 14); internal static readonly Version Wave18_3 = new Version(18, 3); internal static readonly Version Wave18_4 = new Version(18, 4); - internal static readonly Version Wave18_6 = new Version(18, 6); - internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4, Wave18_6]; + internal static readonly Version Wave18_5 = new Version(18, 5); + internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4, Wave18_5]; /// /// Special value indicating that all features behind all Change Waves should be enabled. diff --git a/src/Tasks.UnitTests/FindUnderPath_Tests.cs b/src/Tasks.UnitTests/FindUnderPath_Tests.cs index 378f0295a73..0f9197579ab 100644 --- a/src/Tasks.UnitTests/FindUnderPath_Tests.cs +++ b/src/Tasks.UnitTests/FindUnderPath_Tests.cs @@ -39,16 +39,16 @@ public void BasicFilter() /// /// Tests that invalid file path characters cause the task to fail. - /// This only applies when Wave18_6 is disabled, as the new behavior doesn't throw on invalid path characters. + /// This only applies when Wave18_5 is disabled, as the new behavior doesn't throw on invalid path characters. /// [WindowsFullFrameworkOnlyFact(additionalMessage: ".NET Core 2.1+ no longer validates paths: https://github.com/dotnet/corefx/issues/27779#issuecomment-371253486. On Unix there is no invalid file name characters.")] public void InvalidFile() { using TestEnvironment env = TestEnvironment.Create(); - // TODO: Remove test when Wave18_6 rotates out - new behavior doesn't throw on invalid path characters + // TODO: Remove test when Wave18_5 rotates out - new behavior doesn't throw on invalid path characters ChangeWaves.ResetStateForTests(); - env.SetEnvironmentVariable("MSBUILDDISABLEFEATURESFROMVERSION", ChangeWaves.Wave18_6.ToString()); + env.SetEnvironmentVariable("MSBUILDDISABLEFEATURESFROMVERSION", ChangeWaves.Wave18_5.ToString()); FindUnderPath t = new FindUnderPath(); t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); @@ -68,16 +68,16 @@ public void InvalidFile() /// /// Tests that invalid path characters cause the task to fail. - /// This only applies when Wave18_6 is disabled, as the new behavior doesn't throw on invalid path characters. + /// This only applies when Wave18_5 is disabled, as the new behavior doesn't throw on invalid path characters. /// [WindowsFullFrameworkOnlyFact(additionalMessage: ".NET Core 2.1+ no longer validates paths: https://github.com/dotnet/corefx/issues/27779#issuecomment-371253486. On Unix there is no invalid file name characters.")] public void InvalidPath() { using TestEnvironment env = TestEnvironment.Create(); - // TODO: Remove test when Wave18_6 rotates out - new behavior doesn't throw on invalid path characters + // TODO: Remove test when Wave18_5 rotates out - new behavior doesn't throw on invalid path characters ChangeWaves.ResetStateForTests(); - env.SetEnvironmentVariable("MSBUILDDISABLEFEATURESFROMVERSION", ChangeWaves.Wave18_6.ToString()); + env.SetEnvironmentVariable("MSBUILDDISABLEFEATURESFROMVERSION", ChangeWaves.Wave18_5.ToString()); FindUnderPath t = new FindUnderPath(); t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); diff --git a/src/Tasks/AssignTargetPath.cs b/src/Tasks/AssignTargetPath.cs index 5b71f207332..f017a02fefe 100644 --- a/src/Tasks/AssignTargetPath.cs +++ b/src/Tasks/AssignTargetPath.cs @@ -60,7 +60,7 @@ public override bool Execute() string fullRootPathString = null; bool isRootFolderSameAsCurrentDirectory; - if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_6)) + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_5)) { // Compose a file in the root folder. // NOTE: at this point fullRootPath may or may not have a trailing slash @@ -123,7 +123,7 @@ public override bool Execute() } else { - if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_6)) + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_5)) { AbsolutePath itemSpecFullFileNamePath = TaskEnvironment.GetAbsolutePath(Files[i].ItemSpec).GetCanonicalForm(); diff --git a/src/Tasks/ListOperators/FindUnderPath.cs b/src/Tasks/ListOperators/FindUnderPath.cs index 24b36e5b119..e7370e40a39 100644 --- a/src/Tasks/ListOperators/FindUnderPath.cs +++ b/src/Tasks/ListOperators/FindUnderPath.cs @@ -63,7 +63,7 @@ public override bool Execute() try { - if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_6)) + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_5)) { conePath = Strings.WeakIntern( @@ -94,7 +94,7 @@ public override bool Execute() string fullPath; try { - if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_6)) + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_5)) { fullPath = Strings.WeakIntern(