From 553af0fb30de328b1d33e61e974845d0491abb9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:28:09 +0000 Subject: [PATCH 1/2] Ask access(X_OK) whether an overlay PATH match may run, on every target [patch] #137 skipped a file on the overlay PATH that had no execute bit, but the check used File.GetUnixFileMode and sat under NET7_0_OR_GREATER. The net5.0, net6.0 and netstandard builds accepted any file that existed, so on Linux and macOS a non-executable file earlier on the overlay PATH won and the start failed with "Permission denied" instead of running the executable copy later on. The tests only target net10.0, so they never saw it. TryExecutable now calls libc access(path, X_OK) through a new NativeMethods class on every target outside Windows, so all builds share one check and the net10.0 tests exercise the code the older builds run. access answers for the caller, as a shell's PATH search does, so it also passes over a file only its group or others may execute, which "any execute bit" accepted. If libc cannot be called the file is kept, as before the check existed. New tests: a file whose owner (the caller) lacks the execute bit while group and others have it is skipped in favour of the executable copy (inconclusive as root, which may run it), and CanExecute reports a 755 file as runnable and a 644 file as not. Fixes #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5DyciGEV4wZvik3pQ --- RunCommand.Test/RunCommandTests.cs | 42 ++++++++++++++++++++++++++++ RunCommand/NativeMethods.cs | 44 ++++++++++++++++++++++++++++++ RunCommand/RunCommand.cs | 9 ++---- 3 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 RunCommand/NativeMethods.cs diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs index 3a202a5..7fab2ee 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -934,6 +934,48 @@ public async Task ExecuteAsyncShouldSkipANonExecutableFileOnTheOverlayPath() Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the search to pass over a file without an execute bit, as a shell does."); } + [TestMethod] + public async Task ExecuteAsyncShouldSkipAFileOnTheOverlayPathThatOnlyOthersMayExecute() + { + SkipOnWindows(); + if (Environment.UserName == "root") + { + Assert.Inconclusive("root may execute any file that has an execute bit, so the owner's missing bit is not visible."); + } + + // Group and others may execute this file but its owner, the caller, may not, so a shell passes over it. + // Testing for any execute bit accepted it and the start failed with "Permission denied" (#139). + string root = CreateDirectoryForTest(); + string othersOnly = Directory.CreateDirectory(Path.Join(root, "a")).FullName; + string executable = Directory.CreateDirectory(Path.Join(root, "b")).FullName; + string name = $"runcommand-{nameof(ExecuteAsyncShouldSkipAFileOnTheOverlayPathThatOnlyOthersMayExecute)}"; + WriteScript(othersOnly, name, "echo ran the file the caller cannot run", executable: false); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + Path.Join(othersOnly, name), + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + + WriteScript(executable, name, "echo ran overlay copy", executable: true); + + List output = await RunOnOverlayPathAsync(name, $"{othersOnly}{Path.PathSeparator}{executable}").ConfigureAwait(false); + + Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the search to ask whether the caller may execute the file, as a shell does."); + } + + [TestMethod] + public void CanExecuteShouldReportWhetherTheCallerMayExecuteTheFile() + { + SkipOnWindows(); + string directory = CreateDirectoryForTest(); + WriteScript(directory, "runs", "exit 0", executable: true); + WriteScript(directory, "does-not-run", "exit 0", executable: false); + + Assert.IsTrue(NativeMethods.CanExecute(Path.Join(directory, "runs"))); + Assert.IsFalse(NativeMethods.CanExecute(Path.Join(directory, "does-not-run"))); + } + [TestMethod] public async Task ExecuteAsyncShouldFindTheToolOnTheCallersPathWhenTheOverlayRemovesPath() { diff --git a/RunCommand/NativeMethods.cs b/RunCommand/NativeMethods.cs new file mode 100644 index 0000000..8ac8f8d --- /dev/null +++ b/RunCommand/NativeMethods.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.RunCommand; + +using System; +using System.Runtime.InteropServices; + +/// +/// Calls into the C library on Linux and macOS. +/// +internal static class NativeMethods +{ + /// The X_OK mode of access: test for permission to execute. + private const int ExecuteOk = 1; + + /// + /// Returns whether the calling process may execute the file at . + /// + /// The full path of an existing file. + /// + /// when access(path, X_OK) succeeds, or when the C library cannot be called, which + /// keeps the file as a candidate the way the search did before it checked permissions. + /// + /// + /// access answers for the caller, as a shell's PATH search does. Testing for any execute bit instead + /// would accept a 0700 file another user owns, and File.GetUnixFileMode does not exist below .NET 7, + /// so the net5.0, net6.0 and netstandard builds had no check at all (ktsu-dev/RunCommand#139). + /// + internal static bool CanExecute(string path) + { + try + { + return Access(path, ExecuteOk) == 0; + } + catch (Exception exception) when (exception is DllNotFoundException or EntryPointNotFoundException) + { + return true; + } + } + + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + [DllImport("libc", EntryPoint = "access", CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)] + private static extern int Access(string path, int mode); +} diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs index 2e538d5..9ba0912 100644 --- a/RunCommand/RunCommand.cs +++ b/RunCommand/RunCommand.cs @@ -504,8 +504,8 @@ private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler o /// A directory from the search path. /// The file name to look for. /// - /// The full path, or if there is no such file. Outside Windows the file also needs an - /// execute permission bit, where the platform exposes one. + /// The full path, or if there is no such file. Outside Windows the calling process also + /// needs permission to execute the file, as access(X_OK) reports it, on every target framework. /// private static string? TryExecutable(string directory, string fileName) { @@ -524,13 +524,10 @@ private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler o return null; } -#if NET7_0_OR_GREATER - const UnixFileMode anyExecute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; - if (!OperatingSystem.IsWindows() && (File.GetUnixFileMode(candidate) & anyExecute) == 0) + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !NativeMethods.CanExecute(candidate)) { return null; } -#endif return candidate; } From 1ec4a2b4ad932fcaea1795d81baa86e4f332f48c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:40:47 +0000 Subject: [PATCH 2/2] Test that the overlay PATH search keeps a file when libc can't be called CanExecute's DllNotFoundException / EntryPointNotFoundException fallback had no test, so SonarCloud's new-code coverage was 60% (gate is 80%). The access call is now a parameter of an internal overload, as TryKill's kill is, and a test makes it throw both exceptions and checks the file is kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5DyciGEV4wZvik3pQ --- RunCommand.Test/RunCommandTests.cs | 8 ++++++++ RunCommand/NativeMethods.cs | 12 ++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs index 7fab2ee..227da0f 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -976,6 +976,14 @@ public void CanExecuteShouldReportWhetherTheCallerMayExecuteTheFile() Assert.IsFalse(NativeMethods.CanExecute(Path.Join(directory, "does-not-run"))); } + [TestMethod] + public void CanExecuteShouldKeepTheFileWhenTheCLibraryCannotBeCalled() + { + // Without access(), the search keeps every existing file, as it did before it checked permissions. + Assert.IsTrue(NativeMethods.CanExecute("/any/file", (_, _) => throw new DllNotFoundException("libc"))); + Assert.IsTrue(NativeMethods.CanExecute("/any/file", (_, _) => throw new EntryPointNotFoundException("access"))); + } + [TestMethod] public async Task ExecuteAsyncShouldFindTheToolOnTheCallersPathWhenTheOverlayRemovesPath() { diff --git a/RunCommand/NativeMethods.cs b/RunCommand/NativeMethods.cs index 8ac8f8d..36b8138 100644 --- a/RunCommand/NativeMethods.cs +++ b/RunCommand/NativeMethods.cs @@ -26,11 +26,19 @@ internal static class NativeMethods /// would accept a 0700 file another user owns, and File.GetUnixFileMode does not exist below .NET 7, /// so the net5.0, net6.0 and netstandard builds had no check at all (ktsu-dev/RunCommand#139). /// - internal static bool CanExecute(string path) + internal static bool CanExecute(string path) => CanExecute(path, Access); + + /// + /// Returns whether reports that the calling process may execute . + /// + /// The full path of an existing file. + /// Performs access(path, mode). A parameter so tests can make the C library unavailable. + /// As . + internal static bool CanExecute(string path, Func access) { try { - return Access(path, ExecuteOk) == 0; + return access(path, ExecuteOk) == 0; } catch (Exception exception) when (exception is DllNotFoundException or EntryPointNotFoundException) {