diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index e8b2c41cb77..554c181b15a 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,7 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it as effectIt } from "@effect/vitest"; +import { assert, it as effectIt } from "@effect/vitest"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { describe, expect, it, vi } from "vite-plus/test"; import { @@ -364,6 +366,33 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { expect(result._tag).toBe("Failure"); }), ); + + it.effect("resolves POSIX commands only when the candidate is executable", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-shell-executable-", + }); + const executablePath = path.join(directory, "tool"); + const nonExecutablePath = path.join(directory, "not-a-tool"); + + yield* fileSystem.writeFileString(executablePath, "#!/bin/sh\n"); + yield* fileSystem.writeFileString(nonExecutablePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(executablePath, 0o700); + yield* fileSystem.chmod(nonExecutablePath, 0o600); + + const resolved = yield* resolveCommandPath("tool", { + env: { PATH: directory }, + }).pipe(Effect.provideService(HostProcessPlatform, "linux")); + const nonExecutableResult = yield* resolveCommandPath("not-a-tool", { + env: { PATH: directory }, + }).pipe(Effect.provideService(HostProcessPlatform, "linux"), Effect.result); + + assert.strictEqual(resolved, executablePath); + assert.strictEqual(nonExecutableResult._tag, "Failure"); + }).pipe(Effect.scoped), + ); }); effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index cf2f2417ff4..79e2412a3cb 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -6,6 +6,7 @@ import * as NodeFS from "node:fs"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; @@ -17,6 +18,11 @@ const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; const WINDOWS_PATH_DELIMITER = ";"; const POSIX_PATH_DELIMITER = ":"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; +const POSIX_USER_EXECUTE_MODE = 0o100; +const POSIX_GROUP_EXECUTE_MODE = 0o010; +const POSIX_OTHER_EXECUTE_MODE = 0o001; +const POSIX_ANY_EXECUTE_MODE = + POSIX_USER_EXECUTE_MODE | POSIX_GROUP_EXECUTE_MODE | POSIX_OTHER_EXECUTE_MODE; type ExecFileSyncLike = ( file: string, @@ -33,6 +39,38 @@ function canExecuteFile(filePath: string): boolean { } } +function getCurrentUid(): number | undefined { + return typeof process.getuid === "function" ? process.getuid() : undefined; +} + +function getCurrentGids(): ReadonlyArray { + if (typeof process.getgid !== "function") return []; + const primaryGid = process.getgid(); + const supplementaryGids = typeof process.getgroups === "function" ? process.getgroups() : []; + return Array.from(new Set([primaryGid, ...supplementaryGids])); +} + +function canExecuteFileInfo(info: FileSystem.File.Info): boolean { + if ((info.mode & POSIX_ANY_EXECUTE_MODE) === 0) return false; + + const uid = getCurrentUid(); + if (uid === 0) return true; + if (uid !== undefined && Option.isSome(info.uid)) { + return info.uid.value === uid + ? (info.mode & POSIX_USER_EXECUTE_MODE) !== 0 + : canExecuteFileInfoForGroups(info); + } + + return canExecuteFileInfoForGroups(info) || (info.mode & POSIX_ANY_EXECUTE_MODE) !== 0; +} + +function canExecuteFileInfoForGroups(info: FileSystem.File.Info): boolean { + if (Option.isSome(info.gid) && getCurrentGids().includes(info.gid.value)) { + return (info.mode & POSIX_GROUP_EXECUTE_MODE) !== 0; + } + return (info.mode & POSIX_OTHER_EXECUTE_MODE) !== 0; +} + export interface CommandAvailabilityOptions { readonly env?: NodeJS.ProcessEnv; readonly extendEnv?: boolean; @@ -498,8 +536,8 @@ const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( ): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); - if (stat === null || stat.type !== "File") return false; + const stat = yield* fileSystem.stat(filePath).pipe(Effect.option); + if (Option.isNone(stat) || stat.value.type !== "File") return false; if (platform === "win32") { const extension = path.extname(filePath); @@ -507,7 +545,7 @@ const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( return windowsPathExtensions.includes(extension.toUpperCase()); } - return canExecuteFile(filePath); + return canExecuteFileInfo(stat.value); }); const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* (