Skip to content

Normalize Windows workspace path casing to NTFS canonical form#4406

Open
salmanmkc wants to merge 4 commits intomainfrom
fix/normalize-windows-path-casing
Open

Normalize Windows workspace path casing to NTFS canonical form#4406
salmanmkc wants to merge 4 commits intomainfrom
fix/normalize-windows-path-casing

Conversation

@salmanmkc
Copy link
Copy Markdown
Contributor

@salmanmkc salmanmkc commented May 6, 2026

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.cmd instead of C:\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: in actions/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-runner and C:\actions-runner refer 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 Win32 GetFinalPathNameByHandle API 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, GetCanonicalPath is a no-op that returns the input unchanged.

Why fix here instead of in individual actions?

Individual actions like actions/checkout can work around the issue (e.g. using includeIf.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 — Add GetCanonicalPath() with P/Invoke to GetFinalPathNameByHandle (Windows only)
  • src/Runner.Common/HostContext.cs — Normalize the root directory path through GetCanonicalPath()
  • src/Test/L0/Util/PathUtilL0.cs — Tests for null/empty/nonexistent paths and drive letter normalization

Testing

  • L0 unit tests for GetCanonicalPath covering null, empty, nonexistent, and existing directory cases
  • Windows-specific test verifying drive letter normalization

Copilot AI review requested due to automatic review settings May 6, 2026 21:06
@salmanmkc salmanmkc requested a review from a team as a code owner May 6, 2026 21:06
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.Root through GetCanonicalPath() in HostContext.
  • 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

  • GetFinalPathNameByHandle can return a longer required length than the initial 1024-char buffer. The current logic returns the original path when result > buffer.Capacity and also doesn’t handle the result == buffer.Capacity boundary, so canonicalization may silently fail for longer paths. Consider resizing the StringBuilder to 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 from GetFinalPathNameByHandle (e.g. \\?\UNC\server\share\... becomes UNC\server\share\..., which is not a valid path). Handle the \\?\UNC\ case explicitly (convert to \\server\share\...) and use StringComparison.Ordinal for 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 asserts char.IsUpper(result[0])). If TEMP/TMP is configured to a UNC path (e.g. \\server\share\...), the test will fail even when GetCanonicalPath is 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

Comment thread src/Runner.Sdk/Util/PathUtil.cs
Comment thread src/Test/L0/Util/PathUtilL0.cs
salmanmkc added 4 commits May 6, 2026 22:28
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
@salmanmkc salmanmkc force-pushed the fix/normalize-windows-path-casing branch from 1f80b89 to ed0e5b7 Compare May 6, 2026 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants