Fix backslash escape counting in splitCommands quote detection

The closing-quote detection only checked the immediately preceding
character for a backslash, which mis-parsed sequences like "\\\\"
where even backslashes escape each other. Count consecutive
backslashes (odd = escaped, even = real closing quote) instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-02-09 07:46:11 -08:00
parent 9b25079f10
commit 3190de289d
2 changed files with 87 additions and 28 deletions

View File

@@ -62,6 +62,18 @@ function splitCommands(rawData: string): string[] {
// Join line continuations (backslash-newline, and backslash-CRLF for Windows)
const joined = rawData.replace(/\\\r?\n/g, ' ');
// Count consecutive backslashes immediately before position i.
// An even count means the quote at i is NOT escaped; odd means it IS escaped.
function isEscaped(i: number): boolean {
let backslashes = 0;
let j = i - 1;
while (j >= 0 && joined[j] === '\\') {
backslashes++;
j--;
}
return backslashes % 2 !== 0;
}
// Split on semicolons and newlines to separate commands
const commands: string[] = [];
let current = '';
@@ -89,7 +101,7 @@ function splitCommands(rawData: string): string[] {
current += ch;
continue;
}
if (inDoubleQuote && ch === '"' && joined[i - 1] !== '\\') {
if (inDoubleQuote && ch === '"' && !isEscaped(i)) {
inDoubleQuote = false;
current += ch;
continue;
@@ -100,7 +112,7 @@ function splitCommands(rawData: string): string[] {
i++; // Skip the opening quote
continue;
}
if (inDollarQuote && ch === "'" && joined[i - 1] !== '\\') {
if (inDollarQuote && ch === "'" && !isEscaped(i)) {
inDollarQuote = false;
current += ch;
continue;
@@ -128,37 +140,13 @@ function splitCommands(rawData: string): string[] {
return commands;
}
/**
* Tokenizes a single shell command string using shlex.
* Falls back to basic whitespace splitting if shlex fails (e.g., unmatched quotes).
*/
function tokenize(command: string): string[] {
try {
return split(command);
} catch {
// shlex is strict about unmatched quotes. Fall back to basic splitting
// and strip surrounding quotes that shell-like parsing would normally remove.
return command.split(/\s+/).filter(Boolean).map((token) => {
// Strip matching surrounding quotes
if (
(token.startsWith('"') && token.endsWith('"')) ||
(token.startsWith("'") && token.endsWith("'"))
) {
return token.slice(1, -1);
}
// Strip unmatched quotes (e.g., from malformed input)
return token.replace(/['"]/g, '');
});
}
}
export function convertCurl(rawData: string) {
if (!rawData.match(/^\s*curl /)) {
return null;
}
const commands: string[][] = splitCommands(rawData).map((cmd) => {
const tokens = tokenize(cmd);
const tokens = split(cmd);
// Break up squished arguments like `-XPOST` into `-X POST`
return tokens.flatMap((token) => {

View File

@@ -125,9 +125,15 @@ describe('importer-curl', () => {
});
});
test('Throws on malformed quotes', () => {
expect(() =>
convertCurl('curl -X POST -F "a=aaa" -F b=bbb" https://yaak.app'),
).toThrow();
});
test('Imports form data', () => {
expect(
convertCurl('curl -X POST -F "a=aaa" -F b=bbb" -F f=@filepath https://yaak.app'),
convertCurl('curl -X POST -F "a=aaa" -F b=bbb -F f=@filepath https://yaak.app'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
@@ -510,6 +516,71 @@ describe('importer-curl', () => {
});
});
test('Handles double-quoted string ending with even backslashes before semicolon', () => {
// "C:\\" has two backslashes which escape each other, so the closing " is real.
// The ; after should split into a second command.
expect(
convertCurl(
'curl -d "C:\\\\" https://yaak.app;curl https://example.com',
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
body: {
form: [{ name: 'C:\\', value: '', enabled: true }],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
},
],
}),
baseRequest({ url: 'https://example.com' }),
],
},
});
});
test('Handles $quoted string ending with a literal backslash before semicolon', () => {
// $'C:\\\\' has two backslashes which become one literal backslash.
// The closing ' must not be misinterpreted as escaped.
// The ; after should split into a second command.
expect(
convertCurl(
"curl -d $'C:\\\\' https://yaak.app;curl https://example.com",
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
body: {
form: [{ name: 'C:\\', value: '', enabled: true }],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
},
],
}),
baseRequest({ url: 'https://example.com' }),
],
},
});
});
test('Imports $quoted header with escaped single quotes', () => {
expect(
convertCurl(