This commit is contained in:
iliya 2026-02-27 20:06:03 +02:00
parent 69adf3488e
commit 228e8868ed
2 changed files with 42 additions and 7 deletions

View file

@ -68,14 +68,21 @@ function needsShell(binaryPath: string): boolean {
}
/**
* Minimal quoting for commandline arguments when building a shell
* invocation. We only escape spaces and double quotes since our
* callers only ever use simple strings (paths, flags, literals) and
* the shell itself will handle most quoting rules.
* Quote an argument for cmd.exe shell invocation on Windows.
*
* cmd.exe rules:
* - Double-quote args containing spaces or special characters
* - Inside double quotes, escape literal `"` as `""`
* - `%` is expanded as env var even inside double quotes escape as `%%`
* - `^`, `&`, `|`, `<`, `>` are safe inside double quotes
*
* Our callers only pass controlled strings (binary paths, CLI flags),
* NOT arbitrary user input.
*/
function quoteArg(arg: string): string {
if (/[^A-Za-z0-9_\-/.]/.test(arg)) {
return `"${arg.replace(/"/g, '\\"')}"`;
const escaped = arg.replace(/%/g, '%%').replace(/"/g, '""');
return `"${escaped}"`;
}
return arg;
}
@ -133,7 +140,7 @@ export function spawnCli(
if (process.platform === 'win32' && needsShell(binaryPath)) {
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
return spawn(cmd, { shell: true, ...options });
return spawn(cmd, { ...options, shell: true });
}
try {
@ -144,7 +151,7 @@ export function spawnCli(
if (process.platform === 'win32' && code === 'EINVAL') {
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
return spawn(cmd, { shell: true, ...options });
return spawn(cmd, { ...options, shell: true });
}
throw err;
}

View file

@ -134,6 +134,34 @@ describe('cli child process helpers', () => {
expect(result.stdout).toBe('1.2.3');
});
it('escapes percent signs and quotes for cmd.exe in shell fallback', async () => {
setPlatform('win32');
const execMock = child.exec as unknown as vi.Mock;
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
cb(null, 'ok', '');
return {} as any;
});
await execCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--model', 'test%PATH%"arg']);
const shellCmd = execMock.mock.calls[0][0] as string;
// %PATH% must become %%PATH%% to prevent cmd.exe env var expansion
expect(shellCmd).toContain('%%PATH%%');
// double quote inside arg must become "" (cmd.exe escaping)
expect(shellCmd).toContain('""arg');
// should NOT contain \" (Unix-style escaping)
expect(shellCmd).not.toContain('\\"');
});
it('shell: true cannot be overridden by caller options', () => {
setPlatform('win32');
const spawnMock = child.spawn as unknown as vi.Mock;
spawnMock.mockReturnValue({} as any);
spawnCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--version'], { shell: false } as any);
// shell: true must win over caller's shell: false
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true });
});
it('falls back to shell when execFile throws EINVAL on windows', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as vi.Mock;