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(