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.
///