diff --git a/src/install/lockfile/Tree.rs b/src/install/lockfile/Tree.rs index e13a27b6b90b..e85ef21605dc 100644 --- a/src/install/lockfile/Tree.rs +++ b/src/install/lockfile/Tree.rs @@ -806,11 +806,16 @@ 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..8ddbf274925a --- /dev/null +++ b/test/regression/issue/31652.test.ts @@ -0,0 +1,124 @@ +import { spawn } from "bun"; +import { expect, test } from "bun:test"; +import { bunExe, bunEnv as env, 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); + // 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); +});