diff --git a/src/Runner.Common/HostContext.cs b/src/Runner.Common/HostContext.cs index ffb08684a53..f0a05a12b59 100644 --- a/src/Runner.Common/HostContext.cs +++ b/src/Runner.Common/HostContext.cs @@ -64,6 +64,7 @@ public sealed class HostContext : EventListener, IObserver, private readonly List _userAgents = new() { new ProductInfoHeaderValue($"GitHubActionsRunner-{BuildConstants.RunnerPackage.PackageName}", BuildConstants.RunnerPackage.Version) }; private CancellationTokenSource _runnerShutdownTokenSource = new(); private object _perfLock = new(); + private string _canonicalRootDirectory; private Tracing _trace; private Tracing _actionsHttpTrace; private Tracing _netcoreHttpTrace; @@ -391,7 +392,12 @@ public string GetDirectory(WellKnownDirectory directory) break; case WellKnownDirectory.Root: - path = new DirectoryInfo(GetDirectory(WellKnownDirectory.Bin)).Parent.FullName; + if (_canonicalRootDirectory == null) + { + _canonicalRootDirectory = PathUtil.GetCanonicalPath( + new DirectoryInfo(GetDirectory(WellKnownDirectory.Bin)).Parent.FullName); + } + path = _canonicalRootDirectory; break; case WellKnownDirectory.Temp: diff --git a/src/Runner.Sdk/Util/PathUtil.cs b/src/Runner.Sdk/Util/PathUtil.cs index 98bf82d05f1..8ab7587738b 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,98 @@ public static class PathUtil { #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) + { + return path; + } + + // Retry with a larger buffer if the path was longer than expected + if (result >= buffer.Capacity) + { + buffer = new StringBuilder((int)result + 1); + result = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, VOLUME_NAME_DOS); + if (result == 0 || result >= buffer.Capacity) + { + return path; + } + } + + var canonicalPath = buffer.ToString(); + + // Strip the \\?\UNC\ prefix and convert to standard UNC path + if (canonicalPath.StartsWith(@"\\?\UNC\", System.StringComparison.Ordinal)) + { + canonicalPath = @"\\" + canonicalPath.Substring(8); + } + // Strip the \\?\ prefix for local paths + else if (canonicalPath.StartsWith(@"\\?\", System.StringComparison.Ordinal)) + { + 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/HostContextL0.cs b/src/Test/L0/HostContextL0.cs index 2b6a0b59015..d8eddab7b62 100644 --- a/src/Test/L0/HostContextL0.cs +++ b/src/Test/L0/HostContextL0.cs @@ -299,6 +299,52 @@ public async Task AuthMigrationAutoReset() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void GetDirectoryRootReturnsCachedValue() + { + try + { + Setup(); + + // Call GetDirectory(Root) twice — should return the same reference + var root1 = _hc.GetDirectory(WellKnownDirectory.Root); + var root2 = _hc.GetDirectory(WellKnownDirectory.Root); + + Assert.NotNull(root1); + Assert.Equal(root1, root2); + Assert.True(Directory.Exists(root1)); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void GetDirectoryDerivedPathsUseRootCasing() + { + try + { + Setup(); + + var root = _hc.GetDirectory(WellKnownDirectory.Root); + var diag = _hc.GetDirectory(WellKnownDirectory.Diag); + var externals = _hc.GetDirectory(WellKnownDirectory.Externals); + + // Diag and Externals should start with the same Root prefix + Assert.StartsWith(root, diag); + Assert.StartsWith(root, externals); + } + finally + { + Teardown(); + } + } + private void Setup([CallerMemberName] string testName = "") { _tokenSource = new CancellationTokenSource(); diff --git a/src/Test/L0/Util/PathUtilL0.cs b/src/Test/L0/Util/PathUtilL0.cs new file mode 100644 index 00000000000..444604f20e9 --- /dev/null +++ b/src/Test/L0/Util/PathUtilL0.cs @@ -0,0 +1,151 @@ +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() + { + var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + + // Skip if temp is a UNC path (no drive letter to normalize) + if (tempDir.StartsWith(@"\\")) + { + return; + } + + // 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}"); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void GetCanonicalPath_NormalizesFolderCasing_OnWindows() + { + // Create a directory with known casing, then query with wrong casing + var basePath = Path.GetTempPath(); + if (basePath.StartsWith(@"\\")) + { + return; // Skip UNC + } + + var realName = "PathUtilTest_MiXeDcAsE_" + Path.GetRandomFileName(); + var realDir = Path.Combine(basePath, realName); + try + { + Directory.CreateDirectory(realDir); + + // Query with all-lowercase version + var wrongCased = Path.Combine(basePath, realName.ToLowerInvariant()); + + var result = PathUtil.GetCanonicalPath(wrongCased); + + // The canonical result should contain the original mixed-case name + Assert.Contains(realName, result); + } + finally + { + if (Directory.Exists(realDir)) + { + Directory.Delete(realDir); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void GetCanonicalPath_IsIdempotent_OnWindows() + { + // Calling GetCanonicalPath twice should return the same result + var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + var first = PathUtil.GetCanonicalPath(tempDir); + var second = PathUtil.GetCanonicalPath(first); + Assert.Equal(first, second); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void GetCanonicalPath_ReturnsSameResult_RegardlessOfInputCasing_OnWindows() + { + var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + if (tempDir.StartsWith(@"\\")) + { + return; // Skip UNC + } + + var upper = tempDir.ToUpperInvariant(); + var lower = tempDir.ToLowerInvariant(); + + var resultUpper = PathUtil.GetCanonicalPath(upper); + var resultLower = PathUtil.GetCanonicalPath(lower); + + // Both should resolve to the same canonical path + Assert.Equal(resultUpper, resultLower); + } +#endif + } +}