From ec464e427867c33b7b57fc737be220dfa812d857 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:02:54 +0000 Subject: [PATCH 01/10] url: keep the leading slashes of the pathname fetch("http://host//a//b") sent "GET /a//b". The byte-slice URL parser collapsed every leading "//" of pathname to one "/". The WHATWG parser keeps them and so do node and curl. Remove the collapse so the HTTP client sends the request-target as written. --- src/url/lib.rs | 21 ++++++++++++++------- test/js/web/fetch/fetch.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index 87f5b4b0eea8..0f5aeb887c84 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -821,13 +821,6 @@ impl<'a> URL<'a> { url.pathname = b"/"; } - const SLASH_SLASH: u16 = u16::from_le_bytes(*b"//"); - while url.pathname.len() > 1 - && u16::from_le_bytes([url.pathname[0], url.pathname[1]]) == SLASH_SLASH - { - url.pathname = &url.pathname[1..]; - } - // Only the right side: a protocol-relative origin keeps its leading `//`. url.origin = strings::trim_right(url.origin, b"/ ?#"); url @@ -1865,6 +1858,20 @@ mod tests { assert_eq!(url.hash, b"#frag?x=2"); } + #[test] + fn pathname_keeps_its_leading_slashes() { + // The request-target is `new URL(s).pathname + search`, and WHATWG + // keeps every leading `/` of a special URL's path. + let url = URL::parse(b"http://localhost:3000//a//b?x=1"); + assert_eq!(url.pathname, b"//a//b?x=1"); + assert_eq!(url.search, b"?x=1"); + assert_eq!(url.origin, b"http://localhost:3000"); + + let url = URL::parse(b"http://localhost:3000///#frag"); + assert_eq!(url.pathname, b"///"); + assert_eq!(url.hash, b"#frag"); + } + #[test] fn the_authority_ends_where_new_url_ends_it() { let url = URL::parse(br"http://u:p@first.example:8080\x@second.example/path"); diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index d1d5a037ac52..6f5a6a8fb632 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -3121,6 +3121,31 @@ it("never sends the URL fragment in the request-target", async () => { expect(targets).toEqual(expected); }); +it("keeps the leading slashes of the pathname in the request-target", async () => { + // new URL("http://h//a//b").pathname is "//a//b". The request-target is sent + // as written, like node and curl do. See #45330. + const targets: string[] = []; + await using server = net.createServer(socket => { + socket.once("data", data => { + targets.push(data.toString("utf8").split("\r\n")[0]); + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); + }); + }); + await once(server.listen(0, "localhost"), "listening"); + const { port } = server.address() as AddressInfo; + + const tails = ["//a//b?x=1", "///", "//?q=1", "//a#frag"]; + const expected: string[] = []; + for (const tail of tails) { + const href = `http://localhost:${port}${tail}`; + const url = new URL(href); + expect(url.pathname.startsWith("//")).toBe(true); + expected.push(`GET ${url.pathname}${url.search} HTTP/1.1`); + await (await fetch(new Request(href))).text(); + } + expect(targets).toEqual(expected); +}); + it("combines duplicate response headers per the Fetch spec", async () => { // WHATWG Fetch requires repeated header fields to be combined with ", " when // read via Headers.get(), except Set-Cookie which is stored as separate From fd922c6b528dd74166377cdc44ec8ce9dd9e7fc8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:58:10 +0000 Subject: [PATCH 02/10] test: resolve a path against server.url instead of joining with a slash `server.url` ends with `/`, so `${server.url}/path` is `http://host//path`. fetch() sent that as `/path`. These tests now build the URL they name, with `new URL("/path", server.url)`. (cherry picked from commit d884a5d21aee93d34a6d55b0e983f7564761c6b7) --- test/js/bun/cookie/cookie.test.ts | 14 +++++----- .../js/bun/http/async-iterator-stream.test.ts | 6 ++--- test/js/bun/http/bun-serve-html.test.ts | 6 ++--- test/js/bun/http/bun-serve-static.test.ts | 10 +++---- test/js/bun/http/bun-server.test.ts | 26 +++++++++---------- .../http/js-sink-sourmap-fixture/index.mjs | 2 +- test/js/node/http/max-header-size-fixture.ts | 4 +-- .../node/http/node-http-maxHeaderSize.test.ts | 8 +++--- test/js/web/fetch/abortsignal-leak-fixture.ts | 6 ++--- test/js/web/fetch/fetch-args.test.ts | 2 +- test/js/web/fetch/fetch.brotli.test.ts | 6 ++--- test/regression/issue/18547.test.ts | 2 +- test/regression/issue/24817.test.ts | 14 ++++------ test/regression/issue/test-21049.test.ts | 26 +++++++++++-------- 14 files changed, 66 insertions(+), 66 deletions(-) diff --git a/test/js/bun/cookie/cookie.test.ts b/test/js/bun/cookie/cookie.test.ts index e08d73ee1f7b..f66e1279fc4d 100644 --- a/test/js/bun/cookie/cookie.test.ts +++ b/test/js/bun/cookie/cookie.test.ts @@ -155,7 +155,7 @@ describe("Bun.serve() cookies", () => { }); test("set-cookie", async () => { - const res = await fetch(server.url + "/tester", { + const res = await fetch(new URL("/tester", server.url), { method: "POST", body: JSON.stringify([["test", "test"]]), }); @@ -173,7 +173,7 @@ describe("Bun.serve() cookies", () => { `); }); test("set two cookies", async () => { - const res = await fetch(server.url + "/tester", { + const res = await fetch(new URL("/tester", server.url), { method: "POST", body: JSON.stringify([ ["test", "test"], @@ -196,7 +196,7 @@ describe("Bun.serve() cookies", () => { `); }); test("delete cookie", async () => { - const res = await fetch(server.url + "/tester", { + const res = await fetch(new URL("/tester", server.url), { method: "POST", body: JSON.stringify([["test", null]]), }); @@ -210,7 +210,7 @@ describe("Bun.serve() cookies", () => { `); }); test("request with cookies", async () => { - const res = await fetch(server.url + "/tester", { + const res = await fetch(new URL("/tester", server.url), { method: "POST", body: JSON.stringify([ ["do_modify", "c"], @@ -237,7 +237,7 @@ describe("Bun.serve() cookies", () => { `); }); test("request that doesn't modify cookies doesn't set cookies", async () => { - const res = await fetch(server.url + "/tester", { + const res = await fetch(new URL("/tester", server.url), { method: "POST", body: JSON.stringify([]), headers: { @@ -324,7 +324,7 @@ describe("Bun.serve() cookies 2", () => { `); }); test("server sets cookie on redirect", async () => { - const response = await fetch(server.url + "/redirect", { + const response = await fetch(new URL("/redirect", server.url), { headers: { "Cookie": "abc=def; ghi=jkl", }, @@ -358,7 +358,7 @@ describe("cookie path option", () => { afterAll(() => server.stop()); test("cookie path option", async () => { - const response = await fetch(server.url + "/x/y"); + const response = await fetch(new URL("/x/y", server.url)); expect(response.status).toBe(200); expect(response.headers.getAll("Set-Cookie")).toMatchInlineSnapshot(` [ diff --git a/test/js/bun/http/async-iterator-stream.test.ts b/test/js/bun/http/async-iterator-stream.test.ts index 0af7f5d43390..2dd1976837b0 100644 --- a/test/js/bun/http/async-iterator-stream.test.ts +++ b/test/js/bun/http/async-iterator-stream.test.ts @@ -19,7 +19,7 @@ describe.concurrent("Streaming body via", () => { }, }); - const res = await fetch(`${server.url}/`); + const res = await fetch(server.url); const chunks: Uint8Array[] = []; for await (const chunk of res.body!) { chunks.push(chunk); @@ -244,7 +244,7 @@ describe.concurrent("Streaming body via", () => { }, }); try { - const res = await fetch(`${server.url}/`, { signal: aborter.signal }); + const res = await fetch(server.url, { signal: aborter.signal }); } catch (e: any) { expect(e).toBeInstanceOf(DOMException); expect(e.name).toBe("AbortError"); @@ -271,7 +271,7 @@ describe.concurrent("Streaming body via", () => { }, }); - const res = await fetch(`${server.url}/`); + const res = await fetch(server.url); const chunks: Uint8Array[] = []; for await (const chunk of res.body!) { chunks.push(chunk); diff --git a/test/js/bun/http/bun-serve-html.test.ts b/test/js/bun/http/bun-serve-html.test.ts index 32cbe93c1061..9970d35561ed 100644 --- a/test/js/bun/http/bun-serve-html.test.ts +++ b/test/js/bun/http/bun-serve-html.test.ts @@ -1264,9 +1264,9 @@ test("you can have HTML imports apply to only specific methods outside of the de expect(response3.status).toBe(200); expect(await response3.text()).toBe("PATCH!"); - expect(await (await fetch(server.url + "/boop")).text()).toEqual(htmlText); - expect(await (await fetch(server.url + "/boop", { method: "POST" })).text()).toEqual(htmlText); - expect(await (await fetch(server.url + "/boop", { method: "PATCH" })).text()).toBe(htmlText); + expect(await (await fetch(new URL("/boop", server.url))).text()).toEqual(htmlText); + expect(await (await fetch(new URL("/boop", server.url), { method: "POST" })).text()).toEqual(htmlText); + expect(await (await fetch(new URL("/boop", server.url), { method: "PATCH" })).text()).toBe(htmlText); }); for (let development of [true, false, { hmr: false }]) { diff --git a/test/js/bun/http/bun-serve-static.test.ts b/test/js/bun/http/bun-serve-static.test.ts index da17c7aa4156..c3c9504a7745 100644 --- a/test/js/bun/http/bun-serve-static.test.ts +++ b/test/js/bun/http/bun-serve-static.test.ts @@ -54,7 +54,7 @@ describe.todoIf(isBroken && isMacOS)("static", () => { it("GET", async () => { const previousCallCount = handler.mock.calls.length; - const res = await fetch(`${server.url}${path}`); + const res = await fetch(new URL(path, server.url)); expect(res.status).toBe(200); expect(await res.bytes()).toEqual(await static_responses[path].bytes()); expect(handler.mock.calls.length, "Handler should not be called").toBe(previousCallCount); @@ -63,7 +63,7 @@ describe.todoIf(isBroken && isMacOS)("static", () => { it("HEAD", async () => { const previousCallCount = handler.mock.calls.length; - const res = await fetch(`${server.url}${path}`, { method: "HEAD" }); + const res = await fetch(new URL(path, server.url), { method: "HEAD" }); expect(res.status).toBe(200); expect(await res.bytes()).toHaveLength(0); expect(res.headers.get("Content-Length")).toBe(static_responses[path].size.toString()); @@ -73,7 +73,7 @@ describe.todoIf(isBroken && isMacOS)("static", () => { it("/redirect", async () => { const previousCallCount = handler.mock.calls.length; - const res = await fetch(`${server.url}/redirect`, { redirect: "manual" }); + const res = await fetch(new URL("/redirect", server.url), { redirect: "manual" }); expect(res.status).toBe(302); expect(res.headers.get("Location")).toBe("/foo/bar"); expect(handler.mock.calls.length, "Handler should not be called").toBe(previousCallCount); @@ -81,7 +81,7 @@ describe.todoIf(isBroken && isMacOS)("static", () => { it("/redirect (follow)", async () => { const previousCallCount = handler.mock.calls.length; - const res = await fetch(`${server.url}/redirect`); + const res = await fetch(new URL("/redirect", server.url)); expect(res.status).toBe(200); expect(res.url).toBe(`${server.url}foo/bar`); expect(await res.text()).toBe("/foo/bar"); @@ -91,7 +91,7 @@ describe.todoIf(isBroken && isMacOS)("static", () => { it("/redirect/fallback", async () => { const previousCallCount = handler.mock.calls.length; - const res = await fetch(`${server.url}/redirect/fallback`); + const res = await fetch(new URL("/redirect/fallback", server.url)); expect(res.status).toBe(200); expect(await res.text()).toBe(`${server.url}foo/bar/fallback`); expect(handler.mock.calls.length, "Handler should be called").toBe(previousCallCount + 1); diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 5e154590f13d..474a48264b7a 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -2340,20 +2340,20 @@ describe.concurrent("HEAD requests #15355", () => { }); { - const response = await fetch(server.url + "/content-length"); + const response = await fetch(new URL("/content-length", server.url)); expect(response.status).toBe(200); expect(response.headers.get("content-length")).toBe("11"); expect(await response.text()).toBe("Hello World"); } { - const response = await fetch(server.url + "/chunked"); + const response = await fetch(new URL("/chunked", server.url)); expect(response.status).toBe(200); expect(response.headers.get("transfer-encoding")).toBe("chunked"); expect(await response.text()).toBe("Hello World"); } { - const response = await fetch(server.url + "/content-length", { + const response = await fetch(new URL("/content-length", server.url), { method: "HEAD", }); expect(response.status).toBe(200); @@ -2361,7 +2361,7 @@ describe.concurrent("HEAD requests #15355", () => { expect(await response.text()).toBe(""); } { - const response = await fetch(server.url + "/chunked", { + const response = await fetch(new URL("/chunked", server.url), { method: "HEAD", }); expect(response.status).toBe(200); @@ -2402,20 +2402,20 @@ describe.concurrent("HEAD requests #15355", () => { }); { - const response = await fetch(server.url + "/content-length"); + const response = await fetch(new URL("/content-length", server.url)); expect(response.status).toBe(200); expect(response.headers.get("content-length")).toBe("11"); expect(await response.text()).toBe("Hello World"); } { - const response = await fetch(server.url + "/chunked"); + const response = await fetch(new URL("/chunked", server.url)); expect(response.status).toBe(200); expect(response.headers.get("transfer-encoding")).toBe("chunked"); expect(await response.text()).toBe("Hello World"); } { - const response = await fetch(server.url + "/content-length", { + const response = await fetch(new URL("/content-length", server.url), { method: "HEAD", }); expect(response.status).toBe(200); @@ -2423,7 +2423,7 @@ describe.concurrent("HEAD requests #15355", () => { expect(await response.text()).toBe(""); } { - const response = await fetch(server.url + "/chunked", { + const response = await fetch(new URL("/chunked", server.url), { method: "HEAD", }); expect(response.status).toBe(200); @@ -2472,7 +2472,7 @@ describe.concurrent("HEAD requests #15355", () => { }, }); { - const response = await fetch(server.url + "/content-length", { + const response = await fetch(new URL("/content-length", server.url), { method: "HEAD", }); expect(response.status).toBe(200); @@ -2481,7 +2481,7 @@ describe.concurrent("HEAD requests #15355", () => { expect(await response.text()).toBe(""); } { - const response = await fetch(server.url + "/chunked", { + const response = await fetch(new URL("/chunked", server.url), { method: "HEAD", }); expect(response.status).toBe(200); @@ -2490,7 +2490,7 @@ describe.concurrent("HEAD requests #15355", () => { expect(await response.text()).toBe(""); } { - const response = await fetch(server.url + "/null", { + const response = await fetch(new URL("/null", server.url), { method: "HEAD", }); expect(response.status).toBe(200); @@ -2523,7 +2523,7 @@ describe.concurrent("HEAD requests #15355", () => { expect(await response.text()).toBe("Hello World"); } { - const response = await fetch(server.url + "/file"); + const response = await fetch(new URL("/file", server.url)); expect(response.status).toBe(200); expect(response.headers.get("content-length")).toBe("11"); expect(await response.text()).toBe("Hello World"); @@ -2571,7 +2571,7 @@ describe.concurrent("HEAD requests #15355", () => { expect(await response.text()).toBe(""); } { - const response = await fetch(server.url + "/file", { + const response = await fetch(new URL("/file", server.url), { method: "HEAD", }); expect(response.status).toBe(200); diff --git a/test/js/bun/http/js-sink-sourmap-fixture/index.mjs b/test/js/bun/http/js-sink-sourmap-fixture/index.mjs index 11d5025db0c7..cb641d2ee847 100644 --- a/test/js/bun/http/js-sink-sourmap-fixture/index.mjs +++ b/test/js/bun/http/js-sink-sourmap-fixture/index.mjs @@ -5596,7 +5596,7 @@ try { }); console.log(`Listening on http://localhost:${server.port}...`); - const result = await fetch(`${server.url}/stream`).then(res => res.text()); + const result = await fetch(new URL("/stream", server.url)).then(res => res.text()); process.exit(result == "nitroisawesome" ? 0 : 2); } catch { process.exit(1); diff --git a/test/js/node/http/max-header-size-fixture.ts b/test/js/node/http/max-header-size-fixture.ts index 04954c9c4759..dc346ef956b7 100644 --- a/test/js/node/http/max-header-size-fixture.ts +++ b/test/js/node/http/max-header-size-fixture.ts @@ -11,14 +11,14 @@ using server = Bun.serve({ }, }); -await fetch(`${server.url}/`, { +await fetch(server.url, { headers: { "Huge": Buffer.alloc(Math.max(http.maxHeaderSize, 256) - 256, "abc").toString(), }, }); try { - const response = await fetch(`${server.url}/`, { + const response = await fetch(server.url, { headers: { "Huge": Buffer.alloc(http.maxHeaderSize + 1024, "abc").toString(), }, diff --git a/test/js/node/http/node-http-maxHeaderSize.test.ts b/test/js/node/http/node-http-maxHeaderSize.test.ts index 7679f86c4e6a..6713f69e16c7 100644 --- a/test/js/node/http/node-http-maxHeaderSize.test.ts +++ b/test/js/node/http/node-http-maxHeaderSize.test.ts @@ -21,7 +21,7 @@ test("maxHeaderSize", async () => { }); { - const response = await fetch(`${server.url}/`, { + const response = await fetch(server.url, { headers: { "Huge": Buffer.alloc(8 * 1024, "abc").toString(), }, @@ -30,7 +30,7 @@ test("maxHeaderSize", async () => { } { - const response = await fetch(`${server.url}/`, { + const response = await fetch(server.url, { headers: { "Huge": Buffer.alloc(15 * 1024, "abc").toString(), }, @@ -49,7 +49,7 @@ test("maxHeaderSize", async () => { }); { - const response = await fetch(`${server.url}/`, { + const response = await fetch(server.url, { headers: { "Huge": Buffer.alloc(15 * 1024, "abc").toString(), }, @@ -58,7 +58,7 @@ test("maxHeaderSize", async () => { } { - const response = await fetch(`${server.url}/`, { + const response = await fetch(server.url, { headers: { "Huge": Buffer.alloc(17 * 1024, "abc").toString(), }, diff --git a/test/js/web/fetch/abortsignal-leak-fixture.ts b/test/js/web/fetch/abortsignal-leak-fixture.ts index c3445b215d47..c651d7f909c5 100644 --- a/test/js/web/fetch/abortsignal-leak-fixture.ts +++ b/test/js/web/fetch/abortsignal-leak-fixture.ts @@ -51,7 +51,7 @@ export function createAbortSignalLeakSuite({ http2 = false }: { http2?: boolean // This test checks that calling req.signal doesn't cause the AbortSignal to be leaked. async function testReqSignalGetter() { - const url = `${server.url}/req-signal-aborted`; + const url = new URL("/req-signal-aborted", server.url).href; const batchSize = 50; const iterations = isDebug || isASAN ? 15 : 50; @@ -95,7 +95,7 @@ export function createAbortSignalLeakSuite({ http2 = false }: { http2?: boolean // This test checks that calling req.signal.addEventListener("abort", ...) // doesn't cause the AbortSignal to be leaked after the request is aborted. async function testReqSignalAbortEvent() { - const url = `${server.url}/req-signal-aborted`; + const url = new URL("/req-signal-aborted", server.url).href; const batchSize = 50; const iterations = isDebug || isASAN ? 15 : 50; @@ -153,7 +153,7 @@ export function createAbortSignalLeakSuite({ http2 = false }: { http2?: boolean // This test checks that we decrement the pending activity count for the AbortSignal. async function testReqSignalAbortEventNeverResolves() { - const url = `${server.url}/req-signal-aborted`; + const url = new URL("/req-signal-aborted", server.url).href; const batchSize = 50; const iterations = isDebug || isASAN ? 15 : 50; diff --git a/test/js/web/fetch/fetch-args.test.ts b/test/js/web/fetch/fetch-args.test.ts index 619908e8fffd..87cd1770ebc5 100644 --- a/test/js/web/fetch/fetch-args.test.ts +++ b/test/js/web/fetch/fetch-args.test.ts @@ -25,7 +25,7 @@ test("fetch(request subclass with headers)", async () => { this.headers.set("hello", "world"); } } - const myRequest = new MyRequest(server!.url + "/"); + const myRequest = new MyRequest(server!.url.href); const { headers } = await fetch(myRequest); expect(headers.get("hello")).toBe("world"); diff --git a/test/js/web/fetch/fetch.brotli.test.ts b/test/js/web/fetch/fetch.brotli.test.ts index 014627e050d1..5ce94ceeca8a 100644 --- a/test/js/web/fetch/fetch.brotli.test.ts +++ b/test/js/web/fetch/fetch.brotli.test.ts @@ -34,17 +34,17 @@ test("fetch brotli response works", async () => { }, }); const [firstText, secondText, { headers }] = await Promise.all([ - fetch(`${server.url}/logo.svg`, { + fetch(new URL("/logo.svg", server.url), { headers: { "Accept-Encoding": "br", }, }).then(res => res.text()), - fetch(`${server.url}/logo.svg`, { + fetch(new URL("/logo.svg", server.url), { headers: { "Accept-Encoding": "gzip", }, }).then(res => res.text()), - fetch(`${server.url}/logo.svg`, { + fetch(new URL("/logo.svg", server.url), { headers: { "Accept-Encoding": "br", }, diff --git a/test/regression/issue/18547.test.ts b/test/regression/issue/18547.test.ts index 31980159d001..2f50b315b722 100644 --- a/test/regression/issue/18547.test.ts +++ b/test/regression/issue/18547.test.ts @@ -21,7 +21,7 @@ test("18547", async () => { }, }); - const response = await fetch(`${serve.url}/foo`); + const response = await fetch(new URL("/foo", serve.url)); // Or the context of the original request expect(response.headers.get("set-cookie")).toEqual("sessionToken=123456; Path=/; SameSite=Lax"); }); diff --git a/test/regression/issue/24817.test.ts b/test/regression/issue/24817.test.ts index 931511414318..713338445932 100644 --- a/test/regression/issue/24817.test.ts +++ b/test/regression/issue/24817.test.ts @@ -13,11 +13,9 @@ test("static routes should handle unicode correctly", async () => { }, }); - const baseUrl = server.url.href; - // Test basic unicode character { - const staticResp = await fetch(`${baseUrl}/static`); + const staticResp = await fetch(new URL("/static", server.url)); const staticText = await staticResp.text(); expect(staticText).toBe("▲"); @@ -26,7 +24,7 @@ test("static routes should handle unicode correctly", async () => { // A static route and a dynamic one must describe the same body the same way. { - const dynamicResp = await fetch(`${baseUrl}/dynamic`); + const dynamicResp = await fetch(new URL("/dynamic", server.url)); expect(await dynamicResp.text()).toBe("▲"); expect(dynamicResp.headers.get("content-type")).toBe("text/plain;charset=utf-8"); @@ -34,7 +32,7 @@ test("static routes should handle unicode correctly", async () => { // Test Japanese characters { - const resp = await fetch(`${baseUrl}/unicode-string`); + const resp = await fetch(new URL("/unicode-string", server.url)); const text = await resp.text(); expect(text).toBe("こんにちは世界"); @@ -43,7 +41,7 @@ test("static routes should handle unicode correctly", async () => { // Test emoji { - const resp = await fetch(`${baseUrl}/emoji`); + const resp = await fetch(new URL("/emoji", server.url)); const text = await resp.text(); expect(text).toBe("🎉🚀✨"); @@ -59,9 +57,7 @@ test("static routes with explicit content-type should not override", async () => }, }); - const baseUrl = server.url.href; - - const resp = await fetch(`${baseUrl}/custom`); + const resp = await fetch(new URL("/custom", server.url)); const text = await resp.text(); expect(text).toBe("▲"); diff --git a/test/regression/issue/test-21049.test.ts b/test/regression/issue/test-21049.test.ts index e2802d5a4208..174da1496813 100644 --- a/test/regression/issue/test-21049.test.ts +++ b/test/regression/issue/test-21049.test.ts @@ -22,29 +22,31 @@ test("fetch with Request object respects redirect: 'manual' option", async () => }, }); + const redirect = new URL("/redirect", server.url).href; + // Test 1: Direct fetch with redirect: "manual" (currently works) - const directResponse = await fetch(`${server.url}/redirect`, { + const directResponse = await fetch(redirect, { redirect: "manual", }); expect(directResponse.status).toBe(302); - expect(directResponse.url).toBe(`${server.url}/redirect`); + expect(directResponse.url).toBe(redirect); expect(directResponse.headers.get("location")).toBe("/target"); expect(directResponse.redirected).toBe(false); // Test 2: Fetch with Request object and redirect: "manual" (currently broken) - const request = new Request(`${server.url}/redirect`, { + const request = new Request(redirect, { redirect: "manual", }); const requestResponse = await fetch(request); expect(requestResponse.status).toBe(302); - expect(requestResponse.url).toBe(`${server.url}/redirect`); // This should be the original URL, not the target + expect(requestResponse.url).toBe(redirect); // This should be the original URL, not the target expect(requestResponse.headers.get("location")).toBe("/target"); expect(requestResponse.redirected).toBe(false); // Test 3: Verify the behavior matches Node.js and Deno const testScript = ` async function main() { - const request = new Request("${server.url}/redirect", { + const request = new Request("${redirect}", { redirect: "manual", }); const response = await fetch(request); @@ -76,7 +78,7 @@ test("fetch with Request object respects redirect: 'manual' option", async () => // Actual (bug): status=200, url=target, redirected=true expect(bunResult).toEqual({ status: 302, - url: `${server.url}/redirect`, + url: redirect, redirected: false, location: "/target", }); @@ -98,7 +100,8 @@ test("fetch with Request object respects redirect: 'manual' for external URLs", }, }); - const request = new Request(`${server.url}/redirect`, { + const redirect = new URL("/redirect", server.url).href; + const request = new Request(redirect, { redirect: "manual", }); @@ -106,7 +109,7 @@ test("fetch with Request object respects redirect: 'manual' for external URLs", // When redirect: "manual" is set, we should get the redirect response expect(response.status).toBe(302); - expect(response.url).toBe(`${server.url}/redirect`); + expect(response.url).toBe(redirect); expect(response.redirected).toBe(false); expect(response.headers.get("location")).toBe("/target"); }); @@ -139,7 +142,8 @@ test("fetch with Request respects redirect when fetch has other options but no r }); // Create a Request with redirect: "manual" - const request = new Request(`${server.url}/redirect`, { + const redirect = new URL("/redirect", server.url).href; + const request = new Request(redirect, { redirect: "manual", headers: { "X-Original": "request", @@ -156,7 +160,7 @@ test("fetch with Request respects redirect when fetch has other options but no r }); expect(response1.status).toBe(302); - expect(response1.url).toBe(`${server.url}/redirect`); + expect(response1.url).toBe(redirect); expect(response1.redirected).toBe(false); expect(response1.headers.get("location")).toBe("/target"); @@ -177,6 +181,6 @@ test("fetch with Request respects redirect when fetch has other options but no r const response3 = await fetch(request, {}); expect(response3.status).toBe(302); - expect(response3.url).toBe(`${server.url}/redirect`); + expect(response3.url).toBe(redirect); expect(response3.redirected).toBe(false); }); From d12a7eefd1b1407363420b5098d83d6834e2aa89 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:47:07 +0000 Subject: [PATCH 03/10] install: send a registry path as written, trim a trailing slash of GITHUB_API_DOMAIN A registry typed as http://host//npm/ is requested as //npm/pkg, as npm does. The token keyed to //host//npm/ is the one sent to it. bun upgrade and bun create build https://{GITHUB_API_DOMAIN}/repos/... A domain with a trailing slash wrote //repos, which the URL parser used to hide. Trim the slash. FileSystemRouter#match(url) reports the pathname as new URL() reads it. --- src/runtime/cli/create_command.rs | 1 + src/runtime/cli/upgrade_command.rs | 1 + test/cli/install/bun-upgrade.test.ts | 36 +++++++++++ test/cli/install/npmrc.test.ts | 71 ++++++++++++++++++++++ test/js/bun/util/filesystem_router.test.ts | 17 ++++++ 5 files changed, 126 insertions(+) diff --git a/src/runtime/cli/create_command.rs b/src/runtime/cli/create_command.rs index c2145c8d5658..6b92b7d8bed8 100644 --- a/src/runtime/cli/create_command.rs +++ b/src/runtime/cli/create_command.rs @@ -1666,6 +1666,7 @@ impl Example { let mut github_api_domain: &[u8] = b"api.github.com"; if let Some(api_domain) = env_loader.map.get(b"GITHUB_API_DOMAIN") { + let api_domain = strings::trim_right(api_domain, b"/"); if !api_domain.is_empty() { github_api_domain = api_domain; } diff --git a/src/runtime/cli/upgrade_command.rs b/src/runtime/cli/upgrade_command.rs index 2cba3f5e5983..35f265286766 100644 --- a/src/runtime/cli/upgrade_command.rs +++ b/src/runtime/cli/upgrade_command.rs @@ -201,6 +201,7 @@ impl UpgradeCommand { // Incase they're using a GitHub proxy in e.g. China let mut github_api_domain: &[u8] = b"api.github.com"; if let Some(api_domain) = env_loader.map.get(b"GITHUB_API_DOMAIN") { + let api_domain = strings::trim_right(api_domain, b"/"); if !api_domain.is_empty() { github_api_domain = api_domain; } diff --git a/test/cli/install/bun-upgrade.test.ts b/test/cli/install/bun-upgrade.test.ts index 2f16814a04c5..5ad8baab6300 100644 --- a/test/cli/install/bun-upgrade.test.ts +++ b/test/cli/install/bun-upgrade.test.ts @@ -323,6 +323,42 @@ describe.concurrent(() => { expect(err).toEndWith(unpackFailure); expect(exitCode).toBe(1); }); + + it("GITHUB_API_DOMAIN with a trailing slash still requests /repos/..., not //repos/...", async () => { + const paths: string[] = []; + using server = Bun.serve({ + tls, + port: 0, + fetch(req) { + const { pathname } = new URL(req.url); + paths.push(pathname); + if (pathname !== "/repos/Jarred-Sumner/bun-releases-for-updater/releases/latest") { + return new Response("not found", { status: 404 }); + } + return Response.json({ tag_name: "bun-v9.9.9", assets: [] }); + }, + }); + using stagingRoot = tempDir("bun-upgrade-domain-slash", {}); + await using proc = spawn({ + cmd: [standInExe, "upgrade", "--stable"], + cwd: standInDir, + stdout: null, + stdin: "pipe", + stderr: "pipe", + env: { + ...env, + NODE_TLS_REJECT_UNAUTHORIZED: "0", + GITHUB_API_DOMAIN: `${server.hostname}:${server.port}/`, + BUN_TMPDIR: String(stagingRoot), + ASAN_OPTIONS: [env.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"), + }, + }); + + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(paths).toEqual(["/repos/Jarred-Sumner/bun-releases-for-updater/releases/latest"]); + expect(err).toContain("Bun v9.9.9 is out, but not for this platform"); + expect(exitCode).toBe(0); + }); }); it.concurrent( diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 4064719128ab..7fd166f143e3 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -2842,6 +2842,77 @@ describe.concurrent("a registry URL whose host is easy to misread", () => { }); }); +describe.concurrent("the path of a request to the registry", () => { + // bun sends the path of the URL it built, a leading `//` included, as npm does. Each row has a + // registry of its own, which records the request target and answers 404. + type Row = { files: Record; cmd?: string[] }; + + async function run({ files, cmd = ["install"] }: Row) { + const requests: { target: string; auth: string | null }[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + const url = new URL(req.url); + requests.push({ target: url.pathname + url.search, auth: req.headers.get("authorization") }); + return Response.json({ error: "not found" }, { status: 404 }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("registry-request-path", { + "package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "no-deps": "1.0.0" } }), + ...Object.fromEntries(Object.entries(files).map(([name, text]) => [name, text.replaceAll("HOST", host)])), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...cmd], + cwd: String(dir), + // An ambient proxy would take the requests to the local registry. + env: { + ...env, + BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache"), + http_proxy: "", + https_proxy: "", + HTTP_PROXY: "", + HTTPS_PROXY: "", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { requests, exitCode }; + } + + test.each<[string, Row, { target: string; auth: string | null }]>([ + [ + "a registry at the root, written with two slashes", + { files: { ".npmrc": "registry=http://HOST//\n" } }, + { target: "/no-deps", auth: null }, + ], + [ + "a registry whose path starts with //", + { files: { ".npmrc": "registry=http://HOST//npm/\n" } }, + { target: "//npm/no-deps", auth: null }, + ], + [ + "a registry whose path starts with //, for bun pm view", + { files: { ".npmrc": "registry=http://HOST//npm/\n" }, cmd: ["pm", "view", "no-deps"] }, + { target: "//npm/no-deps", auth: null }, + ], + [ + "a token keyed to the path of the registry as written", + { files: { ".npmrc": "registry=http://HOST//npm/\n//HOST//npm/:_authToken=abc\n" } }, + { target: "//npm/no-deps", auth: "Bearer abc" }, + ], + [ + "a token keyed to /npm/ is not for the registry at //npm/", + { files: { ".npmrc": "registry=http://HOST//npm/\n//HOST/npm/:_authToken=abc\n" } }, + { target: "//npm/no-deps", auth: null }, + ], + ])("%s", async (_, row, request) => { + expect(await run(row)).toEqual({ requests: [request], exitCode: 1 }); + }); +}); + describe("protocol-relative registry", () => { test("bun install rejects registry=//host:port/path/ instead of requesting http://localhost/host/", async () => { // The `:port` used to be taken for a `:option=value` suffix of the pathname and the diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 28c6fd9f0437..a80ee1fed28c 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -281,6 +281,23 @@ it("should support Request", async () => { } }); +it("match(url) keeps the leading slashes of the pathname, as new URL() reads them", () => { + const { dir } = make(["index.tsx", "posts/[id].tsx", "posts.tsx"]); + const router = new Bun.FileSystemRouter({ dir, style: "nextjs" }); + + const pick = (url: string) => { + const match = router.match(url)!; + return { name: match.name, pathname: match.pathname, params: match.params }; + }; + expect(pick("http://example.com//posts/hello-world")).toEqual({ + name: "/posts/[id]", + pathname: "//posts/hello-world", + params: { id: "hello-world" }, + }); + expect(pick("http://example.com//posts")).toEqual({ name: "/posts", pathname: "//posts", params: {} }); + expect(pick("http://example.com//")).toEqual({ name: "/", pathname: "//", params: {} }); +}); + it("assetPrefix, src, and origin", async () => { // set up the test const { dir } = make(["index.tsx", "posts/[id].tsx", "posts.tsx"]); From e74a64f7d7e23d9155f42dc70011e08bdbefaaa5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:00:56 +0000 Subject: [PATCH 04/10] test: read the whole request head before recording the request line --- test/js/web/fetch/fetch.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 6f5a6a8fb632..865ce7a3ef82 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -3126,8 +3126,11 @@ it("keeps the leading slashes of the pathname in the request-target", async () = // as written, like node and curl do. See #45330. const targets: string[] = []; await using server = net.createServer(socket => { - socket.once("data", data => { - targets.push(data.toString("utf8").split("\r\n")[0]); + let head = ""; + socket.on("data", data => { + head += data.toString("utf8"); + if (!head.includes("\r\n\r\n")) return; + targets.push(head.split("\r\n")[0]); socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); }); }); From f9b98d789004ce9140c192ea08d8def26f715dac Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:01:22 +0000 Subject: [PATCH 05/10] url: drop a comment from the unit test --- src/url/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index 0f5aeb887c84..379ee0700232 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -1860,8 +1860,6 @@ mod tests { #[test] fn pathname_keeps_its_leading_slashes() { - // The request-target is `new URL(s).pathname + search`, and WHATWG - // keeps every leading `/` of a special URL's path. let url = URL::parse(b"http://localhost:3000//a//b?x=1"); assert_eq!(url.pathname, b"//a//b?x=1"); assert_eq!(url.search, b"?x=1"); From e10b099bf2714ffb38c9de1f8149f9ca4fb3a69c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:09:35 +0000 Subject: [PATCH 06/10] url: keep the leading slashes of a registry path in href_without_auth, drop every trailing slash of an S3 endpoint A registry with credentials in its URL, http://u:p@host//npm/, is now requested at //npm/pkg like the same registry without credentials. An S3 endpoint is a base: https://host//, https://host/ and https://host all request /bucket/key. parse_s3_endpoint removed one trailing slash and left //bucket/key in the signed path. Tests: the proxy absolute form and the HTTP/2 :path, bun create with a GITHUB_API_DOMAIN that ends in a slash. --- src/url/lib.rs | 31 +++++++++++++--- test/cli/install/bun-create.test.ts | 14 ++++++++ test/cli/install/bun-upgrade.test.ts | 38 +++++++------------- test/cli/install/npmrc.test.ts | 5 +++ test/js/bun/s3/s3.test.ts | 26 ++++++++++++++ test/js/web/fetch/fetch-http2-client.test.ts | 24 +++++++++++++ test/js/web/fetch/fetch.test.ts | 7 ++++ 7 files changed, 116 insertions(+), 29 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index 379ee0700232..14e4a6c97c7f 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -398,7 +398,7 @@ impl<'a> URL<'a> { let mut host_with_path = url.hostname().to_owned_slice(); let pathname = url.pathname(); let path = pathname.to_utf8(); - host_with_path.extend_from_slice(strings::without_suffix_comptime(path.slice(), b"/")); + host_with_path.extend_from_slice(strings::trim_right(path.slice(), b"/")); Some(S3Endpoint { host_with_path: host_with_path.into_boxed_slice(), is_http, @@ -500,9 +500,10 @@ impl<'a> URL<'a> { } /// The URL without its userinfo, ending in exactly one `/`: `http://host/`, `http://host/npm/`. + /// The leading slashes of the path stay as written: `http://host//npm/`. pub fn href_without_auth(&self) -> Box<[u8]> { let proto = self.display_protocol(); - let path = strings::trim(self.pathname, b"/"); + let path = strings::trim_right(self.pathname, b"/"); let mut buf: Vec = Vec::with_capacity(proto.len() + 3 + self.host.len() + 1 + path.len() + 1); @@ -510,9 +511,11 @@ impl<'a> URL<'a> { buf.extend_from_slice(b"://"); // bun_core::io::Write on Vec is infallible. let _ = buf.print(format_args!("{}", self.display_host())); - buf.push(b'/'); + if !path.starts_with(b"/") { + buf.push(b'/'); + } + buf.extend_from_slice(path); if !path.is_empty() { - buf.extend_from_slice(path); buf.push(b'/'); } buf.into_boxed_slice() @@ -1870,6 +1873,26 @@ mod tests { assert_eq!(url.hash, b"#frag"); } + #[test] + fn href_without_auth_keeps_the_leading_slashes_and_ends_in_one() { + let href = |url: &[u8]| URL::parse(url).href_without_auth(); + assert_eq!(&*href(b"http://u:p@localhost:3000"), b"http://localhost:3000/"); + assert_eq!(&*href(b"http://u:p@localhost:3000//"), b"http://localhost:3000/"); + assert_eq!(&*href(b"http://u:p@localhost:3000/npm"), b"http://localhost:3000/npm/"); + assert_eq!( + &*href(b"http://u:p@localhost:3000//npm/"), + b"http://localhost:3000//npm/" + ); + } + + #[test] + fn s3_endpoint_drops_every_trailing_slash() { + let host = |url: &[u8]| URL::parse_s3_endpoint(url).unwrap().host_with_path; + assert_eq!(&*host(b"https://s3.example.com/"), b"s3.example.com"); + assert_eq!(&*host(b"https://s3.example.com//"), b"s3.example.com"); + assert_eq!(&*host(b"https://s3.example.com/prefix//"), b"s3.example.com/prefix"); + } + #[test] fn the_authority_ends_where_new_url_ends_it() { let url = URL::parse(br"http://u:p@first.example:8080\x@second.example/path"); diff --git a/test/cli/install/bun-create.test.ts b/test/cli/install/bun-create.test.ts index 7b42c5944580..ca16d137dfe2 100644 --- a/test/cli/install/bun-create.test.ts +++ b/test/cli/install/bun-create.test.ts @@ -958,6 +958,20 @@ describe.concurrent("GitHub template", () => { }, ); + it("GITHUB_API_DOMAIN with a trailing slash still requests /repos/..., not //repos/...", async () => { + using github = await serveGithub(createTest); + using dir = tempDir("create-github-domain-slash", {}); + const { out, err, exitCode } = await create(String(dir), ["github.com/dylan-conway/create-test", "--no-install"], { + ...github.env, + GITHUB_API_DOMAIN: `${github.env.GITHUB_API_DOMAIN}/`, + }); + + expect(err).not.toContain("error:"); + expect(github.requests).toEqual(["/repos/dylan-conway/create-test/tarball"]); + expect(out).toContain("Success! dylan-conway/create-test loaded into create-test"); + expect(exitCode).toBe(0); + }); + // The destination lost its .npmignore, and `git add` + `git commit` ran in the user's repository. it("leaves the .npmignore and the git repository of an existing destination alone", async () => { using github = await serveGithub({ "tpl.txt": "template" }); diff --git a/test/cli/install/bun-upgrade.test.ts b/test/cli/install/bun-upgrade.test.ts index 5ad8baab6300..66330e7c9042 100644 --- a/test/cli/install/bun-upgrade.test.ts +++ b/test/cli/install/bun-upgrade.test.ts @@ -176,7 +176,7 @@ async function writeFakeReleaseZip(outPath: string, version: string): Promise & { env: Record }; +type ReleaseServer = Bun.Server & { env: Record; requests: string[] }; function startReleaseServer(opts: { tagName: string; @@ -184,13 +184,17 @@ function startReleaseServer(opts: { zipPath?: string; zipBody?: string; digest?: string; + /** Appended to the `GITHUB_API_DOMAIN` the server hands out. */ + domainSuffix?: string; }): ReleaseServer { const assetNames = opts.assetNames ?? allAssetNames(); + const requests: string[] = []; const server = Bun.serve({ tls: tls, port: 0, async fetch(req) { const { pathname } = new URL(req.url); + requests.push(pathname); if (pathname.startsWith("/download/")) { if (opts.zipPath) return new Response(Bun.file(opts.zipPath)); return new Response(opts.zipBody ?? "this is not a real zip archive"); @@ -209,10 +213,11 @@ function startReleaseServer(opts: { ); }, }) as ReleaseServer; + server.requests = requests; server.env = { ...env, NODE_TLS_REJECT_UNAUTHORIZED: "0", - GITHUB_API_DOMAIN: `${server.hostname}:${server.port}`, + GITHUB_API_DOMAIN: `${server.hostname}:${server.port}${opts.domainSuffix ?? ""}`, // The upgrade-failure path exits via Global::exit(1) while the HTTP // thread and the intentionally-leaked progress/download buffers are // still live; LeakSanitizer reports those at exit and abort_on_error @@ -325,19 +330,7 @@ describe.concurrent(() => { }); it("GITHUB_API_DOMAIN with a trailing slash still requests /repos/..., not //repos/...", async () => { - const paths: string[] = []; - using server = Bun.serve({ - tls, - port: 0, - fetch(req) { - const { pathname } = new URL(req.url); - paths.push(pathname); - if (pathname !== "/repos/Jarred-Sumner/bun-releases-for-updater/releases/latest") { - return new Response("not found", { status: 404 }); - } - return Response.json({ tag_name: "bun-v9.9.9", assets: [] }); - }, - }); + using server = startReleaseServer({ tagName: "bun-v9.9.9", domainSuffix: "/" }); using stagingRoot = tempDir("bun-upgrade-domain-slash", {}); await using proc = spawn({ cmd: [standInExe, "upgrade", "--stable"], @@ -345,19 +338,14 @@ describe.concurrent(() => { stdout: null, stdin: "pipe", stderr: "pipe", - env: { - ...env, - NODE_TLS_REJECT_UNAUTHORIZED: "0", - GITHUB_API_DOMAIN: `${server.hostname}:${server.port}/`, - BUN_TMPDIR: String(stagingRoot), - ASAN_OPTIONS: [env.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"), - }, + env: { ...server.env, BUN_TMPDIR: String(stagingRoot) }, }); const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(paths).toEqual(["/repos/Jarred-Sumner/bun-releases-for-updater/releases/latest"]); - expect(err).toContain("Bun v9.9.9 is out, but not for this platform"); - expect(exitCode).toBe(0); + expect(server.requests[0]).toBe("/repos/Jarred-Sumner/bun-releases-for-updater/releases/latest"); + expectTargetsRelease(err, "9.9.9"); + expect(err).toEndWith(unpackFailure); + expect(exitCode).toBe(1); }); }); diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 7fd166f143e3..a3eb51f7f803 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -2898,6 +2898,11 @@ describe.concurrent("the path of a request to the registry", () => { { files: { ".npmrc": "registry=http://HOST//npm/\n" }, cmd: ["pm", "view", "no-deps"] }, { target: "//npm/no-deps", auth: null }, ], + [ + "a registry whose path starts with //, with credentials in its URL", + { files: { ".npmrc": "registry=http://alice:s3cret@HOST//npm/\n" } }, + { target: "//npm/no-deps", auth: `Basic ${btoa("alice:s3cret")}` }, + ], [ "a token keyed to the path of the registry as written", { files: { ".npmrc": "registry=http://HOST//npm/\n//HOST//npm/:_authToken=abc\n" } }, diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts index bb394b7301d2..7dc7ddf9846d 100644 --- a/test/js/bun/s3/s3.test.ts +++ b/test/js/bun/s3/s3.test.ts @@ -1486,6 +1486,32 @@ describe("s3-server", () => { } }); + describe.concurrent("an endpoint with trailing slashes", () => { + // The request path is `/bucket/key` for `https://host`, `https://host/` and `https://host//` + // alike: the endpoint is a base, and a base has no trailing slash. + for (const tail of ["", "/", "//", "/prefix/", "/prefix//"]) { + it(`requests /bucket/key below ${JSON.stringify(tail)}`, async () => { + const paths: string[] = []; + using server = Bun.serve({ + port: 0, + fetch(req) { + paths.push(new URL(req.url).pathname); + return new Response("Hello Bun!"); + }, + }); + const s3 = S3({ + accessKeyId: "test", + secretAccessKey: "test", + endpoint: `${server.url.origin}${tail}`, + bucket: "buntest", + }); + expect(await s3.file("hello.txt").text()).toBe("Hello Bun!"); + const prefix = tail.startsWith("/prefix") ? "/prefix" : ""; + expect(paths).toEqual([`${prefix}/buntest/hello.txt`]); + }); + } + }); + describe.concurrent("should accept / or \\ in start and end of bucket name", () => { let bucketPrefixI = 0; for (let start of ["/", "\\", ""]) { diff --git a/test/js/web/fetch/fetch-http2-client.test.ts b/test/js/web/fetch/fetch-http2-client.test.ts index 5c6ce045a692..f9bc558f8fc1 100644 --- a/test/js/web/fetch/fetch-http2-client.test.ts +++ b/test/js/web/fetch/fetch-http2-client.test.ts @@ -242,6 +242,30 @@ describe.concurrent("fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CL ); }); + test("the :path keeps the leading slashes of the pathname", async () => { + await withH2Server( + (req, res) => { + res.setHeader("x-seen-path", req.url); + res.end("ok"); + }, + async url => { + await using proc = await spawnFetch(` + const seen = []; + for (const tail of ["//a//b?x=1", "///", "//?q=1"]) { + const res = await fetch(${JSON.stringify(url)} + tail, { tls: { rejectUnauthorized: false } }); + await res.text(); + seen.push(res.headers.get("x-seen-path")); + } + console.log(JSON.stringify(seen)); + `); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["//a//b?x=1", "///", "//?q=1"]); + expect(exitCode).toBe(0); + }, + ); + }); + test("POST: request body is delivered as DATA frames", async () => { await withH2Server( (req, res) => { diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 865ce7a3ef82..15988d791bcd 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -3146,6 +3146,13 @@ it("keeps the leading slashes of the pathname in the request-target", async () = expected.push(`GET ${url.pathname}${url.search} HTTP/1.1`); await (await fetch(new Request(href))).text(); } + // Through an HTTP proxy the request-target is the absolute URL. + for (const tail of tails) { + const href = `http://example.test${tail}`; + const url = new URL(href); + expected.push(`GET ${url.origin}${url.pathname}${url.search} HTTP/1.1`); + await (await fetch(href, { proxy: `http://localhost:${port}` })).text(); + } expect(targets).toEqual(expected); }); From 6e186e3be4d56cfc6bc34f289d2f81920fe621e2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:11:04 +0000 Subject: [PATCH 07/10] [autofix.ci] apply automated fixes --- src/url/lib.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index 14e4a6c97c7f..96b2cca8dfbc 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -1876,9 +1876,18 @@ mod tests { #[test] fn href_without_auth_keeps_the_leading_slashes_and_ends_in_one() { let href = |url: &[u8]| URL::parse(url).href_without_auth(); - assert_eq!(&*href(b"http://u:p@localhost:3000"), b"http://localhost:3000/"); - assert_eq!(&*href(b"http://u:p@localhost:3000//"), b"http://localhost:3000/"); - assert_eq!(&*href(b"http://u:p@localhost:3000/npm"), b"http://localhost:3000/npm/"); + assert_eq!( + &*href(b"http://u:p@localhost:3000"), + b"http://localhost:3000/" + ); + assert_eq!( + &*href(b"http://u:p@localhost:3000//"), + b"http://localhost:3000/" + ); + assert_eq!( + &*href(b"http://u:p@localhost:3000/npm"), + b"http://localhost:3000/npm/" + ); assert_eq!( &*href(b"http://u:p@localhost:3000//npm/"), b"http://localhost:3000//npm/" @@ -1890,7 +1899,10 @@ mod tests { let host = |url: &[u8]| URL::parse_s3_endpoint(url).unwrap().host_with_path; assert_eq!(&*host(b"https://s3.example.com/"), b"s3.example.com"); assert_eq!(&*host(b"https://s3.example.com//"), b"s3.example.com"); - assert_eq!(&*host(b"https://s3.example.com/prefix//"), b"s3.example.com/prefix"); + assert_eq!( + &*host(b"https://s3.example.com/prefix//"), + b"s3.example.com/prefix" + ); } #[test] From f91606bfe70b2ea091664f48106998569a2a998f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:28:36 +0000 Subject: [PATCH 08/10] url: drop every trailing slash of an S3 endpoint on the fallback branch too --- src/url/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index 96b2cca8dfbc..021c74f1306e 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -389,7 +389,7 @@ impl<'a> URL<'a> { }; let Some(url) = normalized else { return Some(S3Endpoint { - host_with_path: Box::from(as_written.host_with_path()), + host_with_path: Box::from(strings::trim_right(as_written.host_with_path(), b"/")), is_http: as_written.is_http(), }); }; From 6e29c75078a63d8ad7e31e6e14a1e9ffa45976d1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:37:51 +0000 Subject: [PATCH 09/10] url: drop the S3 endpoint unit test, parse_s3_endpoint needs the WHATWG parser --- src/url/lib.rs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index 021c74f1306e..fe2cb56e3efa 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -1894,17 +1894,6 @@ mod tests { ); } - #[test] - fn s3_endpoint_drops_every_trailing_slash() { - let host = |url: &[u8]| URL::parse_s3_endpoint(url).unwrap().host_with_path; - assert_eq!(&*host(b"https://s3.example.com/"), b"s3.example.com"); - assert_eq!(&*host(b"https://s3.example.com//"), b"s3.example.com"); - assert_eq!( - &*host(b"https://s3.example.com/prefix//"), - b"s3.example.com/prefix" - ); - } - #[test] fn the_authority_ends_where_new_url_ends_it() { let url = URL::parse(br"http://u:p@first.example:8080\x@second.example/path"); From ddd8474a69924111768d2319c4102e797ebb62c1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:49:41 +0000 Subject: [PATCH 10/10] url: document the pathname and path fields, test the HTTP/3 :path The S3 endpoint trailing-slash trim moves to its own pull request: it fixes a signature mismatch that exists with or without the collapse. --- src/url/lib.rs | 6 +++-- test/js/bun/s3/s3.test.ts | 26 -------------------- test/js/web/fetch/fetch-http3-client.test.ts | 12 ++++++++- 3 files changed, 15 insertions(+), 29 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index fe2cb56e3efa..4fcf7065729c 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -204,7 +204,9 @@ pub struct URL<'a> { pub href: &'a [u8], pub origin: &'a [u8], pub password: &'a [u8], + /// The path and the query, as written: `//a//b?x=1`. The HTTP client sends it as the request-target. pub pathname: &'a [u8], + /// The path without the query, with one leading `/` and no trailing `/`: `/a//b`. `/x` collapses to `/`. pub path: &'a [u8], pub port: &'a [u8], pub protocol: &'a [u8], @@ -389,7 +391,7 @@ impl<'a> URL<'a> { }; let Some(url) = normalized else { return Some(S3Endpoint { - host_with_path: Box::from(strings::trim_right(as_written.host_with_path(), b"/")), + host_with_path: Box::from(as_written.host_with_path()), is_http: as_written.is_http(), }); }; @@ -398,7 +400,7 @@ impl<'a> URL<'a> { let mut host_with_path = url.hostname().to_owned_slice(); let pathname = url.pathname(); let path = pathname.to_utf8(); - host_with_path.extend_from_slice(strings::trim_right(path.slice(), b"/")); + host_with_path.extend_from_slice(strings::without_suffix_comptime(path.slice(), b"/")); Some(S3Endpoint { host_with_path: host_with_path.into_boxed_slice(), is_http, diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts index 7dc7ddf9846d..bb394b7301d2 100644 --- a/test/js/bun/s3/s3.test.ts +++ b/test/js/bun/s3/s3.test.ts @@ -1486,32 +1486,6 @@ describe("s3-server", () => { } }); - describe.concurrent("an endpoint with trailing slashes", () => { - // The request path is `/bucket/key` for `https://host`, `https://host/` and `https://host//` - // alike: the endpoint is a base, and a base has no trailing slash. - for (const tail of ["", "/", "//", "/prefix/", "/prefix//"]) { - it(`requests /bucket/key below ${JSON.stringify(tail)}`, async () => { - const paths: string[] = []; - using server = Bun.serve({ - port: 0, - fetch(req) { - paths.push(new URL(req.url).pathname); - return new Response("Hello Bun!"); - }, - }); - const s3 = S3({ - accessKeyId: "test", - secretAccessKey: "test", - endpoint: `${server.url.origin}${tail}`, - bucket: "buntest", - }); - expect(await s3.file("hello.txt").text()).toBe("Hello Bun!"); - const prefix = tail.startsWith("/prefix") ? "/prefix" : ""; - expect(paths).toEqual([`${prefix}/buntest/hello.txt`]); - }); - } - }); - describe.concurrent("should accept / or \\ in start and end of bucket name", () => { let bucketPrefixI = 0; for (let start of ["/", "\\", ""]) { diff --git a/test/js/web/fetch/fetch-http3-client.test.ts b/test/js/web/fetch/fetch-http3-client.test.ts index dfbd650a0a2d..54d995e761b0 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -159,7 +159,7 @@ beforeAll(async () => { ); }, }, - fetch: () => new Response("not found", { status: 404 }), + fetch: req => new Response("not found", { status: 404, headers: { "x-seen-path": new URL(req.url).pathname } }), }); base = `https://127.0.0.1:${server.port}`; @@ -197,6 +197,16 @@ describe("fetch protocol: http3", () => { expect(await res.text()).toBe("hello over h3"); }); + test("the :path keeps the leading slashes of the pathname", async () => { + const seen: string[] = []; + for (const tail of ["//a//b?x=1", "///", "//?q=1"]) { + const res = await fetch(`${base}${tail}`, h3); + await res.text(); + seen.push(res.headers.get("x-seen-path")!); + } + expect(seen).toEqual(["//a//b", "///", "//"]); + }); + test("'h3' alias", async () => { const res = await fetch(`${base}/hello`, { ...h3, protocol: "h3" }); expect(await res.text()).toBe("hello over h3");