Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down
44 changes: 41 additions & 3 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -33,6 +39,38 @@ function canExecuteFile(filePath: string): boolean {
}
}

function getCurrentUid(): number | undefined {
return typeof process.getuid === "function" ? process.getuid() : undefined;
}

function getCurrentGids(): ReadonlyArray<number> {
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;
}
Comment on lines +53 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/shell.ts:53

canExecuteFileInfo at line 64 treats a file as executable whenever any execute bit is set, even for unrelated users. A file with only the owner execute bit set (e.g., mode 0o100) incorrectly returns true for non-owners, causing isExecutableFile to report commands as available that the process cannot actually execute. This diverges from fs.accessSync(..., X_OK) behavior and leads to failed command resolution.

-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;
-}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/shell.ts around lines 53-65:

`canExecuteFileInfo` at line 64 treats a file as executable whenever *any* execute bit is set, even for unrelated users. A file with only the owner execute bit set (e.g., mode `0o100`) incorrectly returns `true` for non-owners, causing `isExecutableFile` to report commands as available that the process cannot actually execute. This diverges from `fs.accessSync(..., X_OK)` behavior and leads to failed command resolution.


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;
Expand Down Expand Up @@ -498,16 +536,16 @@ const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* (
): Effect.fn.Return<boolean, never, FileSystem.FileSystem | Path.Path> {
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);
if (extension.length === 0) return false;
return windowsPathExtensions.includes(extension.toUpperCase());
}

return canExecuteFile(filePath);
return canExecuteFileInfo(stat.value);
});

const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* (
Expand Down
Loading