diff --git a/src/daemon/cmd-argv.test.ts b/src/daemon/cmd-argv.test.ts new file mode 100644 index 0000000000..0b45efed9d --- /dev/null +++ b/src/daemon/cmd-argv.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js"; + +describe("cmd argv helpers", () => { + it.each([ + "plain", + "with space", + "safe&whoami", + "safe|whoami", + "safeout", + "safe^caret", + "%TEMP%", + "!token!", + 'he said "hi"', + ])("round-trips single arg: %p", (arg) => { + const encoded = quoteCmdScriptArg(arg); + expect(parseCmdScriptCommandLine(encoded)).toEqual([arg]); + }); + + it("round-trips mixed command lines", () => { + const args = [ + "node", + "gateway.js", + "--display-name", + "safe&whoami", + "--percent", + "%TEMP%", + "--bang", + "!token!", + "--quoted", + 'he said "hi"', + ]; + const encoded = args.map(quoteCmdScriptArg).join(" "); + expect(parseCmdScriptCommandLine(encoded)).toEqual(args); + }); + + it("rejects CR/LF in command arguments", () => { + expect(() => quoteCmdScriptArg("bad\narg")).toThrow(/Command argument cannot contain CR or LF/); + expect(() => quoteCmdScriptArg("bad\rarg")).toThrow(/Command argument cannot contain CR or LF/); + }); +}); diff --git a/src/daemon/cmd-argv.ts b/src/daemon/cmd-argv.ts new file mode 100644 index 0000000000..01f4b2a775 --- /dev/null +++ b/src/daemon/cmd-argv.ts @@ -0,0 +1,26 @@ +import { splitArgsPreservingQuotes } from "./arg-split.js"; +import { assertNoCmdLineBreak } from "./cmd-set.js"; + +export function quoteCmdScriptArg(value: string): string { + assertNoCmdLineBreak(value, "Command argument"); + if (!value) { + return '""'; + } + const escaped = value.replace(/"/g, '\\"').replace(/%/g, "%%").replace(/!/g, "^!"); + if (!/[ \t"&|<>^()%!]/g.test(value)) { + return escaped; + } + return `"${escaped}"`; +} + +export function unescapeCmdScriptArg(value: string): string { + return value.replace(/\^!/g, "!").replace(/%%/g, "%"); +} + +export function parseCmdScriptCommandLine(value: string): string[] { + // Script renderer escapes quotes (`\"`) and cmd expansions (`%%`, `^!`). + // Keep all other backslashes literal so Windows drive/UNC paths survive. + return splitArgsPreservingQuotes(value, { escapeMode: "backslash-quote-only" }).map( + unescapeCmdScriptArg, + ); +} diff --git a/src/daemon/cmd-set.ts b/src/daemon/cmd-set.ts index ae692ee583..7d2c2a915e 100644 --- a/src/daemon/cmd-set.ts +++ b/src/daemon/cmd-set.ts @@ -1,5 +1,11 @@ export type CmdSetAssignment = { key: string; value: string }; +export function assertNoCmdLineBreak(value: string, field: string): void { + if (/[\r\n]/.test(value)) { + throw new Error(`${field} cannot contain CR or LF in Windows task scripts.`); + } +} + function escapeCmdSetAssignmentComponent(value: string): string { return value.replace(/\^/g, "^^").replace(/%/g, "%%").replace(/!/g, "^!").replace(/"/g, '^"'); } @@ -50,6 +56,8 @@ export function parseCmdSetAssignment(line: string): CmdSetAssignment | null { } export function renderCmdSetAssignment(key: string, value: string): string { + assertNoCmdLineBreak(key, "Environment variable name"); + assertNoCmdLineBreak(value, "Environment variable value"); const escapedKey = escapeCmdSetAssignmentComponent(key); const escapedValue = escapeCmdSetAssignmentComponent(value); return `set "${escapedKey}=${escapedValue}"`; diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index ab64c8c22a..c00d988646 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { splitArgsPreservingQuotes } from "./arg-split.js"; -import { parseCmdSetAssignment, renderCmdSetAssignment } from "./cmd-set.js"; +import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js"; +import { assertNoCmdLineBreak, parseCmdSetAssignment, renderCmdSetAssignment } from "./cmd-set.js"; import { resolveGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; import { formatLine, writeFormattedLines } from "./output.js"; import { resolveGatewayStateDir } from "./paths.js"; @@ -36,12 +36,8 @@ export function resolveTaskScriptPath(env: GatewayServiceEnv): string { return path.join(stateDir, scriptName); } -function assertNoCmdLineBreak(value: string, field: string): void { - if (/[\r\n]/.test(value)) { - throw new Error(`${field} cannot contain CR or LF in Windows task scripts.`); - } -} - +// `/TR` is parsed by schtasks itself, while the generated `gateway.cmd` line is parsed by cmd.exe. +// Keep their quoting strategies separate so each parser gets the encoding it expects. function quoteSchtasksArg(value: string): string { if (!/[ \t"]/g.test(value)) { return value; @@ -49,22 +45,6 @@ function quoteSchtasksArg(value: string): string { return `"${value.replace(/"/g, '\\"')}"`; } -function quoteCmdScriptArg(value: string): string { - assertNoCmdLineBreak(value, "Command argument"); - if (!value) { - return '""'; - } - const escaped = value.replace(/"/g, '\\"').replace(/%/g, "%%").replace(/!/g, "^!"); - if (!/[ \t"&|<>^()%!]/g.test(value)) { - return escaped; - } - return `"${escaped}"`; -} - -function unescapeCmdScriptArg(value: string): string { - return value.replace(/\^!/g, "!").replace(/%%/g, "%"); -} - function resolveTaskUser(env: GatewayServiceEnv): string | null { const username = env.USERNAME || env.USER || env.LOGNAME; if (!username) { @@ -80,14 +60,6 @@ function resolveTaskUser(env: GatewayServiceEnv): string | null { return username; } -function parseCommandLine(value: string): string[] { - // `buildTaskScript` escapes quotes (`\"`) and cmd expansions (`%%`, `^!`). - // Keep all other backslashes literal so drive and UNC paths are preserved. - return splitArgsPreservingQuotes(value, { escapeMode: "backslash-quote-only" }).map( - unescapeCmdScriptArg, - ); -} - export async function readScheduledTaskCommand( env: GatewayServiceEnv, ): Promise { @@ -127,7 +99,7 @@ export async function readScheduledTaskCommand( return null; } return { - programArguments: parseCommandLine(commandLine), + programArguments: parseCmdScriptCommandLine(commandLine), ...(workingDirectory ? { workingDirectory } : {}), ...(Object.keys(environment).length > 0 ? { environment } : {}), }; @@ -180,8 +152,6 @@ function buildTaskScript({ if (!value) { continue; } - assertNoCmdLineBreak(key, "Environment variable name"); - assertNoCmdLineBreak(value, "Environment variable value"); lines.push(renderCmdSetAssignment(key, value)); } }