From b79f1de72fa80d3e4fc7d01719fd4bb1e0ec9e1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:26:17 +0000 Subject: [PATCH 1/3] Skip a non-executable PATH match when looking up a helper [patch] ExecutableLocator.Find returned the first file on PATH with the right name, so a stray non-executable systemd-inhibit earlier on PATH hid the real one: --status reported support and every Acquire failed with Permission denied. Skip a match with no execute bit (off Windows), as the shell and execvp do. Fixes ktsu-dev/NoSleep#34 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW2EzJMFdK7ZQBB2keC --- NoSleep.Test/PlatformIntegrationTests.cs | 41 +++++++++++++++++++ NoSleep/Platforms/ExecutableLocator.cs | 52 ++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/NoSleep.Test/PlatformIntegrationTests.cs b/NoSleep.Test/PlatformIntegrationTests.cs index a761013..c11a6d1 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,46 @@ 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.Combine(root, "shadow")).FullName; + string real = Directory.CreateDirectory(Path.Combine(root, "real")).FullName; + + string stray = Path.Combine(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.Combine(root, "directory"); + Directory.CreateDirectory(Path.Combine(directoryNamedLikeTheCommand, command)); + + string helper = Path.Combine(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] [DataRow("")] [DataRow(" ")] diff --git a/NoSleep/Platforms/ExecutableLocator.cs b/NoSleep/Platforms/ExecutableLocator.cs index 0696d97..c0dad4e 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,40 @@ 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; + } + + try + { + const UnixFileMode anyExecute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + return (File.GetUnixFileMode(candidate) & anyExecute) != 0; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + /// /// Reports whether an executable is available on PATH. /// From e4590f143351bc88548d87deaa5221755637f100 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:32:13 +0000 Subject: [PATCH 2/3] Build the test's temporary paths with Path.Join Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW2EzJMFdK7ZQBB2keC --- NoSleep.Test/PlatformIntegrationTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/NoSleep.Test/PlatformIntegrationTests.cs b/NoSleep.Test/PlatformIntegrationTests.cs index c11a6d1..063050b 100644 --- a/NoSleep.Test/PlatformIntegrationTests.cs +++ b/NoSleep.Test/PlatformIntegrationTests.cs @@ -89,18 +89,18 @@ public void Find_SkipsANonExecutableMatchEarlierOnThePath() try { - string shadow = Directory.CreateDirectory(Path.Combine(root, "shadow")).FullName; - string real = Directory.CreateDirectory(Path.Combine(root, "real")).FullName; + string shadow = Directory.CreateDirectory(Path.Join(root, "shadow")).FullName; + string real = Directory.CreateDirectory(Path.Join(root, "real")).FullName; - string stray = Path.Combine(shadow, command); + 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.Combine(root, "directory"); - Directory.CreateDirectory(Path.Combine(directoryNamedLikeTheCommand, command)); + string directoryNamedLikeTheCommand = Path.Join(root, "directory"); + Directory.CreateDirectory(Path.Join(directoryNamedLikeTheCommand, command)); - string helper = Path.Combine(real, command); + string helper = Path.Join(real, command); File.WriteAllText(helper, "#!/bin/sh\n"); File.SetUnixFileMode(helper, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); From d348347605d56a4fa1db54381d7b21c1937f501b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:20:34 +0000 Subject: [PATCH 3/3] Cover the execute-bit check on every platform SonarCloud measures coverage from the Windows run, where the Unix-only branch never executes, so new-code coverage sat at 66.7%. The decision now takes its mode reader as a parameter and is tested directly, including the unreadable-mode cases, and a cross-platform test drives Find over a given path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5jpiuVuBRXSgitSY1 --- NoSleep.Test/PlatformIntegrationTests.cs | 39 ++++++++++++++++++++++++ NoSleep/Platforms/ExecutableLocator.cs | 17 ++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/NoSleep.Test/PlatformIntegrationTests.cs b/NoSleep.Test/PlatformIntegrationTests.cs index 063050b..b841b84 100644 --- a/NoSleep.Test/PlatformIntegrationTests.cs +++ b/NoSleep.Test/PlatformIntegrationTests.cs @@ -115,6 +115,45 @@ public void Find_SkipsANonExecutableMatchEarlierOnThePath() } } + [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 c0dad4e..15c25e8 100644 --- a/NoSleep/Platforms/ExecutableLocator.cs +++ b/NoSleep/Platforms/ExecutableLocator.cs @@ -90,10 +90,25 @@ private static bool IsExecutableFile(string candidate) 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 (File.GetUnixFileMode(candidate) & anyExecute) != 0; + return (readMode(candidate) & anyExecute) != 0; } catch (IOException) {