Skip to content

install: reject bun.lockb with cyclic tree parents instead of returning a wrong path - #32306

Closed
robobun wants to merge 3 commits into
mainfrom
farm/59f65002/tree-cycle-malformed
Closed

robobun wants to merge 3 commits into
mainfrom
farm/59f65002/tree-cycle-malformed

Conversation

@robobun

@robobun robobun commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

What

relative_path_and_depth in src/install/lockfile/Tree.rs walks a tree node's parent chain to build its node_modules/... path. The Zig original wrote into depth_buf[depth_buf_len] with no bounds check, so a parent-pointer cycle in a corrupted bun.lockb panicked (index out of bounds in safe builds). The Rust port added a depth_buf_len == MAX_DEPTH guard but handled it by returning early with the bare "node_modules" path and depth = 0:

if depth_buf_len == MAX_DEPTH {
    path_buf[path_written] = 0;
    return (ZStr::from_buf(path_buf, path_written), 0);
}

At that point the path-building loop has not run yet, so callers (bun pm ls, PackageInstaller::link_remaining_bins, Lockfile::eql) silently receive the root node_modules path for a node that is actually nested and proceed to list/link into the wrong directory. A loud crash became silent wrong output.

Fix

Report the corruption and crash, matching the adjacent path_too_long and folder_name_is_safe checks in the same function. MAX_DEPTH segments of node_modules alone already overflow MAX_PATH_BYTES, so reaching the guard can only mean the on-disk trees are cyclic; there is no well-formed lockfile this rejects.

Repro

mkdir -p t/pkg-a t/pkg-b
printf '%s' '{"name":"r","dependencies":{"pkg-a":"file:./pkg-a"}}' > t/package.json
printf '%s' '{"name":"pkg-a","dependencies":{"pkg-b":"file:../pkg-b"}}' > t/pkg-a/package.json
printf '%s' '{"name":"pkg-b"}' > t/pkg-b/package.json
printf '%s\n' '[install]' 'saveTextLockfile = false' > t/bunfig.toml
cd t && bun install
# patch tree[1].parent = 1 (self-cycle) in bun.lockb, then:
bun pm ls

Before: exits 0, silently drops pkg-b from the listing.
After: error: Lockfile is malformed (dependency tree has a cycle or exceeds the maximum depth), exit 1.

Test

test/cli/install/bun-lockb.test.ts generates a two-tree bun.lockb via folder deps (no registry needed), patches tree[1].parent = 1 via the <install.lockfile.Tree> array marker, and asserts bun pm ls now reports the malformed lockfile and exits non-zero.

…ng a wrong path

The Zig relativePathAndDepth wrote into depth_buf with no bounds check,
so a parent-pointer cycle in a corrupted bun.lockb panicked loudly. The
Rust port added a MAX_DEPTH guard but handled it by returning the bare
"node_modules" path with depth=0, so callers (bun pm ls, the bin linker,
Lockfile::eql) would silently operate on the wrong directory.

Fail the same way the adjacent path-too-long and unsafe-folder-name
checks do: print a malformed-lockfile error and crash.
@robobun

robobun commented Jun 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is ready; CI blocked on darwin runner availability.

Reproduced with a hex-patched bun.lockb (tree[1].parent set to 1 for the cycle case, 99 for the out-of-bounds case). Fail-before: USE_SYSTEM_BUN=1 bun test test/cli/install/bun-lockb.test.ts -t "tree parents" fails on both cases (stderr empty, bun pm ls exits 0 and silently drops the nested tree). With the fix: bun bd test passes both.

Review feedback addressed in 0bc5d81: the sibling out-of-bounds-parent exit now fails loudly too, and the test covers both shapes via it.each.

CI: 280/286 lanes pass including every Linux, Windows, and FreeBSD lane; bun-lockb.test.ts passes everywhere it runs. The remaining darwin-14 test jobs keep reporting "Expired" waiting for an agent and are auto-requeued (build #62590). The previous build #62492 eventually ran the darwin jobs and failed only on api.github.com HTTP 504s in unrelated GitHub-tarball install tests plus a fetch-leak.test.ts RSS threshold flake; bun-lockb.test.ts passed there too. None of the failures touch this diff.

@coderabbitai

coderabbitai Bot commented Jun 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bb42f720-c20f-4bba-8930-7c0bad096863

📥 Commits

Reviewing files that changed from the base of the PR and between e0acad3 and 243b97a.

📒 Files selected for processing (2)
  • src/install/lockfile/Tree.rs
  • test/cli/install/bun-lockb.test.ts

Walkthrough

relative_path_and_depth in Tree.rs gains two explicit malformed-lockfile crash paths: one when the parent-walk exceeds MAX_DEPTH (cycle or excessive depth) and one when the chain does not terminate at the root. A new parametrized test corrupts a real bun.lockb on disk and verifies bun pm ls fails with the expected error.

Changes

Lockfile Tree Parent-Chain Validation

Layer / File(s) Summary
Parent-chain depth and bounds checks
src/install/lockfile/Tree.rs
relative_path_and_depth now crashes with a targeted error message when the parent-walk reaches MAX_DEPTH (cycle/excessive depth) or when the chain does not terminate at the root (parent_id != 0).
Parametrized binary lockfile corruption test
test/cli/install/bun-lockb.test.ts
it.each test installs a package graph, locates the install.lockfile.Tree binary payload, patches tree[1]'s parent field to a self-cycle or out-of-bounds value, and asserts bun pm ls exits non-zero with "Lockfile is malformed" and the matching corruption reason on stderr.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main fix: rejecting malformed bun.lockb files with cyclic tree parents instead of silently returning incorrect paths.
Description check ✅ Passed The PR description thoroughly covers both required sections: it explains what the issue is (wrong behavior on cyclic parents) and how it was verified (with reproduction steps and test coverage).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented Jun 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:35 PM PT - Jun 15th, 2026

❌ @robobun, your commit 243b97a has 1 failures in Build #62590 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32306

That installs a local version of the PR into your bun-32306 executable, so you can run:

bun-32306 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — small, well-tested hardening that matches the existing path_too_long/folder_name_is_safe crash pattern in the same function; the inline notes are non-blocking follow-ups.

Extended reasoning...

Overview

This PR changes one branch in relative_path_and_depth (src/install/lockfile/Tree.rs): when the parent-chain walk hits MAX_DEPTH (only reachable via a corrupted bun.lockb with cyclic tree parents), it now emits an error and calls Global::crash() instead of silently returning the bare "node_modules" path with depth=0. A new test in test/cli/install/bun-lockb.test.ts hex-patches tree[1].parent = 1 and asserts bun pm ls reports the malformed lockfile and exits non-zero.

Security risks

None introduced. The change strictly hardens handling of corrupted on-disk lockfiles — previously a cyclic parent chain produced a fabricated root path that callers (bun pm ls, link_remaining_bins, Lockfile::eql) would act on; now it fails loudly. The happy path is untouched, and MAX_DEPTH * len("node_modules") already exceeds MAX_PATH_BYTES, so no well-formed lockfile can reach this guard.

Level of scrutiny

Low-to-medium. The diff is ~12 lines (mostly comment) replacing a silent early return with Output::err_generic(...) + Global::crash(), identical to the adjacent path_too_long and folder_name_is_safe checks in the same function. No control-flow or data changes outside the corruption branch. Not under CODEOWNERS.

Other factors

Two inline notes were filed, both non-blocking: (1) the sibling loop exit (parent_id >= trees.len()) still returns a truncated path on out-of-bounds parents — that guard is pre-existing and untouched by this PR, so it's a reasonable follow-up rather than a defect in this change; (2) expect(out).toBeDefined() is vacuous but matches three pre-existing tests in the same file. Neither affects correctness of what's shipped here. The PR has a fail-before/pass-after test and CI is building.

Comment thread src/install/lockfile/Tree.rs
Comment thread test/cli/install/bun-lockb.test.ts Outdated
The parent-chain walk has a second corruption-only exit: when a non-root
tree's parent index is >= trees.len(), the bounds guard drops out of the
loop before reaching the root and the path built below is missing every
segment above the break. Check parent_id == 0 after the loop and reject
the lockfile if not. The test covers both shapes via it.each.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — both earlier comments addressed in 0bc5d81; the remaining note is pre-existing and fine as a follow-up.

Extended reasoning...

Overview

Two-file change: src/install/lockfile/Tree.rs replaces the silent early-return in relative_path_and_depth's MAX_DEPTH guard with Output::err_generic + Global::crash(), and adds a post-loop parent_id != 0 check for the out-of-bounds-parent case. test/cli/install/bun-lockb.test.ts adds an it.each test that hex-patches a generated bun.lockb to exercise both corruption shapes and asserts bun pm ls reports "Lockfile is malformed" and exits non-zero.

Security risks

None introduced — this hardens handling of corrupted/tampered binary lockfiles. Previously a cyclic or out-of-bounds tree.parent made the iterator silently return the root node_modules path (or a truncated one) for a nested node, which could cause link_remaining_bins to link into the wrong directory. Now it fails closed with a clear error. The MAX_DEPTH argument is sound: that many node_modules segments alone exceed MAX_PATH_BYTES, so no well-formed lockfile is rejected.

Level of scrutiny

Low-to-medium. The diff is small, mechanical, and exactly mirrors the adjacent path_too_long / folder_name_is_safe checks in the same function (same err_generic + crash() pattern). Behavior only changes for already-corrupt lockfiles that were previously producing silently wrong output; the well-formed path is untouched. No CODEOWNERS apply to these files.

Other factors

I reviewed the first revision and raised two points (out-of-bounds parent exit; vacuous toBeDefined()). Both were addressed in 0bc5d81 exactly as suggested, and the test was extended to cover the second shape. The one remaining inline note (corrupted tree.id field) is explicitly pre-existing — inherited verbatim from Tree.zig, not touched by this diff — and flagged as a non-blocking follow-up, so it doesn't gate approval.

Comment thread src/install/lockfile/Tree.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — both review comments addressed in 0bc5d81, and the remaining tree.id note is pre-existing and reasonably deferred to a follow-up alongside the Zig change.

Extended reasoning...

Overview

This PR hardens relative_path_and_depth in src/install/lockfile/Tree.rs against two shapes of corrupted bun.lockb tree-parent data: (1) a parent-pointer cycle that hits the MAX_DEPTH guard, and (2) a parent index pointing past trees.len(). Previously the cycle case silently returned the bare "node_modules" path with depth = 0, and the out-of-bounds case fell through with a truncated path — both letting callers (bun pm ls, link_remaining_bins, Lockfile::eql) operate on the wrong directory. The fix replaces both silent exits with Output::err_generic(...) + Global::crash(), matching the adjacent path_too_long and folder_name_is_safe checks in the same function. A new it.each test in bun-lockb.test.ts generates a real two-tree lockfile via folder deps, hex-patches tree[1].parent, and asserts bun pm ls now reports "Lockfile is malformed" with the right reason and exits non-zero for both shapes.

Security risks

None introduced; this is strictly a hardening change. Both new branches are unreachable on a well-formed lockfile (every non-root parent chain terminates at id 0, and MAX_DEPTH segments of node_modules alone overflow MAX_PATH_BYTES), so the only behavior change is for already-corrupted input, where silent-wrong-path becomes a loud error. That reduces the chance of linking bins into the wrong node_modules from a bit-rotted or tampered bun.lockb.

Level of scrutiny

Moderate — install/lockfile is a load-bearing path, but this diff is ~25 lines of Rust confined to two corruption-only guards inside one function, plus an additive test. The change follows the established fail-loud idiom already used three times in the same function body, so there is no new design surface. The new code paths only execute on malformed on-disk data and cannot regress the well-formed case.

Other factors

I reviewed this PR twice previously. The first round flagged the sibling out-of-bounds exit and a vacuous toBeDefined() assertion — both were fixed in 0bc5d81 (the post-loop parent_id != 0 check and the combined expect({out, err}).toEqual(...)). The second round noted a pre-existing tree.id-vs-tree_id gap inherited verbatim from Tree.zig; the author acknowledged it and scoped it to a follow-up that lands alongside the Zig change, which is reasonable since it is not a regression introduced here and the PR title is scoped to .parent. All inline threads are resolved. The only commit since my last review is a CI retrigger with no code changes. Test coverage is solid: both corruption shapes are exercised end-to-end against a real generated lockfile, with sanity assertions on the on-disk layout before patching.

@robobun

robobun commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Stale PR review: keep open, rework.

The defect is real on main: relative_path_and_depth still returns the bare node_modules path with depth 0 when the parent walk hits MAX_DEPTH (src/install/lockfile/Tree.rs:329-332), so a bun.lockb with a parent cycle makes bun pm ls drop packages and exit 0. The worse symptom is elsewhere, though: src/install/prune.rs:1508-1511 walks trees[id].parent with no cycle bound and pushes into a Vec, and this diff does not touch it. No issue reports either symptom, and the trigger is a hand-corrupted legacy binary lockfile.

The shape is the problem. The codebase already has a contract for corrupt bun.lockb: validate at load and return Err(InvalidLockfile) so bun install warns and re-resolves instead of aborting (src/install/lockfile/bun.lockb.rs:439-451, from #31008). This PR instead adds two Global::crash() calls inside the shared path helper, which every consumer (bun update, bun pm dedupe, bun patch) then inherits mid-operation, and its out-of-bounds arm turns a case that prints correctly today into an exit 1. #32753 already validates tree parent ranges at load time; the piece this PR uniquely covers, an in-range cycle, is one more condition there (hoisting guarantees trees[i].parent < i for i > 0). The branch also conflicts with main, and its test calls stderrForInstall, which #37000 removed from the harness. The wanted version is that load-time check plus one byte-patched case in test/cli/install/bun-lockb.test.ts, not a crash in Tree.rs.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-06-15, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant