462 lines
17 KiB
TypeScript
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);
|
|
});
|