From 75b4b335199c44c7ce4dcd5738a257b8c8ff2bbb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:40:43 +0000 Subject: [PATCH 1/5] install: reject bun.lockb packages with an uninitialized resolution The binary lockfile loader accepted resolution tag 0 (Uninitialized) as a valid tag. A package with that tag has no source to install from, but the dependencies that point at it still count as resolved. bun install then skipped the package, printed "Saved lockfile", exited 0, and wrote the unresolved package back out. A debug build hit the unreachable arm in Resolution::eql while cloning the package. A package is appended to a lockfile only once it is resolved, so no saved package carries this tag. Treat it like any other unknown tag byte: fail the load with "invalid resolution tag", so the install ignores the lockfile and resolves again. The format v2 migration arm that turned unknown tags into Uninitialized now returns the same error. --- src/install/lockfile/Package.rs | 29 ++++--- test/cli/install/bun-lockb.test.ts | 130 ++++++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 16 deletions(-) diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index c3f774d9d18c..1e9812312386 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -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())) @@ -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); + } }, }; @@ -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::>(); 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); } } diff --git a/test/cli/install/bun-lockb.test.ts b/test/cli/install/bun-lockb.test.ts index 71bfb293ab51..41b206f2292b 100644 --- a/test/cli/install/bun-lockb.test.ts +++ b/test/cli/install/bun-lockb.test.ts @@ -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(); @@ -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 } }); From 0ca20254d6b7d54c6b11b8ae58b951db76964352 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:42:26 +0000 Subject: [PATCH 2/5] install: also reject the SingleFileModule tag in bun.lockb SingleFileModule is a placeholder tag. Nothing writes a package with it, the bun.lock writer skips it next to Uninitialized, and the installers skip it the same way, so a bun.lockb that carries it has the same effect as one with an Uninitialized package. The loader now accepts exactly the tags the writers produce, spelled with the tag constants instead of their byte values. The tests cover both bytes. --- src/install/lockfile/Package.rs | 26 ++-- test/cli/install/bun-lockb.test.ts | 216 +++++++++++++++-------------- 2 files changed, 132 insertions(+), 110 deletions(-) diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index 1e9812312386..cb1b7340521f 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -3278,9 +3278,6 @@ pub mod serializer { ResolutionTag::RemoteTarball => Resolution::init( TaggedValue::RemoteTarball(*old.resolution.remote_tarball()), ), - ResolutionTag::SingleFileModule => Resolution::init( - TaggedValue::SingleFileModule(*old.resolution.single_file_module()), - ), // `load_fields` already rejected every other tag byte. _ => { return Err(crate::Error::LockfileValidationFailedInvalidResolutionTag); @@ -3329,15 +3326,26 @@ pub mod serializer { // 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. + // tags are the ones a writer produces, the same set the bun.lock + // writer persists. `Uninitialized` is the in-memory default of a + // package that is not resolved yet and `SingleFileModule` is a + // placeholder without a producer. A loaded package with either + // tag keeps its dependents resolved while nothing can install it. let stride = mem::size_of::>(); debug_assert!(stride != 0 && src.len().is_multiple_of(stride)); for raw in src.chunks_exact(stride) { - if !matches!(raw[0], 1 | 2 | 4 | 8 | 16 | 32 | 64 | 72 | 80 | 100) { + if !matches!( + ResolutionTag(raw[0]), + ResolutionTag::Root + | ResolutionTag::Npm + | ResolutionTag::Folder + | ResolutionTag::LocalTarball + | ResolutionTag::Github + | ResolutionTag::Git + | ResolutionTag::Symlink + | ResolutionTag::Workspace + | ResolutionTag::RemoteTarball + ) { return Err(crate::Error::LockfileValidationFailedInvalidResolutionTag); } } diff --git a/test/cli/install/bun-lockb.test.ts b/test/cli/install/bun-lockb.test.ts index 41b206f2292b..e9fb1ec4ff34 100644 --- a/test/cli/install/bun-lockb.test.ts +++ b/test/cli/install/bun-lockb.test.ts @@ -352,8 +352,7 @@ it("rejects a binary lockfile whose package scripts flag byte is out of range", // 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. +// 1 = root, 2 = npm, 72 = workspace, ... function packageResolutionTagOffsets(lockb: Buffer): number[] { const fmt = lockb.readUInt32LE(42); const N = Number(lockb.readBigUInt64LE(86)); @@ -367,115 +366,130 @@ function packageResolutionTagOffsets(lockb: Buffer): number[] { 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 } }); +// Nothing in bun writes a package with one of these tags: 0 (uninitialized) is +// the in-memory state of a package that is not resolved yet, and 100 (single +// file module) is a placeholder. Nothing installs such a package either, so a +// lockfile that contains one has to be rejected. +const resolutionTagsWithoutAWriter = [ + { tag: 0, name: "uninitialized" }, + { tag: 100, name: "single file module" }, +]; + +it.each(resolutionTagsWithoutAWriter)( + "rejects a binary lockfile in which a package has the $name resolution tag", + async ({ tag }) => { + const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } }); + + await write( + packageJson, + JSON.stringify({ + name: "lockb-resolution-without-a-writer", + 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]] = tag; + 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).toBe(""); + expect(code).toBe(1); + } + + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - 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"], + 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 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]); + // The lockfile is rejected and the install resolves every dependency again + // instead of silently skipping the one bound to the broken package. expect(err).toContain("invalid resolution tag"); - expect(out).toBe(""); - expect(code).toBe(1); - } -}); + 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 broken 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.each(resolutionTagsWithoutAWriter)( + "rejects a format v2 binary lockfile in which a package has the $name resolution tag", + async ({ tag }) => { + // `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-resolution-without-a-writer", {}); + 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]] = tag; + 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 } }); From 9485310ee21d22cb5ecf1a9d04e0ea5e93c017b4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:47:43 +0000 Subject: [PATCH 3/5] install: define the saved resolution tags once on Tag Tag::belongs_in_lockfile lists the tags a writer produces. The bun.lock writer and the bun.lockb loader both use it, so the two cannot drift apart, and the loader no longer needs a comment to explain its list. --- src/install/lockfile/Package.rs | 24 +++--------------------- src/install/lockfile/bun.lock.rs | 16 ++-------------- src/install/resolution.rs | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 35 deletions(-) diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index cb1b7340521f..34e63d645cae 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -3322,30 +3322,12 @@ 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 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 - // tags are the ones a writer produces, the same set the bun.lock - // writer persists. `Uninitialized` is the in-memory default of a - // package that is not resolved yet and `SingleFileModule` is a - // placeholder without a producer. A loaded package with either - // tag keeps its dependents resolved while nothing can install it. + // `ResolutionType` is `#[repr(C)]` with the tag as its first + // byte, so validate it before the bytes reach the typed column. let stride = mem::size_of::>(); debug_assert!(stride != 0 && src.len().is_multiple_of(stride)); for raw in src.chunks_exact(stride) { - if !matches!( - ResolutionTag(raw[0]), - ResolutionTag::Root - | ResolutionTag::Npm - | ResolutionTag::Folder - | ResolutionTag::LocalTarball - | ResolutionTag::Github - | ResolutionTag::Git - | ResolutionTag::Symlink - | ResolutionTag::Workspace - | ResolutionTag::RemoteTarball - ) { + if !ResolutionTag(raw[0]).belongs_in_lockfile() { return Err(crate::Error::LockfileValidationFailedInvalidResolutionTag); } } diff --git a/src/install/lockfile/bun.lock.rs b/src/install/lockfile/bun.lock.rs index 87ec99aa581f..97ae6c366d49 100644 --- a/src/install/lockfile/bun.lock.rs +++ b/src/install/lockfile/bun.lock.rs @@ -694,20 +694,8 @@ impl Stringifier { } let res = &pkg_resolutions[pkg_id as usize]; - match res.tag { - ResolutionTag::Root - | ResolutionTag::Npm - | ResolutionTag::Folder - | ResolutionTag::LocalTarball - | ResolutionTag::Github - | ResolutionTag::Git - | ResolutionTag::Symlink - | ResolutionTag::Workspace - | ResolutionTag::RemoteTarball => {} - ResolutionTag::Uninitialized => continue, - // should not be possible, just being safe - ResolutionTag::SingleFileModule => continue, - _ => continue, + if !res.tag.belongs_in_lockfile() { + continue; } if first { diff --git a/src/install/resolution.rs b/src/install/resolution.rs index d1e6fa7424e4..935660b2fe67 100644 --- a/src/install/resolution.rs +++ b/src/install/resolution.rs @@ -1014,6 +1014,24 @@ impl Tag { self == Tag::Git || self == Tag::Github } + /// The tags a package can have in a saved lockfile. A package is appended + /// only once it is resolved, so `Uninitialized` never reaches a writer, and + /// `SingleFileModule` has no producer. Nothing can install either one. + pub(crate) fn belongs_in_lockfile(self) -> bool { + matches!( + self, + Tag::Root + | Tag::Npm + | Tag::Folder + | Tag::LocalTarball + | Tag::Github + | Tag::Git + | Tag::Symlink + | Tag::Workspace + | Tag::RemoteTarball + ) + } + pub(crate) fn can_enqueue_install_task(self) -> bool { self == Tag::Npm || self == Tag::LocalTarball From 502b388a064fba063d080476019a007e6b84a65f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:50:11 +0000 Subject: [PATCH 4/5] install: read the resolution tag offset with offset_of like the other columns --- src/install/lockfile/Package.rs | 5 ++--- src/install/resolution.rs | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index 34e63d645cae..8b6e24c0521b 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -3322,12 +3322,11 @@ 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) { - // `ResolutionType` is `#[repr(C)]` with the tag as its first - // byte, so validate it before the bytes reach the typed column. let stride = mem::size_of::>(); + let tag_at = mem::offset_of!(ResolutionType, tag); debug_assert!(stride != 0 && src.len().is_multiple_of(stride)); for raw in src.chunks_exact(stride) { - if !ResolutionTag(raw[0]).belongs_in_lockfile() { + if !ResolutionTag(raw[tag_at]).belongs_in_lockfile() { return Err(crate::Error::LockfileValidationFailedInvalidResolutionTag); } } diff --git a/src/install/resolution.rs b/src/install/resolution.rs index 935660b2fe67..c23ccde5ce22 100644 --- a/src/install/resolution.rs +++ b/src/install/resolution.rs @@ -1014,9 +1014,8 @@ impl Tag { self == Tag::Git || self == Tag::Github } - /// The tags a package can have in a saved lockfile. A package is appended - /// only once it is resolved, so `Uninitialized` never reaches a writer, and - /// `SingleFileModule` has no producer. Nothing can install either one. + /// Tags a saved package can have. `Uninitialized` is the state before a + /// package is appended and `SingleFileModule` has no producer. pub(crate) fn belongs_in_lockfile(self) -> bool { matches!( self, From e6cc6bacb574b2df4254674a0bf5713792777631 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:07:34 +0000 Subject: [PATCH 5/5] ci: retrigger