125 lines
4.7 KiB
TypeScript
125 lines
4.7 KiB
TypeScript
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/<name>` 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 () => {
|
||
|
|
// `[email protected]` 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);
|
||
|
|
});
|