From 74f94d49c508d0c8671a1fd8dbc6c9e269ca771a Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sun, 10 Mar 2024 16:34:52 -0400 Subject: [PATCH 1/5] Close #32 Searching for a file on the system path --- MedallionShell.Tests/GeneralTest.cs | 45 +++++++++++++++++++++++++++++ MedallionShell/Shell.cs | 27 ++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/MedallionShell.Tests/GeneralTest.cs b/MedallionShell.Tests/GeneralTest.cs index e578453..08af678 100644 --- a/MedallionShell.Tests/GeneralTest.cs +++ b/MedallionShell.Tests/GeneralTest.cs @@ -17,6 +17,51 @@ namespace Medallion.Shell.Tests public class GeneralTest { + [Platform("Win", Reason = "Tests Windows-specific executables")] + [TestCase("dotnet", @"C:\Program Files\dotnet\dotnet.exe")] + [TestCase("dotnet.exe", @"C:\Program Files\dotnet\dotnet.exe")] + [TestCase("where.exe", @"C:\Windows\System32\where.exe")] + [TestCase("cmd", @"C:\Windows\System32\cmd.exe")] + [TestCase("cmd.exe", @"C:\Windows\System32\cmd.exe")] + [TestCase("powershell.exe", @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe")] + [TestCase("explorer.exe", @"C:\Windows\explorer.exe")] + [TestCase("git.exe", @"C:\Program Files\Git\cmd\git.exe")] + [TestCase("echo", null)] // echo is not a program on Windows but an internal command in cmd.exe or powershell.exe + [TestCase("does.not.exist", null)] + public void TestGetFullPathOnWindows(string executable, string? expected) + { + StringAssert.AreEqualIgnoringCase(expected, Shell.GetFullPathUsingSystemPathOrDefault(executable)); + var command = Command.Run("where", executable); + command.StandardOutput.ReadToEnd().Trim().ShouldEqual( + expected ?? string.Empty, + $"Exit code: {command.Result.ExitCode}, StdErr: '{command.Result.StandardError}'"); + } + + [Platform("Unix", Reason = "Tests Unix-specific executables")] + [TestCase("dotnet", "/usr/bin/dotnet")] + [TestCase("which", "/usr/bin/which")] + [TestCase("sh", "/usr/bin/sh")] + [TestCase("ls", "/usr/bin/ls")] + [TestCase("grep", "/usr/bin/grep")] + [TestCase("head", "/usr/bin/head")] + [TestCase("sleep", "/usr/bin/sleep")] + [TestCase("echo", "/usr/bin/echo")] + [TestCase("does.not.exist", null)] + public void TestGetFullPathOnLinux(string executable, string? expected) + { + Shell.GetFullPathUsingSystemPathOrDefault(executable).ShouldEqual(expected); + var command = Command.Run("which", executable); + command.StandardOutput.ReadToEnd().Trim().ShouldEqual( + expected, + $"Exit code: {command.Result.ExitCode}, StdErr: '{command.Result.StandardError}'"); + } + + [Test] + public void TestCommandWithoutFullyQualifiedPath() + { + Assert.That(TestShell.Run("git", "--version").StandardOutput.ReadToEnd(), Does.StartWith("git version")); + } + [Test] public void TestGrep() { diff --git a/MedallionShell/Shell.cs b/MedallionShell/Shell.cs index 95f80e1..973f866 100644 --- a/MedallionShell/Shell.cs +++ b/MedallionShell/Shell.cs @@ -4,7 +4,9 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -36,12 +38,17 @@ public Command Run(string executable, IEnumerable? arguments = null, Act { Throw.If(string.IsNullOrEmpty(executable), "executable is required"); + var executablePath = !executable.Contains(Path.DirectorySeparatorChar) + && GetFullPathUsingSystemPathOrDefault(executable) is { } fullPath + ? fullPath + : executable; + var finalOptions = this.GetOptions(options); var processStartInfo = new ProcessStartInfo { CreateNoWindow = true, - FileName = executable, + FileName = executablePath, RedirectStandardError = true, RedirectStandardInput = true, RedirectStandardOutput = true, @@ -76,6 +83,24 @@ public Command Run(string executable, IEnumerable? arguments = null, Act return command; } + // internal for testing + internal static string? GetFullPathUsingSystemPathOrDefault(string executable) + { + var paths = Environment.GetEnvironmentVariable("PATH")!.Split(Path.PathSeparator); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var pathExtensions = Environment.GetEnvironmentVariable("PATHEXT")! + .Split(Path.PathSeparator) + .Concat(new[] { string.Empty }) + .ToArray(); + return paths.SelectMany(path => pathExtensions.Select(pathExtension => Path.Combine(path, executable + pathExtension))) + .FirstOrDefault(File.Exists); + } + + return paths.Select(path => Path.Combine(path, executable)).FirstOrDefault(File.Exists); + } + private static void PopulateArguments(ProcessStartInfo processStartInfo, IEnumerable? arguments, Options options) { if (arguments is null) { return; } From edde9b98b08b91a5c08b705d3c08f7cb1e113754 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sun, 10 Mar 2024 17:07:54 -0400 Subject: [PATCH 2/5] Fix tests --- MedallionShell.Tests/GeneralTest.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/MedallionShell.Tests/GeneralTest.cs b/MedallionShell.Tests/GeneralTest.cs index 08af678..585b7a4 100644 --- a/MedallionShell.Tests/GeneralTest.cs +++ b/MedallionShell.Tests/GeneralTest.cs @@ -26,8 +26,10 @@ public class GeneralTest [TestCase("powershell.exe", @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe")] [TestCase("explorer.exe", @"C:\Windows\explorer.exe")] [TestCase("git.exe", @"C:\Program Files\Git\cmd\git.exe")] - [TestCase("echo", null)] // echo is not a program on Windows but an internal command in cmd.exe or powershell.exe [TestCase("does.not.exist", null)] + // echo is not a program on Windows but an internal command in cmd.exe or powershell.exe. + // However, things like git may still install echo (e.g. C:\Program Files\Git\usr\bin\echo.EXE) + // so there's no guarantee for echo on Windows. public void TestGetFullPathOnWindows(string executable, string? expected) { StringAssert.AreEqualIgnoringCase(expected, Shell.GetFullPathUsingSystemPathOrDefault(executable)); @@ -40,19 +42,19 @@ public void TestGetFullPathOnWindows(string executable, string? expected) [Platform("Unix", Reason = "Tests Unix-specific executables")] [TestCase("dotnet", "/usr/bin/dotnet")] [TestCase("which", "/usr/bin/which")] - [TestCase("sh", "/usr/bin/sh")] - [TestCase("ls", "/usr/bin/ls")] - [TestCase("grep", "/usr/bin/grep")] [TestCase("head", "/usr/bin/head")] - [TestCase("sleep", "/usr/bin/sleep")] - [TestCase("echo", "/usr/bin/echo")] + [TestCase("sh", "/bin/sh")] + [TestCase("ls", "/bin/ls")] + [TestCase("grep", "/bin/grep")] + [TestCase("sleep", "/bin/sleep")] + [TestCase("echo", "/bin/echo")] [TestCase("does.not.exist", null)] public void TestGetFullPathOnLinux(string executable, string? expected) { Shell.GetFullPathUsingSystemPathOrDefault(executable).ShouldEqual(expected); var command = Command.Run("which", executable); command.StandardOutput.ReadToEnd().Trim().ShouldEqual( - expected, + expected ?? string.Empty, $"Exit code: {command.Result.ExitCode}, StdErr: '{command.Result.StandardError}'"); } From 68a37379a565873dbf27b547e1fee25b638e31cb Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Mon, 13 May 2024 21:43:45 -0400 Subject: [PATCH 3/5] Move to GetFullPathUsingSystemPathOrDefault to a standalone file SystemPathSearcher --- MedallionShell.Tests/GeneralTest.cs | 41 ---------------- .../SystemPathSearcherTest.cs | 49 +++++++++++++++++++ MedallionShell/Shell.cs | 21 +------- MedallionShell/SystemPathSearcher.cs | 26 ++++++++++ 4 files changed, 76 insertions(+), 61 deletions(-) create mode 100644 MedallionShell.Tests/SystemPathSearcherTest.cs create mode 100644 MedallionShell/SystemPathSearcher.cs diff --git a/MedallionShell.Tests/GeneralTest.cs b/MedallionShell.Tests/GeneralTest.cs index 585b7a4..78c04e8 100644 --- a/MedallionShell.Tests/GeneralTest.cs +++ b/MedallionShell.Tests/GeneralTest.cs @@ -17,47 +17,6 @@ namespace Medallion.Shell.Tests public class GeneralTest { - [Platform("Win", Reason = "Tests Windows-specific executables")] - [TestCase("dotnet", @"C:\Program Files\dotnet\dotnet.exe")] - [TestCase("dotnet.exe", @"C:\Program Files\dotnet\dotnet.exe")] - [TestCase("where.exe", @"C:\Windows\System32\where.exe")] - [TestCase("cmd", @"C:\Windows\System32\cmd.exe")] - [TestCase("cmd.exe", @"C:\Windows\System32\cmd.exe")] - [TestCase("powershell.exe", @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe")] - [TestCase("explorer.exe", @"C:\Windows\explorer.exe")] - [TestCase("git.exe", @"C:\Program Files\Git\cmd\git.exe")] - [TestCase("does.not.exist", null)] - // echo is not a program on Windows but an internal command in cmd.exe or powershell.exe. - // However, things like git may still install echo (e.g. C:\Program Files\Git\usr\bin\echo.EXE) - // so there's no guarantee for echo on Windows. - public void TestGetFullPathOnWindows(string executable, string? expected) - { - StringAssert.AreEqualIgnoringCase(expected, Shell.GetFullPathUsingSystemPathOrDefault(executable)); - var command = Command.Run("where", executable); - command.StandardOutput.ReadToEnd().Trim().ShouldEqual( - expected ?? string.Empty, - $"Exit code: {command.Result.ExitCode}, StdErr: '{command.Result.StandardError}'"); - } - - [Platform("Unix", Reason = "Tests Unix-specific executables")] - [TestCase("dotnet", "/usr/bin/dotnet")] - [TestCase("which", "/usr/bin/which")] - [TestCase("head", "/usr/bin/head")] - [TestCase("sh", "/bin/sh")] - [TestCase("ls", "/bin/ls")] - [TestCase("grep", "/bin/grep")] - [TestCase("sleep", "/bin/sleep")] - [TestCase("echo", "/bin/echo")] - [TestCase("does.not.exist", null)] - public void TestGetFullPathOnLinux(string executable, string? expected) - { - Shell.GetFullPathUsingSystemPathOrDefault(executable).ShouldEqual(expected); - var command = Command.Run("which", executable); - command.StandardOutput.ReadToEnd().Trim().ShouldEqual( - expected ?? string.Empty, - $"Exit code: {command.Result.ExitCode}, StdErr: '{command.Result.StandardError}'"); - } - [Test] public void TestCommandWithoutFullyQualifiedPath() { diff --git a/MedallionShell.Tests/SystemPathSearcherTest.cs b/MedallionShell.Tests/SystemPathSearcherTest.cs new file mode 100644 index 0000000..e3202d5 --- /dev/null +++ b/MedallionShell.Tests/SystemPathSearcherTest.cs @@ -0,0 +1,49 @@ +using NUnit.Framework; + +namespace Medallion.Shell.Tests; + +public class SystemPathSearcherTest +{ + [Platform("Win", Reason = "Tests Windows-specific executables")] + [TestCase("dotnet", @"C:\Program Files\dotnet\dotnet.exe")] + [TestCase("dotnet.exe", @"C:\Program Files\dotnet\dotnet.exe")] + [TestCase("where.exe", @"C:\Windows\System32\where.exe")] + [TestCase("cmd", @"C:\Windows\System32\cmd.exe")] + [TestCase("cmd.exe", @"C:\Windows\System32\cmd.exe")] + [TestCase("powershell.exe", @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe")] + [TestCase("explorer.exe", @"C:\Windows\explorer.exe")] + [TestCase("git.exe", @"C:\Program Files\Git\cmd\git.exe")] + [TestCase("does.not.exist", null)] + // echo is not a program on Windows but an internal command in cmd.exe or powershell.exe. + // However, things like git may still install echo (e.g. C:\Program Files\Git\usr\bin\echo.EXE) + // so there's no guarantee for echo on Windows. + public void TestGetFullPathOnWindows(string executable, string? expected) + { + StringAssert.AreEqualIgnoringCase(expected, SystemPathSearcher.GetFullPathUsingSystemPathOrDefault(executable)); + + var command = Command.Run("where", executable); + command.StandardOutput.ReadToEnd().Trim().ShouldEqual( + expected ?? string.Empty, + $"Exit code: {command.Result.ExitCode}, StdErr: '{command.Result.StandardError}'"); + } + + [Platform("Unix", Reason = "Tests Unix-specific executables")] + [TestCase("dotnet", "/usr/bin/dotnet")] + [TestCase("which", "/usr/bin/which")] + [TestCase("head", "/usr/bin/head")] + [TestCase("sh", "/bin/sh")] + [TestCase("ls", "/bin/ls")] + [TestCase("grep", "/bin/grep")] + [TestCase("sleep", "/bin/sleep")] + [TestCase("echo", "/bin/echo")] + [TestCase("does.not.exist", null)] + public void TestGetFullPathOnLinux(string executable, string? expected) + { + SystemPathSearcher.GetFullPathUsingSystemPathOrDefault(executable).ShouldEqual(expected); + + var command = Command.Run("which", executable); + command.StandardOutput.ReadToEnd().Trim().ShouldEqual( + expected ?? string.Empty, + $"Exit code: {command.Result.ExitCode}, StdErr: '{command.Result.StandardError}'"); + } +} \ No newline at end of file diff --git a/MedallionShell/Shell.cs b/MedallionShell/Shell.cs index 973f866..cf986ca 100644 --- a/MedallionShell/Shell.cs +++ b/MedallionShell/Shell.cs @@ -6,7 +6,6 @@ using System.Globalization; using System.IO; using System.Linq; -using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -39,7 +38,7 @@ public Command Run(string executable, IEnumerable? arguments = null, Act Throw.If(string.IsNullOrEmpty(executable), "executable is required"); var executablePath = !executable.Contains(Path.DirectorySeparatorChar) - && GetFullPathUsingSystemPathOrDefault(executable) is { } fullPath + && SystemPathSearcher.GetFullPathUsingSystemPathOrDefault(executable) is { } fullPath ? fullPath : executable; @@ -83,24 +82,6 @@ public Command Run(string executable, IEnumerable? arguments = null, Act return command; } - // internal for testing - internal static string? GetFullPathUsingSystemPathOrDefault(string executable) - { - var paths = Environment.GetEnvironmentVariable("PATH")!.Split(Path.PathSeparator); - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var pathExtensions = Environment.GetEnvironmentVariable("PATHEXT")! - .Split(Path.PathSeparator) - .Concat(new[] { string.Empty }) - .ToArray(); - return paths.SelectMany(path => pathExtensions.Select(pathExtension => Path.Combine(path, executable + pathExtension))) - .FirstOrDefault(File.Exists); - } - - return paths.Select(path => Path.Combine(path, executable)).FirstOrDefault(File.Exists); - } - private static void PopulateArguments(ProcessStartInfo processStartInfo, IEnumerable? arguments, Options options) { if (arguments is null) { return; } diff --git a/MedallionShell/SystemPathSearcher.cs b/MedallionShell/SystemPathSearcher.cs new file mode 100644 index 0000000..d00a75a --- /dev/null +++ b/MedallionShell/SystemPathSearcher.cs @@ -0,0 +1,26 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Medallion.Shell; + +internal static class SystemPathSearcher +{ + public static string? GetFullPathUsingSystemPathOrDefault(string executable) + { + var paths = Environment.GetEnvironmentVariable("PATH")!.Split(Path.PathSeparator); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var pathExtensions = Environment.GetEnvironmentVariable("PATHEXT")! + .Split(Path.PathSeparator) + .Concat([string.Empty]) + .ToArray(); + return paths.SelectMany(path => pathExtensions.Select(pathExtension => Path.Combine(path, executable + pathExtension))) + .FirstOrDefault(File.Exists); + } + + return paths.Select(path => Path.Combine(path, executable)).FirstOrDefault(File.Exists); + } +} From f77706ab32ddd3a14d0f03cce30560f5dd25894b Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Tue, 14 May 2024 20:49:23 -0400 Subject: [PATCH 4/5] Add an option SearchSystemPath --- MedallionShell/Shell.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/MedallionShell/Shell.cs b/MedallionShell/Shell.cs index cf986ca..70e3bfe 100644 --- a/MedallionShell/Shell.cs +++ b/MedallionShell/Shell.cs @@ -37,13 +37,14 @@ public Command Run(string executable, IEnumerable? arguments = null, Act { Throw.If(string.IsNullOrEmpty(executable), "executable is required"); + var finalOptions = this.GetOptions(options); + var executablePath = !executable.Contains(Path.DirectorySeparatorChar) + && finalOptions.SearchOnSystemPath && SystemPathSearcher.GetFullPathUsingSystemPathOrDefault(executable) is { } fullPath ? fullPath : executable; - var finalOptions = this.GetOptions(options); - var processStartInfo = new ProcessStartInfo { CreateNoWindow = true, @@ -207,6 +208,7 @@ internal Options() internal CommandLineSyntax CommandLineSyntax { get; private set; } = default!; // assigned in RestoreDefaults internal bool ThrowExceptionOnError { get; private set; } internal bool DisposeProcessOnExit { get; private set; } + internal bool SearchOnSystemPath { get; private set; } internal TimeSpan ProcessTimeout { get; private set; } internal Encoding? ProcessStreamEncoding { get; private set; } internal CancellationToken ProcessCancellationToken { get; private set; } @@ -324,6 +326,17 @@ public Options DisposeOnExit(bool value = true) return this; } + /// + /// If specified, the underlying will search for the system path, like a shell would. + /// + /// Defaults to false + /// + public Options SearchSystemPath(bool value = false) + { + this.SearchOnSystemPath = value; + return this; + } + /// /// Specifies the to use for escaping arguments. Defaults to /// an appropriate value for the current platform From 0e29d0097a79d98bb01a409f5a266bf0d4c76de4 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Tue, 14 May 2024 20:50:13 -0400 Subject: [PATCH 5/5] Put all logic in GetFullPathUsingSystemPathOrDefault, refactoring to remove duplicate code --- MedallionShell/Shell.cs | 3 +-- MedallionShell/SystemPathSearcher.cs | 20 +++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/MedallionShell/Shell.cs b/MedallionShell/Shell.cs index 70e3bfe..4683259 100644 --- a/MedallionShell/Shell.cs +++ b/MedallionShell/Shell.cs @@ -39,8 +39,7 @@ public Command Run(string executable, IEnumerable? arguments = null, Act var finalOptions = this.GetOptions(options); - var executablePath = !executable.Contains(Path.DirectorySeparatorChar) - && finalOptions.SearchOnSystemPath + var executablePath = finalOptions.SearchOnSystemPath && SystemPathSearcher.GetFullPathUsingSystemPathOrDefault(executable) is { } fullPath ? fullPath : executable; diff --git a/MedallionShell/SystemPathSearcher.cs b/MedallionShell/SystemPathSearcher.cs index d00a75a..e0e194a 100644 --- a/MedallionShell/SystemPathSearcher.cs +++ b/MedallionShell/SystemPathSearcher.cs @@ -9,18 +9,16 @@ internal static class SystemPathSearcher { public static string? GetFullPathUsingSystemPathOrDefault(string executable) { - var paths = Environment.GetEnvironmentVariable("PATH")!.Split(Path.PathSeparator); + if (executable.Contains(Path.DirectorySeparatorChar)) { return null; } + if (Environment.GetEnvironmentVariable("PATH") is not { } pathEnvironmentVariable) { return null; } + + var paths = pathEnvironmentVariable.Split(Path.PathSeparator); + var pathExtensions = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + && Environment.GetEnvironmentVariable("PATHEXT") is { } pathTextEnvironmentVariable + ? [.. pathTextEnvironmentVariable.Split(Path.PathSeparator), string.Empty] + : new[] { string.Empty }; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var pathExtensions = Environment.GetEnvironmentVariable("PATHEXT")! - .Split(Path.PathSeparator) - .Concat([string.Empty]) - .ToArray(); - return paths.SelectMany(path => pathExtensions.Select(pathExtension => Path.Combine(path, executable + pathExtension))) + return paths.SelectMany(path => pathExtensions.Select(pathExtension => Path.Combine(path, executable + pathExtension))) .FirstOrDefault(File.Exists); - } - - return paths.Select(path => Path.Combine(path, executable)).FirstOrDefault(File.Exists); } }