Repository navigation
Look a bare executable name up on the overlay PATH [patch] #137
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
Changes from all commits
46945ca
27958b8
030eae5
d2ac2a0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Diagnostics; | ||
| using System.IO; | ||
| using System.Runtime.InteropServices; | ||
| using System.Threading; | ||
| #if NETSTANDARD2_0 | ||
|
|
@@ -23,7 +24,7 @@ | |
| /// </summary> | ||
| /// <param name="command">The command to execute.</param> | ||
| /// <returns>The exit code of the executed process.</returns> | ||
| [Obsolete("A command string is split on its first space, which cannot handle an executable path " | ||
| + "containing spaces. Use the overload taking a file name and an argument list instead.")] | ||
| public static int Execute(string command) => | ||
| ExecuteAsync(command).Result; | ||
|
|
@@ -34,7 +35,7 @@ | |
| /// <param name="command">The command to execute.</param> | ||
| /// <param name="outputHandler">The handler for processing command output.</param> | ||
| /// <returns>The exit code of the executed process.</returns> | ||
| [Obsolete("A command string is split on its first space, which cannot handle an executable path " | ||
| + "containing spaces. Use the overload taking a file name and an argument list instead.")] | ||
| public static int Execute(string command, OutputHandler outputHandler) => | ||
| ExecuteAsync(command, outputHandler).Result; | ||
|
|
@@ -45,7 +46,7 @@ | |
| /// <param name="command">The command to execute.</param> | ||
| /// <param name="elevation">The privilege level under which to run the command.</param> | ||
| /// <returns>The exit code of the executed process.</returns> | ||
| [Obsolete("A command string is split on its first space, which cannot handle an executable path " | ||
| + "containing spaces. Use the overload taking a file name and an argument list instead.")] | ||
| public static int Execute(string command, Elevation elevation) => | ||
| ExecuteAsync(command, elevation).Result; | ||
|
|
@@ -108,7 +109,7 @@ | |
| /// </summary> | ||
| /// <param name="command">The command to execute.</param> | ||
| /// <returns>A task representing the asynchronous operation with the process exit code.</returns> | ||
| [Obsolete("A command string is split on its first space, which cannot handle an executable path " | ||
| + "containing spaces. Use the overload taking a file name and an argument list instead.")] | ||
| public static async Task<int> ExecuteAsync(string command) | ||
| => await ExecuteAsync(command, new OutputHandler()).ConfigureAwait(false); | ||
|
|
@@ -119,7 +120,7 @@ | |
| /// <param name="command">The command to execute.</param> | ||
| /// <param name="outputHandler">The handler for processing command output.</param> | ||
| /// <returns>A task representing the asynchronous operation with the process exit code.</returns> | ||
| [Obsolete("A command string is split on its first space, which cannot handle an executable path " | ||
| + "containing spaces. Use the overload taking a file name and an argument list instead.")] | ||
| public static async Task<int> ExecuteAsync(string command, OutputHandler outputHandler) | ||
| => await ExecuteAsync(command, outputHandler, Elevation.Default).ConfigureAwait(false); | ||
|
|
@@ -130,7 +131,7 @@ | |
| /// <param name="command">The command to execute.</param> | ||
| /// <param name="elevation">The privilege level under which to run the command.</param> | ||
| /// <returns>A task representing the asynchronous operation with the process exit code.</returns> | ||
| [Obsolete("A command string is split on its first space, which cannot handle an executable path " | ||
| + "containing spaces. Use the overload taking a file name and an argument list instead.")] | ||
| public static async Task<int> ExecuteAsync(string command, Elevation elevation) | ||
| => await ExecuteAsync(command, new(), elevation).ConfigureAwait(false); | ||
|
|
@@ -146,7 +147,7 @@ | |
| /// </param> | ||
| /// <param name="elevation">The privilege level under which to run the command.</param> | ||
| /// <returns>A task representing the asynchronous operation with the process exit code.</returns> | ||
| [Obsolete("A command string is split on its first space, which cannot handle an executable path " | ||
| + "containing spaces. Use the overload taking a file name and an argument list instead.")] | ||
| public static async Task<int> ExecuteAsync(string command, OutputHandler outputHandler, Elevation elevation) | ||
| => await ExecuteAsync(command, outputHandler, elevation, CancellationToken.None).ConfigureAwait(false); | ||
|
|
@@ -159,7 +160,7 @@ | |
| /// A token that, when cancelled, terminates the running process and its children. | ||
| /// </param> | ||
| /// <returns>A task representing the asynchronous operation with the process exit code.</returns> | ||
| [Obsolete("A command string is split on its first space, which cannot handle an executable path " | ||
| + "containing spaces. Use the overload taking a file name and an argument list instead.")] | ||
| public static async Task<int> ExecuteAsync(string command, CancellationToken cancellationToken) | ||
| => await ExecuteAsync(command, new OutputHandler(), cancellationToken).ConfigureAwait(false); | ||
|
|
@@ -174,7 +175,7 @@ | |
| /// A token that, when cancelled, terminates the running process and its children. | ||
| /// </param> | ||
| /// <returns>A task representing the asynchronous operation with the process exit code.</returns> | ||
| [Obsolete("A command string is split on its first space, which cannot handle an executable path " | ||
| + "containing spaces. Use the overload taking a file name and an argument list instead.")] | ||
| public static async Task<int> ExecuteAsync(string command, OutputHandler outputHandler, CancellationToken cancellationToken) | ||
| => await ExecuteAsync(command, outputHandler, Elevation.Default, cancellationToken).ConfigureAwait(false); | ||
|
|
@@ -195,7 +196,7 @@ | |
| /// </param> | ||
| /// <returns>A task representing the asynchronous operation with the process exit code.</returns> | ||
| /// <exception cref="OperationCanceledException">The token was cancelled before the process exited.</exception> | ||
| [Obsolete("A command string is split on its first space, which cannot handle an executable path " | ||
| + "containing spaces. Use the overload taking a file name and an argument list instead.")] | ||
| public static async Task<int> ExecuteAsync(string command, OutputHandler outputHandler, Elevation elevation, CancellationToken cancellationToken) | ||
| { | ||
|
|
@@ -381,6 +382,12 @@ | |
| startInfo.Environment[variable.Key] = variable.Value; | ||
| } | ||
| } | ||
|
|
||
| string? overlayPath = FindOverlayPath(options.EnvironmentVariables, isWindows); | ||
| if (overlayPath is not null) | ||
| { | ||
| startInfo.FileName = ResolveOnSearchPath(startInfo.FileName, overlayPath, startInfo.Environment, isWindows) ?? startInfo.FileName; | ||
| } | ||
| } | ||
|
|
||
| if (isWindows) | ||
|
|
@@ -392,6 +399,121 @@ | |
| return startInfo; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Returns the <c>PATH</c> value an environment overlay sets, if it sets one. | ||
| /// </summary> | ||
| /// <param name="environmentVariables">The overlay the caller passed.</param> | ||
| /// <param name="isWindows">Whether variable names compare case-insensitively.</param> | ||
| /// <returns> | ||
| /// The overlay's <c>PATH</c>, or <see langword="null"/> when the overlay leaves <c>PATH</c> alone or removes it. | ||
| /// A removed <c>PATH</c> gives no directories to search, so the executable is found the way it is without an | ||
| /// overlay. | ||
| /// </returns> | ||
| internal static string? FindOverlayPath(IReadOnlyDictionary<string, string?> environmentVariables, bool isWindows) | ||
| { | ||
| StringComparison comparison = isWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; | ||
| string? path = null; | ||
| foreach (KeyValuePair<string, string?> variable in environmentVariables) | ||
|
Check warning on line 416 in RunCommand/RunCommand.cs
|
||
| { | ||
| if (string.Equals(variable.Key, "PATH", comparison)) | ||
| { | ||
| path = variable.Value; | ||
| } | ||
| } | ||
|
|
||
| return path; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Looks a bare executable name up on the <c>PATH</c> an environment overlay gives the command. | ||
| /// </summary> | ||
| /// <param name="fileName">The executable as the caller gave it.</param> | ||
| /// <param name="searchPath">The overlay's <c>PATH</c>.</param> | ||
| /// <param name="environment">The command's environment, which supplies <c>PATHEXT</c> on Windows.</param> | ||
| /// <param name="isWindows">Whether to try the <c>PATHEXT</c> extensions and accept a backslash as a separator.</param> | ||
| /// <returns>The full path of the first match, or <see langword="null"/> to keep the usual lookup.</returns> | ||
| /// <remarks> | ||
| /// With <see cref="ProcessStartInfo.UseShellExecute"/> off, <c>Process.Start</c> searches the calling process's | ||
| /// <c>PATH</c>, not the one in <see cref="ProcessStartInfo.Environment"/>. Without this, an overlay that points | ||
| /// <c>PATH</c> at a pinned toolchain either failed to start a tool only it holds or started a same-named tool from | ||
| /// the caller's <c>PATH</c>. A name with a directory part is a path rather than a name to search for, so it is | ||
| /// left alone, and so are relative <c>PATH</c> entries, which would otherwise depend on the caller's directory. | ||
| /// </remarks> | ||
| internal static string? ResolveOnSearchPath(string fileName, string searchPath, IDictionary<string, string?> environment, bool isWindows) | ||
| { | ||
| bool hasDirectoryPart = fileName.Contains('/') || (isWindows && (fileName.Contains('\\') || fileName.Contains(':'))); | ||
| if (fileName.Length == 0 || hasDirectoryPart) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| string[] candidates = [fileName]; | ||
| if (isWindows && !Path.HasExtension(fileName)) | ||
| { | ||
| _ = environment.TryGetValue("PATHEXT", out string? pathExt); | ||
| string[] extensions = (pathExt ?? ".COM;.EXE;.BAT;.CMD").Split([';'], StringSplitOptions.RemoveEmptyEntries); | ||
| candidates = Array.ConvertAll(extensions, extension => fileName + extension.Trim()); | ||
| } | ||
|
|
||
| foreach (string directory in searchPath.Split([Path.PathSeparator], StringSplitOptions.RemoveEmptyEntries)) | ||
| { | ||
| foreach (string candidate in candidates) | ||
| { | ||
| string? match = TryExecutable(directory.Trim().Trim('"'), candidate); | ||
| if (match is not null) | ||
| { | ||
| return match; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Returns the full path of <paramref name="fileName"/> in <paramref name="directory"/> if it is a file the | ||
| /// command could start. | ||
| /// </summary> | ||
| /// <param name="directory">A directory from the search path.</param> | ||
| /// <param name="fileName">The file name to look for.</param> | ||
| /// <returns> | ||
| /// The full path, or <see langword="null"/> if there is no such file. Outside Windows the file also needs an | ||
| /// execute permission bit, where the platform exposes one. | ||
| /// </returns> | ||
| private static string? TryExecutable(string directory, string fileName) | ||
| { | ||
| try | ||
| { | ||
| // A rooted name would make Path.Combine drop the directory. The caller only passes bare names, so this | ||
| // is a guard rather than a case that arises. | ||
| if (!Path.IsPathRooted(directory) || Path.IsPathRooted(fileName)) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| string candidate = Path.Combine(directory, fileName); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I traced this, and it can't happen through the current caller. Generated by Claude Code |
||
| if (!File.Exists(candidate)) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| #if NET7_0_OR_GREATER | ||
| const UnixFileMode anyExecute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; | ||
| if (!OperatingSystem.IsWindows() && (File.GetUnixFileMode(candidate) & anyExecute) == 0) | ||
| { | ||
| return null; | ||
| } | ||
| #endif | ||
|
|
||
| return candidate; | ||
| } | ||
| catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) | ||
| { | ||
| // An entry that is not a usable path is skipped, the way a shell skips it. | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| #if NETSTANDARD2_0 | ||
| // ProcessStartInfo.ArgumentList is not part of the netstandard2.0 surface, so build an escaped | ||
| // command line by hand on that target only. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This stays as a loop. The suggestion only changes style, and the loop already gives the "last match wins" result the alert describes. That result matters on Windows, where the overlay dictionary can hold both
PathandPATH.System.Linqis currently imported only fornetstandard2.0, so switching to LINQ would mean adding a using for every target, with no change in behaviour.Generated by Claude Code