diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs index 3a202a5..227da0f 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -934,6 +934,56 @@ 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 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 new file mode 100644 index 0000000..36b8138 --- /dev/null +++ b/RunCommand/NativeMethods.cs @@ -0,0 +1,52 @@ +// 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) => 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; + } + 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; }