diff --git a/NoSleep.Test/PlatformIntegrationTests.cs b/NoSleep.Test/PlatformIntegrationTests.cs index a761013..b841b84 100644 --- a/NoSleep.Test/PlatformIntegrationTests.cs +++ b/NoSleep.Test/PlatformIntegrationTests.cs @@ -3,6 +3,7 @@ namespace ktsu.NoSleep.Test; using System; +using System.IO; using System.Linq; using System.Reflection; using ktsu.NoSleep.Contracts; @@ -74,6 +75,85 @@ public void Find_LocatesACommandThatIsOnThePath() public void Find_ReturnsNullForACommandThatIsNotOnThePath() => Assert.IsNull(ExecutableLocator.Find("definitely-not-a-real-command-9f3a1")); + [TestMethod] + public void Find_SkipsANonExecutableMatchEarlierOnThePath() + { + if (OperatingSystem.IsWindows()) + { + Assert.Inconclusive("Windows has no execute bit, so every match there counts as executable."); + return; + } + + const string command = "nosleep-test-helper"; + string root = Directory.CreateTempSubdirectory("nosleep-path-").FullName; + + try + { + string shadow = Directory.CreateDirectory(Path.Join(root, "shadow")).FullName; + string real = Directory.CreateDirectory(Path.Join(root, "real")).FullName; + + string stray = Path.Join(shadow, command); + File.WriteAllText(stray, "#!/bin/sh\n"); + File.SetUnixFileMode(stray, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); + + // A directory named like the command is not a match either. + string directoryNamedLikeTheCommand = Path.Join(root, "directory"); + Directory.CreateDirectory(Path.Join(directoryNamedLikeTheCommand, command)); + + string helper = Path.Join(real, command); + File.WriteAllText(helper, "#!/bin/sh\n"); + File.SetUnixFileMode(helper, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + string path = string.Join(Path.PathSeparator, shadow, directoryNamedLikeTheCommand, real); + + Assert.AreEqual(helper, ExecutableLocator.Find(command, path)); + Assert.IsNull(ExecutableLocator.Find(command, string.Join(Path.PathSeparator, shadow, directoryNamedLikeTheCommand))); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [TestMethod] + public void Find_ReturnsAnExecutableMatchOnTheGivenPath() + { + string command = OperatingSystem.IsWindows() ? "nosleep-test-helper.exe" : "nosleep-test-helper"; + string root = Directory.CreateTempSubdirectory("nosleep-path-").FullName; + + try + { + string helper = Path.Join(root, command); + File.WriteAllText(helper, "#!/bin/sh\n"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(helper, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + Assert.AreEqual(helper, ExecutableLocator.Find(command, root)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [TestMethod] + [DataRow(UnixFileMode.UserExecute, true)] + [DataRow(UnixFileMode.GroupExecute, true)] + [DataRow(UnixFileMode.OtherExecute, true)] + [DataRow(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead, false)] + [DataRow(UnixFileMode.None, false)] + public void HasExecuteBit_IsTrueOnlyWhenSomeoneMayExecute(UnixFileMode mode, bool expected) => + Assert.AreEqual(expected, ExecutableLocator.HasExecuteBit("helper", _ => mode)); + + [TestMethod] + public void HasExecuteBit_IsFalseWhenTheModeCannotBeRead() + { + Assert.IsFalse(ExecutableLocator.HasExecuteBit("helper", _ => throw new FileNotFoundException("gone"))); + Assert.IsFalse(ExecutableLocator.HasExecuteBit("helper", _ => throw new UnauthorizedAccessException("denied"))); + } + [TestMethod] [DataRow("")] [DataRow(" ")] diff --git a/NoSleep/Platforms/ExecutableLocator.cs b/NoSleep/Platforms/ExecutableLocator.cs index 0696d97..15c25e8 100644 --- a/NoSleep/Platforms/ExecutableLocator.cs +++ b/NoSleep/Platforms/ExecutableLocator.cs @@ -20,11 +20,23 @@ public static class ExecutableLocator /// The command name to look for, without a directory. /// The full path to the first match, or when nothing on PATH matches. /// is or whitespace. - public static string? Find(string fileName) + /// + /// A file that cannot be executed is skipped, as the shell, command -v and execvp skip it, so a + /// stray non-executable copy earlier on PATH does not hide a working helper later on it. + /// + public static string? Find(string fileName) => Find(fileName, Environment.GetEnvironmentVariable("PATH")); + + /// + /// Finds an executable on the given search path. + /// + /// The command name to look for, without a directory. + /// The search path, in the same form as the PATH environment variable. + /// The full path to the first match, or when nothing on matches. + /// is or whitespace. + internal static string? Find(string fileName, string? path) { ArgumentException.ThrowIfNullOrWhiteSpace(fileName); - string? path = Environment.GetEnvironmentVariable("PATH"); if (string.IsNullOrEmpty(path)) { return null; @@ -50,7 +62,7 @@ public static class ExecutableLocator continue; } - if (File.Exists(candidate)) + if (IsExecutableFile(candidate)) { return candidate; } @@ -59,6 +71,55 @@ public static class ExecutableLocator return null; } + /// + /// Reports whether a path names a regular file that this process could execute. + /// + /// + /// is false for a directory already. Off Windows, a file with no execute bit + /// for anyone is skipped; Windows has no such bit, so any file counts there. + /// + private static bool IsExecutableFile(string candidate) + { + if (!File.Exists(candidate)) + { + return false; + } + + if (OperatingSystem.IsWindows()) + { + return true; + } + + return HasExecuteBit(candidate, File.GetUnixFileMode); + } + + /// + /// Reports whether the file's mode, as reads it, grants execute to anyone. + /// + /// The file to ask about. + /// Reads the file's Unix mode; in production. + /// when any execute bit is set; when none is, or the mode cannot be read. + /// + /// The mode reader is a parameter so the decision, including what a file that vanishes or cannot be read + /// means, is covered on every platform rather than only where the bit exists. + /// + internal static bool HasExecuteBit(string candidate, Func readMode) + { + try + { + const UnixFileMode anyExecute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + return (readMode(candidate) & anyExecute) != 0; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + /// /// Reports whether an executable is available on PATH. ///