Files
bun-src/test/cli/install/bun-lockb.test.ts
2026-08-27 21:09:14 +00:00

462 lines
17 KiB
TypeScript

import { file, spawn, write } from "bun";
import { afterAll, beforeAll, expect, it } from "bun:test";
import { copyFile, exists, open, rm, writeFile } from "fs/promises";
import { bunExe, bunEnv as env, isWindows, runBunInstall, VerdaccioRegistry } from "harness";
import { join } from "path";
const registry = new VerdaccioRegistry();
beforeAll(async () => {
await registry.start();
});
afterAll(() => {
registry.stop();
});
it("should not print anything to stderr when running bun.lockb", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } });
// copy bar-0.0.2.tgz to package_dir
await copyFile(join(__dirname, "bar-0.0.2.tgz"), join(packageDir, "bar-0.0.2.tgz"));
// Create a simple package.json
await writeFile(
packageJson,
JSON.stringify({
name: "test-package",
version: "1.0.0",
dependencies: {
"dummy-package": "file:./bar-0.0.2.tgz",
},
}),
);
// Run 'bun install' to generate the lockfile
const installResult = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
env,
});
await installResult.exited;
// Ensure the lockfile was created
expect(await exists(join(packageDir, "bun.lockb"))).toBe(true);
// Assert that the lockfile has the correct permissions
await using file = await open(join(packageDir, "bun.lockb"), "r");
const stat = await file.stat();
// in unix, 0o755 == 33261
let mode = 33261;
// ..but windows is different
if (isWindows) {
mode = 33206;
}
expect(stat.mode).toBe(mode);
// create a .env
await writeFile(join(packageDir, ".env"), "FOO=bar");
// Now test 'bun bun.lockb'
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "bun.lockb"],
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
env,
});
const stdoutOutput = await stdout.text();
expect(stdoutOutput).toContain("# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.");
expect(stdoutOutput).toContain("# yarn lockfile v1");
expect(stdoutOutput).toContain("# bun ./bun.lockb --hash:");
expect(stdoutOutput).toContain('"bar@file:./bar-0.0.2.tgz":');
expect(stdoutOutput).toContain(' version "./bar-0.0.2.tgz"');
expect(stdoutOutput).toContain(' resolved "./bar-0.0.2.tgz"');
expect(stdoutOutput).toContain(" integrity sha512-");
const stderrOutput = await stderr.text();
expect(stderrOutput).toBe("");
expect(await exited).toBe(0);
});
it("should continue using a binary lockfile if it exists", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } });
await write(
packageJson,
JSON.stringify({
name: "binary-lockfile-package",
version: "1.0.0",
dependencies: {
"no-deps": "1.0.0",
},
}),
);
await runBunInstall(env, packageDir);
const firstLockfile = await file(join(packageDir, "bun.lockb")).text();
// now remove the saveTextLockfile option from bunfig
await registry.writeBunfig(packageDir);
// another install will keep the existing binary lockfile
await runBunInstall(env, packageDir, { savesLockfile: false });
expect(await exists(join(packageDir, "bun.lock"))).toBe(false);
const secondLockfile = await file(join(packageDir, "bun.lockb")).text();
expect(firstLockfile).toBe(secondLockfile);
// adding a package will add to the binary lockfile
await runBunInstall(env, packageDir, { packages: ["a-dep"] });
expect(await exists(join(packageDir, "bun.lock"))).toBe(false);
const thirdLockfile = await file(join(packageDir, "bun.lockb")).text();
expect(thirdLockfile).not.toBe(secondLockfile);
});
it("migrating bun.lockb keeps a peer required when it is satisfied only by the dependent's own nested copy", async () => {
// a `file:` override never hoists, so the only no-deps entry is `peer-deps/no-deps`
const files = {
"package.json": JSON.stringify({
name: "lockb-nested-peer",
version: "1.0.0",
dependencies: { "peer-deps": "1.0.0" },
overrides: { "no-deps": "file:./vendor/no-deps" },
}),
"vendor/no-deps/package.json": JSON.stringify({ name: "no-deps", version: "1.0.0" }),
};
const [{ packageDir: fromLockb }, { packageDir: fresh }] = await Promise.all([
registry.createTestDir({ bunfigOpts: { saveTextLockfile: false }, files }),
registry.createTestDir({ bunfigOpts: { saveTextLockfile: true }, files }),
]);
await runBunInstall(env, fromLockb);
expect(await exists(join(fromLockb, "bun.lockb"))).toBe(true);
expect(await exists(join(fromLockb, "bun.lock"))).toBe(false);
await runBunInstall(env, fromLockb, { saveTextLockfile: true });
expect(await exists(join(fromLockb, "bun.lockb"))).toBe(false);
const migrated = await file(join(fromLockb, "bun.lock")).text();
const { packages } = Bun.JSONC.parse(migrated) as { packages: Record<string, unknown[]> };
expect(Object.keys(packages).sort()).toStrictEqual(["peer-deps", "peer-deps/no-deps"]);
expect(packages["peer-deps"][2]).toStrictEqual({ peerDependencies: { "no-deps": "*" } });
expect(packages["peer-deps/no-deps"][0]).toBe("no-deps@file:./vendor/no-deps");
expect(migrated).not.toContain("optionalPeers");
await runBunInstall(env, fresh);
expect(migrated).toBe(await file(join(fresh, "bun.lock")).text());
await runBunInstall(env, fromLockb, { frozenLockfile: true });
expect(await file(join(fromLockb, "bun.lock")).text()).toBe(migrated);
});
it("recovers from a corrupted binary lockfile instead of panicking", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } });
await write(
packageJson,
JSON.stringify({
name: "corrupt-lockb",
version: "1.0.0",
dependencies: {
"no-deps": "1.0.0",
"a-dep": "1.0.1",
},
}),
);
// Generate a valid bun.lockb against the local registry.
await runBunInstall(env, packageDir);
const lockbPath = join(packageDir, "bun.lockb");
expect(await exists(lockbPath)).toBe(true);
// Corrupt `meta[1].id` to an out-of-range value. Packages are stored
// SoA; the `meta` column sits after name (8), name_hash (8), resolution
// (72 for format v3, 64 for v2), dependencies (8) and resolutions (8)
// per package, and `id` is at +8 within each 88-byte Meta.
const lockb = Buffer.from(await file(lockbPath).arrayBuffer());
const fmt = lockb.readUInt32LE(42);
const N = Number(lockb.readBigUInt64LE(86));
const begin = Number(lockb.readBigUInt64LE(110));
const resolutionSize = fmt === 2 ? 64 : 72;
const metaStart = begin + N * (8 + 8 + resolutionSize + 8 + 8);
expect(N).toBeGreaterThan(1);
// Sanity: in a well-formed lockfile meta[i].id == i.
expect(lockb.readUInt32LE(metaStart + 0 * 88 + 8)).toBe(0);
expect(lockb.readUInt32LE(metaStart + 1 * 88 + 8)).toBe(1);
lockb.writeUInt32LE(0x7fffffff, metaStart + 1 * 88 + 8);
await write(lockbPath, lockb);
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--no-progress"],
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
// The garbage `meta.id` deserialized from the corrupt lockfile used to
// panic_bounds_check in Package::clone. Released Bun tolerates it: it
// re-resolves and completes the install. The fix matches that.
expect(err).toContain("Ignoring lockfile");
expect(err).not.toContain("error:");
expect(out).toContain("[email protected]");
expect(out).toContain("[email protected]");
expect(code).toBe(0);
expect(await exists(join(packageDir, "node_modules", "no-deps"))).toBe(true);
expect(await exists(join(packageDir, "node_modules", "a-dep"))).toBe(true);
});
it("rejects a binary lockfile whose patched-dependency flag byte is out of range", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } });
// `[email protected]` from the local registry, patched via
// `patchedDependencies` so the lockfile gains a `pAtChEdD` section.
const patch = `diff --git a/package.json b/package.json
index d156130662798530e852e1afaec5b1c03d429cdc..b4ddf35975a952fdaed99f2b14236519694f850d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
"name": "optional-peer-deps",
"version": "1.0.0",
+ "hi": true,
"peerDependencies": {
"no-deps": "*"
},
`;
await write(
packageJson,
JSON.stringify({
name: "patched-lockb",
version: "1.0.0",
dependencies: {
"optional-peer-deps": "1.0.0",
},
patchedDependencies: {
"[email protected]": "patches/[email protected]",
},
}),
);
await write(join(packageDir, "patches", "[email protected]"), patch);
await runBunInstall(env, packageDir);
const lockbPath = join(packageDir, "bun.lockb");
expect(await exists(lockbPath)).toBe(true);
// A valid lockfile with a patched dependency still loads cleanly
// (runBunInstall asserts no "error:" / "warn:" on stderr).
await runBunInstall(env, packageDir, { savesLockfile: false });
// The patched-dependencies section is the `pAtChEdD` tag followed by two
// arrays, each stored as [start: u64][end: u64][type-name prefix][padding]
// [data] where start/end are absolute file offsets. The second array holds
// 24-byte PatchedDep records laid out as:
// path (8) | padding (7) | patchfile_hash_is_null (1) | patchfile_hash (8)
const lockb = Buffer.from(await file(lockbPath).arrayBuffer());
const tagOff = lockb.indexOf("pAtChEdD");
expect(tagOff).toBeGreaterThan(0);
const hashesEnd = Number(lockb.readBigUInt64LE(tagOff + 16));
const depsStart = Number(lockb.readBigUInt64LE(hashesEnd));
const depsEnd = Number(lockb.readBigUInt64LE(hashesEnd + 8));
expect(depsEnd - depsStart).toBe(24);
// Sanity: the flag byte of the only entry is currently a valid bool
// (0 = patchfile hash present).
expect(lockb[depsStart + 15]).toBe(0);
// Any value other than 0 or 1 is not a valid bool and must be rejected by
// the parser, never reinterpreted.
lockb[depsStart + 15] = 0x42;
await write(lockbPath, lockb);
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--no-progress"],
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
// The out-of-range flag byte must fail lockfile parsing so the install
// falls back to a fresh resolve instead of consuming the bad byte.
expect(err).toContain("Ignoring lockfile");
expect(out).toContain("[email protected]");
expect(code).toBe(0);
expect(await exists(join(packageDir, "node_modules", "optional-peer-deps"))).toBe(true);
});
function packageScriptsFilledOffsets(lockb: Buffer): number[] {
const fmt = lockb.readUInt32LE(42);
const N = Number(lockb.readBigUInt64LE(86));
const begin = Number(lockb.readBigUInt64LE(110));
const resolutionSize = fmt === 2 ? 64 : 72;
const scriptsStart = begin + N * (8 + 8 + resolutionSize + 8 + 8 + 88 + 20);
const offsets: number[] = [];
for (let i = 0; i < N; i++) {
offsets.push(scriptsStart + i * 49 + 48);
}
return offsets;
}
it("rejects a binary lockfile whose package scripts flag byte is out of range", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } });
await write(
packageJson,
JSON.stringify({
name: "lockb-scripts-flag",
version: "1.0.0",
dependencies: {
"no-deps": "1.0.0",
},
}),
);
await runBunInstall(env, packageDir);
const lockbPath = join(packageDir, "bun.lockb");
expect(await exists(lockbPath)).toBe(true);
const lockb = Buffer.from(await file(lockbPath).arrayBuffer());
const offsets = packageScriptsFilledOffsets(lockb);
expect(offsets.length).toBe(2);
expect(lockb[offsets[0]]).toBe(1);
expect(lockb[offsets[1]]).toBe(0);
lockb[offsets[1]] = 0x42;
await write(lockbPath, lockb);
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--no-progress"],
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
expect(err).toContain("invalid package scripts");
expect(err).toContain("Ignoring lockfile");
expect(out).toContain("[email protected]");
expect(code).toBe(0);
expect(await exists(join(packageDir, "node_modules", "no-deps"))).toBe(true);
});
it("rejects a binary lockfile whose git resolved tag contains path separators", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } });
// A git dependency pointing at an unreachable loopback endpoint (port 0) so
// this test stays offline. The commit below never has to exist: migrating
// package-lock.json transcribes the resolved commit into the lockfile
// without contacting the git host, and `--lockfile-only` saves it as
// bun.lockb (saveTextLockfile = false) without installing anything.
const gitUrl = "git+ssh://[email protected]:0/example/repo.git";
const sha = "aabbccddeeff00112233445566778899aabbccdd";
const installEnv = {
...env,
GIT_SSH_COMMAND: "ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=5",
GIT_TERMINAL_PROMPT: "0",
};
await write(
packageJson,
JSON.stringify({
name: "lockb-git-tag",
version: "1.0.0",
dependencies: { dep: gitUrl },
}),
);
await write(
join(packageDir, "package-lock.json"),
JSON.stringify({
name: "lockb-git-tag",
version: "1.0.0",
lockfileVersion: 3,
requires: true,
packages: {
"": { name: "lockb-git-tag", version: "1.0.0", dependencies: { dep: gitUrl } },
"node_modules/dep": { version: "1.0.0", resolved: `${gitUrl}#${sha}` },
},
}),
);
// Generate bun.lockb from the npm lockfile without performing an install.
{
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--lockfile-only", "--no-progress"],
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
env: installEnv,
});
const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(out).toBeDefined();
expect(code).toBe(0);
}
const lockbPath = join(packageDir, "bun.lockb");
expect(await exists(lockbPath)).toBe(true);
expect(await exists(join(packageDir, "bun.lock"))).toBe(false);
// The legitimate case: a binary lockfile whose git resolution carries a
// well-formed 40-hex commit loads cleanly.
{
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--lockfile-only", "--no-progress"],
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
env: installEnv,
});
const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
expect(err).not.toContain("Invalid git dependency tag");
expect(err).not.toContain("error:");
expect(out).toBeDefined();
expect(code).toBe(0);
}
// Rewrite every stored copy of the resolved commit in place (same length) so
// it contains ".." and a path separator. The resolved value becomes a cache
// folder name and a `git checkout` argument downstream, so it must only ever
// be a single safe path component.
const lockb = Buffer.from(await file(lockbPath).arrayBuffer());
let occurrences = 0;
for (let off = lockb.indexOf(sha); off !== -1; off = lockb.indexOf(sha, off + 1)) {
lockb.write("../", off, "latin1");
occurrences++;
}
expect(occurrences).toBeGreaterThan(0);
await write(lockbPath, lockb);
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--no-progress"],
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
env: installEnv,
});
const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
// The tampered resolved value must fail binary lockfile loading (the same
// fail-closed rule the text lockfile parser applies) instead of flowing into
// cache folder names and git commands. The install then falls back to a
// fresh resolve rather than consuming the tampered resolution.
expect(err).toContain("Invalid git dependency tag");
expect(err).toContain("in bun.lockb");
expect(err).toContain("Ignoring lockfile");
expect(out).toBeDefined();
// Nothing was installed from the tampered resolution (the git host is
// unreachable, so the fallback resolve cannot fetch it either).
expect(await exists(join(packageDir, "node_modules", "dep"))).toBe(false);
expect(code).not.toBe(0);
});