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 + } +}