diff --git a/src/Framework.UnitTests/AbsolutePath_Tests.cs b/src/Framework.UnitTests/AbsolutePath_Tests.cs
index 09a87b60b09..784170a1bfb 100644
--- a/src/Framework.UnitTests/AbsolutePath_Tests.cs
+++ b/src/Framework.UnitTests/AbsolutePath_Tests.cs
@@ -281,6 +281,58 @@ private static void ValidateGetCanonicalFormMatchesSystem(string inputPath)
result.OriginalValue.ShouldBe(absolutePath.OriginalValue);
}
+ ///
+ /// Windows rooted-but-not-fully-qualified inputs (root-relative "\foo", drive-relative "X:foo")
+ /// must be anchored to the supplied base path, not to process state (current drive, per-drive cwd).
+ /// Required for multithreaded task isolation. Anchoring happens at construction so both
+ /// and the canonical form are deterministic.
+ ///
+ [WindowsOnlyTheory]
+ // Root-relative — anchored at base path's drive root.
+ [InlineData(@"X:\proj", @"\foo", @"X:\foo")]
+ [InlineData(@"X:\proj", @"\foo\bar", @"X:\foo\bar")]
+ [InlineData(@"X:\proj", @"\sub\dir\file.txt", @"X:\sub\dir\file.txt")]
+ // Drive-relative on the same drive as base path — anchored under base path itself.
+ [InlineData(@"X:\proj", @"X:foo", @"X:\proj\foo")]
+ [InlineData(@"X:\proj", @"X:", @"X:\proj")]
+ [InlineData(@"X:\proj", @"X:sub\file.txt", @"X:\proj\sub\file.txt")]
+ // Drive-relative with a drive different from base path — drive dropped, remainder anchored.
+ [InlineData(@"C:\proj", @"D:foo", @"C:\proj\foo")]
+ // Root-relative against UNC base path — re-rooted under the UNC share.
+ [InlineData(@"\\server\share\base", @"\foo", @"\\server\share\foo")]
+ [InlineData(@"\\server\share\base", @"\sub\file.txt", @"\\server\share\sub\file.txt")]
+ // Drive-relative against UNC base path — drive dropped, remainder anchored.
+ [InlineData(@"\\server\share\base", @"X:foo", @"\\server\share\base\foo")]
+ // Root-relative against DOS device base path (\\?\ and \\.\) — re-rooted under the device root.
+ [InlineData(@"\\?\C:\base", @"\foo", @"\\?\C:\foo")]
+ [InlineData(@"\\.\C:\base", @"\foo", @"\\.\C:\foo")]
+ // Drive-relative against DOS device base path — drive dropped, remainder anchored.
+ [InlineData(@"\\?\C:\base", @"X:foo", @"\\?\C:\base\foo")]
+ [InlineData(@"\\.\C:\base", @"X:foo", @"\\.\C:\base\foo")]
+ // Pass-through: fully qualified inputs win and are not touched by anchoring.
+ [InlineData(@"C:\proj", @"D:\absolute\file.txt", @"D:\absolute\file.txt")]
+ [InlineData(@"C:\proj", @"\\server\share\file.txt", @"\\server\share\file.txt")]
+ [InlineData(@"C:\proj", @"\\?\C:\file.txt", @"\\?\C:\file.txt")]
+ // Plain relative input — anchored to basePath via Path.Combine, never touches CWD.
+ [InlineData(@"C:\proj", @"foo", @"C:\proj\foo")]
+ [InlineData(@"C:\proj", @"sub\file.txt", @"C:\proj\sub\file.txt")]
+ public void AnchorsToBasePath_NotProcessState(string baseDir, string input, string expected)
+ {
+ var basePath = new AbsolutePath(baseDir);
+ var combined = new AbsolutePath(input, basePath);
+
+ // Construction anchors rooted-but-not-fully-qualified inputs to basePath, so Value
+ // is already fully qualified and does not depend on process state.
+ combined.Value.ShouldBe(expected,
+ customMessage: $"Construction leaked process state: '{input}' did not anchor to '{baseDir}'.");
+
+ AbsolutePath canonical = combined.GetCanonicalForm();
+
+ canonical.Value.ShouldBe(expected,
+ customMessage: $"GetCanonicalForm leaked process state: '{input}' did not anchor to '{baseDir}'.");
+ canonical.OriginalValue.ShouldBe(input);
+ }
+
[Fact]
public void GetCanonicalForm_InvalidPathCharacters_ShouldThrowSameAsPathGetFullPath()
{
diff --git a/src/Framework/PathHelpers/AbsolutePath.cs b/src/Framework/PathHelpers/AbsolutePath.cs
index 59d08dc102f..853d1b720fc 100644
--- a/src/Framework/PathHelpers/AbsolutePath.cs
+++ b/src/Framework/PathHelpers/AbsolutePath.cs
@@ -109,10 +109,56 @@ public AbsolutePath(string path, AbsolutePath basePath)
// This function should not throw when path has illegal characters.
// For .NET Framework, Microsoft.IO.Path.Combine should be used instead of System.IO.Path.Combine to achieve it.
// For .NET Core, System.IO.Path.Combine already does not throw in this case.
- Value = Path.Combine(basePath.Value, path);
+ string combined = Path.Combine(basePath.Value, path);
+
+// Path.IsPathFullyQualified is not available in .NET Standard 2.0
+// in .NET Framework it's provided by package and in .NET it's built-in
+#if NETFRAMEWORK || NET
+ combined = MakeFullyQualifiedRelativeToBasePath(combined, basePath.Value);
+#endif
+
+ Value = combined;
OriginalValue = path;
}
+#if NETFRAMEWORK || NET
+ ///
+ /// Anchors Windows rooted-but-not-fully-qualified paths ("\foo", "X:foo") to
+ /// so the result is independent of the current drive and per-drive cwd.
+ ///
+ ///
+ /// Uses string operations instead of Path.GetFullPath(path, basePath) to preserve the
+ /// un-normalized form; is the single normalization step.
+ ///
+ private static string MakeFullyQualifiedRelativeToBasePath(string combined, string basePath)
+ {
+ if (!NativeMethods.IsWindows
+ || string.IsNullOrEmpty(combined)
+ || Path.IsPathFullyQualified(combined))
+ {
+ return combined;
+ }
+
+ char first = combined[0];
+ // Root-relative ("\foo"): re-root under basePath's root.
+ if (first == '\\' || first == '/')
+ {
+ string? root = Path.GetPathRoot(basePath);
+ return root is { Length: > 0 } baseRoot
+ ? baseRoot.TrimEnd('\\', '/') + combined
+ : combined;
+ }
+
+ // Drive-relative ("X:foo"): drop the unrelated drive and anchor the remainder to basePath.
+ if (combined.Length >= 2 && combined[1] == ':')
+ {
+ return Path.Combine(basePath, combined.Substring(2));
+ }
+
+ return combined;
+ }
+#endif
+
///
/// Implicitly converts an AbsolutePath to a string.
///