From 7585eb30aa47ecaabc7ac17eadcea71a2680014f Mon Sep 17 00:00:00 2001 From: Salman Chishti Date: Wed, 6 May 2026 21:05:50 +0000 Subject: [PATCH] Normalize Windows path casing using GetFinalPathNameByHandle 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 --- src/Runner.Common/HostContext.cs | 1 + src/Runner.Sdk/Util/PathUtil.cs | 77 +++++++++++++++++++++++++++++++ src/Test/L0/Util/PathUtilL0.cs | 79 ++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 src/Test/L0/Util/PathUtilL0.cs diff --git a/src/Runner.Common/HostContext.cs b/src/Runner.Common/HostContext.cs index ffb08684a..1dff2d706 100644 --- a/src/Runner.Common/HostContext.cs +++ b/src/Runner.Common/HostContext.cs @@ -392,6 +392,7 @@ namespace GitHub.Runner.Common case WellKnownDirectory.Root: path = new DirectoryInfo(GetDirectory(WellKnownDirectory.Bin)).Parent.FullName; + path = PathUtil.GetCanonicalPath(path); break; case WellKnownDirectory.Temp: diff --git a/src/Runner.Sdk/Util/PathUtil.cs b/src/Runner.Sdk/Util/PathUtil.cs index 98bf82d05..3d6004934 100644 --- a/src/Runner.Sdk/Util/PathUtil.cs +++ b/src/Runner.Sdk/Util/PathUtil.cs @@ -1,4 +1,7 @@ using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; namespace GitHub.Runner.Sdk { @@ -6,8 +9,82 @@ namespace GitHub.Runner.Sdk { #if OS_WINDOWS public static readonly string PathVariable = "Path"; + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern SafeFileHandle CreateFile( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + System.IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + System.IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle hFile, + [Out] StringBuilder lpszFilePath, + uint cchFilePath, + uint dwFlags); + + private const uint FILE_READ_ATTRIBUTES = 0x80; + private const uint FILE_SHARE_READ = 0x1; + private const uint FILE_SHARE_WRITE = 0x2; + private const uint FILE_SHARE_DELETE = 0x4; + private const uint OPEN_EXISTING = 3; + private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + private const uint VOLUME_NAME_DOS = 0x0; + + /// + /// Returns the NTFS canonical path for a directory, resolving drive letter + /// and folder name casing to match what is stored on disk. + /// On non-Windows platforms, returns the path unchanged. + /// + public static string GetCanonicalPath(string path) + { + if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) + { + return path; + } + + using var handle = CreateFile( + path, + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + System.IntPtr.Zero, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + System.IntPtr.Zero); + + if (handle.IsInvalid) + { + return path; + } + + var buffer = new StringBuilder(1024); + var result = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, VOLUME_NAME_DOS); + if (result == 0 || result > buffer.Capacity) + { + return path; + } + + var canonicalPath = buffer.ToString(); + + // Strip the \\?\ prefix that GetFinalPathNameByHandle adds + if (canonicalPath.StartsWith(@"\\?\")) + { + canonicalPath = canonicalPath.Substring(4); + } + + return canonicalPath; + } #else public static readonly string PathVariable = "PATH"; + + public static string GetCanonicalPath(string path) + { + return path; + } #endif public static string PrependPath(string path, string currentPath) diff --git a/src/Test/L0/Util/PathUtilL0.cs b/src/Test/L0/Util/PathUtilL0.cs new file mode 100644 index 000000000..787f5abf6 --- /dev/null +++ b/src/Test/L0/Util/PathUtilL0.cs @@ -0,0 +1,79 @@ +using GitHub.Runner.Sdk; +using System.IO; +using System.Runtime.InteropServices; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Util +{ + public sealed class PathUtilL0 + { + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void GetCanonicalPath_ReturnsPath_WhenDirectoryDoesNotExist() + { + var fakePath = Path.Combine(Path.GetTempPath(), "nonexistent_" + Path.GetRandomFileName()); + var result = PathUtil.GetCanonicalPath(fakePath); + Assert.Equal(fakePath, result); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void GetCanonicalPath_ReturnsPath_WhenNull() + { + Assert.Null(PathUtil.GetCanonicalPath(null)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void GetCanonicalPath_ReturnsEmpty_WhenEmpty() + { + Assert.Equal(string.Empty, PathUtil.GetCanonicalPath(string.Empty)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void GetCanonicalPath_ReturnsValidPath_ForExistingDirectory() + { + var tempDir = Path.Combine(Path.GetTempPath(), "pathutil_test_" + Path.GetRandomFileName()); + try + { + Directory.CreateDirectory(tempDir); + var result = PathUtil.GetCanonicalPath(tempDir); + Assert.NotNull(result); + Assert.True(Directory.Exists(result)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir); + } + } + } + +#if OS_WINDOWS + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void GetCanonicalPath_NormalizesDriveLetter_OnWindows() + { + // 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}"); + } +#endif + } +}