From 839fd384908bb733a46ec112f3592836424eb15b Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Fri, 15 May 2026 13:32:53 +0200 Subject: [PATCH 1/5] Fix AbsolutePath(string, AbsolutePath) returning invalid absolute path on Windows (#13748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, Path.Combine 'resets' on a rooted second argument, so AbsolutePath(string, AbsolutePath) was leaving root-relative ('\foo') and drive-relative ('X:foo') inputs as Value unchanged. The result was a path that was rooted but not fully qualified — i.e. not a valid absolute path. Any subsequent use of Value (direct I/O or GetCanonicalForm) then resolved the path against process-global state (current drive, per-drive cwd), producing non-deterministic results across host processes and breaking multithreaded task isolation. Anchor those shapes against basePath inside the constructor using purely string-based operations, so Value is always a valid, fully qualified absolute path. GetCanonicalForm is left untouched and continues to call System.IO.Path.GetFullPath(string) — preserving its existing canonicalization semantics (including invalid-character rejection) across .NET and .NET Framework. The two-argument Path.GetFullPath overload is intentionally avoided: on .NET Framework it lives in Microsoft.IO.Path with different internal logic. Adds regression theory GetCanonicalForm_WindowsRootedButNotFullyQualifiedPath_AnchorsToBasePath_NotProcessState covering both root-relative and drive-relative inputs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Framework.UnitTests/AbsolutePath_Tests.cs | 32 +++++++++++++++ src/Framework/PathHelpers/AbsolutePath.cs | 41 ++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/Framework.UnitTests/AbsolutePath_Tests.cs b/src/Framework.UnitTests/AbsolutePath_Tests.cs index 09a87b60b09..71089465254 100644 --- a/src/Framework.UnitTests/AbsolutePath_Tests.cs +++ b/src/Framework.UnitTests/AbsolutePath_Tests.cs @@ -281,6 +281,38 @@ 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(@"\foo", @"X:\foo")] + [InlineData(@"\foo\bar", @"X:\foo\bar")] + [InlineData(@"\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:foo", @"X:\proj\foo")] + [InlineData(@"X:sub\file.txt", @"X:\proj\sub\file.txt")] + public void GetCanonicalForm_WindowsRootedButNotFullyQualifiedPath_AnchorsToBasePath_NotProcessState(string input, string expected) + { + const string baseDir = @"X:\proj"; + 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..98c31dea66d 100644 --- a/src/Framework/PathHelpers/AbsolutePath.cs +++ b/src/Framework/PathHelpers/AbsolutePath.cs @@ -102,6 +102,14 @@ private static void ValidatePath(string path) /// The path to combine with the base path. /// The base path to combine with. /// Thrown if is null or empty. + /// + /// "resets" on a rooted second argument, so on + /// Windows the root-relative ("\foo") and drive-relative ("X:foo") inputs survive + /// unchanged — yielding a result that is rooted but not fully qualified, i.e. not a valid absolute + /// path. Anchor those shapes to here so is always + /// a valid, fully qualified absolute path that does not depend on process state (current drive, + /// per-drive cwd). Required for multithreaded task isolation. + /// public AbsolutePath(string path, AbsolutePath basePath) { ArgumentException.ThrowIfNullOrEmpty(path); @@ -109,7 +117,38 @@ 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); + +#if NETFRAMEWORK || NET + // Path.Combine returns a rooted-but-not-fully-qualified result for Windows shapes like + // "\foo" and "X:foo" (it lets a rooted second argument override the first). That is not + // a valid absolute path. Anchor those shapes against basePath using purely string + // operations so Value is always fully qualified and deterministic. + // + // We intentionally do not delegate to the two-argument Path.GetFullPath overload — on + // .NET Framework it lives in Microsoft.IO.Path with different normalization semantics + // than the System.IO.Path implementation used elsewhere in this struct. + if (NativeMethods.IsWindows + && !Path.IsPathFullyQualified(combined) + && basePath.Value is { Length: >= 2 } baseValue && baseValue[1] == ':') + { + if (combined[0] == '\\' || combined[0] == '/') + { + // Root-relative ("\foo"): prepend basePath's drive prefix ("X:" + "\foo"). + combined = baseValue.Substring(0, 2) + combined; + } + else if (combined.Length >= 2 && combined[1] == ':') + { + // Drive-relative ("X:foo"): combine basePath with the remainder after the colon. + // When the path's drive differs from basePath's, this still anchors to basePath + // — a deliberate trade-off; the alternative (the path-drive's cwd) re-introduces + // the process-state leak we're fixing. + combined = Path.Combine(baseValue, combined.Substring(2)); + } + } +#endif + + Value = combined; OriginalValue = path; } From ea03d2ba41c289d0bd52abb77ccbfdba4d0de53b Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Mon, 25 May 2026 13:59:37 +0200 Subject: [PATCH 2/5] Fix --- src/Framework.UnitTests/AbsolutePath_Tests.cs | 33 +++++++-- src/Framework/PathHelpers/AbsolutePath.cs | 73 ++++++++++--------- 2 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/Framework.UnitTests/AbsolutePath_Tests.cs b/src/Framework.UnitTests/AbsolutePath_Tests.cs index 71089465254..8dcf00289fd 100644 --- a/src/Framework.UnitTests/AbsolutePath_Tests.cs +++ b/src/Framework.UnitTests/AbsolutePath_Tests.cs @@ -289,15 +289,34 @@ private static void ValidateGetCanonicalFormMatchesSystem(string inputPath) /// [WindowsOnlyTheory] // Root-relative — anchored at base path's drive root. - [InlineData(@"\foo", @"X:\foo")] - [InlineData(@"\foo\bar", @"X:\foo\bar")] - [InlineData(@"\sub\dir\file.txt", @"X:\sub\dir\file.txt")] + [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:foo", @"X:\proj\foo")] - [InlineData(@"X:sub\file.txt", @"X:\proj\sub\file.txt")] - public void GetCanonicalForm_WindowsRootedButNotFullyQualifiedPath_AnchorsToBasePath_NotProcessState(string input, string expected) + [InlineData(@"X:\proj", @"X:foo", @"X:\proj\foo")] + [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 GetCanonicalForm_WindowsRootedButNotFullyQualifiedPath_AnchorsToBasePath_NotProcessState(string baseDir, string input, string expected) { - const string baseDir = @"X:\proj"; var basePath = new AbsolutePath(baseDir); var combined = new AbsolutePath(input, basePath); diff --git a/src/Framework/PathHelpers/AbsolutePath.cs b/src/Framework/PathHelpers/AbsolutePath.cs index 98c31dea66d..6d0a5beff4f 100644 --- a/src/Framework/PathHelpers/AbsolutePath.cs +++ b/src/Framework/PathHelpers/AbsolutePath.cs @@ -102,14 +102,6 @@ private static void ValidatePath(string path) /// The path to combine with the base path. /// The base path to combine with. /// Thrown if is null or empty. - /// - /// "resets" on a rooted second argument, so on - /// Windows the root-relative ("\foo") and drive-relative ("X:foo") inputs survive - /// unchanged — yielding a result that is rooted but not fully qualified, i.e. not a valid absolute - /// path. Anchor those shapes to here so is always - /// a valid, fully qualified absolute path that does not depend on process state (current drive, - /// per-drive cwd). Required for multithreaded task isolation. - /// public AbsolutePath(string path, AbsolutePath basePath) { ArgumentException.ThrowIfNullOrEmpty(path); @@ -120,38 +112,51 @@ public AbsolutePath(string path, AbsolutePath basePath) string combined = Path.Combine(basePath.Value, path); #if NETFRAMEWORK || NET - // Path.Combine returns a rooted-but-not-fully-qualified result for Windows shapes like - // "\foo" and "X:foo" (it lets a rooted second argument override the first). That is not - // a valid absolute path. Anchor those shapes against basePath using purely string - // operations so Value is always fully qualified and deterministic. - // - // We intentionally do not delegate to the two-argument Path.GetFullPath overload — on - // .NET Framework it lives in Microsoft.IO.Path with different normalization semantics - // than the System.IO.Path implementation used elsewhere in this struct. - if (NativeMethods.IsWindows - && !Path.IsPathFullyQualified(combined) - && basePath.Value is { Length: >= 2 } baseValue && baseValue[1] == ':') - { - if (combined[0] == '\\' || combined[0] == '/') - { - // Root-relative ("\foo"): prepend basePath's drive prefix ("X:" + "\foo"). - combined = baseValue.Substring(0, 2) + combined; - } - else if (combined.Length >= 2 && combined[1] == ':') - { - // Drive-relative ("X:foo"): combine basePath with the remainder after the colon. - // When the path's drive differs from basePath's, this still anchors to basePath - // — a deliberate trade-off; the alternative (the path-drive's cwd) re-introduces - // the process-state leak we're fixing. - combined = Path.Combine(baseValue, combined.Substring(2)); - } - } + combined = MakeFullyQualified(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 string MakeFullyQualified(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. /// From 37120ccd1c7c23d7675a44bfa4defd247b2cee5e Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Mon, 25 May 2026 15:15:16 +0200 Subject: [PATCH 3/5] Make test name shorter --- src/Framework.UnitTests/AbsolutePath_Tests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Framework.UnitTests/AbsolutePath_Tests.cs b/src/Framework.UnitTests/AbsolutePath_Tests.cs index 8dcf00289fd..d30d84645eb 100644 --- a/src/Framework.UnitTests/AbsolutePath_Tests.cs +++ b/src/Framework.UnitTests/AbsolutePath_Tests.cs @@ -315,7 +315,7 @@ private static void ValidateGetCanonicalFormMatchesSystem(string inputPath) // 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 GetCanonicalForm_WindowsRootedButNotFullyQualifiedPath_AnchorsToBasePath_NotProcessState(string baseDir, string input, string expected) + public void AnchorsToBasePath_NotProcessState(string baseDir, string input, string expected) { var basePath = new AbsolutePath(baseDir); var combined = new AbsolutePath(input, basePath); From 675ebb46a34926d561f6df5c5cc5174ce654bf54 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Wed, 27 May 2026 15:17:19 +0200 Subject: [PATCH 4/5] Address comments --- src/Framework.UnitTests/AbsolutePath_Tests.cs | 1 + src/Framework/PathHelpers/AbsolutePath.cs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Framework.UnitTests/AbsolutePath_Tests.cs b/src/Framework.UnitTests/AbsolutePath_Tests.cs index d30d84645eb..784170a1bfb 100644 --- a/src/Framework.UnitTests/AbsolutePath_Tests.cs +++ b/src/Framework.UnitTests/AbsolutePath_Tests.cs @@ -294,6 +294,7 @@ private static void ValidateGetCanonicalFormMatchesSystem(string inputPath) [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")] diff --git a/src/Framework/PathHelpers/AbsolutePath.cs b/src/Framework/PathHelpers/AbsolutePath.cs index 6d0a5beff4f..8a7dcb98767 100644 --- a/src/Framework/PathHelpers/AbsolutePath.cs +++ b/src/Framework/PathHelpers/AbsolutePath.cs @@ -112,7 +112,7 @@ public AbsolutePath(string path, AbsolutePath basePath) string combined = Path.Combine(basePath.Value, path); #if NETFRAMEWORK || NET - combined = MakeFullyQualified(combined, basePath.Value); + combined = MakeFullyQualifiedRelativeToBasePath(combined, basePath.Value); #endif Value = combined; @@ -128,7 +128,7 @@ public AbsolutePath(string path, AbsolutePath basePath) /// Uses string operations instead of Path.GetFullPath(path, basePath) to preserve the /// un-normalized form; is the single normalization step. /// - private string MakeFullyQualified(string combined, string basePath) + private static string MakeFullyQualifiedRelativeToBasePath(string combined, string basePath) { if (!NativeMethods.IsWindows || string.IsNullOrEmpty(combined) From 057bb0d7c1f9a3a666158f8447313825c1a408f2 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Fri, 29 May 2026 14:00:14 +0200 Subject: [PATCH 5/5] return the comment --- src/Framework/PathHelpers/AbsolutePath.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Framework/PathHelpers/AbsolutePath.cs b/src/Framework/PathHelpers/AbsolutePath.cs index 8a7dcb98767..853d1b720fc 100644 --- a/src/Framework/PathHelpers/AbsolutePath.cs +++ b/src/Framework/PathHelpers/AbsolutePath.cs @@ -111,6 +111,8 @@ public AbsolutePath(string path, AbsolutePath basePath) // For .NET Core, System.IO.Path.Combine already does not throw in this case. 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