Skip to content

install: preserve empty trustedDependencies across install + lockfile round-trip#31197

Open
robobun wants to merge 8 commits into
mainfrom
farm/00f0a41e/fix-trusted-dependencies-empty-lockfile-roundtrip
Open

install: preserve empty trustedDependencies across install + lockfile round-trip#31197
robobun wants to merge 8 commits into
mainfrom
farm/00f0a41e/fix-trusted-dependencies-empty-lockfile-roundtrip

Conversation

@robobun

@robobun robobun commented May 21, 2026

Copy link
Copy Markdown
Collaborator

What

Reported in #31026 (comment) — companion fix for the docs PR #31027.

Setting "trustedDependencies": [] in package.json blocked lifecycle scripts on the first install but silently stopped working on every subsequent one. With the default allow list re-applying under the user's nose, packages like bcrypt, electron, and esbuild had their postinstalls run even though the user had asked to trust nothing.

Repro (before this PR)

$ cat package.json
{ "trustedDependencies": [], "dependencies": { "bcrypt": "5.1.1" } }

$ bun install
Blocked 1 postinstall. Run `bun pm untrusted` for details.    ← correct

$ bun pm untrusted
Found 0 untrusted dependencies with scripts.                  ← disagrees with install!

$ rm -rf node_modules && bun install
58 packages installed                                         ← no "Blocked" message
$ find node_modules/bcrypt -name '*.node'
node_modules/bcrypt/lib/binding/napi-v3/bcrypt_lib.node       ← postinstall RAN

Root cause

Two separate layers dropped the signal:

  1. Text lockfile writer (src/install/lockfile/bun.lock.rs) only emitted "trustedDependencies" when at least one installed dep matched the set. With [] (or any list whose names aren't yet installed), nothing matched, the key was dropped, and reloading yielded trusted_dependencies = None.

    has_trusted_dependency (src/install/lockfile.rs):

    if let Some(trusted_dependencies) = &self.trusted_dependencies {
        return trusted_dependencies.contains(&hash);   // explicit list — empty means trust nothing
    }
    resolution.tag == ResolutionTag::Npm && default_trusted_dependencies::has(name)
    //                                      ^^^ fallback — "use Bun's defaults"

    The two code paths depend on Some vs None, so losing Some(empty) quietly flips the contract.

  2. Install-time diff (src/install/lockfile/Package.rs, Diff::generate): a None → Some(empty) transition on trusted_dependencies — adding "trustedDependencies": [] to an existing project with no dependency changes — produced zero added/removed entries, so has_diffs() returned false and the lockfile was never rewritten, leaving the stale None on disk.

Fix

  • bun.lock.rs: emit trustedDependencies whenever the user defined it, even as []. trusted_dependencies.is_some() iff the key was present; None still means "use Bun's defaults".
  • Package.rs: in Diff::generate case 4, flag the None → Some({}) transition via a new trusted_dependencies_changed summary field so has_diffs() reports it and the lockfile is rewritten to persist the [].

Verification

New regression tests in test/cli/install/bun-install-lifecycle-scripts.test.ts, covering both a fresh-install round-trip and editing trustedDependencies: [] onto an existing project with no dep changes. Uses a file: dep so the lockfile round-trip can be verified without a registry.

  • Fails on main: lockfile is missing the "trustedDependencies": [] line after install.
  • Passes with this PR: both installs write the key and the lockfile survives the round-trip.

The full repro from the issue comment (bcrypt + rm -rf node_modules && bun install + bun pm untrusted) also works end-to-end with this build.

Related

Companion to #31027 (docs clarification). Together these land the behavior the docs describe.

Setting "trustedDependencies": [] in package.json blocked lifecycle
scripts on the first install — the lockfile's trusted_dependencies was
Some(empty_set), so has_trusted_dependency fell through to the empty
membership check and correctly returned false for everything.

But the text lockfile writer only emitted "trustedDependencies" when at
least one installed dep matched the set (bun.lock.rs:401). With an empty
set nothing matched, the key was dropped, and reloading yielded
trusted_dependencies = None. has_trusted_dependency then fell through to
the default allow list and silently re-trusted packages like bcrypt,
electron, and esbuild.

bun pm untrusted shows the same regression: it only reads the lockfile
(not package.json), so after the round-trip it reports 'Found 0
untrusted dependencies with scripts' while bun install was still saying
'Blocked 1 postinstall'.

Fix:
- bun.lock.rs: emit trustedDependencies whenever the user defined it,
  even as []. trusted_dependencies.is_some() iff the key was present;
  None preserves the 'use Bun's defaults' semantics.
- install_with_manager.rs: in the !had_any_diffs branch, also clone
  trusted_dependencies from the freshly-parsed package.json onto the
  manager's lockfile. The diff only compares dependency lists, so
  editing trustedDependencies alone (no dep changes) would otherwise
  get ignored at install time until a dep also changed.

Regression test in bun-install-lifecycle-scripts.test.ts uses a file:
dep so the lockfile round-trip can be verified without a registry.

Reported in #31026 (comment)
@robobun

robobun commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:15 AM PT - May 28th, 2026

@alii, your commit bb0b233 has some failures in Build #58621 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 31197

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

bun-31197 --bun

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes enable explicit empty trustedDependencies: [] to persist through lockfile round-trips. A new diff flag detects a None→Some(empty) transition, the serializer emits the field when present (including []), and regression tests validate persistence, round-trip, blocking semantics, and removal.

Changes

Trusted Dependencies Empty Set Persistence

Layer / File(s) Summary
Diff detection for trusted dependencies changes
src/install/lockfile/Package.rs
DiffSummary adds trusted_dependencies_changed to flag the None → Some(empty) transition. has_diffs() includes this flag. Diff::generate sets it when lockfile.trusted_dependencies moves from None to Some(...).
Lockfile serialization of trusted dependencies
src/install/lockfile/bun.lock.rs
The serializer now checks lockfile.trusted_dependencies.is_some() and emits "trustedDependencies": [] when the explicit set is present but yields no matches, instead of omitting the key.
Round-trip and semantics regression tests
test/cli/install/bun-install-lifecycle-scripts.test.ts
Adds tempDir import and multiple concurrent regression tests validating that trustedDependencies: [] is written to bun.lock, survives reinstall, blocks default-trusted packages when persisted, can be added to an existing project to force a lockfile update, and is removed from bun.lock when deleted from package.json.

Possibly related PRs:

  • oven-sh/bun#31218: Also touches lockfile trustedDependencies handling; changes the underlying trusted-dependency representation/matching while this PR changes persistence/serialization of an explicit empty set.

Suggested reviewers:

  • alii
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: preserving empty trustedDependencies across install and lockfile round-trip, which is the primary focus of this PR.
Description check ✅ Passed The description provides both required sections: a comprehensive 'What' explaining the bug, reproduction steps, root cause analysis, and the fix; and a detailed 'How did you verify' section with regression tests and verification approach.
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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/cli/install/bun-install-lifecycle-scripts.test.ts`:
- Around line 3227-3229: The test currently consumes stderr and exitCode
together (const [stderr, exitCode] = await Promise.all([proc.stderr.text(),
proc.exited])) which checks exitCode before reading stdout; update the spawn
checks to also read proc.stdout.text() and assert stdout contents before
asserting exitCode. Specifically, change to await
Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]) (assigning to
stderr, stdout, exitCode), add the appropriate expect on stdout (e.g.,
expect(stdout).not.toContain("error:") or other existing stdout assertions) and
then assert expect(exitCode).toBe(0); apply the same change to both blocks that
use proc, stderr, and exitCode in this test file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6998c84f-2f99-4a42-bc01-fe1cfed3b827

📥 Commits

Reviewing files that changed from the base of the PR and between 832edbc and 2d53c66.

📒 Files selected for processing (3)
  • src/install/PackageManager/install_with_manager.rs
  • src/install/lockfile/bun.lock.rs
  • test/cli/install/bun-install-lifecycle-scripts.test.ts

Comment thread test/cli/install/bun-install-lifecycle-scripts.test.ts Outdated
Comment thread src/install/PackageManager/install_with_manager.rs Outdated
robobun and others added 3 commits May 21, 2026 23:57
Follow-up on the review in #31197: the original fix refreshed
`lockfile.trusted_dependencies` from package.json unconditionally in
the `!had_any_diffs` branch, which made install behaviour correct but
left `bun pm untrusted` stale on disk — the lockfile save was still
gated on `has_diffs()`, and `None → Some({})` (adding
`trustedDependencies: []` to an existing project with no dep changes)
was the one transition that produced zero added/removed entries and
therefore no diff.

Move the signal into `Diff::generate` case 4: when transitioning from
"use defaults" (None) to an explicit empty set, flag the summary as
changed so `has_diffs()` reports it. The existing had-diffs branch in
`install_with_manager.rs` already clones
`trusted_dependencies` onto the manager's lockfile and triggers a
save, so the `!had_any_diffs`-branch refresh from the prior commit is
no longer needed and is removed.

Net effect:
- Install still blocks postinstalls correctly on every run.
- `bun.lock` is rewritten with `"trustedDependencies": []` even when
  only the key was added (no dep edits).
- `bun pm untrusted`, which reads the lockfile directly, now sees the
  empty set on reload and matches `bun install`'s blocked count.

Adds a second regression test covering the edit-on-existing-project
scenario and updates the original round-trip test to read stdout
before asserting the exit code.
The debian-13-x64-asan-test-bun failure on the previous CI run hit
test/js/bun/http/serve-body-leak.test.ts, which is a well-documented
ASAN-flaky memory-leak test (see PR #28301 and test/no-validate-leaksan.txt:274).
This PR only touches src/install/lockfile/ and test/cli/install/ —
nothing in the HTTP/Bun.serve path. Re-rolling once.
@robobun

robobun commented May 22, 2026

Copy link
Copy Markdown
Collaborator Author

Added the end-to-end test from the review: install electron (a default-trusted package with a preinstall script) with trustedDependencies: [], then rm -rf node_modules and reinstall. The postinstall must stay blocked on every install.

  • Fails on main: the reinstall re-runs electron's preinstall (the dropped empty array makes the defaults re-apply).
  • Passes with this PR: [] round-trips through bun.lock and the postinstall stays blocked.

Also covered the Some → None case (deleting the key returns a package to the default allow list). Fix in #31197.

@alii alii changed the title install: text lockfile must preserve empty trustedDependencies install: preserve empty trustedDependencies across install + lockfile round-trip May 26, 2026

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Did a thorough pass on this — walked the writer, parser, the full Diff transition matrix, the binary/migration paths, workspaces, every trust consumer, --frozen-lockfile, and parity with #31027. The fix is correct and sufficient — behavior now matches the documented contract (omitted → defaults, ["a"] → only a, [] → trust nothing).

Both halves are load-bearing:

  • Writer (bun.lock.rs): the first install now persists [], so it reloads as Some(empty) instead of the key vanishing.
  • Diff flag (Package.rs trusted_dependencies_changed): handles the None → Some({}) transition — adding [] to an existing project, or upgrading from a lockfile written by the buggy version. Without it had_any_diffs stays false, so package.json's Some({}) is never copied into the in-memory lockfile and the lockfile is never rewritten. It's genuinely non-redundant since generate_meta_hash excludes trusted_dependencies.

Also nice that this brings the text lockfile in line with what bun.lockb already did via HAS_EMPTY_TRUSTED_DEPENDENCIES_TAG. Parser is already symmetric ([] ⟷ Some(empty)), has_trusted_dependency already treats Some(empty) as "trust nothing" (never falls through to the default list), and it merges clean with main despite the #31218 TrustedDependenciesSet change — no rebase needed.

One real ask (non-blocking): the tests don't prove the security property

Both added tests use a file: dep and assert only that bun.lock contains "trustedDependencies": []. But a file: dep is a non-Npm tag, and the default-allow-list fallback only applies to Npm-tag packages — so under both Some(empty) and None a file: dep is "not trusted." These tests guard serialization, but they can't actually exercise the regression (a default-trusted npm package's postinstall re-running after reload).

Could you add one end-to-end test — essentially automating the repro in the description? electron is already on the default-trusted list and has a registry fixture with a preinstall script, and this file already uses the exists(node_modules/electron/preinstall.txt) pattern — so: install electron with trustedDependencies: [], reload/re-install, assert preinstall.txt does not exist. That fails on main (defaults re-apply → preinstall runs) and passes here, which is the property that actually matters. A Some → None (delete-the-key) case dropping the key from bun.lock would be a nice bonus.

Two pre-existing things I noticed while in here (out of scope, just flagging): Some({the full default set}) → None isn't detected as a diff, and --frozen-lockfile won't flag a stale trustedDependencies key because eql/meta-hash both exclude it.

@alii

alii commented May 26, 2026

Copy link
Copy Markdown
Member

@robobun ^

@mizulu

mizulu commented May 26, 2026

Copy link
Copy Markdown
Contributor

@robobun wake up and read the comments.

…stall across reload

Add end-to-end coverage using electron (on Bun's built-in default allow
list, with a preinstall script) to exercise the actual security property:
with trustedDependencies: [], the postinstall stays blocked on the first
install AND on reinstall from the persisted lockfile. On main the text
lockfile dropped the empty array, so the reload fell back to the default
allow list and electron's preinstall ran again.

The existing file: round-trip tests guard the lockfile serialization but
can't catch this regression: file: deps carry a non-Npm tag, and the
default-list fallback only applies to Npm-tag packages, so they are 'not
trusted' under both Some(empty) and None.

Also adds the Some -> None case: removing the trustedDependencies key
drops it from bun.lock and returns the package to the default allow list.
@robobun

robobun commented May 27, 2026

Copy link
Copy Markdown
Collaborator Author

Added the end-to-end test you asked for in f2f8927 — it automates the repro with electron (on the default allow list, with a preinstall script):

  • Install electron with trustedDependencies: [] → first install blocks the postinstall and persists "trustedDependencies": [] in bun.lock.
  • rm -rf node_modules + reinstall → the postinstall stays blocked and preinstall.txt is never written.

That second install is where main regresses: the dropped empty array makes the reload fall back to the default allow list, so electron's preinstall runs again. Verified it fails on main (preinstall.txt appears after reinstall) and passes here.

Also added the Some → None bonus: deleting the trustedDependencies key drops it from bun.lock and returns electron to the default allow list, which is the correct behavior for that transition.

Review nits addressed (343104e); reinstall step asserts the deterministic ground truth — electron's preinstall.txt stays absent — rather than the Blocked N postinstall summary line that the text lockfile doesn't emit on a reinstall (c67d21a).

Status (after the latest main merge, bb0b233): all 16 test-bun lanes are green and bun-install-lifecycle-scripts.test.ts appears in zero failure annotations. The finished build has no ERROR annotations — the only failure status is the overall buildkite rollup, which flips when flaky tests consume retries during the run (those retries passed). The flaky tests are all unrelated to this PR (leak tests, s3.*, webview-chrome, napi, update_interactive_install). The diff is green and ready to merge.

The two pre-existing gaps you flagged (Some({full default set}) → None not detected as a diff, and --frozen-lockfile not flagging a stale key since eql/meta-hash exclude trustedDependencies) are out of scope here — happy to follow up in a separate PR if you want them closed.

@alii alii closed this May 27, 2026
@alii alii reopened this May 27, 2026
Comment thread test/cli/install/bun-install-lifecycle-scripts.test.ts Outdated
Comment thread test/cli/install/bun-install-lifecycle-scripts.test.ts Outdated
@alii

alii commented May 27, 2026

Copy link
Copy Markdown
Member

@robobun two small review nits still open on test/cli/install/bun-install-lifecycle-scripts.test.ts:

  1. both spawn blocks set stdout: "pipe" but only destructure { stderr, exited } — pull stdout in too (or drop the pipe).
  2. the two tempDir-based tests are declared test.concurrent but don't go through setupTest.

Can you tidy those up and push? Otherwise this looks ready.

@robobun

robobun commented May 27, 2026

Copy link
Copy Markdown
Collaborator Author

Both tidied up in 343104e:

  1. Both spawn blocks in the removal test now destructure { stdout, stderr, exited } and assert stdout before the exit code.
  2. The two tempDir tests now go through acquireSlot() / releaseSlot() (wrapped in try/finally) so they respect the file-level semaphore — they keep skipping setupTest() since they do not need the registry.

Resolved both review threads.

robobun and others added 2 commits May 27, 2026 17:43
…e on reinstall

The text lockfile doesn't persist per-package script metadata, so the
"Blocked N postinstall" summary is not emitted on a reinstall from
bun.lock even though the package stays correctly blocked. Assert the
deterministic ground truth (electron's preinstall.txt is absent) instead
of the cosmetic summary line, which was failing CI on the reinstall step.
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.

3 participants