diff --git a/plugins/importer-curl/src/index.ts b/plugins/importer-curl/src/index.ts index 8a46883f..5a489fa1 100644 --- a/plugins/importer-curl/src/index.ts +++ b/plugins/importer-curl/src/index.ts @@ -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) => { diff --git a/plugins/importer-curl/tests/index.test.ts b/plugins/importer-curl/tests/index.test.ts index cd7b1ce7..df7e712f 100644 --- a/plugins/importer-curl/tests/index.test.ts +++ b/plugins/importer-curl/tests/index.test.ts @@ -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(