refactor(utils): share shell argv tokenizer

This commit is contained in:
Peter Steinberger
2026-02-14 14:54:20 +00:00
parent 8218a94a31
commit e1e05e57cb
3 changed files with 33 additions and 70 deletions

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ExecAllowlistEntry } from "./exec-approvals.js";
import { splitShellArgs } from "../utils/shell-argv.js";
export const DEFAULT_SAFE_BINS = ["jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc"];
@@ -582,75 +583,6 @@ export function isWindowsPlatform(platform?: string | null): boolean {
return normalized.startsWith("win");
}
function tokenizeShellSegment(segment: string): string[] | null {
const tokens: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
const pushToken = () => {
if (buf.length > 0) {
tokens.push(buf);
buf = "";
}
};
for (let i = 0; i < segment.length; i += 1) {
const ch = segment[i];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && !inDouble && ch === "\\") {
escaped = true;
continue;
}
if (inSingle) {
if (ch === "'") {
inSingle = false;
} else {
buf += ch;
}
continue;
}
if (inDouble) {
const next = segment[i + 1];
if (ch === "\\" && isDoubleQuoteEscape(next)) {
buf += next;
i += 1;
continue;
}
if (ch === '"') {
inDouble = false;
} else {
buf += ch;
}
continue;
}
if (ch === "'") {
inSingle = true;
continue;
}
if (ch === '"') {
inDouble = true;
continue;
}
if (/\s/.test(ch)) {
pushToken();
continue;
}
buf += ch;
}
if (escaped || inSingle || inDouble) {
return null;
}
pushToken();
return tokens;
}
function parseSegmentsFromParts(
parts: string[],
cwd?: string,
@@ -658,7 +590,7 @@ function parseSegmentsFromParts(
): ExecCommandSegment[] | null {
const segments: ExecCommandSegment[] = [];
for (const raw of parts) {
const argv = tokenizeShellSegment(raw);
const argv = splitShellArgs(raw);
if (!argv || argv.length === 0) {
return null;
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { splitShellArgs } from "./shell-argv.js";
describe("splitShellArgs", () => {
it("splits whitespace and respects quotes", () => {
expect(splitShellArgs(`qmd --foo "bar baz"`)).toEqual(["qmd", "--foo", "bar baz"]);
expect(splitShellArgs(`qmd --foo 'bar baz'`)).toEqual(["qmd", "--foo", "bar baz"]);
});
it("supports backslash escapes inside double quotes", () => {
expect(splitShellArgs(String.raw`echo "a\"b"`)).toEqual(["echo", `a"b`]);
expect(splitShellArgs(String.raw`echo "\$HOME"`)).toEqual(["echo", "$HOME"]);
});
it("returns null for unterminated quotes", () => {
expect(splitShellArgs(`echo "oops`)).toBeNull();
expect(splitShellArgs(`echo 'oops`)).toBeNull();
});
});

View File

@@ -1,3 +1,9 @@
const DOUBLE_QUOTE_ESCAPES = new Set(["\\", '"', "$", "`", "\n", "\r"]);
function isDoubleQuoteEscape(next: string | undefined): next is string {
return Boolean(next && DOUBLE_QUOTE_ESCAPES.has(next));
}
export function splitShellArgs(raw: string): string[] | null {
const tokens: string[] = [];
let buf = "";
@@ -32,6 +38,12 @@ export function splitShellArgs(raw: string): string[] | null {
continue;
}
if (inDouble) {
const next = raw[i + 1];
if (ch === "\\" && isDoubleQuoteEscape(next)) {
buf += next;
i += 1;
continue;
}
if (ch === '"') {
inDouble = false;
} else {