From 18bc03e2cb13f8ac00daa0a1f7d70683dd8862bc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:59:02 +0000 Subject: [PATCH 1/5] url: emit a single slash in href_without_auth() for root-path URLs href_without_auth() appended "/" + trimmed pathname + "/" unconditionally, so a registry URL whose credentials were stripped and whose path was "/" was stored as "http://host//". Manifest URLs are joined onto that href, so every "GET - " line for such a registry printed a double slash. Skip the path and its trailing slash when the trimmed path is empty. --- src/url/lib.rs | 10 +++- test/cli/install/npmrc.test.ts | 102 +++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index 8c02d641839a..2299f294f44e 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -390,7 +390,9 @@ impl<'a> URL<'a> { } } - /// Formats `:////`. + /// Formats `:////`, or + /// `:///` when the pathname is `/`, so the + /// result always ends in exactly one slash. /// /// `display_host()` yields a `bun_core::fmt::HostFormatter` (impls /// `Display`); the other two pieces are raw byte slices, so we assemble @@ -407,8 +409,10 @@ impl<'a> URL<'a> { // bun_core::io::Write on Vec is infallible. let _ = buf.print(format_args!("{}", self.display_host())); buf.push(b'/'); - buf.extend_from_slice(path); - buf.push(b'/'); + if !path.is_empty() { + buf.extend_from_slice(path); + buf.push(b'/'); + } buf.into_boxed_slice() } diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index ec2e6adfcaee..0e07a18ff684 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -626,6 +626,108 @@ registry=https://somehost.com/org1/npm/registry/ }); }); +describe.concurrent("registry URL with embedded credentials", () => { + // Credentials written into the registry URL are split off and the URL is + // stored without them. The stored URL is what manifest URLs are joined onto + // and what error messages print, so a registry at the root of its host has + // to come back as "http://host/", not "http://host//". + test.each([ + ["http://alice:s3cret@registry.example.com/", "http://registry.example.com/"], + ["http://alice:s3cret@registry.example.com", "http://registry.example.com/"], + ["http://alice:s3cret@registry.example.com:8080", "http://registry.example.com:8080/"], + ["http://alice:s3cret@registry.example.com/npm/", "http://registry.example.com/npm/"], + ["http://alice:s3cret@registry.example.com/npm", "http://registry.example.com/npm/"], + ])("registry=%s is stored as %s", (registry, url) => { + expect(loadNpmrc(`registry=${registry}\n`)).toMatchObject({ + default_registry_url: url, + default_registry_username: "alice", + default_registry_password: "s3cret", + }); + }); + + test.each([ + ["http://:tok@registry.example.com/", "http://registry.example.com/"], + ["http://:tok@registry.example.com:8080", "http://registry.example.com:8080/"], + ["http://:tok@registry.example.com:8080/a/b/", "http://registry.example.com:8080/a/b/"], + ])("registry=%s is stored as %s", (registry, url) => { + expect(loadNpmrc(`registry=${registry}\n`)).toMatchObject({ + default_registry_url: url, + default_registry_token: "tok", + }); + }); + + type Req = { path: string; auth: string | null }; + const basicAuth = `Basic ${Buffer.from("alice:s3cret").toString("base64")}`; + + function unauthorizedRegistry(reqs: Req[]) { + return Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + reqs.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); + return new Response("unauthorized", { status: 401 }); + }, + }); + } + + async function install(dir: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: dir, + env: { ...env, BUN_INSTALL_CACHE_DIR: join(dir, ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + return { stderr: stderr.split(/\r?\n/), exitCode }; + } + + test("username:password in the .npmrc registry URL", async () => { + const reqs: Req[] = []; + await using server = unauthorizedRegistry(reqs); + using dir = tempDir("npmrc-userinfo-root", { + ".npmrc": `registry=http://alice:s3cret@127.0.0.1:${server.port}/\n`, + "package.json": JSON.stringify({ name: "app", dependencies: { "needs-creds": "1.0.0" } }), + }); + + const { stderr, exitCode } = await install(String(dir)); + + expect(stderr).toContain(`error: GET http://127.0.0.1:${server.port}/needs-creds - 401`); + expect(reqs).toEqual([{ path: "/needs-creds", auth: basicAuth }]); + expect(exitCode).toBe(1); + }); + + test("_authToken= appended to the bunfig.toml registry URL", async () => { + const reqs: Req[] = []; + await using server = unauthorizedRegistry(reqs); + using dir = tempDir("bunfig-authtoken-suffix-root", { + "bunfig.toml": `[install.registry]\nurl = "http://127.0.0.1:${server.port}/_authToken=tok"\n`, + "package.json": JSON.stringify({ name: "app", dependencies: { "needs-token": "1.0.0" } }), + }); + + const { stderr, exitCode } = await install(String(dir)); + + expect(stderr).toContain(`error: GET http://127.0.0.1:${server.port}/needs-token - 401`); + expect(reqs).toEqual([{ path: "/needs-token", auth: "Bearer tok" }]); + expect(exitCode).toBe(1); + }); + + test("registry URL with a path keeps the path", async () => { + const reqs: Req[] = []; + await using server = unauthorizedRegistry(reqs); + using dir = tempDir("npmrc-userinfo-path", { + ".npmrc": `registry=http://alice:s3cret@127.0.0.1:${server.port}/npm/\n`, + "package.json": JSON.stringify({ name: "app", dependencies: { "needs-creds": "1.0.0" } }), + }); + + const { stderr, exitCode } = await install(String(dir)); + + expect(stderr).toContain(`error: GET http://127.0.0.1:${server.port}/npm/needs-creds - 401`); + expect(reqs).toEqual([{ path: "/npm/needs-creds", auth: basicAuth }]); + expect(exitCode).toBe(1); + }); +}); + describe("scoped registry routing", () => { // A request for a @scope package must be sent only to that scope's configured // registry with that scope's token. The registry map was keyed by a bare From 9ace3eaabc179db135b800518bae1862380e597d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:12:37 +0000 Subject: [PATCH 2/5] test: drain stdout of the spawned install as well --- test/cli/install/npmrc.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 0e07a18ff684..538c4c5525d9 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -678,7 +678,7 @@ describe.concurrent("registry URL with embedded credentials", () => { stdout: "pipe", stderr: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); return { stderr: stderr.split(/\r?\n/), exitCode }; } From 985d16649667a3300f1435ed3b9380b8f4fa7b57 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:07:43 +0000 Subject: [PATCH 3/5] url: shorten the href_without_auth doc comment --- src/url/lib.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index 2299f294f44e..6d196feb009b 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -390,14 +390,11 @@ impl<'a> URL<'a> { } } - /// Formats `:////`, or - /// `:///` when the pathname is `/`, so the - /// result always ends in exactly one slash. + /// `:////`, or just + /// `:///` when the pathname is `/`. /// - /// `display_host()` yields a `bun_core::fmt::HostFormatter` (impls - /// `Display`); the other two pieces are raw byte slices, so we assemble - /// into a `Vec` directly rather than going through `format!` and - /// risking lossy UTF-8 round-trips. + /// Assembled as bytes because `display_host()` is a `Display` impl while + /// the other pieces are raw byte slices. pub fn href_without_auth(&self) -> Box<[u8]> { let proto = self.display_protocol(); let path = strings::trim(self.pathname, b"/"); From 7bebf73fbb7e423ff772bcc44cd8a02990b84eca Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:09:45 +0000 Subject: [PATCH 4/5] url: one-line doc comment for href_without_auth --- src/url/lib.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index 6d196feb009b..dc1dd00fdb7a 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -390,11 +390,7 @@ impl<'a> URL<'a> { } } - /// `:////`, or just - /// `:///` when the pathname is `/`. - /// - /// Assembled as bytes because `display_host()` is a `Display` impl while - /// the other pieces are raw byte slices. + /// The URL without its userinfo, ending in exactly one `/`: `http://host/`, `http://host/npm/`. pub fn href_without_auth(&self) -> Box<[u8]> { let proto = self.display_protocol(); let path = strings::trim(self.pathname, b"/"); From 0ae2bd4f15fe8641bafa956e5fb99216cef9c55b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:45:35 +0000 Subject: [PATCH 5/5] test: assert the stored registry href through bun pm whoami The loadNpmrc rows and the whoami cases read the stored href directly, so they keep exercising href_without_auth() even once the manifest join normalizes its base on its own. One install case keeps pinning the GET line. --- test/cli/install/npmrc.test.ts | 69 +++++++++++++++------------------- 1 file changed, 30 insertions(+), 39 deletions(-) diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 538c4c5525d9..65103879b123 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -628,9 +628,9 @@ registry=https://somehost.com/org1/npm/registry/ describe.concurrent("registry URL with embedded credentials", () => { // Credentials written into the registry URL are split off and the URL is - // stored without them. The stored URL is what manifest URLs are joined onto - // and what error messages print, so a registry at the root of its host has - // to come back as "http://host/", not "http://host//". + // stored without them. The stored URL is what requests are built from and + // what error messages print, so a registry at the root of its host has to + // come back as "http://host/", not "http://host//". test.each([ ["http://alice:s3cret@registry.example.com/", "http://registry.example.com/"], ["http://alice:s3cret@registry.example.com", "http://registry.example.com/"], @@ -657,73 +657,64 @@ describe.concurrent("registry URL with embedded credentials", () => { }); type Req = { path: string; auth: string | null }; - const basicAuth = `Basic ${Buffer.from("alice:s3cret").toString("base64")}`; - function unauthorizedRegistry(reqs: Req[]) { + function mockRegistry(reqs: Req[], respond: () => Response) { return Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(req) { reqs.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); - return new Response("unauthorized", { status: 401 }); + return respond(); }, }); } - async function install(dir: string) { + async function run(dir: string, ...args: string[]) { await using proc = Bun.spawn({ - cmd: [bunExe(), "install"], + cmd: [bunExe(), ...args], cwd: dir, env: { ...env, BUN_INSTALL_CACHE_DIR: join(dir, ".cache") }, stdout: "pipe", stderr: "pipe", }); const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - return { stderr: stderr.split(/\r?\n/), exitCode }; + return { stderr, exitCode }; } - test("username:password in the .npmrc registry URL", async () => { - const reqs: Req[] = []; - await using server = unauthorizedRegistry(reqs); - using dir = tempDir("npmrc-userinfo-root", { - ".npmrc": `registry=http://alice:s3cret@127.0.0.1:${server.port}/\n`, - "package.json": JSON.stringify({ name: "app", dependencies: { "needs-creds": "1.0.0" } }), - }); - - const { stderr, exitCode } = await install(String(dir)); - - expect(stderr).toContain(`error: GET http://127.0.0.1:${server.port}/needs-creds - 401`); - expect(reqs).toEqual([{ path: "/needs-creds", auth: basicAuth }]); - expect(exitCode).toBe(1); - }); - - test("_authToken= appended to the bunfig.toml registry URL", async () => { + // The documented bunfig form for a token: the token rides on the end of the + // URL's path. `bun pm whoami` prints the stored URL verbatim when the + // registry does not return a username. + test.each([ + ["/", "/-/whoami"], + ["/npm/", "/npm/-/whoami"], + ])("_authToken= appended to a bunfig.toml registry URL with path %s", async (path, whoami) => { const reqs: Req[] = []; - await using server = unauthorizedRegistry(reqs); - using dir = tempDir("bunfig-authtoken-suffix-root", { - "bunfig.toml": `[install.registry]\nurl = "http://127.0.0.1:${server.port}/_authToken=tok"\n`, - "package.json": JSON.stringify({ name: "app", dependencies: { "needs-token": "1.0.0" } }), + await using server = mockRegistry(reqs, () => Response.json({})); + const base = `http://127.0.0.1:${server.port}${path}`; + using dir = tempDir("bunfig-authtoken-suffix", { + "bunfig.toml": `[install.registry]\nurl = "${base}_authToken=tok"\n`, + "package.json": JSON.stringify({ name: "app" }), }); - const { stderr, exitCode } = await install(String(dir)); + const { stderr, exitCode } = await run(String(dir), "pm", "whoami"); - expect(stderr).toContain(`error: GET http://127.0.0.1:${server.port}/needs-token - 401`); - expect(reqs).toEqual([{ path: "/needs-token", auth: "Bearer tok" }]); + expect(stderr).toBe(`error: failed to authenticate with registry '${base}'\n`); + expect(reqs).toEqual([{ path: whoami, auth: "Bearer tok" }]); expect(exitCode).toBe(1); }); - test("registry URL with a path keeps the path", async () => { + test("username:password in the .npmrc registry URL", async () => { const reqs: Req[] = []; - await using server = unauthorizedRegistry(reqs); - using dir = tempDir("npmrc-userinfo-path", { - ".npmrc": `registry=http://alice:s3cret@127.0.0.1:${server.port}/npm/\n`, + await using server = mockRegistry(reqs, () => new Response("unauthorized", { status: 401 })); + using dir = tempDir("npmrc-userinfo", { + ".npmrc": `registry=http://alice:s3cret@127.0.0.1:${server.port}/\n`, "package.json": JSON.stringify({ name: "app", dependencies: { "needs-creds": "1.0.0" } }), }); - const { stderr, exitCode } = await install(String(dir)); + const { stderr, exitCode } = await run(String(dir), "install"); - expect(stderr).toContain(`error: GET http://127.0.0.1:${server.port}/npm/needs-creds - 401`); - expect(reqs).toEqual([{ path: "/npm/needs-creds", auth: basicAuth }]); + expect(stderr.split(/\r?\n/)).toContain(`error: GET http://127.0.0.1:${server.port}/needs-creds - 401`); + expect(reqs).toEqual([{ path: "/needs-creds", auth: `Basic ${Buffer.from("alice:s3cret").toString("base64")}` }]); expect(exitCode).toBe(1); }); });