From 46945ca49ac06912a45e0e9c05d96a3e3fe6003b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:28:23 +0000 Subject: [PATCH 1/4] [patch] Look a bare executable name up on the overlay PATH With UseShellExecute off, Process.Start searches the calling process's PATH, not the PATH in ProcessStartInfo.Environment. An overlay that set PATH to pin a toolchain either failed to start a tool only it held, or silently started a same-named tool from the caller's PATH. When the overlay sets PATH and the name has no directory part, the name is now resolved against the overlay's PATH: first executable match wins, relative entries are skipped, and PATHEXT is applied on Windows. With no match, or no PATH in the overlay, the old lookup is kept. Fixes ktsu-dev/RunCommand#122 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5RAKCxdwrsVmygQDP --- README.md | 2 + RunCommand.Test/RunCommandTests.cs | 75 ++++++++++++++++++ RunCommand/RunCommand.cs | 120 +++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+) 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..2bda586 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -14,6 +14,7 @@ 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 string GetCopyCommand(string source, string destination) => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) @@ -839,6 +840,80 @@ 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."); + } + + 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..6425711 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 @@ -316,6 +317,119 @@ public static async Task ExecuteAsync(string fileName, IEnumerable return await RunAsync(startInfo, outputHandler, useElevation, cancellationToken).ConfigureAwait(false); } + /// + /// 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. + /// + private 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. + /// + private 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 + { + if (!Path.IsPathRooted(directory)) + { + 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; + } + } + private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler outputHandler, CommandOptions options, out bool useElevation) { bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); @@ -381,6 +495,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) From 27958b850e47eb27da7dc30e0e725d40433aaaef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:28:40 +0000 Subject: [PATCH 2/4] Keep the PATH lookup helpers below CreateStartInfo Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5RAKCxdwrsVmygQDP --- RunCommand/RunCommand.cs | 164 +++++++++++++++++++-------------------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs index 6425711..bd0608d 100644 --- a/RunCommand/RunCommand.cs +++ b/RunCommand/RunCommand.cs @@ -317,6 +317,88 @@ public static async Task ExecuteAsync(string fileName, IEnumerable return await RunAsync(startInfo, outputHandler, useElevation, cancellationToken).ConfigureAwait(false); } + private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler outputHandler, CommandOptions options, out bool useElevation) + { + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + useElevation = options.Elevation == Elevation.Elevated && isWindows; + + if (useElevation && options.StandardInput == StandardInputMode.Closed) + { + // Same reason as the environment check below: UseShellExecute offers no stream to + // redirect, so the request cannot be honoured. Saying so beats starting a command whose + // standard input is still the caller's, which is the hang StandardInputMode.Closed exists + // to prevent. + throw new ArgumentException( + "Standard input cannot be closed for an elevated command, because elevation requires UseShellExecute.", + nameof(options)); + } + + if (useElevation && options.EnvironmentVariables is not null) + { + // Elevation needs UseShellExecute, which starts the process through the shell and offers + // nowhere to put an environment. Saying so here beats letting Process.Start fail with a + // message that does not mention either setting, and beats silently dropping variables a + // caller may be relying on for credentials or machine-parseable output. + throw new ArgumentException( + "Environment variables cannot be set for an elevated command, because elevation requires UseShellExecute.", + nameof(options)); + } + + ProcessStartInfo startInfo = new() + { + FileName = fileName, + CreateNoWindow = true, + }; + + if (options.WorkingDirectory is not null) + { + startInfo.WorkingDirectory = options.WorkingDirectory.WeakString; + } + + if (useElevation) + { + startInfo.UseShellExecute = true; + startInfo.Verb = "runas"; + } + else + { + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + startInfo.StandardOutputEncoding = outputHandler.Encoding; + startInfo.StandardErrorEncoding = outputHandler.Encoding; + startInfo.RedirectStandardInput = options.StandardInput == StandardInputMode.Closed; + startInfo.UseShellExecute = false; + + if (options.EnvironmentVariables is not null) + { + foreach (KeyValuePair variable in options.EnvironmentVariables) + { + if (variable.Value is null) + { + _ = startInfo.Environment.Remove(variable.Key); + } + else + { + 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) + { + startInfo.LoadUserProfile = true; + } + } + + return startInfo; + } + /// /// Returns the PATH value an environment overlay sets, if it sets one. /// @@ -430,88 +512,6 @@ public static async Task ExecuteAsync(string fileName, IEnumerable } } - private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler outputHandler, CommandOptions options, out bool useElevation) - { - bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - useElevation = options.Elevation == Elevation.Elevated && isWindows; - - if (useElevation && options.StandardInput == StandardInputMode.Closed) - { - // Same reason as the environment check below: UseShellExecute offers no stream to - // redirect, so the request cannot be honoured. Saying so beats starting a command whose - // standard input is still the caller's, which is the hang StandardInputMode.Closed exists - // to prevent. - throw new ArgumentException( - "Standard input cannot be closed for an elevated command, because elevation requires UseShellExecute.", - nameof(options)); - } - - if (useElevation && options.EnvironmentVariables is not null) - { - // Elevation needs UseShellExecute, which starts the process through the shell and offers - // nowhere to put an environment. Saying so here beats letting Process.Start fail with a - // message that does not mention either setting, and beats silently dropping variables a - // caller may be relying on for credentials or machine-parseable output. - throw new ArgumentException( - "Environment variables cannot be set for an elevated command, because elevation requires UseShellExecute.", - nameof(options)); - } - - ProcessStartInfo startInfo = new() - { - FileName = fileName, - CreateNoWindow = true, - }; - - if (options.WorkingDirectory is not null) - { - startInfo.WorkingDirectory = options.WorkingDirectory.WeakString; - } - - if (useElevation) - { - startInfo.UseShellExecute = true; - startInfo.Verb = "runas"; - } - else - { - startInfo.RedirectStandardOutput = true; - startInfo.RedirectStandardError = true; - startInfo.StandardOutputEncoding = outputHandler.Encoding; - startInfo.StandardErrorEncoding = outputHandler.Encoding; - startInfo.RedirectStandardInput = options.StandardInput == StandardInputMode.Closed; - startInfo.UseShellExecute = false; - - if (options.EnvironmentVariables is not null) - { - foreach (KeyValuePair variable in options.EnvironmentVariables) - { - if (variable.Value is null) - { - _ = startInfo.Environment.Remove(variable.Key); - } - else - { - 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) - { - startInfo.LoadUserProfile = true; - } - } - - return startInfo; - } - #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. From 030eae5422af11f39f7fd9f111609522fffe3280 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:32:01 +0000 Subject: [PATCH 3/4] Skip a rooted name before combining it with a search directory Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5RAKCxdwrsVmygQDP --- RunCommand/RunCommand.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs index bd0608d..587e5c2 100644 --- a/RunCommand/RunCommand.cs +++ b/RunCommand/RunCommand.cs @@ -484,7 +484,9 @@ private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler o { try { - if (!Path.IsPathRooted(directory)) + // 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; } From d2ac2a031e860bb1d8510ef51334b583fef214b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:46:58 +0000 Subject: [PATCH 4/4] Cover the PATH lookup's fallback, Windows and skip paths Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5RAKCxdwrsVmygQDP --- RunCommand.Test/RunCommandTests.cs | 62 ++++++++++++++++++++++++++++++ RunCommand/RunCommand.cs | 4 +- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs index 2bda586..f3917e4 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -15,6 +15,7 @@ public class RunCommandTests 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) @@ -883,6 +884,67 @@ public async Task ExecuteAsyncShouldSkipANonExecutableFileOnTheOverlayPath() 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)) diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs index 587e5c2..1407741 100644 --- a/RunCommand/RunCommand.cs +++ b/RunCommand/RunCommand.cs @@ -409,7 +409,7 @@ private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler o /// A removed PATH gives no directories to search, so the executable is found the way it is without an /// overlay. /// - private static string? FindOverlayPath(IReadOnlyDictionary environmentVariables, bool isWindows) + internal static string? FindOverlayPath(IReadOnlyDictionary environmentVariables, bool isWindows) { StringComparison comparison = isWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; string? path = null; @@ -439,7 +439,7 @@ private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler o /// 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. /// - private static string? ResolveOnSearchPath(string fileName, string searchPath, IDictionary environment, bool isWindows) + 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)