Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Environment.ProcessPath hangs with exe path > MAX_PATH #110800

Open
jborean93 opened this issue Dec 18, 2024 · 4 comments · May be fixed by #110888
Open

Environment.ProcessPath hangs with exe path > MAX_PATH #110800

jborean93 opened this issue Dec 18, 2024 · 4 comments · May be fixed by #110888
Labels
area-System.Runtime in-pr There is an active PR which will close this issue when it is merged untriaged New issue has not been triaged by the area owner

Comments

@jborean93
Copy link
Contributor

jborean93 commented Dec 18, 2024

Description

Running an exe that is located in a path that exceeds the MAX_PATH (260 chars) will hang if it calls Environment.ProcessPath. Currently Environment.ProcessPath on Windows calls GetProcessPath

private static string? GetProcessPath()
{
var builder = new ValueStringBuilder(stackalloc char[Interop.Kernel32.MAX_PATH]);
uint length;
while ((length = Interop.Kernel32.GetModuleFileName(IntPtr.Zero, ref builder.GetPinnableReference(), (uint)builder.Capacity)) >= builder.Capacity)
{
builder.EnsureCapacity((int)length);
}
if (length == 0)
throw Win32Marshal.GetExceptionForLastWin32Error();
builder.Length = (int)length;
return builder.ToString();
}

The current logic is

  • Allocate ValueStringBuilder to a capacity of 260
  • When the length returned by GetModuleFileNameW is greater than or equal to the capacity, grow it

The issue is that GetModuleFileNameW does not return the required capacity if the provided buffer size is too small but rather the return value is the length of the string that is copied to the buffer. If the exe is located in a path that exceeds 260 the first call will be with a buffer of 260 and the return value of 260. As 260 >= 260 the builder.EnsureCapacity(260) call is a no-op because the capacity is already that size. As the capacity is never increased the loop will continue to run indefinitely.

A quick example in a PowerShell script showing this behaviour when the provided capacity is less than what is required. This example runs in an exe that does not exceed MAX_PATH but it illustrates how GetModuleFileNameW acts when the provided size is not enough to copy the full file name.

Add-Type -Namespace Native -Name Kernel32 -MemberDefinition @'
[DllImport("Kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern int GetModuleFileNameW(IntPtr hModule, IntPtr lpFilename, int nSize);
'@

$buffer = [System.Runtime.InteropServices.Marshal]::AllocHGlobal(4)
[Native.Kernel32]::GetModuleFileNameW([IntPtr]::Zero, $buffer, 4)
# Returns 4

$buffer = [System.Runtime.InteropServices.Marshal]::ReAllocHGlobal($buffer, 260)
[Native.Kernel32]::GetModuleFileNameW([IntPtr]::Zero, $buffer, 260)
# Returns something less than 260

The logic will have to change to see if the return value is equal to the provided size and increase the value string builder by x amount until the return value is less than the provided size.

Reproduction Steps

Use the following C# project that will copy itself to a path that exceeds MAX_PATH and start the process. It uses CreateProcess here internally because .NET cannot start an exe for something that exceeds MAX_PATH.

This uses the following csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net9.0-windows</TargetFramework>
    <OutputType>Exe</OutputType>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <Nullable>enable</Nullable>
    <PublishAot>true</PublishAot>
  </PropertyGroup>

</Project>
using System;
using System.Buffers;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

namespace LongPathTest;

public static class Program
{
    public static void Main(string[] args)
    {
        if (args.Length == 0)
        {
            Setup();
            return;
        }

        string? processPath = args[0] switch
        {
            "PInvoke" => GetModuleFileName(),
            "ProcessMainModule" => Process.GetCurrentProcess()?.MainModule?.FileName,
            "ProcessPath" => Environment.ProcessPath,
            _ => throw new ArgumentException($"Unknown action {args[0]}"),
        };
        Console.WriteLine("Path: '{0}'", processPath);
    }

    public static void Setup()
    {
        string basePath = Path.GetDirectoryName(typeof(Program).Assembly.Location)
            ?? throw new Exception("Could not find current dll path");

        string[] filesToCopy = Directory.GetFiles(basePath);

        string dir1 = "\\\\?\\" + Path.Combine(basePath, new string('a', 255));
        string dir2 = Path.Combine(dir1, new string('b', 255));
        try
        {
            Directory.CreateDirectory(dir1);
            Directory.CreateDirectory(dir2);

            string? shortExePath = null;
            string? longExePath = null;
            foreach (string toCopy in filesToCopy)
            {
                string filename = Path.GetFileName(toCopy);
                string destPath = Path.Combine(dir2, filename);

                File.Copy(toCopy, destPath, true);
                if (Path.GetExtension(filename) == ".exe")
                {
                    shortExePath = toCopy;
                    longExePath = destPath;
                }
            }
            Debug.Assert(shortExePath is not null);
            Debug.Assert(longExePath is not null);

            string banner = new string('=', Console.BufferWidth);
            foreach (string scenario in new [] {"PInvoke", "ProcessMainModule", "ProcessPath"})
            {
                foreach (string exePath in new[] { shortExePath, longExePath })
                {
                    (string stdout, string stderr, int rc) = RunProcess(exePath, scenario);
                    Console.WriteLine("{0}\n{1} RC {2}\nSTDOUT:\n{3}\nSTDERR:\n{4}", banner, scenario, rc, stdout, stderr);
                }
            }
        }
        finally
        {
            if (Directory.Exists(dir1))
            {
                Directory.Delete(dir1, true);
            }
        }
    }

    public static (string, string, int) RunProcess(
        ReadOnlySpan<char> executable,
        ReadOnlySpan<char> commandLine)
    {
        using AnonymousPipeServerStream stdoutPipe = new(PipeDirection.In, HandleInheritability.Inheritable);
        using AnonymousPipeServerStream stderrPipe = new(PipeDirection.In, HandleInheritability.Inheritable);

        Kernel32.STARTUPINFOW si = new()
        {
            cb = Marshal.SizeOf<Kernel32.STARTUPINFOW>(),
            dwFlags = Kernel32.STARTF_USESTDHANDLES,
            hStdOutput = stdoutPipe.ClientSafePipeHandle.DangerousGetHandle(),
            hStdError = stderrPipe.ClientSafePipeHandle.DangerousGetHandle(),
        };
        Kernel32.PROCESS_INFORMATION pi = new();

        int commandLineLength = executable.Length + commandLine.Length + 4;
        char[] commandLineBuffer = ArrayPool<char>.Shared.Rent(commandLineLength);
        try
        {
            commandLineBuffer[0] = '"';
            executable.CopyTo(commandLineBuffer.AsSpan(1));
            commandLineBuffer[executable.Length + 1] = '"';
            commandLineBuffer[executable.Length + 2] = ' ';
            commandLine.CopyTo(commandLineBuffer.AsSpan(executable.Length + 3));
            commandLineBuffer[commandLineLength - 1] = '\0';

            unsafe
            {
                fixed (char* executablePtr = executable)
                fixed (char* commandLinePtr = commandLineBuffer)
                {
                    bool res = Kernel32.CreateProcessW(
                        executablePtr,
                        commandLinePtr,
                        nint.Zero,
                        nint.Zero,
                        true,
                        Kernel32.CREATE_UNICODE_ENVIRONMENT,
                        nint.Zero,
                        nint.Zero,
                        ref si,
                        ref pi);
                    if (!res)
                    {
                        throw new Win32Exception();
                    }
                }
            }

            stdoutPipe.DisposeLocalCopyOfClientHandle();
            stderrPipe.DisposeLocalCopyOfClientHandle();

            using StreamReader stdoutReader = new(stdoutPipe);
            using StreamReader stderrReader = new(stderrPipe);
            Task<string> stdoutTask = Task.Run(stdoutReader.ReadToEndAsync);
            Task<string> stderrTask = Task.Run(stderrReader.ReadToEndAsync);

            bool terminated = false;
            int waitRes = Kernel32.WaitForSingleObject(pi.hProcess, 5000);
            if (waitRes == Kernel32.WAIT_TIMEOUT)
            {
                Kernel32.TerminateProcess(pi.hProcess, -1);
                terminated = true;
            }
            else if (waitRes == Kernel32.WAIT_FAILED)
            {
                throw new Win32Exception();
            }

            int rc;
            if (!Kernel32.GetExitCodeProcess(pi.hProcess, out rc))
            {
                throw new Win32Exception();
            }

            string stdout = stdoutTask.GetAwaiter().GetResult();
            string stderr = stderrTask.GetAwaiter().GetResult();
            if (terminated && string.IsNullOrWhiteSpace(stderr))
            {
                stderr = "Timed out waiting for process output";
            }
            return (stdout, stderr, rc);
        }
        finally
        {
            ArrayPool<char>.Shared.Return(commandLineBuffer);
            if (pi.hProcess != nint.Zero)
            {
                Kernel32.CloseHandle(pi.hProcess);
            }
            if (pi.hThread != nint.Zero)
            {
                Kernel32.CloseHandle(pi.hThread);
            }
        }
    }

    public static string GetModuleFileName()
    {
        int bufferLength = 260;
        char[] pool = ArrayPool<char>.Shared.Rent(bufferLength);
        try {
            while (true)
            {
                unsafe
                {
                    fixed (char* poolPtr = pool)
                    {
                        int res = Kernel32.GetModuleFileNameW(nint.Zero, poolPtr, bufferLength);
                        if (res < bufferLength)
                        {
                            return new string(pool.AsSpan(0, res));
                        }
                        else if (res == bufferLength)
                        {
                            bufferLength += 260;
                            ArrayPool<char>.Shared.Return(pool);
                            pool = ArrayPool<char>.Shared.Rent(bufferLength);
                        }
                        else
                        {
                            throw new Win32Exception();
                        }
                    }
                }
            }
        }
        finally
        {
            ArrayPool<char>.Shared.Return(pool);
        }
    }
}

internal static partial class Kernel32
{
    public const int CREATE_UNICODE_ENVIRONMENT = 0x00000400;
    public const int STARTF_USESTDHANDLES = 0x00000100;
    public const int WAIT_TIMEOUT = 0x00000102;
    public const int WAIT_FAILED = unchecked((int)0xFFFFFFFF);

    [StructLayout(LayoutKind.Sequential)]
    public struct PROCESS_INFORMATION
    {
        public nint hProcess;
        public nint hThread;
        public int dwProcessId;
        public int dwThreadId;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct STARTUPINFOW
    {
        public int cb;
        public nint lpReserved;
        public nint lpDesktop;
        public nint lpTitle;
        public int dwX;
        public int dwY;
        public int dwXSize;
        public int dwYSize;
        public int dwXCountChars;
        public int dwYCountChars;
        public int dwFileAttribute;
        public int dwFlags;
        public short wShowWindow;
        public short cbReserved2;
        public nint lpReserved2;
        public nint hStdInput;
        public nint hStdOutput;
        public nint hStdError;
    }

    [LibraryImport("kernel32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static partial bool CloseHandle(
        nint hObject);

    [LibraryImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static unsafe partial bool CreateProcessW(
        char* lpApplicationName,
        char* lpCommandLine,
        nint lpProcessAttributes,
        nint lpThreadAttributes,
        [MarshalAs(UnmanagedType.Bool)] bool bInheritHandles,
        int dwCreationFlags,
        nint lpEnvironment,
        nint lpCurrentDirectory,
        ref STARTUPINFOW lpStartupInfo,
        ref PROCESS_INFORMATION lpProcessInformation);

    [LibraryImport("kernel32.dll", SetLastError = true)]
    public static unsafe partial int GetModuleFileNameW(
        nint hModule,
        void* lpFilename,
        int nSize);

    [LibraryImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static partial bool GetExitCodeProcess(
        nint hProcess,
        out int lpExitCode);

    [LibraryImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static partial bool TerminateProcess(
        nint hProcess,
        int uExitCode);

    [LibraryImport("kernel32.dll", SetLastError = true)]
    public static partial int WaitForSingleObject(
        nint hHandle,
        int dwMilliseconds);
}

Expected behavior

Exe path is returned. I'm unsure whether it should have the \\?\ prefix or not.

Actual behavior

Process hangs indefinitely. With the reproducer above, it will be killed after 5 seconds.

PS D:\LongPathTest> dotnet run
==============================================================================================================================
PInvoke RC 0
STDOUT:
Path: 'D:\LongPathTest\bin\Debug\net9.0-windows\LongPathTest.exe'

STDERR:

==============================================================================================================================
PInvoke RC 0
STDOUT:
Path: '\\?\D:\LongPathTest\bin\Debug\net9.0-windows\aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\LongPathTest.exe'

STDERR:

==============================================================================================================================
ProcessMainModule RC 0
STDOUT:
Path: 'D:\LongPathTest\bin\Debug\net9.0-windows\LongPathTest.exe'

STDERR:

==============================================================================================================================
ProcessMainModule RC 0
STDOUT:
Path: 'D:\LongPathTest\bin\Debug\net9.0-windows\aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\LongPathTest.exe'

STDERR:

==============================================================================================================================
ProcessPath RC 0
STDOUT:
Path: 'D:\LongPathTest\bin\Debug\net9.0-windows\LongPathTest.exe'

STDERR:

==============================================================================================================================
ProcessPath RC -1
STDOUT:

STDERR:
Timed out waiting for process output

Regression?

No

Known Workarounds

You can use Process.GetCurrentProcess().MainModule.FileName or PInvoke the call to GetModuleFileNameW with the correct logic.

Configuration

.NET 9
Windows 2025 but same behaviour occurs on older WIndows versions
Tested on ARM64 but same behaviour occurs on x64 and probably x86
The only thing specific to this configuration is that it runs on Windows. I think Windows XP had a slightly different return value behaviour of GetModuleFileNameW but that has long been unsupported.

Other information

No response

@dotnet-issue-labeler dotnet-issue-labeler bot added the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Dec 18, 2024
@dotnet-policy-service dotnet-policy-service bot added the untriaged New issue has not been triaged by the area owner label Dec 18, 2024
@huoyaoyuan huoyaoyuan added area-System.Runtime and removed needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners labels Dec 18, 2024
Copy link
Contributor

Tagging subscribers to this area: @dotnet/area-system-runtime
See info in area-owners.md if you want to be subscribed.

@huoyaoyuan
Copy link
Member

Quoting documentation:

If the function succeeds, the return value is the length of the string that is copied to the buffer, in characters, not including the terminating null character. If the buffer is too small to hold the module name, the string is truncated to nSize characters including the terminating null character, the function returns nSize, and the function sets the last error to ERROR_INSUFFICIENT_BUFFER.

Thus, it is correct to test for length >= capacity because the null terminator will not be counted. However, it doesn't follow the pattern that returns required buffer size. The parameter of EnsureCapacity needs to be changed.

@jborean93
Copy link
Contributor Author

jborean93 commented Dec 18, 2024

I agree, a fix would be to call builder.EnsureCapacity with a value that is greater than length. I'm unsure what amount it should be increased by; should it double the existing capacity, should it add x amount each time. It should probably also put a limit on the amount to ensure it doesn't exceed a sane limit like 32 KiB that I believe is the true maximum path limit on Windows/NT. I'm also not sure how to setup the tests for this due to the limitations in .NET in starting an executable that exceeds MAX_PATH.

@jkotas
Copy link
Member

jkotas commented Dec 18, 2024

should it double the existing capacity

Yes, it is what we do in other places

char[] toReturn = chars;
chars = ArrayPool<char>.Shared.Rent(length * 2);
ArrayPool<char>.Shared.Return(toReturn);

It should probably also put a limit on the amount to ensure it doesn't exceed a sane limit like 32 KiB

I do not think it is necessary.

@huoyaoyuan huoyaoyuan linked a pull request Dec 22, 2024 that will close this issue
@dotnet-policy-service dotnet-policy-service bot added the in-pr There is an active PR which will close this issue when it is merged label Dec 22, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-System.Runtime in-pr There is an active PR which will close this issue when it is merged untriaged New issue has not been triaged by the area owner
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants