authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 00:36:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 00:36:04-07:00
logbd2346eda2e2c380d1844869c7fd16c279d2190f
tree3de314eede98077a9c723ef138be6a19a8895946
parent6d5ea190a4a54b01dfe6500e032526839e3258d1
signature Signed by PGP key B5690EEEBB952194

fix(server): refuse editor paths with line breaks or quotes when the editor is a Windows command shim (#17749)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

3 files changed, 115 insertions(+), 0 deletions(-)

apps/server/src/process/externalLauncher.test.ts+89
...@@ -7,7 +7,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -7,7 +7,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
7import { assert, it } from "@effect/vitest";7import { assert, it } from "@effect/vitest";
8import * as ConfigProvider from "effect/ConfigProvider";8import * as ConfigProvider from "effect/ConfigProvider";
9import * as Deferred from "effect/Deferred";9import * as Deferred from "effect/Deferred";
10import * as Cause from "effect/Cause";
10import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
11import * as Fiber from "effect/Fiber";13import * as Fiber from "effect/Fiber";
12import * as FileSystem from "effect/FileSystem";14import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
...@@ -1381,6 +1383,93 @@ it.effect("keeps scanning after the caller is interrupted and shares that scan",...@@ -1381,6 +1383,93 @@ it.effect("keeps scanning after the caller is interrupted and shares that scan",
1381 );1383 );
1382});1384});
13831385
1386const launchWindowsShimEditor = (cwd: string) =>
1387 Effect.gen(function* () {
1388 const fileSystem = yield* FileSystem.FileSystem;
1389 const path = yield* Path.Path;
1390 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
1391 yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n");
1392 let spawned: ChildProcess.StandardCommand | undefined;
1393 const exit = yield* Effect.gen(function* () {
1394 const launcher = yield* ExternalLauncher.ExternalLauncher;
1395 return yield* launcher.launchEditor({ editor: "vscode", cwd }).pipe(Effect.exit);
1396 }).pipe(
1397 Effect.provide(
1398 layerTest({
1399 platform: "win32",
1400 env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
1401 resolveExecutable: (command) =>
1402 command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command,
1403 onSpawn: (command) => {
1404 spawned = command;
1405 },
1406 }),
1407 ),
1408 );
1409 return { exit, spawned };
1410 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
1411
1412it.effect.each([
1413 ["CRLF", "C:\\workspace\\file.ts\r\ncalc.exe"],
1414 ["LF", "C:\\workspace\\file.ts\ncalc.exe"],
1415 ["double quote", 'C:\\workspace\\file.ts" & calc.exe & "'],
1416] as const)("does not start a Windows command shim for a path with a $0", ([, cwd]) =>
1417 Effect.gen(function* () {
1418 const { exit, spawned } = yield* launchWindowsShimEditor(cwd);
1419 assert.equal(spawned, undefined);
1420 assert.isTrue(Exit.isFailure(exit));
1421 if (Exit.isFailure(exit)) {
1422 assert.instanceOf(
1423 Cause.squash(exit.cause),
1424 ExternalLauncher.ExternalLauncherUnsupportedTargetError,
1425 );
1426 }
1427 }),
1428);
1429
1430it.effect("starts a Windows command shim for unicode paths with cmd metacharacters", () =>
1431 Effect.gen(function* () {
1432 const { exit, spawned } = yield* launchWindowsShimEditor(
1433 "C:\\Users\\jö\\R&D (100%)\\naïve ^file!.ts:3:7",
1434 );
1435 assert.isTrue(Exit.isSuccess(exit));
1436 assert.ok(spawned);
1437 assert.equal(spawned.options.shell, true);
1438 assert.deepEqual(spawned.args, [
1439 '^"--goto^"',
1440 '^"C:\\Users\\jö\\R^&D^ ^(100^%^)\\naïve^ ^^file^!.ts:3:7^"',
1441 ]);
1442 }),
1443);
1444
1445it.effect.skipIf(windowsHost)("passes line breaks through to editors started without a shell", () =>
1446 Effect.gen(function* () {
1447 const fileSystem = yield* FileSystem.FileSystem;
1448 const path = yield* Path.Path;
1449 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
1450 const zed = path.join(binDir, "zed");
1451 yield* fileSystem.writeFileString(zed, "#!/bin/sh\n");
1452 yield* fileSystem.chmod(zed, 0o755);
1453 let spawned: ChildProcess.StandardCommand | undefined;
1454 yield* Effect.gen(function* () {
1455 const launcher = yield* ExternalLauncher.ExternalLauncher;
1456 yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace/odd\nname.ts" });
1457 }).pipe(
1458 Effect.provide(
1459 layerTest({
1460 platform: "linux",
1461 env: { PATH: binDir, HOME: binDir },
1462 onSpawn: (command) => {
1463 spawned = command;
1464 },
1465 }),
1466 ),
1467 );
1468 assert.ok(spawned);
1469 assert.deepEqual(spawned.args, ["/workspace/odd\nname.ts"]);
1470 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1471);
1472
1384it.effect("rejects unknown editors through the service API", () =>1473it.effect("rejects unknown editors through the service API", () =>
1385 Effect.gen(function* () {1474 Effect.gen(function* () {
1386 const launcher = yield* ExternalLauncher.ExternalLauncher;1475 const launcher = yield* ExternalLauncher.ExternalLauncher;
apps/server/src/process/externalLauncher.ts+13
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 ExternalLauncherEditorSpawnError,14 ExternalLauncherEditorSpawnError,
15 ExternalLauncherUnknownEditorError,15 ExternalLauncherUnknownEditorError,
16 ExternalLauncherUnsupportedEditorError,16 ExternalLauncherUnsupportedEditorError,
17 ExternalLauncherUnsupportedTargetError,
17 type EditorId,18 type EditorId,
18 type FileManagerRevealKind,19 type FileManagerRevealKind,
19 type LaunchEditorInput,20 type LaunchEditorInput,
...@@ -53,6 +54,7 @@ export {...@@ -53,6 +54,7 @@ export {
53 ExternalLauncherEditorSpawnError,54 ExternalLauncherEditorSpawnError,
54 ExternalLauncherUnknownEditorError,55 ExternalLauncherUnknownEditorError,
55 ExternalLauncherUnsupportedEditorError,56 ExternalLauncherUnsupportedEditorError,
57 ExternalLauncherUnsupportedTargetError,
56} from "@t3tools/contracts";58} from "@t3tools/contracts";
57export type { LaunchEditorInput };59export type { LaunchEditorInput };
58interface EditorLaunch {60interface EditorLaunch {
...@@ -74,6 +76,14 @@ interface TargetPathAndPosition {...@@ -74,6 +76,14 @@ interface TargetPathAndPosition {
74 readonly column: Option.Option<string>;76 readonly column: Option.Option<string>;
75}77}
7678
79/**
80 * Windows command shims (`code.cmd`) forward arguments through `%*`, which
81 * cmd.exe parses a second time after the first escaping layer is gone: a
82 * line break ends the command there, and a double quote closes the quoting
83 * that keeps `&` or `|` literal. Neither can occur in a Windows path.
84 */
85// oxlint-disable-next-line no-control-regex
86const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/;
77const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;87const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
78const POWERSHELL_ARGUMENTS_PREFIX = [88const POWERSHELL_ARGUMENTS_PREFIX = [
79 "-NoProfile",89 "-NoProfile",
...@@ -726,6 +736,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu...@@ -726,6 +736,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
726 }736 }
727737
728 const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env });738 const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env });
739 if (spawnCommand.shell && launch.args.some((arg) => WINDOWS_SHIM_UNSAFE_ARG_PATTERN.test(arg))) {
740 return yield* new ExternalLauncherUnsupportedTargetError({ editor: launch.editor });
741 }
729 yield* launchAndUnref(742 yield* launchAndUnref(
730 {743 {
731 command: spawnCommand.command,744 command: spawnCommand.command,
packages/contracts/src/editor.ts+13
...@@ -283,6 +283,18 @@ export class ExternalLauncherCommandNotFoundError extends Schema.TaggedError<Ext...@@ -283,6 +283,18 @@ export class ExternalLauncherCommandNotFoundError extends Schema.TaggedError<Ext
283 }283 }
284}284}
285285
286/** The target path cannot reach the editor intact, so it was not started. */
287export class ExternalLauncherUnsupportedTargetError extends Schema.TaggedError<ExternalLauncherUnsupportedTargetError>()(
288 "ExternalLauncherUnsupportedTargetError",
289 {
290 editor: EditorId,
291 },
292) {
293 override get message(): string {
294 return `${this.editor} cannot open a path containing line breaks or double quotes`;
295 }
296}
297
286const ExternalLauncherSpawnFields = {298const ExternalLauncherSpawnFields = {
287 command: Schema.String,299 command: Schema.String,
288 args: Schema.Array(Schema.String),300 args: Schema.Array(Schema.String),
...@@ -318,6 +330,7 @@ export const ExternalLauncherError = Schema.Union([...@@ -318,6 +330,7 @@ export const ExternalLauncherError = Schema.Union([
318 ExternalLauncherUnknownEditorError,330 ExternalLauncherUnknownEditorError,
319 ExternalLauncherUnsupportedEditorError,331 ExternalLauncherUnsupportedEditorError,
320 ExternalLauncherCommandNotFoundError,332 ExternalLauncherCommandNotFoundError,
333 ExternalLauncherUnsupportedTargetError,
321 ExternalLauncherBrowserSpawnError,334 ExternalLauncherBrowserSpawnError,
322 ExternalLauncherEditorSpawnError,335 ExternalLauncherEditorSpawnError,
323]);336]);