File: src\Shared\PathLookupHelper.cs
Web Access
Project: src\tests\Aspire.Hosting.Browsers.Tests\Aspire.Hosting.Browsers.Tests.csproj (Aspire.Hosting.Browsers.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
 
/// <summary>
/// Provides helper methods for looking up executables on the system PATH.
/// </summary>
internal static class PathLookupHelper
{
    /// <summary>
    /// Resolves an executable path or command name by returning explicit paths as-is or by searching the system PATH.
    /// </summary>
    /// <param name="executablePath">The executable path or command name to resolve.</param>
    /// <param name="environmentVariables">Optional environment variable overrides to use for lookup.</param>
    /// <returns>The resolved executable path if found; otherwise, <paramref name="executablePath"/> when it is an explicit path.</returns>
    /// <exception cref="FileNotFoundException">Thrown when <paramref name="executablePath"/> is a command name that is not found on PATH.</exception>
    public static string ResolveExecutablePath(string executablePath, IDictionary<string, string>? environmentVariables = null)
    {
        Debug.Assert(!string.IsNullOrWhiteSpace(executablePath));
 
        // Values with a directory/root component are explicit paths (relative, absolute, UNC, drive-relative, etc.).
        // Do not PATH-search or append PATHEXT here; let Process.Start handle missing files or platform-specific path rules.
        if (IsExplicitExecutablePath(executablePath))
        {
            return executablePath;
        }
 
        // The actual PATH walk is shared by FindFullPathFromPath below. This wrapper first computes the
        // effective PATH/PATHEXT from command-specific environment overrides so lookup matches the child process.
        var (pathOverride, pathExtensionsOverride) = GetPathLookupEnvironmentVariables(environmentVariables);
 
        var path = pathOverride ?? Environment.GetEnvironmentVariable("PATH");
        var pathExtensions = OperatingSystem.IsWindows()
            ? (pathExtensionsOverride ?? Environment.GetEnvironmentVariable("PATHEXT"))?.Split(';', StringSplitOptions.RemoveEmptyEntries) ?? []
            : null;
 
        // Non-explicit command names must resolve through the effective PATH before Process.Start sees them. On Windows,
        // CreateProcessW also searches the AppHost executable directory and current directory for bare names, which would
        // bypass the intended PATH-only lookup if we returned the original name on miss.
        // See https://learn.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw
        return FindFullPathFromPath(executablePath, path, Path.PathSeparator, FileExistsAndIsExecutable, pathExtensions)
            ?? throw new FileNotFoundException($"Executable '{executablePath}' was not found on PATH.", executablePath);
    }
 
    /// <summary>
    /// Tries to resolve an executable path or command name by returning explicit paths as-is or by searching the system PATH.
    /// </summary>
    /// <param name="executablePath">The executable path or command name to resolve.</param>
    /// <param name="resolvedExecutablePath">The resolved command path, or the original value when <paramref name="executablePath"/> is explicit.</param>
    /// <param name="environmentVariables">Optional environment variable overrides to use for lookup.</param>
    /// <returns><see langword="true"/> when the executable path is explicit or the command name was resolved; otherwise, <see langword="false"/>.</returns>
    public static bool TryResolveExecutablePath(
        string executablePath,
        [NotNullWhen(true)] out string? resolvedExecutablePath,
        IDictionary<string, string>? environmentVariables = null)
    {
        try
        {
            resolvedExecutablePath = ResolveExecutablePath(executablePath, environmentVariables);
            return true;
        }
        catch (FileNotFoundException)
        {
            resolvedExecutablePath = null;
            return false;
        }
    }
 
    /// <summary>
    /// Finds the full path of a command by searching the system PATH.
    /// On Windows, this also searches for executables with common extensions (.exe, .cmd, .bat, etc.).
    /// </summary>
    /// <param name="command">The command name to search for.</param>
    /// <returns>The full path to the executable if found; otherwise, <c>null</c>.</returns>
    public static string? FindFullPathFromPath(string command)
    {
        var pathExtensions = OperatingSystem.IsWindows()
            ? Environment.GetEnvironmentVariable("PATHEXT")?.Split(';', StringSplitOptions.RemoveEmptyEntries) ?? []
            : null;
 
        return FindFullPathFromPath(command, Environment.GetEnvironmentVariable("PATH"), Path.PathSeparator, FileExistsAndIsExecutable, pathExtensions);
    }
 
    /// <summary>
    /// Finds all full paths of a command by searching the system PATH.
    /// </summary>
    /// <param name="command">The command name to search for.</param>
    /// <returns>The matching executable paths in PATH lookup order.</returns>
    public static IEnumerable<string> FindAllFullPathsFromPath(string command)
    {
        var pathExtensions = OperatingSystem.IsWindows()
            ? Environment.GetEnvironmentVariable("PATHEXT")?.Split(';', StringSplitOptions.RemoveEmptyEntries) ?? []
            : null;
 
        return FindAllFullPathsFromPath(command, Environment.GetEnvironmentVariable("PATH"), Path.PathSeparator, FileExistsAndIsExecutable, pathExtensions);
    }
 
    /// <summary>
    /// Finds the full path of a command by searching the specified PATH variable.
    /// </summary>
    /// <param name="command">The command name to search for.</param>
    /// <param name="pathVariable">The PATH environment variable value to search.</param>
    /// <param name="pathSeparator">The character used to separate paths in the PATH variable.</param>
    /// <param name="fileExists">A function to check if a file exists at a given path.</param>
    /// <param name="pathExtensions">Optional array of executable extensions to try (e.g., .exe, .cmd). When provided, these extensions will be appended to the command if not already present.</param>
    /// <returns>The full path to the executable if found; otherwise, <c>null</c>.</returns>
    internal static string? FindFullPathFromPath(string command, string? pathVariable, char pathSeparator, Func<string, bool> fileExists, string[]? pathExtensions = null)
    {
        Debug.Assert(!string.IsNullOrWhiteSpace(command));
 
        // If the command already has a known extension, just search for it directly.
        if (pathExtensions is not null && pathExtensions.Any(ext => command.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
        {
            return FindFullPath(command, pathVariable, pathSeparator, fileExists, pathExtensions: null);
        }
 
        return FindFullPath(command, pathVariable, pathSeparator, fileExists, pathExtensions);
    }
 
    /// <summary>
    /// Finds all full paths of a command by searching the specified PATH variable.
    /// </summary>
    /// <param name="command">The command name to search for.</param>
    /// <param name="pathVariable">The PATH environment variable value to search.</param>
    /// <param name="pathSeparator">The character used to separate paths in the PATH variable.</param>
    /// <param name="fileExists">A function to check if a file exists at a given path.</param>
    /// <param name="pathExtensions">Optional array of executable extensions to try (e.g. .exe, .cmd). When provided, these extensions will be appended to the command if not already present.</param>
    /// <returns>The matching executable paths in PATH lookup order.</returns>
    internal static IEnumerable<string> FindAllFullPathsFromPath(string command, string? pathVariable, char pathSeparator, Func<string, bool> fileExists, string[]? pathExtensions = null)
    {
        Debug.Assert(!string.IsNullOrWhiteSpace(command));
 
        // If the command already has a known extension, just search for it directly.
        if (pathExtensions is not null && pathExtensions.Any(ext => command.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
        {
            return FindAllFullPaths(command, pathVariable, pathSeparator, fileExists, pathExtensions: null);
        }
 
        return FindAllFullPaths(command, pathVariable, pathSeparator, fileExists, pathExtensions);
    }
 
    private static string? FindFullPath(string command, string? pathVariable, char pathSeparator, Func<string, bool> fileExists, string[]? pathExtensions)
    {
        foreach (var fullPath in FindAllFullPaths(command, pathVariable, pathSeparator, fileExists, pathExtensions))
        {
            return fullPath;
        }
 
        return null;
    }
 
    private static IEnumerable<string> FindAllFullPaths(string command, string? pathVariable, char pathSeparator, Func<string, bool> fileExists, string[]? pathExtensions)
    {
        foreach (var directory in (pathVariable ?? string.Empty).Split(pathSeparator, StringSplitOptions.RemoveEmptyEntries))
        {
            // On Windows, search each directory completely with all PATHEXT extensions before moving to the next.
            // This matches Windows command lookup behavior where directory order takes precedence.
            if (pathExtensions is not null && pathExtensions.Length > 0)
            {
                var foundExtensionMatch = false;
                foreach (var extension in pathExtensions)
                {
                    if (TryCombine(directory, command + extension, out var fullPathWithExt) &&
                        FileExistsSafe(fileExists, fullPathWithExt))
                    {
                        yield return fullPathWithExt;
                        foundExtensionMatch = true;
                        break;
                    }
                }
 
                if (foundExtensionMatch)
                {
                    continue;
                }
            }
 
            // Try exact match (for non-Windows, or as fallback on Windows if no extension match found in this directory).
            if (TryCombine(directory, command, out var fullPath) &&
                FileExistsSafe(fileExists, fullPath))
            {
                yield return fullPath;
            }
        }
    }
 
    private static bool TryCombine(string directory, string fileName, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? path)
    {
        try
        {
            path = Path.Combine(directory, fileName);
            return true;
        }
        catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
        {
            path = null;
            return false;
        }
    }
 
    // Wrap a possibly-throwing existence probe so callers do not have to handle
    // the IO/permission failure modes themselves. Used by the PATH-walk above
    // when a directory on PATH is unreadable or a candidate path is malformed
    // in a way that File.Exists rejects with an exception rather than false.
    private static bool FileExistsSafe(Func<string, bool> fileExists, string path)
    {
        try
        {
            return fileExists(path);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PathTooLongException or System.Security.SecurityException)
        {
            return false;
        }
    }
 
    private static bool IsExplicitExecutablePath(string executablePath)
    {
        return !string.Equals(Path.GetFileName(executablePath), executablePath, StringComparison.Ordinal)
            || Path.IsPathRooted(executablePath);
    }
 
    private static bool FileExistsAndIsExecutable(string path)
    {
        if (!File.Exists(path))
        {
            return false;
        }
 
        if (OperatingSystem.IsWindows())
        {
            return true;
        }
 
        try
        {
            // Match Unix command lookup behavior by skipping PATH entries that exist but have no execute bit set.
            const UnixFileMode ExecuteBits = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
 
            return (File.GetUnixFileMode(path) & ExecuteBits) != 0;
        }
        catch (IOException)
        {
            return false;
        }
        catch (UnauthorizedAccessException)
        {
            return false;
        }
    }
 
    private static (string? Path, string? PathExtensions) GetPathLookupEnvironmentVariables(IDictionary<string, string>? environmentVariables)
    {
        if (environmentVariables is null)
        {
            return default;
        }
 
        var hasPath = environmentVariables.TryGetValue("PATH", out var path);
        string? pathExtensions = null;
        var hasPathExtensions = OperatingSystem.IsWindows() && environmentVariables.TryGetValue("PATHEXT", out pathExtensions);
 
        // Environment variables are case-insensitive on Windows and case-sensitive on Unix-like systems.
        // Scan once for both PATH and PATHEXT casing variants instead of scanning once per variable.
        if (OperatingSystem.IsWindows() && (!hasPath || !hasPathExtensions))
        {
            foreach (var (key, environmentValue) in environmentVariables)
            {
                if (!hasPath && string.Equals(key, "PATH", StringComparison.OrdinalIgnoreCase))
                {
                    path = environmentValue;
                    hasPath = true;
                }
                else if (!hasPathExtensions && string.Equals(key, "PATHEXT", StringComparison.OrdinalIgnoreCase))
                {
                    pathExtensions = environmentValue;
                    hasPathExtensions = true;
                }
 
                if (hasPath && hasPathExtensions)
                {
                    break;
                }
            }
        }
 
        return (path, pathExtensions);
    }
}