From 8e8986333732157917d0d640ed077ad4bae38f16 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:59:40 +0000 Subject: [PATCH 1/4] install: don't fail on an unresolved optional dependency with an empty name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An optional dependency declared under an empty key ("optionalDependencies": { "": "..." }) whose target does not resolve on the current platform keeps its empty name — the resolved-package name is only substituted once the dependency resolves. The hoisting tree builder then rejected that empty name with `error: Invalid dependency name ""` and aborted the whole install. A dependency with an empty name has no `node_modules/` folder to escape, so guard `!name.is_empty()` before the safety check, matching the lockfile parser (bun.lock.rs) and isolated installer (isolated_install.rs) which already skip empty names instead of failing. Closes #31652 --- src/install/lockfile/Tree.rs | 13 +-- test/regression/issue/31652.test.ts | 123 ++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 test/regression/issue/31652.test.ts diff --git a/src/install/lockfile/Tree.rs b/src/install/lockfile/Tree.rs index e13a27b6b90b..3779e17e302a 100644 --- a/src/install/lockfile/Tree.rs +++ b/src/install/lockfile/Tree.rs @@ -806,11 +806,14 @@ impl Tree { let dependency = &dependencies[dep_id as usize]; - if !crate::dependency::is_safe_install_folder_name( - dependency - .name - .slice(lockfile.buffers.string_bytes.as_slice()), - ) { + // An empty alias has no `node_modules/` folder to escape, so + // don't treat it as unsafe — match the lockfile parser and isolated + // installer (`bun.lock.rs`, `isolated_install.rs`) which guard + // `!name.is_empty()` here rather than failing the whole install. + let dependency_name = dependency.name.slice(lockfile.buffers.string_bytes.as_slice()); + if !dependency_name.is_empty() + && !crate::dependency::is_safe_install_folder_name(dependency_name) + { builder.maybe_report_error(format_args!( "Invalid dependency name \"{}\"", dependency diff --git a/test/regression/issue/31652.test.ts b/test/regression/issue/31652.test.ts new file mode 100644 index 000000000000..16f350d4940a --- /dev/null +++ b/test/regression/issue/31652.test.ts @@ -0,0 +1,123 @@ +import { spawn } from "bun"; +import { expect, test } from "bun:test"; +import { bunEnv as env, bunExe, tempDir } from "harness"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import { join } from "path"; + +// Build a minimal gzipped npm tarball containing a single package.json. +function octal(n: number, width: number) { + return n.toString(8).padStart(width - 1, "0") + "\0"; +} +function tarHeader(name: string, size: number) { + const buf = Buffer.alloc(512, 0); + buf.write(name, 0, 100, "utf8"); + buf.write(octal(0o644, 8), 100); + buf.write(octal(0, 8), 108); + buf.write(octal(0, 8), 116); + buf.write(octal(size, 12), 124); + buf.write(octal(0, 12), 136); + buf.fill(" ", 148, 156); + buf.write("0", 156); // typeflag: regular file + buf.write("ustar\0", 257); + buf.write("00", 263); + let sum = 0; + for (let i = 0; i < 512; i++) sum += buf[i]; + buf.write(octal(sum, 8), 148); // checksum + return buf; +} +function pad512(len: number) { + return Buffer.alloc((512 - (len % 512)) % 512, 0); +} +function buildTarball(pkgJson: object) { + const body = Buffer.from(JSON.stringify(pkgJson) + "\n"); + const tar = Buffer.concat([ + tarHeader("package/package.json", body.length), + body, + pad512(body.length), + Buffer.alloc(1024, 0), // two zero blocks = end-of-archive + ]); + const tgz = gzipSync(tar); + return { tgz, integrity: "sha512-" + createHash("sha512").update(tgz).digest("base64") }; +} + +// Regression test for https://github.com/oven-sh/bun/issues/31652 +// +// `bun install -g @openai/codex` (and similar) aborted with +// `error: Invalid dependency name ""`. The trigger is an *optional* dependency +// declared with an empty key (`"optionalDependencies": { "": "..." }`) whose +// target does not resolve on the current platform. An optional dependency that +// fails to resolve keeps its empty name (the resolved-package name is never +// substituted), and the hoisting tree builder then rejected that empty name +// instead of skipping it — so the whole install failed. +// +// A dependency with an empty name has no `node_modules/` folder to +// escape, so it must be tolerated the same way the lockfile parser and +// isolated installer already handle it. Previously this worked (Bun 1.3.x); +// it broke on 1.4.0-canary. +test("install does not abort on an unresolved optional dependency with an empty name (#31652)", async () => { + // `top@1.0.0` declares an optional dependency under an empty key whose target + // (a package literally named "") cannot be resolved. + const top = buildTarball({ name: "top", version: "1.0.0" }); + + let emptyNameManifestRequested = false; + + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const url = new URL(req.url); + const base = `http://127.0.0.1:${server.port}`; + if (url.pathname === "/top") { + return Response.json({ + name: "top", + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: "top", + version: "1.0.0", + optionalDependencies: { "": "1.0.0" }, + dist: { integrity: top.integrity, tarball: `${base}/top/-/top-1.0.0.tgz` }, + }, + }, + }); + } + if (url.pathname === "/top/-/top-1.0.0.tgz") { + return new Response(top.tgz, { headers: { "content-length": String(top.tgz.length) } }); + } + // The empty-name dependency resolves to a request for a package named "", + // i.e. the registry root. Make it 404 so the optional dep fails to resolve. + if (url.pathname === "/") { + emptyNameManifestRequested = true; + } + return new Response("Not found", { status: 404 }); + }, + }); + + using dir = tempDir("issue-31652", { + "package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { top: "1.0.0" }, + }), + "bunfig.toml": `[install]\ncache = false\nregistry = "http://127.0.0.1:${server.port}/"\n`, + }); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The empty-name optional dependency must not abort the install. + expect(stderr).not.toContain('Invalid dependency name ""'); + // The requested package must still be installed. + expect(await Bun.file(join(String(dir), "node_modules", "top", "package.json")).exists()).toBe(true); + expect(exitCode).toBe(0); + // Sanity check that we actually exercised the empty-name resolution path. + expect(emptyNameManifestRequested).toBe(true); +}); From a29d0c38e8ab0167ccbfb07d980cd53ac57f83ce Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:01:56 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- src/install/lockfile/Tree.rs | 4 +++- test/regression/issue/31652.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/install/lockfile/Tree.rs b/src/install/lockfile/Tree.rs index 3779e17e302a..e85ef21605dc 100644 --- a/src/install/lockfile/Tree.rs +++ b/src/install/lockfile/Tree.rs @@ -810,7 +810,9 @@ impl Tree { // don't treat it as unsafe — match the lockfile parser and isolated // installer (`bun.lock.rs`, `isolated_install.rs`) which guard // `!name.is_empty()` here rather than failing the whole install. - let dependency_name = dependency.name.slice(lockfile.buffers.string_bytes.as_slice()); + let dependency_name = dependency + .name + .slice(lockfile.buffers.string_bytes.as_slice()); if !dependency_name.is_empty() && !crate::dependency::is_safe_install_folder_name(dependency_name) { diff --git a/test/regression/issue/31652.test.ts b/test/regression/issue/31652.test.ts index 16f350d4940a..a0064d3cf29e 100644 --- a/test/regression/issue/31652.test.ts +++ b/test/regression/issue/31652.test.ts @@ -1,6 +1,6 @@ import { spawn } from "bun"; import { expect, test } from "bun:test"; -import { bunEnv as env, bunExe, tempDir } from "harness"; +import { bunExe, bunEnv as env, tempDir } from "harness"; import { createHash } from "node:crypto"; import { gzipSync } from "node:zlib"; import { join } from "path"; From b6698156e465a2d79567123f534c34a9c71f66aa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:04:05 +0000 Subject: [PATCH 3/4] test: assert exit code last in #31652 regression test --- test/regression/issue/31652.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/regression/issue/31652.test.ts b/test/regression/issue/31652.test.ts index a0064d3cf29e..8ddbf274925a 100644 --- a/test/regression/issue/31652.test.ts +++ b/test/regression/issue/31652.test.ts @@ -117,7 +117,8 @@ test("install does not abort on an unresolved optional dependency with an empty expect(stderr).not.toContain('Invalid dependency name ""'); // The requested package must still be installed. expect(await Bun.file(join(String(dir), "node_modules", "top", "package.json")).exists()).toBe(true); - expect(exitCode).toBe(0); // Sanity check that we actually exercised the empty-name resolution path. expect(emptyNameManifestRequested).toBe(true); + // Assert the exit code last for a more useful message if a behavioral check fails. + expect(exitCode).toBe(0); }); From 17c6b90ca3e8a9be96e5acd8164f48fc0483852e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:45:52 +0000 Subject: [PATCH 4/4] ci: retrigger