Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/Framework.UnitTests/AbsolutePath_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,58 @@ private static void ValidateGetCanonicalFormMatchesSystem(string inputPath)
result.OriginalValue.ShouldBe(absolutePath.OriginalValue);
}

/// <summary>
/// Windows rooted-but-not-fully-qualified inputs (root-relative <c>"\foo"</c>, drive-relative <c>"X:foo"</c>)
/// 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
/// <see cref="AbsolutePath.Value"/> and the canonical form are deterministic.
/// </summary>
[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()
{
Expand Down
48 changes: 47 additions & 1 deletion src/Framework/PathHelpers/AbsolutePath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +116 to +118
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The netstandard2.0 build of Microsoft.Build.Framework exists only as a compatibility surface — MSBuild itself never executes against it at runtime

Copy link
Copy Markdown
Member

@AR-May AR-May May 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This might be a good comment to have in the code as well to make it more friendly, I was baffled the first time I saw such a condition.


Value = combined;
OriginalValue = path;
}

#if NETFRAMEWORK || NET
/// <summary>
/// Anchors Windows rooted-but-not-fully-qualified paths (<c>"\foo"</c>, <c>"X:foo"</c>) to
/// <paramref name="basePath"/> so the result is independent of the current drive and per-drive cwd.
/// </summary>
/// <remarks>
/// Uses string operations instead of <c>Path.GetFullPath(path, basePath)</c> to preserve the
/// un-normalized form; <see cref="GetCanonicalForm"/> is the single normalization step.
/// </remarks>
private static string MakeFullyQualifiedRelativeToBasePath(string combined, string basePath)
{
if (!NativeMethods.IsWindows
|| string.IsNullOrEmpty(combined)
Comment thread
OvesN marked this conversation as resolved.
|| Path.IsPathFullyQualified(combined))
{
return combined;
}

char first = combined[0];
// Root-relative ("\foo"): re-root under basePath's root.
if (first == '\\' || first == '/')
Comment thread
OvesN marked this conversation as resolved.
{
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.
Comment thread
OvesN marked this conversation as resolved.
Comment thread
OvesN marked this conversation as resolved.
if (combined.Length >= 2 && combined[1] == ':')
Comment thread
OvesN marked this conversation as resolved.
{
return Path.Combine(basePath, combined.Substring(2));
}

return combined;
}
#endif

/// <summary>
/// Implicitly converts an AbsolutePath to a string.
/// </summary>
Expand Down