From 68fc9b9bd6d614f599ef7293a5587625e4bd1315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 8 Apr 2026 16:28:07 -0400 Subject: [PATCH] Replace external PATH lookup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/UniGetUI.Core.Tools.Tests/ToolsTests.cs | 184 ++++++++++++++++ src/UniGetUI.Core.Tools/Tools.cs | 222 +++++++++++++++----- 2 files changed, 353 insertions(+), 53 deletions(-) diff --git a/src/UniGetUI.Core.Tools.Tests/ToolsTests.cs b/src/UniGetUI.Core.Tools.Tests/ToolsTests.cs index aad0f98075..9af3159172 100644 --- a/src/UniGetUI.Core.Tools.Tests/ToolsTests.cs +++ b/src/UniGetUI.Core.Tools.Tests/ToolsTests.cs @@ -50,6 +50,164 @@ public async Task TestWhichFunctionForNonExistingFile() Assert.Equal("", result.Item2); } + [Fact] + public void TestWhichFunctionForDirectPath() + { + string tempDirectory = CreateTemporaryDirectory(); + string commandPath = Path.Combine( + tempDirectory, + OperatingSystem.IsWindows() ? "unigetui-direct-path-test.cmd" : "unigetui-direct-path-test" + ); + + try + { + CreateCommandFile(commandPath); + + Tuple result = CoreTools.Which(commandPath); + + Assert.True(result.Item1); + Assert.Equal(commandPath, result.Item2); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + + [Fact] + public void TestWhichMultipleRespectsProcessPathOrder() + { + string tempDirectory1 = CreateTemporaryDirectory(); + string tempDirectory2 = CreateTemporaryDirectory(); + string commandName = OperatingSystem.IsWindows() + ? "unigetui-path-order-test.exe" + : "unigetui-path-order-test"; + string commandPath1 = Path.Combine(tempDirectory1, commandName); + string commandPath2 = Path.Combine(tempDirectory2, commandName); + string? oldPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process); + + try + { + CreateCommandFile(commandPath1); + CreateCommandFile(commandPath2); + Environment.SetEnvironmentVariable( + "PATH", + string.Join(Path.PathSeparator, tempDirectory1, tempDirectory2), + EnvironmentVariableTarget.Process + ); + + List result = CoreTools.WhichMultiple(commandName); + + Assert.Equal([commandPath1, commandPath2], result); + } + finally + { + Environment.SetEnvironmentVariable( + "PATH", + oldPath, + EnvironmentVariableTarget.Process + ); + Directory.Delete(tempDirectory1, true); + Directory.Delete(tempDirectory2, true); + } + } + + [Fact] + public void TestWhichFunctionResolvesPathextOnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + string tempDirectory = CreateTemporaryDirectory(); + string commandPath = Path.Combine(tempDirectory, "unigetui-pathext-test.exe"); + string? oldPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process); + string? oldPathExt = Environment.GetEnvironmentVariable( + "PATHEXT", + EnvironmentVariableTarget.Process + ); + + try + { + CreateCommandFile(commandPath); + Environment.SetEnvironmentVariable( + "PATH", + tempDirectory, + EnvironmentVariableTarget.Process + ); + Environment.SetEnvironmentVariable( + "PATHEXT", + ".EXE;.CMD", + EnvironmentVariableTarget.Process + ); + + Tuple result = CoreTools.Which("unigetui-pathext-test"); + + Assert.True(result.Item1); + Assert.Equal(commandPath, result.Item2, StringComparer.OrdinalIgnoreCase); + } + finally + { + Environment.SetEnvironmentVariable( + "PATH", + oldPath, + EnvironmentVariableTarget.Process + ); + Environment.SetEnvironmentVariable( + "PATHEXT", + oldPathExt, + EnvironmentVariableTarget.Process + ); + Directory.Delete(tempDirectory, true); + } + } + + [Fact] + public void TestWhichFunctionRequiresExecutableBitOnUnix() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + string tempDirectory = CreateTemporaryDirectory(); + string commandName = "unigetui-unix-executable-bit-test"; + string commandPath = Path.Combine(tempDirectory, commandName); + string? oldPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process); + + try + { + File.WriteAllText(commandPath, string.Empty); + File.SetUnixFileMode( + commandPath, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.GroupRead + | UnixFileMode.OtherRead + ); + Environment.SetEnvironmentVariable( + "PATH", + tempDirectory, + EnvironmentVariableTarget.Process + ); + + Tuple result = CoreTools.Which(commandName); + + Assert.False(result.Item1); + Assert.Equal("", result.Item2); + } + finally + { + Environment.SetEnvironmentVariable( + "PATH", + oldPath, + EnvironmentVariableTarget.Process + ); + Directory.Delete(tempDirectory, true); + } + } + [Theory] [InlineData("7zip19.00-helpEr", "7zip19.00 HelpEr")] [InlineData("packagename", "Packagename")] @@ -242,5 +400,31 @@ public void TestFormatSize(long size, int decPlaces, string expected) { Assert.Equal(CoreTools.FormatAsSize(size, decPlaces).Replace(',', '.'), expected); } + + private static string CreateTemporaryDirectory() + { + string path = Path.Combine(Path.GetTempPath(), $"UniGetUI-ToolsTests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + private static void CreateCommandFile(string path) + { + File.WriteAllText(path, string.Empty); + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute + ); + } + } } } diff --git a/src/UniGetUI.Core.Tools/Tools.cs b/src/UniGetUI.Core.Tools/Tools.cs index cab09ebeb5..b7e683d1b7 100644 --- a/src/UniGetUI.Core.Tools/Tools.cs +++ b/src/UniGetUI.Core.Tools/Tools.cs @@ -110,61 +110,27 @@ public static List WhichMultiple(string command, bool updateEnv = true) command = command.Replace(";", "").Replace("&", "").Trim(); Logger.Debug($"Begin \"which\" search for command {command}"); - string pathValue = GetSearchPath(); + _ = updateEnv; - Process process = new() - { - StartInfo = new ProcessStartInfo - { - FileName = OperatingSystem.IsWindows() - ? Path.Join(Environment.SystemDirectory, "where.exe") - : "which", - Arguments = command, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - StandardOutputEncoding = GetCommandOutputEncoding(), - StandardErrorEncoding = GetCommandOutputEncoding(), - }, - }; - if (updateEnv) + if (string.IsNullOrWhiteSpace(command)) { - process.StartInfo = UpdateEnvironmentVariables(process.StartInfo); + Logger.ImportantInfo($"Command {command} was not found on the system"); + return []; } - process.StartInfo.Environment["PATH"] = pathValue; - - try - { - process.Start(); - string[] lines = process - .StandardOutput.ReadToEnd() - .Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries); - - process.WaitForExit(); - if (process.ExitCode is not 0) - Logger.Warn( - $"Call to WhichMultiple with file {command} returned non-zero status {process.ExitCode}" - ); - - if (lines.Length is 0) - { - Logger.ImportantInfo($"Command {command} was not found on the system"); - return []; - } + string pathValue = GetSearchPath(); - Logger.Debug( - $"Command {command} was found on {lines[0]} (with {lines.Length - 1} more occurrences)" - ); - return lines.ToList(); - } - catch + List lines = FindExecutableMatches(command, pathValue); + if (lines.Count is 0) { - if (updateEnv) - return WhichMultiple(command, false); - throw; + Logger.ImportantInfo($"Command {command} was not found on the system"); + return []; } + + Logger.Debug( + $"Command {command} was found on {lines[0]} (with {lines.Count - 1} more occurrences)" + ); + return lines; } public static Tuple Which(string command, bool updateEnv = true) @@ -884,11 +850,161 @@ public static async void FinalizeDangerousTask(Task t) } } - private static Encoding GetCommandOutputEncoding() => - OperatingSystem.IsWindows() - ? CodePagesEncodingProvider.Instance.GetEncoding(CoreData.CODE_PAGE) - ?? Encoding.UTF8 - : Encoding.UTF8; + private static List FindExecutableMatches(string command, string pathValue) + { + List matches = []; + HashSet seen = new( + OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal + ); + + if (HasDirectoryComponent(command)) + { + AddExecutableMatches( + matches, + seen, + Path.GetDirectoryName(command) ?? string.Empty, + Path.GetFileName(command) + ); + return matches; + } + + foreach (string directory in EnumerateSearchDirectories(pathValue)) + { + AddExecutableMatches(matches, seen, directory, command); + } + + return matches; + } + + private static void AddExecutableMatches( + List matches, + HashSet seen, + string directory, + string command + ) + { + string searchDirectory = NormalizeSearchDirectory(directory); + if (searchDirectory.Length is 0) + { + return; + } + + foreach (string candidateName in EnumerateCandidateFileNames(command)) + { + TryAddExecutablePath(Path.Combine(searchDirectory, candidateName), matches, seen); + } + } + + private static IEnumerable EnumerateSearchDirectories(string pathValue) + { + if (OperatingSystem.IsWindows()) + { + yield return Environment.CurrentDirectory; + } + + foreach (string pathEntry in pathValue.Split(Path.PathSeparator)) + { + string normalizedEntry = NormalizeSearchDirectory(pathEntry); + if (normalizedEntry.Length is not 0) + { + yield return normalizedEntry; + } + } + } + + private static string NormalizeSearchDirectory(string? pathEntry) + { + if (string.IsNullOrWhiteSpace(pathEntry)) + { + return Environment.CurrentDirectory; + } + + return pathEntry.Trim().Trim('"'); + } + + private static IEnumerable EnumerateCandidateFileNames(string command) + { + if (!OperatingSystem.IsWindows() || Path.HasExtension(command)) + { + yield return command; + yield break; + } + + foreach (string extension in GetWindowsExecutableExtensions()) + { + yield return command + extension; + } + } + + private static IReadOnlyList GetWindowsExecutableExtensions() + { + List extensions = ( + Environment.GetEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD" + ) + .Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(extension => extension.Trim()) + .Where(extension => !string.IsNullOrWhiteSpace(extension)) + .Select(extension => extension.StartsWith('.') ? extension : "." + extension) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return extensions.Count > 0 ? extensions : [".COM", ".EXE", ".BAT", ".CMD"]; + } + + private static void TryAddExecutablePath( + string candidatePath, + List matches, + HashSet seen + ) + { + if (!File.Exists(candidatePath) || !IsExecutablePath(candidatePath)) + { + return; + } + + string fullPath = Path.GetFullPath(candidatePath); + if (seen.Add(fullPath)) + { + matches.Add(fullPath); + } + } + + private static bool IsExecutablePath(string candidatePath) + { + if (OperatingSystem.IsWindows()) + { + return true; + } + + try + { + const UnixFileMode ExecutableBits = + UnixFileMode.UserExecute + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute; + + return (File.GetUnixFileMode(candidatePath) & ExecutableBits) is not 0; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + } + + private static bool HasDirectoryComponent(string command) => + Path.IsPathRooted(command) + || command.Contains(Path.DirectorySeparatorChar) + || command.Contains(Path.AltDirectorySeparatorChar); private static string GetSearchPath() {