diff --git a/README.md b/README.md index 6388281..76eb4b2 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,8 @@ EnvironmentVariables = new Dictionary Environment variables are the only control surface some tools expose, so this covers behaviour with no command-line equivalent — `GIT_TERMINAL_PROMPT=0` to make an authenticating `git fetch` fail rather than block forever on a prompt no terminal will answer, `GIT_ASKPASS`/`SSH_ASKPASS` to supply credentials without putting them on a command line where any process listing can read them, and `LC_ALL=C` to force stable, machine-parseable output rather than whatever the host locale produces. +When the overlay sets `PATH`, a bare executable name such as `node` or `dotnet` is looked up on that `PATH`, so an overlay can pin which toolchain runs. The first directory holding the name wins. Relative `PATH` entries are skipped. On Windows, a name with no extension is tried with each `PATHEXT` extension. On other platforms the file must have an execute bit. A name with a directory part is a path rather than a name to search for, so it is never looked up. If the overlay `PATH` holds no match, or the overlay leaves `PATH` alone or removes it with `null`, the name is found the usual way, on the calling process's `PATH`. + > **_NOTE:_** _`EnvironmentVariables` cannot be combined with `Elevation.Elevated` on Windows. Elevation requires `UseShellExecute`, which offers nowhere to pass an environment, so the call throws `ArgumentException` rather than silently dropping the variables._ ### Standard Input diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs index 64562b3..f3917e4 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -14,6 +14,8 @@ public class RunCommandTests private static readonly string[] ExpectedALast = ["a", "last"]; private static readonly string[] ExpectedALastNext = ["a", "last", "next"]; private static readonly string[] ExpectedDone = ["done"]; + private static readonly string[] ExpectedOverlayCopy = ["ran overlay copy"]; + private static readonly string[] ExpectedStarted = ["started"]; private static string GetCopyCommand(string source, string destination) => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) @@ -839,6 +841,141 @@ public async Task ExecuteAsyncShouldInheritTheEnvironmentWhenNoVariablesAreGiven } } + [TestMethod] + public async Task ExecuteAsyncShouldStartAToolThatOnlyTheOverlayPathHolds() + { + SkipOnWindows(); + string directory = CreateDirectoryForTest(); + string name = $"runcommand-{nameof(ExecuteAsyncShouldStartAToolThatOnlyTheOverlayPathHolds)}"; + WriteScript(directory, name, "echo ran overlay copy", executable: true); + + List output = await RunOnOverlayPathAsync(name, directory).ConfigureAwait(false); + + Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the tool on the overlay PATH to start."); + } + + [TestMethod] + public async Task ExecuteAsyncShouldPreferTheOverlayPathOverTheCallersPath() + { + SkipOnWindows(); + + // echo is on the caller's PATH too, so the old lookup would have started that one and printed the argument. + string directory = CreateDirectoryForTest(); + WriteScript(directory, "echo", "printf 'ran overlay copy\\n'", executable: true); + + List output = await RunOnOverlayPathAsync("echo", directory, "ran caller copy").ConfigureAwait(false); + + Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the overlay PATH to choose which echo runs."); + } + + [TestMethod] + public async Task ExecuteAsyncShouldSkipANonExecutableFileOnTheOverlayPath() + { + SkipOnWindows(); + string root = CreateDirectoryForTest(); + string notExecutable = Directory.CreateDirectory(Path.Join(root, "a")).FullName; + string executable = Directory.CreateDirectory(Path.Join(root, "b")).FullName; + string name = $"runcommand-{nameof(ExecuteAsyncShouldSkipANonExecutableFileOnTheOverlayPath)}"; + WriteScript(notExecutable, name, "echo ran the file that cannot run", executable: false); + WriteScript(executable, name, "echo ran overlay copy", executable: true); + + List output = await RunOnOverlayPathAsync(name, $"{notExecutable}{Path.PathSeparator}{executable}").ConfigureAwait(false); + + Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the search to pass over a file without an execute bit, as a shell does."); + } + + [TestMethod] + public async Task ExecuteAsyncShouldFindTheToolOnTheCallersPathWhenTheOverlayRemovesPath() + { + SkipOnWindows(); + List output = []; + + int exitCode = await RunCommand.ExecuteAsync( + "sh", + ["-c", "echo started"], + new LineOutputHandler(onStandardOutput: output.Add), + new CommandOptions { EnvironmentVariables = new Dictionary { ["PATH"] = null } }).ConfigureAwait(false); + + Assert.AreEqual(0, exitCode); + Assert.AreSequenceEqual(ExpectedStarted, output, "Expected a removed PATH to leave the usual lookup in place."); + } + + [TestMethod] + public void FindOverlayPathShouldMatchTheKeyIgnoringCaseOnlyOnWindows() + { + Dictionary overlay = new(StringComparer.Ordinal) { ["Path"] = "/pinned" }; + + Assert.AreEqual("/pinned", RunCommand.FindOverlayPath(overlay, isWindows: true)); + Assert.IsNull(RunCommand.FindOverlayPath(overlay, isWindows: false)); + } + + [TestMethod] + public void ResolveOnSearchPathShouldApplyPathExtOnWindows() + { + string directory = CreateDirectoryForTest(); + string name = $"tool-{nameof(ResolveOnSearchPathShouldApplyPathExtOnWindows)}"; + WriteScript(directory, $"{name}.CMD", "exit 0", executable: true); + Dictionary environment = new() { ["PATHEXT"] = ".EXE;.CMD" }; + + string? resolved = RunCommand.ResolveOnSearchPath(name, directory, environment, isWindows: true); + + Assert.AreEqual(Path.Join(directory, $"{name}.CMD"), resolved); + } + + [TestMethod] + public void ResolveOnSearchPathShouldLeaveANameWithADirectoryPartAlone() + { + string directory = CreateDirectoryForTest(); + WriteScript(directory, "tool", "exit 0", executable: true); + + Assert.IsNull(RunCommand.ResolveOnSearchPath("./tool", directory, new Dictionary(), isWindows: false)); + Assert.IsNull(RunCommand.ResolveOnSearchPath(@"bin\tool", directory, new Dictionary(), isWindows: true)); + Assert.IsNull(RunCommand.ResolveOnSearchPath("C:tool", directory, new Dictionary(), isWindows: true)); + } + + [TestMethod] + public void ResolveOnSearchPathShouldSkipRelativeAndMissingEntries() + { + string directory = CreateDirectoryForTest(); + string name = $"tool-{nameof(ResolveOnSearchPathShouldSkipRelativeAndMissingEntries)}"; + WriteScript(directory, name, "exit 0", executable: true); + string searchPath = string.Join(Path.PathSeparator, "relative", Path.Join(directory, "missing"), "", directory); + + Assert.AreEqual(Path.Join(directory, name), RunCommand.ResolveOnSearchPath(name, searchPath, new Dictionary(), isWindows: false)); + Assert.IsNull(RunCommand.ResolveOnSearchPath(name, "relative", new Dictionary(), isWindows: false)); + } + + private static void SkipOnWindows() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Inconclusive("Uses shell scripts and execute permission bits. The lookup this covers is shared, and the Unix legs cover it."); + } + } + + private static void WriteScript(string directory, string name, string body, bool executable) + { + string path = Path.Join(directory, name); + File.WriteAllText(path, $"#!/bin/sh\n{body}\n"); + if (!OperatingSystem.IsWindows()) + { + UnixFileMode mode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + File.SetUnixFileMode(path, executable ? mode | UnixFileMode.UserExecute : mode); + } + } + + private static async Task> RunOnOverlayPathAsync(string fileName, string overlayDirectories, params string[] arguments) + { + List output = []; + int exitCode = await RunCommand.ExecuteAsync( + fileName, + arguments, + new LineOutputHandler(onStandardOutput: output.Add), + new CommandOptions { EnvironmentVariables = new Dictionary { ["PATH"] = $"{overlayDirectories}{Path.PathSeparator}/usr/bin{Path.PathSeparator}/bin" } }).ConfigureAwait(false); + Assert.AreEqual(0, exitCode); + return output; + } + [TestMethod] public async Task ExecuteAsyncShouldRejectEnvironmentVariablesCombinedWithElevation() { diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs index 9d41adb..1407741 100644 --- a/RunCommand/RunCommand.cs +++ b/RunCommand/RunCommand.cs @@ -5,6 +5,7 @@ namespace ktsu.RunCommand; using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Runtime.InteropServices; using System.Threading; #if NETSTANDARD2_0 @@ -381,6 +382,12 @@ private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler o 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 @@ private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler o return startInfo; } + /// + /// Returns the PATH value an environment overlay sets, if it sets one. + /// + /// The overlay the caller passed. + /// Whether variable names compare case-insensitively. + /// + /// The overlay's PATH, or when the overlay leaves PATH alone or removes it. + /// A removed PATH gives no directories to search, so the executable is found the way it is without an + /// overlay. + /// + internal static string? FindOverlayPath(IReadOnlyDictionary environmentVariables, bool isWindows) + { + StringComparison comparison = isWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + string? path = null; + foreach (KeyValuePair variable in environmentVariables) + { + if (string.Equals(variable.Key, "PATH", comparison)) + { + path = variable.Value; + } + } + + return path; + } + + /// + /// Looks a bare executable name up on the PATH an environment overlay gives the command. + /// + /// The executable as the caller gave it. + /// The overlay's PATH. + /// The command's environment, which supplies PATHEXT on Windows. + /// Whether to try the PATHEXT extensions and accept a backslash as a separator. + /// The full path of the first match, or to keep the usual lookup. + /// + /// With off, Process.Start searches the calling process's + /// PATH, not the one in . Without this, an overlay that points + /// PATH at a pinned toolchain either failed to start a tool only it holds or started a same-named tool from + /// the caller's PATH. 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 PATH entries, which would otherwise depend on the caller's directory. + /// + internal static string? ResolveOnSearchPath(string fileName, string searchPath, IDictionary 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; + } + + /// + /// Returns the full path of in if it is a file the + /// command could start. + /// + /// A directory from the search path. + /// The file name to look for. + /// + /// The full path, or if there is no such file. Outside Windows the file also needs an + /// execute permission bit, where the platform exposes one. + /// + 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); + 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.