From 7449b56dba8fe27c39efff21c4a6179929c4feb0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:10:39 +0000 Subject: [PATCH 01/14] fetch: redact credential headers in the verbose curl line and the response log BUN_CONFIG_VERBOSE_FETCH=curl printed Authorization and Proxy-Authorization raw, one line above the redacted request block. Cookie, Set-Cookie and x-amz-security-token were printed raw in both modes, and the curl line carried the URL password. Move the redaction into one formatter on picohttp::Header. Every verbose printer (the > and < header lines and the curl line) uses it. The curl line masks the URL the same way the request line does. --- docs/runtime/debugger.mdx | 2 ++ src/http/lib.rs | 15 +------- src/picohttp/lib.rs | 54 +++++++++++++++++++++++++--- test/js/web/fetch/fetch.test.ts | 63 +++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 19 deletions(-) diff --git a/docs/runtime/debugger.mdx b/docs/runtime/debugger.mdx index d83684a0dca7..49c303d94e24 100644 --- a/docs/runtime/debugger.mdx +++ b/docs/runtime/debugger.mdx @@ -169,6 +169,8 @@ await fetch("https://example.com", { The lines with `[fetch] >` are the request from your local code, and the lines with `[fetch] <` are the response from the remote server. +Credentials are not logged. The values of the `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie` and `x-amz-security-token` headers print as `[redacted]` (an auth scheme such as `Bearer` is kept), and a password in the URL is masked with `*`. This applies to the `curl` command as well, so add the credentials back before you run it. The request body is printed in full. + To print without the `curl` command, set `BUN_CONFIG_VERBOSE_FETCH` to `true`. ```ts index.ts icon="/icons/typescript.svg" diff --git a/src/http/lib.rs b/src/http/lib.rs index 265ba9db804e..63d2f54a4153 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -1388,20 +1388,7 @@ pub(crate) fn print_request( bun_core::fmt::redacted_npm_url(url), ); for header in request.headers { - let name = header.name(); - if strings::eql_case_insensitive_ascii(name, b"authorization", true) - || strings::eql_case_insensitive_ascii(name, b"proxy-authorization", true) - { - let value = header.value(); - let scheme_len = strings::index_of_char_usize(value, b' ').map_or(0, |i| i + 1); - bun_core::pretty_errorln!( - "> {}: {}[redacted]", - BStr::new(name), - BStr::new(&value[..scheme_len]), - ); - } else { - bun_core::pretty_errorln!("> {}", header); - } + bun_core::pretty_errorln!("> {}", header); } Output::flush(); } diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index 09a6f8e3cd5c..4b3bc6b10250 100644 --- a/src/picohttp/lib.rs +++ b/src/picohttp/lib.rs @@ -156,6 +156,46 @@ impl Header { pub(crate) fn curl(&self) -> HeaderCurlFormatter<'_> { HeaderCurlFormatter { header: self } } + + /// The value as verbose logging prints it. A credential header keeps only + /// its auth scheme: `Bearer [redacted]`, `[redacted]`. + pub fn logged_value(&self) -> LoggedHeaderValue<'_> { + LoggedHeaderValue { header: self } + } +} + +/// The value of a credential header is never logged. The `Display` impls +/// below (the `> name: value` lines and the `curl` line of +/// `BUN_CONFIG_VERBOSE_FETCH`) all go through `logged_value`. +pub struct LoggedHeaderValue<'a> { + header: &'a Header, +} + +impl LoggedHeaderValue<'_> { + /// `Authorization: `: the scheme is kept. + const SCHEME_HEADERS: [&[u8]; 2] = [b"authorization", b"proxy-authorization"]; + /// The whole value is a secret. + const SECRET_HEADERS: [&[u8]; 3] = [b"cookie", b"set-cookie", b"x-amz-security-token"]; + + fn matches(name: &[u8], list: &[&[u8]]) -> bool { + list.iter() + .any(|n| strings::eql_case_insensitive_ascii(name, n, true)) + } +} + +impl fmt::Display for LoggedHeaderValue<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = self.header.name(); + let value = self.header.value(); + if Self::matches(name, &Self::SCHEME_HEADERS) { + let scheme_len = strings::index_of_char_usize(value, b' ').map_or(0, |i| i + 1); + write!(f, "{}[redacted]", BStr::new(&value[..scheme_len])) + } else if Self::matches(name, &Self::SECRET_HEADERS) { + f.write_str("[redacted]") + } else { + write!(f, "{}", BStr::new(value)) + } + } } impl fmt::Display for Header { @@ -170,7 +210,7 @@ impl fmt::Display for Header { f, pretty_fmt!("{}: {}", true), BStr::new(self.name()), - BStr::new(self.value()), + self.logged_value(), ) } } else { @@ -185,7 +225,7 @@ impl fmt::Display for Header { f, pretty_fmt!("{}: {}", false), BStr::new(self.name()), - BStr::new(self.value()), + self.logged_value(), ) } } @@ -207,7 +247,7 @@ impl fmt::Display for HeaderCurlFormatter<'_> { f, "-H \"{}: {}\"", BStr::new(header.name()), - BStr::new(header.value()) + header.logged_value() ) } else { write!(f, "-H \"{}\"", BStr::new(header.name())) @@ -351,10 +391,14 @@ impl fmt::Display for RequestCurlFormatter<'_> { write!( f, pretty_fmt!("curl --http1.1 \"{}\"", true), - BStr::new(request.path), + bun_core::fmt::redacted_npm_url(request.path), )?; } else { - write!(f, "curl --http1.1 \"{}\"", BStr::new(request.path))?; + write!( + f, + "curl --http1.1 \"{}\"", + bun_core::fmt::redacted_npm_url(request.path) + )?; } if request.method != b"GET" { diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 1dc818b7887d..cbd5ea6860f2 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -4197,3 +4197,66 @@ it("verbose fetch logging prints [redacted] in place of Authorization credential expect(stderr).not.toContain("sekret-token"); expect(exitCode).toBe(0); }); + +describe.concurrent("verbose fetch logging redacts credentials", () => { + const secrets = { + password: "url-pw-sekret", + authorization: "auth-sekret", + proxyAuthorization: "proxy-sekret", + cookie: "cookie-sekret", + sessionToken: "session-sekret", + setCookie: "set-cookie-sekret", + }; + + for (const mode of ["1", "curl"]) { + it(`BUN_CONFIG_VERBOSE_FETCH=${mode}`, async () => { + using server = Bun.serve({ + port: 0, + fetch(req) { + return new Response(req.headers.get("authorization") ?? "", { + headers: { "Set-Cookie": `sid=${secrets.setCookie}` }, + }); + }, + }); + const url = new URL(server.url); + url.username = "user"; + url.password = secrets.password; + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const res = await fetch(process.env.SERVER_URL, { + headers: { + Authorization: "Bearer ${secrets.authorization}", + "Proxy-Authorization": "Basic ${secrets.proxyAuthorization}", + Cookie: "sid=${secrets.cookie}", + "x-amz-security-token": "${secrets.sessionToken}", + "X-Plain": "plain-value", + }, + }); + console.log(await res.text());`, + ], + env: { ...bunEnv, BUN_CONFIG_VERBOSE_FETCH: mode, SERVER_URL: url.href }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(`Bearer ${secrets.authorization}\n`); + for (const secret of Object.values(secrets)) { + expect(stderr).not.toContain(secret); + } + expect(stderr).toContain("Authorization: Bearer [redacted]"); + expect(stderr).toContain("Proxy-Authorization: Basic [redacted]"); + expect(stderr).toContain("Cookie: [redacted]"); + expect(stderr).toContain("x-amz-security-token: [redacted]"); + expect(stderr).toContain("X-Plain: plain-value"); + if (mode === "curl") { + expect(stderr).toContain(`curl --http1.1 "http://user:***`); + } + expect(exitCode).toBe(0); + }); + } +}); From 32c449925e786922afa688736d08ee780aedf778 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:21:23 +0000 Subject: [PATCH 02/14] picohttp: use strings::eql_any_case_insensitive_ascii; docs: list everything the URL masks --- docs/runtime/debugger.mdx | 2 +- src/picohttp/lib.rs | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/runtime/debugger.mdx b/docs/runtime/debugger.mdx index 49c303d94e24..2275737b6556 100644 --- a/docs/runtime/debugger.mdx +++ b/docs/runtime/debugger.mdx @@ -169,7 +169,7 @@ await fetch("https://example.com", { The lines with `[fetch] >` are the request from your local code, and the lines with `[fetch] <` are the response from the remote server. -Credentials are not logged. The values of the `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie` and `x-amz-security-token` headers print as `[redacted]` (an auth scheme such as `Bearer` is kept), and a password in the URL is masked with `*`. This applies to the `curl` command as well, so add the credentials back before you run it. The request body is printed in full. +Credentials are not logged. The values of the `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie` and `x-amz-security-token` headers print as `[redacted]` (an auth scheme such as `Bearer` is kept). In the URL, a password is masked with `*`, and a UUID or an npm token (`npm_...`) anywhere in it prints as `***`. This applies to the `curl` command as well, so add these values back before you run it. The request body is printed in full. To print without the `curl` command, set `BUN_CONFIG_VERBOSE_FETCH` to `true`. diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index 4b3bc6b10250..1c9c6e4bb98b 100644 --- a/src/picohttp/lib.rs +++ b/src/picohttp/lib.rs @@ -176,21 +176,16 @@ impl LoggedHeaderValue<'_> { const SCHEME_HEADERS: [&[u8]; 2] = [b"authorization", b"proxy-authorization"]; /// The whole value is a secret. const SECRET_HEADERS: [&[u8]; 3] = [b"cookie", b"set-cookie", b"x-amz-security-token"]; - - fn matches(name: &[u8], list: &[&[u8]]) -> bool { - list.iter() - .any(|n| strings::eql_case_insensitive_ascii(name, n, true)) - } } impl fmt::Display for LoggedHeaderValue<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = self.header.name(); let value = self.header.value(); - if Self::matches(name, &Self::SCHEME_HEADERS) { + if strings::eql_any_case_insensitive_ascii(name, &Self::SCHEME_HEADERS) { let scheme_len = strings::index_of_char_usize(value, b' ').map_or(0, |i| i + 1); write!(f, "{}[redacted]", BStr::new(&value[..scheme_len])) - } else if Self::matches(name, &Self::SECRET_HEADERS) { + } else if strings::eql_any_case_insensitive_ascii(name, &Self::SECRET_HEADERS) { f.write_str("[redacted]") } else { write!(f, "{}", BStr::new(value)) From 8a2841e3cbc48831222b7e2d27e9b47e149c0900 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:23:41 +0000 Subject: [PATCH 03/14] picohttp: trim doc comments --- src/picohttp/lib.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index 1c9c6e4bb98b..998361734d86 100644 --- a/src/picohttp/lib.rs +++ b/src/picohttp/lib.rs @@ -157,16 +157,12 @@ impl Header { HeaderCurlFormatter { header: self } } - /// The value as verbose logging prints it. A credential header keeps only - /// its auth scheme: `Bearer [redacted]`, `[redacted]`. + /// The value for the `BUN_CONFIG_VERBOSE_FETCH` trace: credentials print as `[redacted]`. pub fn logged_value(&self) -> LoggedHeaderValue<'_> { LoggedHeaderValue { header: self } } } -/// The value of a credential header is never logged. The `Display` impls -/// below (the `> name: value` lines and the `curl` line of -/// `BUN_CONFIG_VERBOSE_FETCH`) all go through `logged_value`. pub struct LoggedHeaderValue<'a> { header: &'a Header, } From e66d9b73896eb86c954f07521b54f0ef48f4c72b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:51:11 +0000 Subject: [PATCH 04/14] test: check each redacted header line and the curl -H flags independently --- test/js/web/fetch/fetch.test.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index cbd5ea6860f2..22c547c16619 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -4248,13 +4248,28 @@ describe.concurrent("verbose fetch logging redacts credentials", () => { for (const secret of Object.values(secrets)) { expect(stderr).not.toContain(secret); } - expect(stderr).toContain("Authorization: Bearer [redacted]"); - expect(stderr).toContain("Proxy-Authorization: Basic [redacted]"); - expect(stderr).toContain("Cookie: [redacted]"); - expect(stderr).toContain("x-amz-security-token: [redacted]"); - expect(stderr).toContain("X-Plain: plain-value"); + + // One `> name: value` (request) or `< name: value` (response) trace line per + // header. Match on the full header name so `Cookie` and `Set-Cookie` are + // checked independently. + const traceLines = stderr.split(/\r?\n/).map(line => line.replace(/^(?:\[fetch\])?\s*[<>]?\s*/, "")); + const headerLines = (name: string) => + traceLines.filter(line => line.toLowerCase().startsWith(name.toLowerCase() + ":")); + expect(headerLines("Authorization")).toEqual(["Authorization: Bearer [redacted]"]); + expect(headerLines("Proxy-Authorization")).toEqual(["Proxy-Authorization: Basic [redacted]"]); + expect(headerLines("Cookie")).toEqual(["Cookie: [redacted]"]); + expect(headerLines("x-amz-security-token")).toEqual(["x-amz-security-token: [redacted]"]); + expect(headerLines("X-Plain")).toEqual(["X-Plain: plain-value"]); + expect(headerLines("Set-Cookie").map(line => line.toLowerCase())).toEqual(["set-cookie: [redacted]"]); + if (mode === "curl") { - expect(stderr).toContain(`curl --http1.1 "http://user:***`); + const curlLine = stderr.split(/\r?\n/).find(line => line.includes("curl --http1.1")) ?? ""; + expect(curlLine).toContain(`curl --http1.1 "http://user:***`); + expect(curlLine).toContain(`-H "Authorization: Bearer [redacted]"`); + expect(curlLine).toContain(`-H "Proxy-Authorization: Basic [redacted]"`); + expect(curlLine).toContain(`-H "Cookie: [redacted]"`); + expect(curlLine).toContain(`-H "x-amz-security-token: [redacted]"`); + expect(curlLine).toContain(`-H "X-Plain: plain-value"`); } expect(exitCode).toBe(0); }); From bfa137ae946b33e62e48aa5d736c2b702f474e74 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:04:07 +0000 Subject: [PATCH 05/14] picohttp: print a nameless continuation line as [redacted]; test: it.each over the two modes --- src/picohttp/lib.rs | 13 ++-- test/js/web/fetch/fetch.test.ts | 106 ++++++++++++++++---------------- 2 files changed, 58 insertions(+), 61 deletions(-) diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index 998361734d86..3c338aec33d9 100644 --- a/src/picohttp/lib.rs +++ b/src/picohttp/lib.rs @@ -181,7 +181,10 @@ impl fmt::Display for LoggedHeaderValue<'_> { if strings::eql_any_case_insensitive_ascii(name, &Self::SCHEME_HEADERS) { let scheme_len = strings::index_of_char_usize(value, b' ').map_or(0, |i| i + 1); write!(f, "{}[redacted]", BStr::new(&value[..scheme_len])) - } else if strings::eql_any_case_insensitive_ascii(name, &Self::SECRET_HEADERS) { + } else if self.header.is_multiline() + || strings::eql_any_case_insensitive_ascii(name, &Self::SECRET_HEADERS) + { + // A folded continuation line has no name: the header it continues is unknown here. f.write_str("[redacted]") } else { write!(f, "{}", BStr::new(value)) @@ -195,7 +198,7 @@ impl fmt::Display for Header { // codes). if enable_ansi_colors_stderr() { if self.is_multiline() { - write!(f, pretty_fmt!("{}", true), BStr::new(self.value())) + write!(f, pretty_fmt!("{}", true), self.logged_value()) } else { write!( f, @@ -206,11 +209,7 @@ impl fmt::Display for Header { } } else { if self.is_multiline() { - write!( - f, - pretty_fmt!("{}", false), - BStr::new(self.value()) - ) + write!(f, pretty_fmt!("{}", false), self.logged_value()) } else { write!( f, diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 22c547c16619..fca3855444f5 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -4208,25 +4208,24 @@ describe.concurrent("verbose fetch logging redacts credentials", () => { setCookie: "set-cookie-sekret", }; - for (const mode of ["1", "curl"]) { - it(`BUN_CONFIG_VERBOSE_FETCH=${mode}`, async () => { - using server = Bun.serve({ - port: 0, - fetch(req) { - return new Response(req.headers.get("authorization") ?? "", { - headers: { "Set-Cookie": `sid=${secrets.setCookie}` }, - }); - }, - }); - const url = new URL(server.url); - url.username = "user"; - url.password = secrets.password; - - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `const res = await fetch(process.env.SERVER_URL, { + it.each(["1", "curl"])("BUN_CONFIG_VERBOSE_FETCH=%s", async mode => { + using server = Bun.serve({ + port: 0, + fetch(req) { + return new Response(req.headers.get("authorization") ?? "", { + headers: { "Set-Cookie": `sid=${secrets.setCookie}` }, + }); + }, + }); + const url = new URL(server.url); + url.username = "user"; + url.password = secrets.password; + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const res = await fetch(process.env.SERVER_URL, { headers: { Authorization: "Bearer ${secrets.authorization}", "Proxy-Authorization": "Basic ${secrets.proxyAuthorization}", @@ -4236,42 +4235,41 @@ describe.concurrent("verbose fetch logging redacts credentials", () => { }, }); console.log(await res.text());`, - ], - env: { ...bunEnv, BUN_CONFIG_VERBOSE_FETCH: mode, SERVER_URL: url.href }, - stdout: "pipe", - stderr: "pipe", - }); + ], + env: { ...bunEnv, BUN_CONFIG_VERBOSE_FETCH: mode, SERVER_URL: url.href }, + stdout: "pipe", + stderr: "pipe", + }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toBe(`Bearer ${secrets.authorization}\n`); - for (const secret of Object.values(secrets)) { - expect(stderr).not.toContain(secret); - } + expect(stdout).toBe(`Bearer ${secrets.authorization}\n`); + for (const secret of Object.values(secrets)) { + expect(stderr).not.toContain(secret); + } - // One `> name: value` (request) or `< name: value` (response) trace line per - // header. Match on the full header name so `Cookie` and `Set-Cookie` are - // checked independently. - const traceLines = stderr.split(/\r?\n/).map(line => line.replace(/^(?:\[fetch\])?\s*[<>]?\s*/, "")); - const headerLines = (name: string) => - traceLines.filter(line => line.toLowerCase().startsWith(name.toLowerCase() + ":")); - expect(headerLines("Authorization")).toEqual(["Authorization: Bearer [redacted]"]); - expect(headerLines("Proxy-Authorization")).toEqual(["Proxy-Authorization: Basic [redacted]"]); - expect(headerLines("Cookie")).toEqual(["Cookie: [redacted]"]); - expect(headerLines("x-amz-security-token")).toEqual(["x-amz-security-token: [redacted]"]); - expect(headerLines("X-Plain")).toEqual(["X-Plain: plain-value"]); - expect(headerLines("Set-Cookie").map(line => line.toLowerCase())).toEqual(["set-cookie: [redacted]"]); - - if (mode === "curl") { - const curlLine = stderr.split(/\r?\n/).find(line => line.includes("curl --http1.1")) ?? ""; - expect(curlLine).toContain(`curl --http1.1 "http://user:***`); - expect(curlLine).toContain(`-H "Authorization: Bearer [redacted]"`); - expect(curlLine).toContain(`-H "Proxy-Authorization: Basic [redacted]"`); - expect(curlLine).toContain(`-H "Cookie: [redacted]"`); - expect(curlLine).toContain(`-H "x-amz-security-token: [redacted]"`); - expect(curlLine).toContain(`-H "X-Plain: plain-value"`); - } - expect(exitCode).toBe(0); - }); - } + // One `> name: value` (request) or `< name: value` (response) trace line per + // header. Match on the full header name so `Cookie` and `Set-Cookie` are + // checked independently. + const traceLines = stderr.split(/\r?\n/).map(line => line.replace(/^(?:\[fetch\])?\s*[<>]?\s*/, "")); + const headerLines = (name: string) => + traceLines.filter(line => line.toLowerCase().startsWith(name.toLowerCase() + ":")); + expect(headerLines("Authorization")).toEqual(["Authorization: Bearer [redacted]"]); + expect(headerLines("Proxy-Authorization")).toEqual(["Proxy-Authorization: Basic [redacted]"]); + expect(headerLines("Cookie")).toEqual(["Cookie: [redacted]"]); + expect(headerLines("x-amz-security-token")).toEqual(["x-amz-security-token: [redacted]"]); + expect(headerLines("X-Plain")).toEqual(["X-Plain: plain-value"]); + expect(headerLines("Set-Cookie").map(line => line.toLowerCase())).toEqual(["set-cookie: [redacted]"]); + + if (mode === "curl") { + const curlLine = stderr.split(/\r?\n/).find(line => line.includes("curl --http1.1")) ?? ""; + expect(curlLine).toContain(`curl --http1.1 "http://user:***`); + expect(curlLine).toContain(`-H "Authorization: Bearer [redacted]"`); + expect(curlLine).toContain(`-H "Proxy-Authorization: Basic [redacted]"`); + expect(curlLine).toContain(`-H "Cookie: [redacted]"`); + expect(curlLine).toContain(`-H "x-amz-security-token: [redacted]"`); + expect(curlLine).toContain(`-H "X-Plain: plain-value"`); + } + expect(exitCode).toBe(0); + }); }); From 60bc0e0f0b1581c898a59c90690fddfcda47d2f6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:42:19 +0000 Subject: [PATCH 06/14] picohttp: keep a UUID in the curl line URL The curl line used redacted_npm_url, which also prints every UUID as `***`. A UUID in a fetch URL is usually a resource id, and the command no longer ran for such a URL. Add redacted_url_credentials, which masks the password and npm tokens and keeps a UUID, and use it for the curl line. The > request line is unchanged. --- docs/runtime/debugger.mdx | 2 +- src/bun_core/fmt.rs | 17 +++++++++++++++-- src/picohttp/lib.rs | 10 ++++------ test/js/web/fetch/fetch.test.ts | 10 +++++++++- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/runtime/debugger.mdx b/docs/runtime/debugger.mdx index 2275737b6556..09c17cca95ba 100644 --- a/docs/runtime/debugger.mdx +++ b/docs/runtime/debugger.mdx @@ -169,7 +169,7 @@ await fetch("https://example.com", { The lines with `[fetch] >` are the request from your local code, and the lines with `[fetch] <` are the response from the remote server. -Credentials are not logged. The values of the `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie` and `x-amz-security-token` headers print as `[redacted]` (an auth scheme such as `Bearer` is kept). In the URL, a password is masked with `*`, and a UUID or an npm token (`npm_...`) anywhere in it prints as `***`. This applies to the `curl` command as well, so add these values back before you run it. The request body is printed in full. +Credentials are not logged. The values of the `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie` and `x-amz-security-token` headers print as `[redacted]` (an auth scheme such as `Bearer` is kept). In the URL, a password is masked with `*` and an npm token (`npm_...`) prints as `***`. This applies to the `curl` command as well, so add these values back before you run it. The `[fetch] >` line also prints a UUID in the URL as `***`. The `curl` command keeps it. The request body is printed in full. To print without the `curl` command, set `BUN_CONFIG_VERBOSE_FETCH` to `true`. diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 63195972f907..ea08d1855a50 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -243,6 +243,8 @@ impl<'a, const L: usize, const R: usize, const C: bool> Table<'a, L, R, C> { pub struct RedactedNpmUrlFormatter<'a> { pub(crate) url: &'a [u8], + /// A UUID in a registry URL can be a legacy npm token. + pub(crate) uuids: bool, } impl Display for RedactedNpmUrlFormatter<'_> { @@ -258,7 +260,7 @@ impl Display for RedactedNpmUrlFormatter<'_> { continue; } - if strings::starts_with_uuid(&self.url[i..]) { + if self.uuids && strings::starts_with_uuid(&self.url[i..]) { f.write_str("***")?; i += 36; continue; @@ -294,7 +296,18 @@ impl Display for RedactedNpmUrlFormatter<'_> { } pub fn redacted_npm_url(str: &[u8]) -> RedactedNpmUrlFormatter<'_> { - RedactedNpmUrlFormatter { url: str } + RedactedNpmUrlFormatter { + url: str, + uuids: true, + } +} + +/// Masks the password and npm tokens of a URL. A UUID stays: outside a registry it is usually an id. +pub fn redacted_url_credentials(str: &[u8]) -> RedactedNpmUrlFormatter<'_> { + RedactedNpmUrlFormatter { + url: str, + uuids: false, + } } // ─────────────────────────────────────────────────────────────────────────── diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index 3c338aec33d9..3aadb40d04cf 100644 --- a/src/picohttp/lib.rs +++ b/src/picohttp/lib.rs @@ -375,20 +375,18 @@ impl<'a> RequestCurlFormatter<'a> { impl fmt::Display for RequestCurlFormatter<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let request = self.request; + // Not `redacted_npm_url`: a UUID in the URL is kept so that the command stays runnable. + let url = bun_core::fmt::redacted_url_credentials(request.path); if enable_ansi_colors_stderr() { f.write_str(pretty_fmt!("[fetch] $ ", true))?; write!( f, pretty_fmt!("curl --http1.1 \"{}\"", true), - bun_core::fmt::redacted_npm_url(request.path), + url, )?; } else { - write!( - f, - "curl --http1.1 \"{}\"", - bun_core::fmt::redacted_npm_url(request.path) - )?; + write!(f, "curl --http1.1 \"{}\"", url)?; } if request.method != b"GET" { diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index fca3855444f5..c04d678c703d 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -4206,7 +4206,10 @@ describe.concurrent("verbose fetch logging redacts credentials", () => { cookie: "cookie-sekret", sessionToken: "session-sekret", setCookie: "set-cookie-sekret", + npmToken: "npm_" + Buffer.alloc(36, "a").toString(), }; + // An id, not a credential: the curl command has to keep it to stay usable. + const orderId = "550e8400-e29b-41d4-a716-446655440000"; it.each(["1", "curl"])("BUN_CONFIG_VERBOSE_FETCH=%s", async mode => { using server = Bun.serve({ @@ -4220,6 +4223,8 @@ describe.concurrent("verbose fetch logging redacts credentials", () => { const url = new URL(server.url); url.username = "user"; url.password = secrets.password; + url.pathname = `/orders/${orderId}`; + url.searchParams.set("registry_token", secrets.npmToken); await using proc = Bun.spawn({ cmd: [ @@ -4263,7 +4268,10 @@ describe.concurrent("verbose fetch logging redacts credentials", () => { if (mode === "curl") { const curlLine = stderr.split(/\r?\n/).find(line => line.includes("curl --http1.1")) ?? ""; - expect(curlLine).toContain(`curl --http1.1 "http://user:***`); + const maskedPassword = Buffer.alloc(secrets.password.length, "*").toString(); + expect(curlLine).toContain( + `curl --http1.1 "http://user:${maskedPassword}@${url.host}/orders/${orderId}?registry_token=***"`, + ); expect(curlLine).toContain(`-H "Authorization: Bearer [redacted]"`); expect(curlLine).toContain(`-H "Proxy-Authorization: Basic [redacted]"`); expect(curlLine).toContain(`-H "Cookie: [redacted]"`); From 934c9cf2e3624d76020e55b59012e7881dc95051 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:56:40 +0000 Subject: [PATCH 07/14] docs: say which credentials the verbose fetch trace redacts and what still prints --- docs/runtime/debugger.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/runtime/debugger.mdx b/docs/runtime/debugger.mdx index 09c17cca95ba..d2b021b2206e 100644 --- a/docs/runtime/debugger.mdx +++ b/docs/runtime/debugger.mdx @@ -169,7 +169,9 @@ await fetch("https://example.com", { The lines with `[fetch] >` are the request from your local code, and the lines with `[fetch] <` are the response from the remote server. -Credentials are not logged. The values of the `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie` and `x-amz-security-token` headers print as `[redacted]` (an auth scheme such as `Bearer` is kept). In the URL, a password is masked with `*` and an npm token (`npm_...`) prints as `***`. This applies to the `curl` command as well, so add these values back before you run it. The `[fetch] >` line also prints a UUID in the URL as `***`. The `curl` command keeps it. The request body is printed in full. +Some credentials are redacted. The values of the `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie` and `x-amz-security-token` headers print as `[redacted]` (an auth scheme such as `Bearer` is kept). In the URL, a password is masked with `*` and an npm token (`npm_...`) prints as `***`. This applies to the `curl` command as well, so add these values back before you run it. The `[fetch] >` line also prints a UUID in the URL as `***`. The `curl` command keeps it. + +Everything else prints as is, so the output can still contain secrets: other headers that carry a credential (for example `X-Api-Key`), tokens in the query string, and the request body. To print without the `curl` command, set `BUN_CONFIG_VERBOSE_FETCH` to `true`. From 40225c138dc5889dace6979b620e20e9f3b22f1a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:43:18 +0000 Subject: [PATCH 08/14] Remove the trace redaction from this branch The curl line prints credentials on purpose, so that the command can be pasted and re-run as-is. On the header lines only Authorization and Proxy-Authorization are masked on purpose. Both choices are recorded in the review replies on #37669. This puts the five files back to their state on main. --- docs/runtime/debugger.mdx | 4 -- src/bun_core/fmt.rs | 17 +------ src/http/lib.rs | 15 +++++- src/picohttp/lib.rs | 54 +++++---------------- test/js/web/fetch/fetch.test.ts | 84 --------------------------------- 5 files changed, 27 insertions(+), 147 deletions(-) diff --git a/docs/runtime/debugger.mdx b/docs/runtime/debugger.mdx index d2b021b2206e..d83684a0dca7 100644 --- a/docs/runtime/debugger.mdx +++ b/docs/runtime/debugger.mdx @@ -169,10 +169,6 @@ await fetch("https://example.com", { The lines with `[fetch] >` are the request from your local code, and the lines with `[fetch] <` are the response from the remote server. -Some credentials are redacted. The values of the `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie` and `x-amz-security-token` headers print as `[redacted]` (an auth scheme such as `Bearer` is kept). In the URL, a password is masked with `*` and an npm token (`npm_...`) prints as `***`. This applies to the `curl` command as well, so add these values back before you run it. The `[fetch] >` line also prints a UUID in the URL as `***`. The `curl` command keeps it. - -Everything else prints as is, so the output can still contain secrets: other headers that carry a credential (for example `X-Api-Key`), tokens in the query string, and the request body. - To print without the `curl` command, set `BUN_CONFIG_VERBOSE_FETCH` to `true`. ```ts index.ts icon="/icons/typescript.svg" diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index ea08d1855a50..63195972f907 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -243,8 +243,6 @@ impl<'a, const L: usize, const R: usize, const C: bool> Table<'a, L, R, C> { pub struct RedactedNpmUrlFormatter<'a> { pub(crate) url: &'a [u8], - /// A UUID in a registry URL can be a legacy npm token. - pub(crate) uuids: bool, } impl Display for RedactedNpmUrlFormatter<'_> { @@ -260,7 +258,7 @@ impl Display for RedactedNpmUrlFormatter<'_> { continue; } - if self.uuids && strings::starts_with_uuid(&self.url[i..]) { + if strings::starts_with_uuid(&self.url[i..]) { f.write_str("***")?; i += 36; continue; @@ -296,18 +294,7 @@ impl Display for RedactedNpmUrlFormatter<'_> { } pub fn redacted_npm_url(str: &[u8]) -> RedactedNpmUrlFormatter<'_> { - RedactedNpmUrlFormatter { - url: str, - uuids: true, - } -} - -/// Masks the password and npm tokens of a URL. A UUID stays: outside a registry it is usually an id. -pub fn redacted_url_credentials(str: &[u8]) -> RedactedNpmUrlFormatter<'_> { - RedactedNpmUrlFormatter { - url: str, - uuids: false, - } + RedactedNpmUrlFormatter { url: str } } // ─────────────────────────────────────────────────────────────────────────── diff --git a/src/http/lib.rs b/src/http/lib.rs index 63d2f54a4153..265ba9db804e 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -1388,7 +1388,20 @@ pub(crate) fn print_request( bun_core::fmt::redacted_npm_url(url), ); for header in request.headers { - bun_core::pretty_errorln!("> {}", header); + let name = header.name(); + if strings::eql_case_insensitive_ascii(name, b"authorization", true) + || strings::eql_case_insensitive_ascii(name, b"proxy-authorization", true) + { + let value = header.value(); + let scheme_len = strings::index_of_char_usize(value, b' ').map_or(0, |i| i + 1); + bun_core::pretty_errorln!( + "> {}: {}[redacted]", + BStr::new(name), + BStr::new(&value[..scheme_len]), + ); + } else { + bun_core::pretty_errorln!("> {}", header); + } } Output::flush(); } diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index 3aadb40d04cf..09a6f8e3cd5c 100644 --- a/src/picohttp/lib.rs +++ b/src/picohttp/lib.rs @@ -156,40 +156,6 @@ impl Header { pub(crate) fn curl(&self) -> HeaderCurlFormatter<'_> { HeaderCurlFormatter { header: self } } - - /// The value for the `BUN_CONFIG_VERBOSE_FETCH` trace: credentials print as `[redacted]`. - pub fn logged_value(&self) -> LoggedHeaderValue<'_> { - LoggedHeaderValue { header: self } - } -} - -pub struct LoggedHeaderValue<'a> { - header: &'a Header, -} - -impl LoggedHeaderValue<'_> { - /// `Authorization: `: the scheme is kept. - const SCHEME_HEADERS: [&[u8]; 2] = [b"authorization", b"proxy-authorization"]; - /// The whole value is a secret. - const SECRET_HEADERS: [&[u8]; 3] = [b"cookie", b"set-cookie", b"x-amz-security-token"]; -} - -impl fmt::Display for LoggedHeaderValue<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = self.header.name(); - let value = self.header.value(); - if strings::eql_any_case_insensitive_ascii(name, &Self::SCHEME_HEADERS) { - let scheme_len = strings::index_of_char_usize(value, b' ').map_or(0, |i| i + 1); - write!(f, "{}[redacted]", BStr::new(&value[..scheme_len])) - } else if self.header.is_multiline() - || strings::eql_any_case_insensitive_ascii(name, &Self::SECRET_HEADERS) - { - // A folded continuation line has no name: the header it continues is unknown here. - f.write_str("[redacted]") - } else { - write!(f, "{}", BStr::new(value)) - } - } } impl fmt::Display for Header { @@ -198,24 +164,28 @@ impl fmt::Display for Header { // codes). if enable_ansi_colors_stderr() { if self.is_multiline() { - write!(f, pretty_fmt!("{}", true), self.logged_value()) + write!(f, pretty_fmt!("{}", true), BStr::new(self.value())) } else { write!( f, pretty_fmt!("{}: {}", true), BStr::new(self.name()), - self.logged_value(), + BStr::new(self.value()), ) } } else { if self.is_multiline() { - write!(f, pretty_fmt!("{}", false), self.logged_value()) + write!( + f, + pretty_fmt!("{}", false), + BStr::new(self.value()) + ) } else { write!( f, pretty_fmt!("{}: {}", false), BStr::new(self.name()), - self.logged_value(), + BStr::new(self.value()), ) } } @@ -237,7 +207,7 @@ impl fmt::Display for HeaderCurlFormatter<'_> { f, "-H \"{}: {}\"", BStr::new(header.name()), - header.logged_value() + BStr::new(header.value()) ) } else { write!(f, "-H \"{}\"", BStr::new(header.name())) @@ -375,18 +345,16 @@ impl<'a> RequestCurlFormatter<'a> { impl fmt::Display for RequestCurlFormatter<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let request = self.request; - // Not `redacted_npm_url`: a UUID in the URL is kept so that the command stays runnable. - let url = bun_core::fmt::redacted_url_credentials(request.path); if enable_ansi_colors_stderr() { f.write_str(pretty_fmt!("[fetch] $ ", true))?; write!( f, pretty_fmt!("curl --http1.1 \"{}\"", true), - url, + BStr::new(request.path), )?; } else { - write!(f, "curl --http1.1 \"{}\"", url)?; + write!(f, "curl --http1.1 \"{}\"", BStr::new(request.path))?; } if request.method != b"GET" { diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index c04d678c703d..1dc818b7887d 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -4197,87 +4197,3 @@ it("verbose fetch logging prints [redacted] in place of Authorization credential expect(stderr).not.toContain("sekret-token"); expect(exitCode).toBe(0); }); - -describe.concurrent("verbose fetch logging redacts credentials", () => { - const secrets = { - password: "url-pw-sekret", - authorization: "auth-sekret", - proxyAuthorization: "proxy-sekret", - cookie: "cookie-sekret", - sessionToken: "session-sekret", - setCookie: "set-cookie-sekret", - npmToken: "npm_" + Buffer.alloc(36, "a").toString(), - }; - // An id, not a credential: the curl command has to keep it to stay usable. - const orderId = "550e8400-e29b-41d4-a716-446655440000"; - - it.each(["1", "curl"])("BUN_CONFIG_VERBOSE_FETCH=%s", async mode => { - using server = Bun.serve({ - port: 0, - fetch(req) { - return new Response(req.headers.get("authorization") ?? "", { - headers: { "Set-Cookie": `sid=${secrets.setCookie}` }, - }); - }, - }); - const url = new URL(server.url); - url.username = "user"; - url.password = secrets.password; - url.pathname = `/orders/${orderId}`; - url.searchParams.set("registry_token", secrets.npmToken); - - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `const res = await fetch(process.env.SERVER_URL, { - headers: { - Authorization: "Bearer ${secrets.authorization}", - "Proxy-Authorization": "Basic ${secrets.proxyAuthorization}", - Cookie: "sid=${secrets.cookie}", - "x-amz-security-token": "${secrets.sessionToken}", - "X-Plain": "plain-value", - }, - }); - console.log(await res.text());`, - ], - env: { ...bunEnv, BUN_CONFIG_VERBOSE_FETCH: mode, SERVER_URL: url.href }, - stdout: "pipe", - stderr: "pipe", - }); - - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect(stdout).toBe(`Bearer ${secrets.authorization}\n`); - for (const secret of Object.values(secrets)) { - expect(stderr).not.toContain(secret); - } - - // One `> name: value` (request) or `< name: value` (response) trace line per - // header. Match on the full header name so `Cookie` and `Set-Cookie` are - // checked independently. - const traceLines = stderr.split(/\r?\n/).map(line => line.replace(/^(?:\[fetch\])?\s*[<>]?\s*/, "")); - const headerLines = (name: string) => - traceLines.filter(line => line.toLowerCase().startsWith(name.toLowerCase() + ":")); - expect(headerLines("Authorization")).toEqual(["Authorization: Bearer [redacted]"]); - expect(headerLines("Proxy-Authorization")).toEqual(["Proxy-Authorization: Basic [redacted]"]); - expect(headerLines("Cookie")).toEqual(["Cookie: [redacted]"]); - expect(headerLines("x-amz-security-token")).toEqual(["x-amz-security-token: [redacted]"]); - expect(headerLines("X-Plain")).toEqual(["X-Plain: plain-value"]); - expect(headerLines("Set-Cookie").map(line => line.toLowerCase())).toEqual(["set-cookie: [redacted]"]); - - if (mode === "curl") { - const curlLine = stderr.split(/\r?\n/).find(line => line.includes("curl --http1.1")) ?? ""; - const maskedPassword = Buffer.alloc(secrets.password.length, "*").toString(); - expect(curlLine).toContain( - `curl --http1.1 "http://user:${maskedPassword}@${url.host}/orders/${orderId}?registry_token=***"`, - ); - expect(curlLine).toContain(`-H "Authorization: Bearer [redacted]"`); - expect(curlLine).toContain(`-H "Proxy-Authorization: Basic [redacted]"`); - expect(curlLine).toContain(`-H "Cookie: [redacted]"`); - expect(curlLine).toContain(`-H "x-amz-security-token: [redacted]"`); - expect(curlLine).toContain(`-H "X-Plain: plain-value"`); - } - expect(exitCode).toBe(0); - }); -}); From 3fbe99c65edf9361ecbbe397f43e30b93d80a406 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:48:16 +0000 Subject: [PATCH 09/14] fetch: quote the arguments of the verbose curl line for a POSIX shell BUN_CONFIG_VERBOSE_FETCH=curl prints a command to paste and re-run. Its values were inside double quotes with no escaping, so a POSIX shell expanded $(...), backticks and $VAR in a header value, in a body and in the URL. The URL of a redirected request comes from the server. On POSIX builds each argument is now one word in single quotes (quote_posix_shell in bun_core::fmt), with '\'' for a quote. The body is printed as it was sent, not as a JSON string. The values do not change, credentials included. curl gets --globoff when the URL has [ ] { }. Windows keeps the double-quote form. --- docs/runtime/debugger.mdx | 6 +- src/bun_core/fmt.rs | 27 ++++++++ src/picohttp/lib.rs | 61 +++++++++++----- test/js/web/fetch/fetch.test.ts | 104 ++++++++++++++++++++++++++++ test/regression/issue/12042.test.ts | 8 ++- 5 files changed, 186 insertions(+), 20 deletions(-) diff --git a/docs/runtime/debugger.mdx b/docs/runtime/debugger.mdx index d83684a0dca7..6bd2c658c66e 100644 --- a/docs/runtime/debugger.mdx +++ b/docs/runtime/debugger.mdx @@ -130,7 +130,7 @@ Set the `BUN_CONFIG_VERBOSE_FETCH` environment variable to log network requests ### Print fetch & node:http requests as curl commands -Set `BUN_CONFIG_VERBOSE_FETCH` to `curl` to print each `fetch()` and `node:http` request as a single-line `curl` command. You can copy-paste the command into your terminal to replicate the request. +Set `BUN_CONFIG_VERBOSE_FETCH` to `curl` to print each `fetch()` and `node:http` request as a `curl` command. You can copy-paste the command into your terminal to replicate the request. ```ts index.ts icon="/icons/typescript.svg" process.env.BUN_CONFIG_VERBOSE_FETCH = "curl"; @@ -145,7 +145,7 @@ await fetch("https://example.com", { ``` ```txt -[fetch] $ curl --http1.1 "https://example.com/" -X POST -H "content-type: application/json" -H "Connection: keep-alive" -H "User-Agent: Bun/1.3.3" -H "Accept: */*" -H "Host: example.com" -H "Accept-Encoding: gzip, deflate, br" --compressed -H "Content-Length: 13" --data-raw "{\"foo\":\"bar\"}" +[fetch] $ curl --http1.1 'https://example.com/' -X POST -H 'content-type: application/json' -H 'Connection: keep-alive' -H 'User-Agent: Bun/1.3.3' -H 'Accept: */*' -H 'Host: example.com' -H 'Accept-Encoding: gzip, deflate, br' --compressed -H 'Content-Length: 13' --data-raw '{"foo":"bar"}' [fetch] > HTTP/1.1 POST https://example.com/ [fetch] > content-type: application/json [fetch] > Connection: keep-alive @@ -169,6 +169,8 @@ await fetch("https://example.com", { The lines with `[fetch] >` are the request from your local code, and the lines with `[fetch] <` are the response from the remote server. +The `curl` command holds the request as it was sent, with its credentials, so that it can run again. On macOS and Linux, each argument is in single quotes, so a POSIX shell (`sh`, `bash`, `zsh`) expands nothing in it. A body of more than one line makes a command of more than one line. `--globoff` is added when the URL has `[`, `]`, `{` or `}`, because `curl` expands them otherwise. On Windows the arguments are in double quotes and are not escaped for `cmd.exe` or PowerShell, so read a command before you run it there. + To print without the `curl` command, set `BUN_CONFIG_VERBOSE_FETCH` to `true`. ```ts index.ts icon="/icons/typescript.svg" diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 63195972f907..ba03e202c502 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -3336,6 +3336,33 @@ impl Display for NullableFallback<'_, T> { } } +// ─────────────────────────────────────────────────────────────────────────── +// quotePosixShell +// ─────────────────────────────────────────────────────────────────────────── + +/// One word of a POSIX shell command: `parts` joined inside `'...'`. The shell expands nothing in it. +pub struct QuotePosixShell<'a>(pub(crate) &'a [&'a [u8]]); + +pub fn quote_posix_shell<'a>(parts: &'a [&'a [u8]]) -> QuotePosixShell<'a> { + QuotePosixShell(parts) +} + +impl Display for QuotePosixShell<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str("'")?; + for part in self.0 { + let mut rest = *part; + // `'...'` has no escape for a quote: close it, add `\'`, open it again. + while let Some(i) = strings::index_of_char_usize(rest, b'\'') { + write!(f, "{}'\\''", bstr::BStr::new(&rest[..i]))?; + rest = &rest[i + 1..]; + } + write!(f, "{}", bstr::BStr::new(rest))?; + } + f.write_str("'") + } +} + // ─────────────────────────────────────────────────────────────────────────── // escapePowershell // ─────────────────────────────────────────────────────────────────────────── diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index 09a6f8e3cd5c..1096471434cb 100644 --- a/src/picohttp/lib.rs +++ b/src/picohttp/lib.rs @@ -203,14 +203,28 @@ impl fmt::Display for HeaderCurlFormatter<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let header = self.header; if header.value_len > 0 { - write!( - f, - "-H \"{}: {}\"", - BStr::new(header.name()), - BStr::new(header.value()) - ) + let parts: [&[u8]; 3] = [header.name(), b": ", header.value()]; + write!(f, "-H {}", CurlArg(&parts)) + } else { + write!(f, "-H {}", CurlArg(&[header.name()])) + } + } +} + +/// One argument of the printed `curl` command: `parts` joined and quoted. +struct CurlArg<'a>(&'a [&'a [u8]]); + +impl fmt::Display for CurlArg<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if cfg!(windows) { + // cmd.exe and PowerShell have no quoting rule in common, so Windows keeps the plain form. + f.write_str("\"")?; + for part in self.0 { + write!(f, "{}", BStr::new(part))?; + } + f.write_str("\"") } else { - write!(f, "-H \"{}\"", BStr::new(header.name())) + write!(f, "{}", bun_core::fmt::quote_posix_shell(self.0)) } } } @@ -345,20 +359,31 @@ impl<'a> RequestCurlFormatter<'a> { impl fmt::Display for RequestCurlFormatter<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let request = self.request; + let url = [request.path]; + let url = CurlArg(&url); if enable_ansi_colors_stderr() { f.write_str(pretty_fmt!("[fetch] $ ", true))?; write!( f, - pretty_fmt!("curl --http1.1 \"{}\"", true), - BStr::new(request.path), + pretty_fmt!("curl --http1.1 {}", true), + url, )?; } else { - write!(f, "curl --http1.1 \"{}\"", BStr::new(request.path))?; + write!(f, "curl --http1.1 {}", url)?; + } + + // curl expands `[1-3]` and `{a,b}` in a URL unless globbing is off. + if strings::index_of_any(request.path, b"[]{}").is_some() { + f.write_str(" --globoff")?; } if request.method != b"GET" { - write!(f, " -X {}", BStr::new(request.method))?; + if cfg!(windows) || request.method.iter().all(u8::is_ascii_alphanumeric) { + write!(f, " -X {}", BStr::new(request.method))?; + } else { + write!(f, " -X {}", CurlArg(&[request.method]))?; + } } if self.ignore_insecure { @@ -384,11 +409,15 @@ impl fmt::Display for RequestCurlFormatter<'_> { if !self.body.is_empty() && Self::is_printable_body(content_type) { f.write_str(" --data-raw ")?; - bun_core::js_printer::write_json_string( - self.body, - f, - bun_core::strings::Encoding::Utf8, - )?; + if cfg!(windows) { + bun_core::js_printer::write_json_string( + self.body, + f, + bun_core::strings::Encoding::Utf8, + )?; + } else { + write!(f, "{}", CurlArg(&[self.body]))?; + } } Ok(()) diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 1dc818b7887d..cabc89ebf938 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -4197,3 +4197,107 @@ it("verbose fetch logging prints [redacted] in place of Authorization credential expect(stderr).not.toContain("sekret-token"); expect(exitCode).toBe(0); }); + +// Windows keeps the "..." form: cmd.exe and PowerShell have no quoting rule in common. +it.skipIf(isWindows)("verbose fetch curl command is read back by a shell as the request that was sent", async () => { + using dir = tempDir("verbose-fetch-curl-paste", {}); + const marker = (name: string) => join(String(dir), name); + const headers = { + // Credentials stay in the command: it has to re-run the request as it was sent. + "authorization": "Bearer sekret-token", + "x-note": `$(touch ${marker("header")})`, + "x-quote": `it's "q" \\ back`, + "content-type": "text/plain", + }; + const body = `line1\nline2 \`touch ${marker("body")}\` $HOME it's`; + // The server chooses this URL. curl would also expand [1-2] in it. + const landed = `/landed?a=$(touch\${IFS}${marker("url")})&ids[1-2]=x`; + + using server = Bun.serve({ + port: 0, + fetch(req) { + if (new URL(req.url).pathname !== "/hop") return new Response("ok"); + return new Response(null, { status: 302, headers: { Location: landed } }); + }, + }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { SERVER_URL, HEADERS, BODY } = process.env; + const init = { method: "POST", headers: JSON.parse(HEADERS), body: BODY }; + await (await fetch(SERVER_URL + "post", init)).text(); + await (await fetch(SERVER_URL + "hop")).text();`, + ], + env: { + ...bunEnv, + BUN_CONFIG_VERBOSE_FETCH: "curl", + SERVER_URL: server.url.href, + HEADERS: JSON.stringify(headers), + BODY: body, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + // A command ends where the request line of the trace starts. A body can make it longer than one line. + const commands = [...stderr.matchAll(/^curl --http1\.1 [\s\S]*?(?=\r?\n[> ]*HTTP\/)/gm)].map(match => match[0]); + expect(commands).toHaveLength(3); + + // A shell function named `curl` prints the arguments that the real curl would get. + async function readBack(shell: string, command: string) { + await using proc = Bun.spawn({ + cmd: [shell, "-c", `curl() { printf '%s\\0' "$@"; }\n${command}`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout] = await Promise.all([proc.stdout.text(), proc.exited]); + return stdout.split("\0").slice(0, -1); + } + + const origin = server.url.origin; + // `sh` is dash on Debian and Ubuntu. + for (const shell of ["sh", "bash"].filter(shell => Bun.which(shell))) { + const [post, hop, redirected] = await Promise.all(commands.map(command => readBack(shell, command))); + + expect(post.slice(0, 4)).toEqual(["--http1.1", `${origin}/post`, "-X", "POST"]); + const sent = Object.fromEntries( + post.flatMap((arg, i) => { + const colon = arg.indexOf(": "); + return post[i - 1] === "-H" ? [[arg.slice(0, colon).toLowerCase(), arg.slice(colon + 2)]] : []; + }), + ); + expect(sent).toMatchObject(headers); + expect(post.slice(-2)).toEqual(["--data-raw", body]); + expect(hop.slice(0, 2)).toEqual(["--http1.1", `${origin}/hop`]); + expect(redirected.slice(0, 3)).toEqual(["--http1.1", `${origin}${landed}`, "--globoff"]); + } + + const created = await Promise.all(["header", "body", "url"].map(name => Bun.file(marker(name)).exists())); + expect(created).toEqual([false, false, false]); + expect(exitCode).toBe(0); +}); + +it("verbose fetch curl command turns off curl globbing for a URL with brackets or braces", async () => { + using server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `await (await fetch(process.env.SERVER_URL + "plain")).text(); + await (await fetch(process.env.SERVER_URL + "glob?ids[1-2]=x")).text();`, + ], + env: { ...bunEnv, BUN_CONFIG_VERBOSE_FETCH: "curl", SERVER_URL: server.url.href }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + const commands = stderr.split(/\r?\n/).filter(line => line.startsWith("curl --http1.1")); + expect(commands.map(command => command.includes(" --globoff"))).toEqual([false, true]); + expect(exitCode).toBe(0); +}); diff --git a/test/regression/issue/12042.test.ts b/test/regression/issue/12042.test.ts index f882aa78febf..4092b12e895c 100644 --- a/test/regression/issue/12042.test.ts +++ b/test/regression/issue/12042.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, normalizeBunSnapshot, tempDir } from "harness"; test("#12042 curl verbose fetch logs form-urlencoded body", async () => { using dir = tempDir("issue-12042", { @@ -43,5 +43,9 @@ await server.stop(); const output = stdout + stderr; const normalized = normalizeBunSnapshot(output, dirPath); - expect(normalized).toContain('--data-raw "grant_type=client_credentials&client_id=abc&client_secret=xyz'); + // The body is one quoted argument: '...' for a POSIX shell, "..." on Windows. + const quote = isWindows ? '"' : "'"; + expect(normalized).toContain( + `--data-raw ${quote}grant_type=client_credentials&client_id=abc&client_secret=xyz${quote}`, + ); }); From 29d45e1e04d499f77aeb64143a80ad0eaede598f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:18:12 +0000 Subject: [PATCH 10/14] Write the printed curl line and the audit fix --ignore line as shell words bun_core::fmt::shell_word replaces quote_posix_shell. It writes bytes as one argument that a POSIX shell passes unchanged and that fish and PowerShell cannot run: - letters, digits and '-' only: as is - a control character or a byte that is not UTF-8: one $'...' word, all ASCII, so the command stays on one line and a GBK or Big5 shell cannot pair a byte with the backslash of the next escape - else '...' runs, with ' and U+2018..U+201B in "..." and a backslash in front of a backslash or a quote outside the quotes The curl line uses it for the URL, the method, each -H and the body on every platform. A body with a NUL byte is not printed. bun audit fix prints each --ignore token through it, and ignore_token returns the GHSA id instead of the rest of the advisory URL. --- docs/runtime/debugger.mdx | 4 +- src/bun_core/fmt.rs | 205 ++++++++++++++++++--- src/install/audit_fix.rs | 13 +- src/picohttp/lib.rs | 54 ++---- src/runtime/cli/audit_command.rs | 14 +- test/cli/install/bun-audit.test.ts | 57 ++++++ test/js/web/fetch/fetch.test.ts | 276 ++++++++++++++++++++-------- test/regression/issue/12042.test.ts | 8 +- 8 files changed, 476 insertions(+), 155 deletions(-) diff --git a/docs/runtime/debugger.mdx b/docs/runtime/debugger.mdx index 6bd2c658c66e..23cd636f56bb 100644 --- a/docs/runtime/debugger.mdx +++ b/docs/runtime/debugger.mdx @@ -130,7 +130,7 @@ Set the `BUN_CONFIG_VERBOSE_FETCH` environment variable to log network requests ### Print fetch & node:http requests as curl commands -Set `BUN_CONFIG_VERBOSE_FETCH` to `curl` to print each `fetch()` and `node:http` request as a `curl` command. You can copy-paste the command into your terminal to replicate the request. +Set `BUN_CONFIG_VERBOSE_FETCH` to `curl` to print each `fetch()` and `node:http` request as a single-line `curl` command. You can copy-paste the command into your terminal to replicate the request. ```ts index.ts icon="/icons/typescript.svg" process.env.BUN_CONFIG_VERBOSE_FETCH = "curl"; @@ -169,7 +169,7 @@ await fetch("https://example.com", { The lines with `[fetch] >` are the request from your local code, and the lines with `[fetch] <` are the response from the remote server. -The `curl` command holds the request as it was sent, with its credentials, so that it can run again. On macOS and Linux, each argument is in single quotes, so a POSIX shell (`sh`, `bash`, `zsh`) expands nothing in it. A body of more than one line makes a command of more than one line. `--globoff` is added when the URL has `[`, `]`, `{` or `}`, because `curl` expands them otherwise. On Windows the arguments are in double quotes and are not escaped for `cmd.exe` or PowerShell, so read a command before you run it there. +The `curl` command holds the request as it was sent, with its credentials, so that it can run again. Each argument is quoted for a POSIX shell such as `bash` or `zsh`: the shell passes the bytes of the request to `curl` and runs nothing from them. An argument with a control character (for example a line feed in the body) or with bytes that are not UTF-8 is written as `$'...'`, so the command stays on one line. `dash` and `fish` do not have `$'...'`, and PowerShell reads quotes in another way: these shells can pass other bytes for some arguments, but they also run nothing from them. Do not paste the command into `cmd.exe`, which has no single quotes. `--globoff` is added when the URL has `[`, `]`, `{` or `}`, because `curl` expands them otherwise. A request body with a NUL byte is not printed. To print without the `curl` command, set `BUN_CONFIG_VERBOSE_FETCH` to `true`. diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index ba03e202c502..79f6b5b6ef29 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -3336,33 +3336,6 @@ impl Display for NullableFallback<'_, T> { } } -// ─────────────────────────────────────────────────────────────────────────── -// quotePosixShell -// ─────────────────────────────────────────────────────────────────────────── - -/// One word of a POSIX shell command: `parts` joined inside `'...'`. The shell expands nothing in it. -pub struct QuotePosixShell<'a>(pub(crate) &'a [&'a [u8]]); - -pub fn quote_posix_shell<'a>(parts: &'a [&'a [u8]]) -> QuotePosixShell<'a> { - QuotePosixShell(parts) -} - -impl Display for QuotePosixShell<'_> { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - f.write_str("'")?; - for part in self.0 { - let mut rest = *part; - // `'...'` has no escape for a quote: close it, add `\'`, open it again. - while let Some(i) = strings::index_of_char_usize(rest, b'\'') { - write!(f, "{}'\\''", bstr::BStr::new(&rest[..i]))?; - rest = &rest[i + 1..]; - } - write!(f, "{}", bstr::BStr::new(rest))?; - } - f.write_str("'") - } -} - // ─────────────────────────────────────────────────────────────────────────── // escapePowershell // ─────────────────────────────────────────────────────────────────────────── @@ -3390,6 +3363,184 @@ fn escape_powershell_impl(str: &[u8], writer: &mut impl fmt::Write) -> fmt::Resu write_bytes(writer, remain) } +// ─────────────────────────────────────────────────────────────────────────── +// shellWord +// ─────────────────────────────────────────────────────────────────────────── + +/// One argument of a command line that bun prints for a person to paste: the +/// joined `parts`. +/// +/// A POSIX shell (sh, bash, zsh) passes exactly these bytes. fish and +/// PowerShell read quotes differently, so every form below also stays data +/// there: the argument can arrive changed, it cannot run. cmd.exe has no +/// single quotes and is not covered. +/// +/// - letters, digits and `-` only: as is. +/// - a control character or a byte that is not UTF-8: `$'...'`. +/// - else `'...'`, with each `'` in a `"..."` of its own. +pub struct ShellWord<'a>(pub(crate) &'a [&'a [u8]]); + +pub fn shell_word<'a>(parts: &'a [&'a [u8]]) -> ShellWord<'a> { + ShellWord(parts) +} + +impl Display for ShellWord<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let mut empty = true; + let mut bare = true; + let mut ansi_c = false; + for part in self.0 { + empty &= part.is_empty(); + for chunk in part.utf8_chunks() { + ansi_c |= !chunk.invalid().is_empty(); + for c in chunk.valid().chars() { + bare &= c.is_ascii_alphanumeric() || c == '-'; + // C0, DEL and C1. + ansi_c |= matches!(c, '\0'..='\x1f' | '\x7f'..='\u{9f}'); + } + } + } + + if ansi_c { + f.write_str("$'")?; + self.0 + .iter() + .try_for_each(|part| shell_word_ansi_c(f, part))?; + f.write_str("'") + } else if empty { + f.write_str("''") + } else if bare { + self.0.iter().try_for_each(|part| shell_word_utf8(f, part)) + } else { + let mut quoted = ShellWordQuoted { + f, + state: ShellWordIn::Nothing, + held_backslash: false, + }; + self.0.iter().try_for_each(|part| quoted.part(part))?; + quoted.finish() + } + } +} + +fn shell_word_utf8(f: &mut Formatter<'_>, bytes: &[u8]) -> fmt::Result { + f.write_str(core::str::from_utf8(bytes).map_err(|_| fmt::Error)?) +} + +/// The inside of `$'...'`. It is on one line and all ASCII: the terminal gets +/// no ESC or CR, and in a GBK or Big5 locale no byte can take the backslash of +/// the escape after it into a two-byte character. +/// dash and fish do not know `$'...'` and read it as `'...'`, so a `'` is +/// `\047`, never `\'`, and the rest of the line stays quoted there. +fn shell_word_ansi_c(f: &mut Formatter<'_>, part: &[u8]) -> fmt::Result { + let mut run = 0; + for (i, &byte) in part.iter().enumerate() { + let escape = match byte { + b'\\' => "\\\\", + b'\n' => "\\n", + b'\r' => "\\r", + b'\t' => "\\t", + b' '..=b'~' if byte != b'\'' => continue, + _ => "", + }; + shell_word_utf8(f, &part[run..i])?; + if escape.is_empty() { + write!(f, "\\{byte:03o}")?; + } else { + f.write_str(escape)?; + } + run = i + 1; + } + shell_word_utf8(f, &part[run..]) +} + +#[derive(Clone, Copy, PartialEq)] +enum ShellWordIn { + Single, + Double, + Nothing, +} + +/// `'...'` runs, for UTF-8 without a control character. +struct ShellWordQuoted<'a, 'f> { + f: &'a mut Formatter<'f>, + state: ShellWordIn, + /// fish reads `\\` and `\'` inside `'...'` as escapes. A backslash in front + /// of a backslash or of a quote goes outside the quotes, where `\\` is one + /// backslash in every shell. It is held until the byte after it is known. + held_backslash: bool, +} + +impl ShellWordQuoted<'_, '_> { + fn enter(&mut self, next: ShellWordIn) -> fmt::Result { + let delimiter = |state| match state { + ShellWordIn::Single => "'", + ShellWordIn::Double => "\"", + ShellWordIn::Nothing => "", + }; + if self.state != next { + self.f.write_str(delimiter(self.state))?; + self.f.write_str(delimiter(next))?; + self.state = next; + } + Ok(()) + } + + fn settle_backslash(&mut self, stays_quoted: bool) -> fmt::Result { + if !core::mem::take(&mut self.held_backslash) { + Ok(()) + } else if stays_quoted { + self.enter(ShellWordIn::Single)?; + self.f.write_str("\\") + } else { + self.enter(ShellWordIn::Nothing)?; + self.f.write_str("\\\\") + } + } + + fn part(&mut self, mut rest: &[u8]) -> fmt::Result { + // A backslash, a `'`, or U+2018..=U+201B: PowerShell ends a `'...'` string at those too. + fn special(rest: &[u8]) -> Option<(usize, usize)> { + let mut from = 0; + while let Some(at) = strings::index_of_any_pos(rest, b"\\'\xe2", from) { + match &rest[at..] { + [b'\\' | b'\'', ..] => return Some((at, 1)), + [0xe2, 0x80, 0x98..=0x9b, ..] => return Some((at, 3)), + _ => from = at + 1, + } + } + None + } + + while let Some((at, len)) = special(rest) { + if at > 0 { + self.settle_backslash(true)?; + self.enter(ShellWordIn::Single)?; + shell_word_utf8(self.f, &rest[..at])?; + } + self.settle_backslash(false)?; + if rest[at] == b'\\' { + self.held_backslash = true; + } else { + self.enter(ShellWordIn::Double)?; + shell_word_utf8(self.f, &rest[at..at + len])?; + } + rest = &rest[at + len..]; + } + if !rest.is_empty() { + self.settle_backslash(true)?; + self.enter(ShellWordIn::Single)?; + shell_word_utf8(self.f, rest)?; + } + Ok(()) + } + + fn finish(&mut self) -> fmt::Result { + self.settle_backslash(false)?; + self.enter(ShellWordIn::Nothing) + } +} + // js_bindings (fmtString for highlighter.test.ts) lives in src/jsc/fmt_jsc.rs // alongside fmt_jsc.bind.ts; bun_core/ stays JSC-free. diff --git a/src/install/audit_fix.rs b/src/install/audit_fix.rs index 30184dc83f21..1eb7f8f0f002 100644 --- a/src/install/audit_fix.rs +++ b/src/install/audit_fix.rs @@ -1002,16 +1002,19 @@ impl FixPlan { print_tokens(&item.ignore_tokens); prettyln!(""); for token in &item.ignore_tokens { - if !all_tokens.contains(token) { + if !token.is_empty() && !all_tokens.contains(token) { all_tokens.push(token.clone()); } } } - pretty!(" bun audit fix"); - for token in &all_tokens { - pretty!(" --ignore {}", BStr::new(token)); + if !all_tokens.is_empty() { + pretty!(" bun audit fix"); + for token in &all_tokens { + // The token is the registry's text, and this line is there to be pasted. + pretty!(" --ignore {}", bun_core::fmt::shell_word(&[token])); + } + prettyln!(""); } - prettyln!(""); prettyln!(""); } if !self.unmatched.is_empty() { diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index 1096471434cb..aeac64f68832 100644 --- a/src/picohttp/lib.rs +++ b/src/picohttp/lib.rs @@ -4,6 +4,7 @@ use core::fmt; use bstr::BStr; +use bun_core::fmt::shell_word; use bun_core::output::enable_ansi_colors_stderr; use bun_core::pretty_fmt; @@ -203,28 +204,13 @@ impl fmt::Display for HeaderCurlFormatter<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let header = self.header; if header.value_len > 0 { - let parts: [&[u8]; 3] = [header.name(), b": ", header.value()]; - write!(f, "-H {}", CurlArg(&parts)) - } else { - write!(f, "-H {}", CurlArg(&[header.name()])) - } - } -} - -/// One argument of the printed `curl` command: `parts` joined and quoted. -struct CurlArg<'a>(&'a [&'a [u8]]); - -impl fmt::Display for CurlArg<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if cfg!(windows) { - // cmd.exe and PowerShell have no quoting rule in common, so Windows keeps the plain form. - f.write_str("\"")?; - for part in self.0 { - write!(f, "{}", BStr::new(part))?; - } - f.write_str("\"") + write!( + f, + "-H {}", + shell_word(&[header.name(), b": ", header.value()]) + ) } else { - write!(f, "{}", bun_core::fmt::quote_posix_shell(self.0)) + write!(f, "-H {}", shell_word(&[header.name()])) } } } @@ -344,8 +330,9 @@ pub struct RequestCurlFormatter<'a> { } impl<'a> RequestCurlFormatter<'a> { - fn is_printable_body(content_type: &[u8]) -> bool { - if content_type.is_empty() { + fn is_printable_body(content_type: &[u8], body: &[u8]) -> bool { + // No argument of a command can hold a NUL. + if content_type.is_empty() || body.is_empty() || strings::contains_char(body, 0) { return false; } @@ -360,7 +347,7 @@ impl fmt::Display for RequestCurlFormatter<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let request = self.request; let url = [request.path]; - let url = CurlArg(&url); + let url = shell_word(&url); if enable_ansi_colors_stderr() { f.write_str(pretty_fmt!("[fetch] $ ", true))?; @@ -379,11 +366,7 @@ impl fmt::Display for RequestCurlFormatter<'_> { } if request.method != b"GET" { - if cfg!(windows) || request.method.iter().all(u8::is_ascii_alphanumeric) { - write!(f, " -X {}", BStr::new(request.method))?; - } else { - write!(f, " -X {}", CurlArg(&[request.method]))?; - } + write!(f, " -X {}", shell_word(&[request.method]))?; } if self.ignore_insecure { @@ -407,17 +390,8 @@ impl fmt::Display for RequestCurlFormatter<'_> { } } - if !self.body.is_empty() && Self::is_printable_body(content_type) { - f.write_str(" --data-raw ")?; - if cfg!(windows) { - bun_core::js_printer::write_json_string( - self.body, - f, - bun_core::strings::Encoding::Utf8, - )?; - } else { - write!(f, "{}", CurlArg(&[self.body]))?; - } + if Self::is_printable_body(content_type, self.body) { + write!(f, " --data-raw {}", shell_word(&[self.body]))?; } Ok(()) diff --git a/src/runtime/cli/audit_command.rs b/src/runtime/cli/audit_command.rs index 89e08a198860..5723ffd0d557 100644 --- a/src/runtime/cli/audit_command.rs +++ b/src/runtime/cli/audit_command.rs @@ -1016,10 +1016,18 @@ fn keep_vulnerability( } fn ignore_token(vulnerability: &VulnerabilityInfo) -> Box<[u8]> { - match strings::index_of(&vulnerability.url, b"GHSA-") { - Some(i) => Box::from(&vulnerability.url[i..]), - None => vulnerability.id.clone(), + if let Some(i) = strings::index_of(&vulnerability.url, b"GHSA-") { + // The id, not the rest of the url after it. + let id = &vulnerability.url[i..]; + let len = id + .iter() + .position(|byte| !(byte.is_ascii_alphanumeric() || *byte == b'-')) + .unwrap_or(id.len()); + if len > b"GHSA-".len() { + return Box::from(&id[..len]); + } } + vulnerability.id.clone() } fn to_advisory(vulnerability: VulnerabilityInfo) -> Advisory { diff --git a/test/cli/install/bun-audit.test.ts b/test/cli/install/bun-audit.test.ts index b0197c013fb3..b98678f00fde 100644 --- a/test/cli/install/bun-audit.test.ts +++ b/test/cli/install/bun-audit.test.ts @@ -7,10 +7,12 @@ import { bunEnv, bunExe, gunzipJsonRequest, + isWindows, normalizeBunSnapshot, runBunInstall, tempDir, } from "harness"; +import { readdirSync } from "node:fs"; import { join } from "node:path"; import { resolveBulkAdvisoryFixture } from "./registry/fixtures/audit/audit-fixtures"; @@ -1832,6 +1834,61 @@ describe("`bun audit fix`", () => { await runBunInstall(installEnv(dir), dir, { frozenLockfile: true }); }); + // The advisory's `url` and `id` are the registry's text, and the `--ignore` line is printed to be pasted. + test.concurrent("the --ignore line takes each advisory as one shell word", async () => { + const ghsa = "GHSA-xxxx-xxxx-xxxx"; + const id = "2 --latest; touch pwned"; + await using server = startRegistry({ + "a-dep": [ + { ...adv("<=1.0.10"), url: "https://example.invalid/" + ghsa + ";touch${IFS}pwned" }, + { ...adv("<=1.0.10"), id: id as unknown as number }, + ], + }); + using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "^1.0.0" } }); + + const { stdout, exitCode } = await auditFix(dir); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` + "bun audit fix () + + no published version fixes: + a-dep@1.0.10 2 --latest; touch pwned, GHSA-xxxx-xxxx-xxxx + bun audit fix --ignore '2 --latest; touch pwned' --ignore GHSA-xxxx-xxxx-xxxx + + Fixed 0 of 2 vulnerabilities (checked 1) + 2 vulnerabilities remaining" + `); + expect(exitCode).toBe(1); + + if (!isWindows) { + // Paste the line: `bun` is a function that prints its arguments. + const line = stdout.split("\n").find(line => line.includes("--ignore"))!; + using cwd = tempDir("audit-ignore-paste", {}); + await using sh = Bun.spawn({ + cmd: ["sh", "-c", `bun() { printf '[%s]\\n' "$@"; }\n${line}`], + env: bunEnv, + cwd: String(cwd), + stdout: "pipe", + stderr: "pipe", + }); + const [pasted, stderr, shExitCode] = await Promise.all([sh.stdout.text(), sh.stderr.text(), sh.exited]); + expect({ pasted, stderr, shExitCode, created: readdirSync(String(cwd)) }).toEqual({ + pasted: ["audit", "fix", "--ignore", id, "--ignore", ghsa].map(word => `[${word}]\n`).join(""), + stderr: "", + shExitCode: 0, + created: [], + }); + } + + // The printed tokens are what `--ignore` takes. + const ignored = await auditFix(dir, "--ignore", id, "--ignore", ghsa); + expect(normalizeBunSnapshot(ignored.stdout)).toMatchInlineSnapshot(` + "bun audit fix () + + No vulnerabilities found (checked 1 package, 2 ignored)" + `); + expect(ignored.exitCode).toBe(0); + }); + // pnpm#11101: a workspace package sharing a name with an advised npm package is not audited. test.concurrent("a workspace package is never matched against an advisory for its name", async () => { await using server = startRegistry({ "no-deps": [adv("<1.0.1")] }); diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index cabc89ebf938..c3bfb3fb9d50 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -1,6 +1,17 @@ import { AnyFunction, serve, ServeOptions, Server, sleep, TCPSocketListener } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; -import { chmodSync, closeSync, ftruncateSync, openSync, rmSync, writeFileSync } from "fs"; +import { + chmodSync, + closeSync, + existsSync, + ftruncateSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; import { bunEnv, bunExe, @@ -4198,87 +4209,208 @@ it("verbose fetch logging prints [redacted] in place of Authorization credential expect(exitCode).toBe(0); }); -// Windows keeps the "..." form: cmd.exe and PowerShell have no quoting rule in common. -it.skipIf(isWindows)("verbose fetch curl command is read back by a shell as the request that was sent", async () => { - using dir = tempDir("verbose-fetch-curl-paste", {}); - const marker = (name: string) => join(String(dir), name); - const headers = { - // Credentials stay in the command: it has to re-run the request as it was sent. - "authorization": "Bearer sekret-token", - "x-note": `$(touch ${marker("header")})`, - "x-quote": `it's "q" \\ back`, - "content-type": "text/plain", - }; - const body = `line1\nline2 \`touch ${marker("body")}\` $HOME it's`; - // The server chooses this URL. curl would also expand [1-2] in it. - const landed = `/landed?a=$(touch\${IFS}${marker("url")})&ids[1-2]=x`; - - using server = Bun.serve({ - port: 0, - fetch(req) { - if (new URL(req.url).pathname !== "/hop") return new Response("ok"); - return new Response(null, { status: 302, headers: { Location: landed } }); - }, - }); - - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `const { SERVER_URL, HEADERS, BODY } = process.env; - const init = { method: "POST", headers: JSON.parse(HEADERS), body: BODY }; - await (await fetch(SERVER_URL + "post", init)).text(); - await (await fetch(SERVER_URL + "hop")).text();`, - ], - env: { - ...bunEnv, - BUN_CONFIG_VERBOSE_FETCH: "curl", - SERVER_URL: server.url.href, - HEADERS: JSON.stringify(headers), - BODY: body, - }, - stdout: "pipe", - stderr: "pipe", - }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - - // A command ends where the request line of the trace starts. A body can make it longer than one line. - const commands = [...stderr.matchAll(/^curl --http1\.1 [\s\S]*?(?=\r?\n[> ]*HTTP\/)/gm)].map(match => match[0]); - expect(commands).toHaveLength(3); +describe.concurrent("verbose fetch logging curl line", () => { + // One request per case: `url` is fetched with `headers`, and with `body` (hex) as text/plain. + type Case = { url: string; headers?: Record; body?: string }; - // A shell function named `curl` prints the arguments that the real curl would get. - async function readBack(shell: string, command: string) { + // Returns the printed `curl` lines, one per request (two for a request that is redirected). + async function curlLines(cases: Case[]) { await using proc = Bun.spawn({ - cmd: [shell, "-c", `curl() { printf '%s\\0' "$@"; }\n${command}`], - env: bunEnv, + cmd: [ + bunExe(), + "-e", + `for (const { url, headers = {}, body } of JSON.parse(process.env.CASES)) { + const init = { verbose: "curl", headers }; + if (body !== undefined) { + headers["content-type"] = "text/plain"; + init.method = "POST"; + init.body = Buffer.from(body, "hex"); + } + await (await fetch(url, init)).arrayBuffer(); + }`, + ], + env: { ...bunEnv, CASES: JSON.stringify(cases) }, stdout: "pipe", stderr: "pipe", }); - const [stdout] = await Promise.all([proc.stdout.text(), proc.exited]); - return stdout.split("\0").slice(0, -1); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const lines = stderr.split(/\r?\n/).flatMap(line => { + const at = line.indexOf("curl --http1.1 "); + return at === -1 ? [] : [line.slice(at)]; + }); + expect({ stdout, exitCode }).toEqual({ stdout: "", exitCode: 0 }); + return lines; } - const origin = server.url.origin; - // `sh` is dash on Debian and Ubuntu. - for (const shell of ["sh", "bash"].filter(shell => Bun.which(shell))) { - const [post, hop, redirected] = await Promise.all(commands.map(command => readBack(shell, command))); + const hex = (text: string | number[]) => Buffer.from(text as string).toString("hex"); + + it("writes each word so that a shell reads it as data", async () => { + using server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); + const url = server.url.href; + + // [what the request carries, the word that is printed] + const bodies: [string | number[], string][] = [ + ["hello-1", "hello-1"], + ["a b", "'a b'"], + [`{"name":"$(touch pwned)"}`, `'{"name":"$(touch pwned)"}'`], + ["`touch pwned`", "'`touch pwned`'"], + [`"; touch pwned; "`, `'"; touch pwned; "'`], + ["it's", `'it'"'"'s'`], + // PowerShell ends a '...' string at a typographic quote too. + ["it\u2019s", `'it'"\u2019"'s'`], + // fish reads \\ and \' inside '...' as escapes, so these backslashes are written outside. + ["a\\", "'a'\\\\"], + ["C:\\\\dir", "'C:'\\\\'\\dir'"], + ["\\'", `\\\\"'"`], + [`say \\"hi\\"`, `'say \\"hi\\"'`], + // A control character or a byte that is not UTF-8 makes the word one $'...'. + [`{\n "a": 1\n}`, `$'{\\n "a": 1\\n}'`], + ["a\tb\r\x1b[0m'\\", "$'a\\tb\\r\\033[0m\\047\\\\'"], + [[0x61, 0xe9, 0xff], "$'a\\351\\377'"], + // $'...' is all ASCII. '...' is not. + ["caf\u00e9 \u2019\n", "$'caf\\303\\251 \\342\\200\\231\\n'"], + ["caf\u00e9", "'caf\u00e9'"], + ]; + const lines = await curlLines([ + ...bodies.map(([body]) => ({ url, body: hex(body) })), + // No argument can hold a NUL: this body is not printed. + { url, body: hex("a\0b") }, + { url: url + "p?x=1&y=$(touch${IFS}pwned)", headers: { "x-data": "caf\xe9" } }, + // The command has to run the request again as it was sent: a credential stays in it. + { url, headers: { "x-data": "$(touch pwned)", "authorization": "Bearer sekret-token" } }, + ]); - expect(post.slice(0, 4)).toEqual(["--http1.1", `${origin}/post`, "-X", "POST"]); - const sent = Object.fromEntries( - post.flatMap((arg, i) => { - const colon = arg.indexOf(": "); - return post[i - 1] === "-H" ? [[arg.slice(0, colon).toLowerCase(), arg.slice(colon + 2)]] : []; - }), + const word = (line: string, flag: string) => line.split(` ${flag} `)[1]; + expect(lines.slice(0, bodies.length).map(line => word(line, "--data-raw"))).toEqual(bodies.map(([, word]) => word)); + const [nul, latin1, substitution] = lines.slice(bodies.length); + expect(nul).not.toContain("--data-raw"); + // `--globoff`: curl itself expands { } in a URL. + expect(latin1).toStartWith( + `curl --http1.1 '${url}p?x=1&y=$(touch\${IFS}pwned)' --globoff -H $'x-data: caf\\351' -H `, ); - expect(sent).toMatchObject(headers); - expect(post.slice(-2)).toEqual(["--data-raw", body]); - expect(hop.slice(0, 2)).toEqual(["--http1.1", `${origin}/hop`]); - expect(redirected.slice(0, 3)).toEqual(["--http1.1", `${origin}${landed}`, "--globoff"]); - } + expect(substitution).toStartWith( + `curl --http1.1 '${url}' -H 'x-data: $(touch pwned)' -H 'Authorization: Bearer sekret-token' -H `, + ); + expect(lines).toHaveLength(bodies.length + 3); + }); - const created = await Promise.all(["header", "body", "url"].map(name => Bun.file(marker(name)).exists())); - expect(created).toEqual([false, false, false]); - expect(exitCode).toBe(0); + it.skipIf(isWindows)("a pasted line runs curl with the bytes of the request and nothing else", async () => { + // The server chooses this URL. curl would also expand [1-2] in it. + const location = "/b?q=`touch${IFS}pwned`&ids[1-2]=x"; + using server = Bun.serve({ + port: 0, + fetch: req => + new URL(req.url).pathname === "/a" + ? new Response(null, { status: 302, headers: { location } }) + : new Response("ok"), + }); + const base = server.url.href; + + // Text that a shell acts on when it is not quoted. Each is sent in the URL, as a header and as a body. + const plain = [ + "$(touch pwned)", + "`touch pwned`", + "'; touch pwned; '", + "\\'; touch pwned; '\\", + `\\'";touch pwned;#`, + `"; touch pwned; "`, + "' & touch pwned & '", + "$HOME ~ * {a,b} !! #c", + "a\\", + "\\\\", + ">pwned", + ]; + // These need $'...', which not every shell has. A header value is Latin-1: its \xe9 is not UTF-8. + const control = ["\t`touch pwned`", "\x1b[8m'; touch pwned; '", "a\tb\x7f", "caf\xe9"]; + const requests: (Case & { ansiC: boolean })[] = [ + ...plain.map(text => ({ + url: base + "p'" + text + "?q=" + text, + headers: { "x-data": text }, + body: hex(text), + ansiC: false, + })), + { url: base, body: hex("\u2019; touch pwned; \u2018"), ansiC: false }, + ...control.map(text => ({ url: base, headers: { "x-data": "-" + text }, body: hex(text), ansiC: true })), + { url: base, body: hex(`{\n "a": "'; touch pwned; '"\n}`), ansiC: true }, + ]; + const lines = await curlLines([{ url: base + "a" }, ...requests]); + // The first request is redirected: its second line holds the server's `Location`. + const expected: (Case & { ansiC: boolean })[] = [ + { url: base + "a", ansiC: false }, + { url: new URL(location, base).href, ansiC: false }, + ...requests.map(request => ({ ...request, url: new URL(request.url).href })), + ]; + expect(lines).toHaveLength(expected.length); + + using dir = tempDir("curl-line-paste", { + // `curl` is a function that records its arguments. Its first call tells if this shell has $'...'. + "paste.sh": + `curl() { printf '%s\\0' "$@" > "$CURL_ARGV"; }\n` + + `CURL_ARGV="$OUT/probe" curl $'\\101'\n` + + lines.map((line, i) => `CURL_ARGV="$OUT/${i}" ${line}\n`).join(""), + }); + // The arguments of one `curl` call, one Latin-1 string per argument. + const argv = (file: string) => { + const words: string[] = []; + if (!existsSync(file)) return words; + const bytes = readFileSync(file); + for (let start = 0, end; (end = bytes.indexOf(0, start)) !== -1; start = end + 1) { + words.push(bytes.latin1Slice(start, end)); + } + return words; + }; + + const shells = ["sh", "bash", "zsh", "dash"].flatMap(name => { + const path = Bun.which(name); + const flags = { sh: [], bash: ["--norc", "--noprofile"], zsh: ["-f"], dash: [] }[name]!; + return path ? [{ name, cmd: [path, ...flags] }] : []; + }); + expect(shells.map(shell => shell.name)).toContain("sh"); + + await Promise.all( + shells.map(async ({ name, cmd }) => { + const out = join(String(dir), "out-" + name); + const cwd = join(String(dir), "cwd-" + name); + mkdirSync(out); + mkdirSync(cwd); + await using proc = Bun.spawn({ + cmd: [...cmd, join(String(dir), "paste.sh")], + env: { ...bunEnv, HOME: String(dir), OUT: out }, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ name, stdout, stderr, exitCode }).toEqual({ name, stdout: "", stderr: "", exitCode: 0 }); + // A command out of the data would leave `pwned` or another file here. + expect({ name, created: readdirSync(cwd) }).toEqual({ name, created: [] }); + + const hasAnsiC = argv(join(out, "probe"))[0] === "A"; + const got = expected.map((request, i) => { + const words = argv(join(out, String(i))); + // Without $'...' the bytes of such a word differ, but the line still is one `curl` call. + if (request.ansiC && !hasAnsiC) return words.length > 0 ? "ran" : "did not run"; + const header = words.findIndex((word, at) => words[at - 1] === "-H" && word.startsWith("x-data: ")); + const body = words.indexOf("--data-raw"); + return { + url: words.slice(0, words[2] === "--globoff" ? 3 : 2), + header: header === -1 ? undefined : words[header].slice("x-data: ".length), + body: body === -1 ? undefined : words.slice(body + 1), + }; + }); + const want = expected.map(request => + request.ansiC && !hasAnsiC + ? "ran" + : { + url: ["--http1.1", request.url, .../[\[\]{}]/.test(request.url) ? ["--globoff"] : []], + header: request.headers?.["x-data"], + body: request.body === undefined ? undefined : [Buffer.from(request.body, "hex").latin1Slice()], + }, + ); + expect({ name, got }).toEqual({ name, got: want }); + }), + ); + }); }); it("verbose fetch curl command turns off curl globbing for a URL with brackets or braces", async () => { diff --git a/test/regression/issue/12042.test.ts b/test/regression/issue/12042.test.ts index 4092b12e895c..660fbefaf1e5 100644 --- a/test/regression/issue/12042.test.ts +++ b/test/regression/issue/12042.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isWindows, normalizeBunSnapshot, tempDir } from "harness"; +import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; test("#12042 curl verbose fetch logs form-urlencoded body", async () => { using dir = tempDir("issue-12042", { @@ -43,9 +43,5 @@ await server.stop(); const output = stdout + stderr; const normalized = normalizeBunSnapshot(output, dirPath); - // The body is one quoted argument: '...' for a POSIX shell, "..." on Windows. - const quote = isWindows ? '"' : "'"; - expect(normalized).toContain( - `--data-raw ${quote}grant_type=client_credentials&client_id=abc&client_secret=xyz${quote}`, - ); + expect(normalized).toContain("--data-raw 'grant_type=client_credentials&client_id=abc&client_secret=xyz'"); }); From d57cddc422c61ebeea21f2414b620c2e0648bd41 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:48:01 +0000 Subject: [PATCH 11/14] bun_core: drop the section banner above quote_posix_shell --- src/bun_core/fmt.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index ba03e202c502..94977637ce9c 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -3336,10 +3336,6 @@ impl Display for NullableFallback<'_, T> { } } -// ─────────────────────────────────────────────────────────────────────────── -// quotePosixShell -// ─────────────────────────────────────────────────────────────────────────── - /// One word of a POSIX shell command: `parts` joined inside `'...'`. The shell expands nothing in it. pub struct QuotePosixShell<'a>(pub(crate) &'a [&'a [u8]]); From 8388540bcf1a1e46a39f99122714bbcfeb59ec00 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:32:31 +0000 Subject: [PATCH 12/14] test: do not depend on the order of the request headers --- test/js/web/fetch/fetch.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index c3bfb3fb9d50..2e550dcfb440 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -4288,9 +4288,9 @@ describe.concurrent("verbose fetch logging curl line", () => { expect(latin1).toStartWith( `curl --http1.1 '${url}p?x=1&y=$(touch\${IFS}pwned)' --globoff -H $'x-data: caf\\351' -H `, ); - expect(substitution).toStartWith( - `curl --http1.1 '${url}' -H 'x-data: $(touch pwned)' -H 'Authorization: Bearer sekret-token' -H `, - ); + expect(substitution).toStartWith(`curl --http1.1 '${url}' -H `); + expect(substitution).toContain(` -H 'x-data: $(touch pwned)' `); + expect(substitution).toContain(` -H 'Authorization: Bearer sekret-token' `); expect(lines).toHaveLength(bodies.length + 3); }); From 056722127c10a849e1e2d87657e27b2caf43e993 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:14:02 +0000 Subject: [PATCH 13/14] fmt: one-line comments for shell_word --- src/bun_core/fmt.rs | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 79f6b5b6ef29..76c008cb1241 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -3363,21 +3363,7 @@ fn escape_powershell_impl(str: &[u8], writer: &mut impl fmt::Write) -> fmt::Resu write_bytes(writer, remain) } -// ─────────────────────────────────────────────────────────────────────────── -// shellWord -// ─────────────────────────────────────────────────────────────────────────── - -/// One argument of a command line that bun prints for a person to paste: the -/// joined `parts`. -/// -/// A POSIX shell (sh, bash, zsh) passes exactly these bytes. fish and -/// PowerShell read quotes differently, so every form below also stays data -/// there: the argument can arrive changed, it cannot run. cmd.exe has no -/// single quotes and is not covered. -/// -/// - letters, digits and `-` only: as is. -/// - a control character or a byte that is not UTF-8: `$'...'`. -/// - else `'...'`, with each `'` in a `"..."` of its own. +/// One argument of a printed command: a POSIX shell passes `parts` unchanged, fish and PowerShell cannot run them. pub struct ShellWord<'a>(pub(crate) &'a [&'a [u8]]); pub fn shell_word<'a>(parts: &'a [&'a [u8]]) -> ShellWord<'a> { @@ -3402,6 +3388,7 @@ impl Display for ShellWord<'_> { } if ansi_c { + // `'...'` would put a line feed, ESC or CR on the terminal as it is. f.write_str("$'")?; self.0 .iter() @@ -3427,11 +3414,7 @@ fn shell_word_utf8(f: &mut Formatter<'_>, bytes: &[u8]) -> fmt::Result { f.write_str(core::str::from_utf8(bytes).map_err(|_| fmt::Error)?) } -/// The inside of `$'...'`. It is on one line and all ASCII: the terminal gets -/// no ESC or CR, and in a GBK or Big5 locale no byte can take the backslash of -/// the escape after it into a two-byte character. -/// dash and fish do not know `$'...'` and read it as `'...'`, so a `'` is -/// `\047`, never `\'`, and the rest of the line stays quoted there. +/// The inside of `$'...'`, all ASCII: a GBK or Big5 shell cannot pair a byte with the backslash of the next escape. fn shell_word_ansi_c(f: &mut Formatter<'_>, part: &[u8]) -> fmt::Result { let mut run = 0; for (i, &byte) in part.iter().enumerate() { @@ -3440,6 +3423,7 @@ fn shell_word_ansi_c(f: &mut Formatter<'_>, part: &[u8]) -> fmt::Result { b'\n' => "\\n", b'\r' => "\\r", b'\t' => "\\t", + // dash, fish and PowerShell read `$'...'` as `'...'`: a `'` is `\047`, never `\'`. b' '..=b'~' if byte != b'\'' => continue, _ => "", }; @@ -3465,9 +3449,7 @@ enum ShellWordIn { struct ShellWordQuoted<'a, 'f> { f: &'a mut Formatter<'f>, state: ShellWordIn, - /// fish reads `\\` and `\'` inside `'...'` as escapes. A backslash in front - /// of a backslash or of a quote goes outside the quotes, where `\\` is one - /// backslash in every shell. It is held until the byte after it is known. + /// A backslash that waits for the byte after it. held_backslash: bool, } @@ -3493,6 +3475,7 @@ impl ShellWordQuoted<'_, '_> { self.enter(ShellWordIn::Single)?; self.f.write_str("\\") } else { + // fish reads `\\` and `\'` inside `'...'` as escapes. Outside, `\\` is one backslash in every shell. self.enter(ShellWordIn::Nothing)?; self.f.write_str("\\\\") } @@ -3522,6 +3505,7 @@ impl ShellWordQuoted<'_, '_> { if rest[at] == b'\\' { self.held_backslash = true; } else { + // Not `'\''`: PowerShell leaves the text after it outside any string. self.enter(ShellWordIn::Double)?; shell_word_utf8(self.f, &rest[at..at + len])?; } From f6db1bd17bc13e8952bc06155d4e9a451156c2f7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:06:22 +0000 Subject: [PATCH 14/14] [autofix.ci] apply automated fixes --- test/js/web/fetch/fetch.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 2e550dcfb440..b22ebf91e312 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -4402,7 +4402,7 @@ describe.concurrent("verbose fetch logging curl line", () => { request.ansiC && !hasAnsiC ? "ran" : { - url: ["--http1.1", request.url, .../[\[\]{}]/.test(request.url) ? ["--globoff"] : []], + url: ["--http1.1", request.url, ...(/[\[\]{}]/.test(request.url) ? ["--globoff"] : [])], header: request.headers?.["x-data"], body: request.body === undefined ? undefined : [Buffer.from(request.body, "hex").latin1Slice()], },