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. ///