Skip to content
28 changes: 28 additions & 0 deletions apps/desktop/src/app/DesktopCliCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,34 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
}).pipe(Effect.scoped),
);

it.effect("skips a t3 directory or a file this user cannot execute", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const folder = path.join(home, "folder");
const plain = path.join(home, "plain");
const shadow = path.join(home, "shadow");
yield* fs.makeDirectory(path.join(folder, "t3"), { recursive: true });
yield* fs.makeDirectory(plain);
yield* fs.writeFileString(path.join(plain, "t3"), "notes\n");
// Only others may execute it, so its owner's shell skips it.
yield* fs.chmod(path.join(plain, "t3"), 0o601);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
yield* fs.makeDirectory(shadow);
yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 });
process.env.PATH = [folder, plain, shadow, path.join(home, ".local", "bin")].join(":");

const command = yield* commandIn({ home });
expect((yield* command.state).shadowedBy).toBe(path.join(shadow, "t3"));

yield* fs.remove(shadow, { recursive: true });
expect(yield* command.install).toMatchObject({
onPath: true,
installedPath: path.join(home, ".local", "bin", "t3"),
});
}).pipe(Effect.scoped),
);

it.effect("leaves the Windows PATH alone when it cannot be read", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/app/DesktopCliCommand.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// @effect-diagnostics nodeBuiltinImport:off -- FileSystem.access has no execute check, and the shell's PATH lookup needs access(X_OK).
import * as NodeFS from "node:fs";
import * as NodeFSP from "node:fs/promises";

import type { DesktopCliCommandState } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -122,10 +126,19 @@ export const make = Effect.gen(function* () {
Effect.mapError(() => fail("Could not update your PATH.")),
);

// The shell runs only a regular file this user may execute. stat and access
// both follow symlinks, so a link counts by what it points to.
const isExecutableFile = (target: string) =>
Effect.gen(function* () {
if ((yield* fs.stat(target)).type !== "File") return false;
yield* Effect.tryPromise(() => NodeFSP.access(target, NodeFS.constants.X_OK));
return true;
}).pipe(Effect.orElseSucceed(() => false));

const firstOnPath = Effect.gen(function* () {
for (const directory of pathEntries(process.env.PATH, ":")) {
const candidate = path.join(directory, "t3");
if (yield* exists(candidate)) return Option.some(candidate);
if (yield* isExecutableFile(candidate)) return Option.some(candidate);
}
return Option.none<string>();
});
Expand Down
Loading