fix(copy-as): emit shell-safe arguments in the curl and gRPCurl exporters (#593)

This commit is contained in:
Ngo Quoc Viet
2026-08-20 08:41:47 -07:00
committed by GitHub
parent cb295b7102
commit 388dd8815f
4 changed files with 67 additions and 5 deletions
+7 -2
View File
@@ -83,7 +83,9 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
if (p.file) {
let v = `${p.name}=@${p.file}`;
v += p.contentType ? `;type=${p.contentType}` : "";
xs.push(flag, v);
// A bare `;` separates commands and a path can hold spaces, so this
// argument needs quoting like every other one.
xs.push(flag, quote(v));
} else {
xs.push(flag, quote(`${p.name}=${p.value}`));
}
@@ -157,7 +159,10 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
}
function quote(arg: string): string {
const escaped = arg.replace(/'/g, "\\'");
// A single-quoted POSIX string takes no escapes, so `\'` does not close it:
// the string ends one character early and the rest of the command is left
// dangling. Step out of the quotes, emit an escaped quote, step back in.
const escaped = arg.replace(/'/g, `'\\''`);
return `'${escaped}'`;
}
+39 -2
View File
@@ -120,7 +120,7 @@ describe("exporter-curl", () => {
`curl -X PUT 'https://yaak.app'`,
`--form 'a=aaa'`,
`--form 'b=bbb'`,
"--form f=@/foo/bar.png;type=image/png",
"--form 'f=@/foo/bar.png;type=image/png'",
].join(" \\\n "),
);
});
@@ -140,11 +140,48 @@ describe("exporter-curl", () => {
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data '{"foo":"bar\\'s"}'`,
`--data '{"foo":"bar'\\''s"}'`,
].join(" \\\n "),
);
});
test("Quotes an apostrophe so the command still parses", async () => {
// POSIX single quotes take no escapes: `\'` ends the string one
// character early and everything after it is left dangling, so the copied
// command is a syntax error rather than a request.
const command = await convertToCurl({
url: "https://yaak.app/it's",
method: "POST",
bodyType: "application/json",
body: { text: `{"note":"don't stop"}` },
headers: [{ name: "X-Note", value: "it's fine" }],
});
expect(command).toEqual(
[
`curl -X POST 'https://yaak.app/it'\\''s'`,
`--header 'X-Note: it'\\''s fine'`,
`--data '{"note":"don'\\''t stop"}'`,
].join(" \\\n "),
);
});
test("Quotes a file form field so its type suffix survives", async () => {
// A bare `;` separates commands, so an unquoted `f=@x.png;type=image/png`
// reaches curl as `f=@x.png` and the rest runs as its own command.
expect(
await convertToCurl({
url: "https://yaak.app",
method: "POST",
bodyType: "multipart/form-data",
body: { form: [{ name: "f", file: "/my files/a.png", contentType: "image/png" }] },
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--form 'f=@/my files/a.png;type=image/png'`].join(
" \\\n ",
),
);
});
test("Exports multi-line JSON body", async () => {
expect(
await convertToCurl({
+4 -1
View File
@@ -129,7 +129,10 @@ export async function convert(request: Partial<GrpcRequest>, allProtoFiles: stri
}
function quote(arg: string): string {
const escaped = arg.replace(/'/g, "\\'");
// A single-quoted POSIX string takes no escapes, so `\'` does not close it:
// the string ends one character early and the rest of the command is left
// dangling. Step out of the quotes, emit an escaped quote, step back in.
const escaped = arg.replace(/'/g, `'\\''`);
return `'${escaped}'`;
}
@@ -175,4 +175,21 @@ describe("exporter-curl", () => {
].join(" \\\n "),
);
});
test("Quotes an apostrophe so the command still parses", async () => {
// POSIX single quotes take no escapes: `\'` ends the string one
// character early and leaves the rest of the command dangling.
const command = await convert(
{
url: "https://yaak.app",
service: "Service",
method: "Method",
message: `{"note":"don't stop"}`,
metadata: [{ name: "x-note", value: "it's fine" }],
},
[],
);
expect(command).toContain(`'{"note":"don'\\''t stop"}'`);
expect(command).toContain(`'x-note: it'\\''s fine'`);
});
});