Normalize Windows workspace path casing to NTFS canonical form#4406
Open
Normalize Windows workspace path casing to NTFS canonical form#4406
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR addresses Windows self-hosted runner path casing inconsistencies by introducing a canonicalization helper and applying it to the runner root directory so that derived directories (workspace/temp/actions/tools) inherit stable NTFS casing.
Changes:
- Add
PathUtil.GetCanonicalPath()(Windows:GetFinalPathNameByHandle; non-Windows: no-op). - Normalize
WellKnownDirectory.RootthroughGetCanonicalPath()inHostContext. - Add L0 tests covering null/empty/nonexistent/existing paths and a Windows drive-letter normalization scenario.
Show a summary per file
| File | Description |
|---|---|
| src/Runner.Sdk/Util/PathUtil.cs | Adds Windows canonicalization via Win32 APIs and a non-Windows passthrough implementation. |
| src/Runner.Common/HostContext.cs | Applies canonicalization to the computed runner root directory. |
| src/Test/L0/Util/PathUtilL0.cs | Adds unit tests for canonical path behavior across key input cases. |
Copilot's findings
Comments suppressed due to low confidence (3)
src/Runner.Sdk/Util/PathUtil.cs:69
GetFinalPathNameByHandlecan return a longer required length than the initial 1024-char buffer. The current logic returns the original path whenresult > buffer.Capacityand also doesn’t handle theresult == buffer.Capacityboundary, so canonicalization may silently fail for longer paths. Consider resizing theStringBuilderto the returned required size and retrying (and use a>=check).
var buffer = new StringBuilder(1024);
var result = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, VOLUME_NAME_DOS);
if (result == 0 || result > buffer.Capacity)
{
return path;
}
src/Runner.Sdk/Util/PathUtil.cs:77
- Stripping only the
\\?\prefix breaks UNC results fromGetFinalPathNameByHandle(e.g.\\?\UNC\server\share\...becomesUNC\server\share\..., which is not a valid path). Handle the\\?\UNC\case explicitly (convert to\\server\share\...) and useStringComparison.Ordinalfor the prefix checks to avoid culture-sensitive comparisons.
// Strip the \\?\ prefix that GetFinalPathNameByHandle adds
if (canonicalPath.StartsWith(@"\\?\"))
{
canonicalPath = canonicalPath.Substring(4);
}
src/Test/L0/Util/PathUtilL0.cs:75
- This Windows-only test assumes the temp path starts with a drive letter (indexes
tempDir[0]and assertschar.IsUpper(result[0])). IfTEMP/TMPis configured to a UNC path (e.g.\\server\share\...), the test will fail even whenGetCanonicalPathis correct. Consider guarding against UNC temp roots (or constructing a local-drive directory explicitly) so the test is environment-agnostic.
// The temp directory should always have an uppercase drive letter
// when resolved through GetFinalPathNameByHandle
var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar);
// Force lowercase drive letter
var lowerCased = char.ToLower(tempDir[0]) + tempDir.Substring(1);
var result = PathUtil.GetCanonicalPath(lowerCased);
// The canonical path should have an uppercase drive letter
Assert.True(char.IsUpper(result[0]),
$"Expected uppercase drive letter but got: {result}");
- Files reviewed: 3/3 changed files
- Comments generated: 2
On Windows, the runner inherits whatever path casing is used to start it (e.g. c:\actions-runner vs C:\actions-runner). NTFS is case-insensitive but tools like git's includeIf.gitdir do exact string matching, causing auth failures when the casing doesn't match the canonical NTFS path. This adds PathUtil.GetCanonicalPath which uses the Win32 GetFinalPathNameByHandle API to resolve paths to their NTFS canonical casing. It is called when resolving the runner root directory, so all derived paths (workspace, temp, etc.) use the correct casing. Fixes actions/checkout#2345
Retry GetFinalPathNameByHandle with a larger buffer when the path exceeds 1024 chars. Handle \?\UNC\ prefix conversion to standard UNC paths. Use StringComparison.Ordinal for prefix checks. Skip the drive letter test when TEMP is a UNC path.
GetDirectory(WellKnownDirectory.Root) is called ~44 times during a run. Cache the result since the root directory is immutable for the lifetime of HostContext.
PathUtilL0: - Folder casing normalization (create MiXeDcAsE, query lowercase) - Idempotency (calling twice returns same result) - Input casing independence (upper and lower resolve to same canonical) HostContextL0: - Root directory returns cached value across calls - Derived paths (Diag, Externals) share Root prefix casing
1f80b89 to
ed0e5b7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On Windows self-hosted runners, the path casing for
GITHUB_WORKSPACE,RUNNER_TEMP, and other derived paths depends on how the runner process was started. If a scheduled task or script uses a lowercase drive letter (e.g.c:\actions-runner\run.cmdinstead ofC:\actions-runner\run.cmd), the runner propagates that casing into all environment variables.However, tools like git resolve paths to their NTFS canonical casing. This creates mismatches that break case-sensitive operations — most notably
includeIf.gitdir:inactions/checkout@v6, which does exact string matching and fails to load credentials when the casing differs.Tracked in actions/checkout#2345
Root cause
Windows is case-insensitive at the filesystem level, so
c:\actions-runnerandC:\actions-runnerrefer to the same directory. But the runner uses whatever casing the parent process provides, while git queries NTFS for the canonical casing. When these differ, any tool doing exact path comparison sees a mismatch.GitHub-hosted runners aren't affected because their workspace paths already use canonical casing.
Fix
Add
PathUtil.GetCanonicalPath()which uses the Win32GetFinalPathNameByHandleAPI to resolve a directory path to its NTFS canonical casing. This is called when resolving the runner root directory (WellKnownDirectory.Root), so all derived paths (workspace, temp, actions, tools, etc.) inherit the correct casing.On non-Windows platforms,
GetCanonicalPathis a no-op that returns the input unchanged.Why fix here instead of in individual actions?
Individual actions like
actions/checkoutcan work around the issue (e.g. usingincludeIf.gitdir/i:for case-insensitive matching), but the root cause is the runner providing inconsistently-cased paths. Fixing it at the runner level prevents the entire class of problems for all actions.Changes
src/Runner.Sdk/Util/PathUtil.cs— AddGetCanonicalPath()with P/Invoke toGetFinalPathNameByHandle(Windows only)src/Runner.Common/HostContext.cs— Normalize the root directory path throughGetCanonicalPath()src/Test/L0/Util/PathUtilL0.cs— Tests for null/empty/nonexistent paths and drive letter normalizationTesting
GetCanonicalPathcovering null, empty, nonexistent, and existing directory cases