Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 14 additions & 15 deletions src/install/lockfile/Package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3253,9 +3253,6 @@ pub mod serializer {
resolutions: old.resolutions,
scripts: old.scripts,
resolution: match old.resolution.tag {
ResolutionTag::Uninitialized => {
Resolution::init(TaggedValue::Uninitialized)
}
ResolutionTag::Root => Resolution::init(TaggedValue::Root),
ResolutionTag::Npm => {
Resolution::init(TaggedValue::Npm(old.resolution.npm().migrate()))
Expand Down Expand Up @@ -3284,7 +3281,10 @@ pub mod serializer {
ResolutionTag::SingleFileModule => Resolution::init(
TaggedValue::SingleFileModule(*old.resolution.single_file_module()),
),
_ => Resolution::init(TaggedValue::Uninitialized),
// `load_fields` already rejected every other tag byte.
_ => {
return Err(crate::Error::LockfileValidationFailedInvalidResolutionTag);
}
},
};

Expand Down Expand Up @@ -3325,20 +3325,19 @@ pub mod serializer {
if end_pos as u64 <= end_at {
let src = &stream.buffer[stream.pos..stream.pos + bytes.len()];
if matches!(field, PackageField::Resolution) {
// Validate the tag discriminant on the *raw stream bytes*
// before they are copied into the typed column. `ResolutionTag`
// is a `#[repr(u8)]` enum with non-contiguous discriminants
// (0,1,2,4,8,16,32,64,72,80,100); copying an out-of-range byte
// into `ResolutionType.tag` and then reading it would be
// immediate UB, and a `matches!` over all 11 typed variants is
// provably exhaustive and would be optimized away. Check the
// raw u8 here. Layout: `ResolutionType` is `#[repr(C)]
// { tag: Tag, _padding: [u8; 7], value: ... }`, so the
// discriminant is the first byte of each element.
// Validate the tag of every element on the raw stream bytes
// before they are copied into the typed column. `ResolutionType`
// is `#[repr(C)] { tag: Tag, _padding: [u8; 7], value: ... }`,
// so the tag is the first byte of each element. The accepted
// values are the `ResolutionTag` constants except
// `Uninitialized` (0): a package is appended only once it is
// resolved, so no saved package carries that tag. Loading one
// would keep its dependents bound to a package nothing can
// install, and `Resolution::eql` has no arm for it.
let stride = mem::size_of::<ResolutionType<SemverIntType>>();
debug_assert!(stride != 0 && src.len().is_multiple_of(stride));
for raw in src.chunks_exact(stride) {
if !matches!(raw[0], 0 | 1 | 2 | 4 | 8 | 16 | 32 | 64 | 72 | 80 | 100) {
if !matches!(raw[0], 1 | 2 | 4 | 8 | 16 | 32 | 64 | 72 | 80 | 100) {
return Err(crate::Error::LockfileValidationFailedInvalidResolutionTag);
}
}
Expand Down
130 changes: 129 additions & 1 deletion test/cli/install/bun-lockb.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 { bunExe, bunEnv as env, isWindows, runBunInstall, tempDir, VerdaccioRegistry } from "harness";
import { join } from "path";

const registry = new VerdaccioRegistry();
Expand Down Expand Up @@ -349,6 +349,134 @@ it("rejects a binary lockfile whose package scripts flag byte is out of range",
expect(code).toBe(0);
expect(await exists(join(packageDir, "node_modules", "no-deps"))).toBe(true);
});

// The `resolution` column follows the name (8) and name_hash (8) columns. Each
// entry is a 72-byte (64 in format v2) `Resolution` whose first byte is the tag:
// 1 = root, 2 = npm, 72 = workspace, ... and 0 = uninitialized, which a saved
// package never has.
function packageResolutionTagOffsets(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 resolutionStart = begin + N * (8 + 8);
const offsets: number[] = [];
for (let i = 0; i < N; i++) {
offsets.push(resolutionStart + i * resolutionSize);
}
return offsets;
}

it("rejects a binary lockfile in which a package has an uninitialized resolution", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } });

await write(
packageJson,
JSON.stringify({
name: "lockb-uninitialized-resolution",
version: "1.0.0",
dependencies: {
"no-deps": "1.0.0",
"a-dep": "1.0.1",
},
}),
);

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 = packageResolutionTagOffsets(lockb);
expect(offsets.map(offset => lockb[offset])).toEqual([1, 2, 2]);
// One of the root's dependencies still resolves to package 1, but the
// package no longer says where it comes from.
lockb[offsets[1]] = 0;
await write(lockbPath, lockb);

{
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "bun.lockb"],
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
expect(err).toContain("invalid resolution tag");
expect(out).not.toContain('version ""');
expect(code).toBe(1);
}

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 lockfile is rejected and the install resolves every dependency again
// instead of silently skipping the one bound to the unresolved package.
expect(err).toContain("invalid resolution tag");
expect(err).toContain("Ignoring lockfile");
expect(out).toContain("no-deps@1.0.0");
expect(out).toContain("a-dep@1.0.1");
expect(out).toContain("2 packages installed");
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);

// The unresolved package is not written back out.
const saved = Buffer.from(await file(lockbPath).arrayBuffer());
expect(packageResolutionTagOffsets(saved).map(offset => saved[offset])).toEqual([1, 2, 2]);
});

it("rejects a format v2 binary lockfile in which a package has an uninitialized resolution", async () => {
// `bun bun.lockb` only prints the lockfile, so this exercises the v2
// migration path of the loader without a registry.
using dir = tempDir("lockb-v2-uninitialized-resolution", {});
const lockbPath = join(String(dir), "bun.lockb");
const lockb = Buffer.from(await file(join(__dirname, "fixtures", "bun.lockb.v2")).arrayBuffer());
expect(lockb.readUInt32LE(42)).toBe(2);
const offsets = packageResolutionTagOffsets(lockb);
expect(offsets.map(offset => lockb[offset])).toEqual([1, 2, 2, 2, 2, 2, 2]);

await write(lockbPath, lockb);
{
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "bun.lockb"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
expect(err).toBe("");
expect(out).toContain("# yarn lockfile v1");
expect(code).toBe(0);
}

lockb[offsets[1]] = 0;
await write(lockbPath, lockb);
{
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "bun.lockb"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
expect(err).toContain("invalid resolution tag");
expect(out).toBe("");
expect(code).toBe(1);
}
});

it("rejects a binary lockfile whose git resolved tag contains path separators", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } });

Expand Down
Loading