diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 6df4ea16db887f4735c23c3104c9d9b2ec340e98..d16391a3ae70033feee88c532a3db9a65407c08f 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -7,7 +7,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -1381,6 +1383,93 @@ it.effect("keeps scanning after the caller is interrupted and shares that scan", ); }); +const launchWindowsShimEditor = (cwd: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + let spawned: ChildProcess.StandardCommand | undefined; + const exit = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.launchEditor({ editor: "vscode", cwd }).pipe(Effect.exit); + }).pipe( + Effect.provide( + layerTest({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + return { exit, spawned }; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +it.effect.each([ + ["CRLF", "C:\\workspace\\file.ts\r\ncalc.exe"], + ["LF", "C:\\workspace\\file.ts\ncalc.exe"], + ["double quote", 'C:\\workspace\\file.ts" & calc.exe & "'], +] as const)("does not start a Windows command shim for a path with a $0", ([, cwd]) => + Effect.gen(function* () { + const { exit, spawned } = yield* launchWindowsShimEditor(cwd); + assert.equal(spawned, undefined); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.instanceOf( + Cause.squash(exit.cause), + ExternalLauncher.ExternalLauncherUnsupportedTargetError, + ); + } + }), +); + +it.effect("starts a Windows command shim for unicode paths with cmd metacharacters", () => + Effect.gen(function* () { + const { exit, spawned } = yield* launchWindowsShimEditor( + "C:\\Users\\jö\\R&D (100%)\\naïve ^file!.ts:3:7", + ); + assert.isTrue(Exit.isSuccess(exit)); + assert.ok(spawned); + assert.equal(spawned.options.shell, true); + assert.deepEqual(spawned.args, [ + '^"--goto^"', + '^"C:\\Users\\jö\\R^&D^ ^(100^%^)\\naïve^ ^^file^!.ts:3:7^"', + ]); + }), +); + +it.effect.skipIf(windowsHost)("passes line breaks through to editors started without a shell", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const zed = path.join(binDir, "zed"); + yield* fileSystem.writeFileString(zed, "#!/bin/sh\n"); + yield* fileSystem.chmod(zed, 0o755); + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace/odd\nname.ts" }); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { PATH: binDir, HOME: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.ok(spawned); + assert.deepEqual(spawned.args, ["/workspace/odd\nname.ts"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 74e95b9ddcc47de09f9cf951405a1c63331db0b1..8ab130b01e38ed8292ce70b939ff1cb6bdf4cedd 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -14,6 +14,7 @@ import { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, + ExternalLauncherUnsupportedTargetError, type EditorId, type FileManagerRevealKind, type LaunchEditorInput, @@ -53,6 +54,7 @@ export { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, + ExternalLauncherUnsupportedTargetError, } from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { @@ -74,6 +76,14 @@ interface TargetPathAndPosition { readonly column: Option.Option; } +/** + * Windows command shims (`code.cmd`) forward arguments through `%*`, which + * cmd.exe parses a second time after the first escaping layer is gone: a + * line break ends the command there, and a double quote closes the quoting + * that keeps `&` or `|` literal. Neither can occur in a Windows path. + */ +// oxlint-disable-next-line no-control-regex +const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/; const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ "-NoProfile", @@ -726,6 +736,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu } const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env }); + if (spawnCommand.shell && launch.args.some((arg) => WINDOWS_SHIM_UNSAFE_ARG_PATTERN.test(arg))) { + return yield* new ExternalLauncherUnsupportedTargetError({ editor: launch.editor }); + } yield* launchAndUnref( { command: spawnCommand.command, diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 75dca95786bfa28f9f42e5f8197658fd92cf67e1..7fa324a425ad231e4c544e04b200f448a45031d7 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -283,6 +283,18 @@ export class ExternalLauncherCommandNotFoundError extends Schema.TaggedError()( + "ExternalLauncherUnsupportedTargetError", + { + editor: EditorId, + }, +) { + override get message(): string { + return `${this.editor} cannot open a path containing line breaks or double quotes`; + } +} + const ExternalLauncherSpawnFields = { command: Schema.String, args: Schema.Array(Schema.String), @@ -318,6 +330,7 @@ export const ExternalLauncherError = Schema.Union([ ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, ExternalLauncherCommandNotFoundError, + ExternalLauncherUnsupportedTargetError, ExternalLauncherBrowserSpawnError, ExternalLauncherEditorSpawnError, ]);