Skip to content

install: compare trusted dependency names, not just truncated hashes#31218

Merged
Jarred-Sumner merged 6 commits into
mainfrom
claude/hardening-fix-lockfile-rs-3359-27
May 23, 2026
Merged

install: compare trusted dependency names, not just truncated hashes#31218
Jarred-Sumner merged 6 commits into
mainfrom
claude/hardening-fix-lockfile-rs-3359-27

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Trusted-dependency membership checks (trustedDependencies, the --trust subtree, and the install diff summary) keyed only on a truncated 32-bit name hash. This change stores the exact name each entry was registered under as the map value and requires it to match at every lookup, mirroring what the default trusted-dependencies list already does. Entries loaded from a legacy binary bun.lockb have no stored name and keep matching by hash alone, so existing projects are unaffected.

Adds two tests to bun-install-lifecycle-scripts.test.ts using precomputed truncated-hash collisions: one for a trustedDependencies entry whose hash collides with a different dependency's name, and one for a --trust install with a colliding sibling dependency. Both verify the colliding package's scripts stay blocked and that only the intended name is written back to package.json and bun.lock.

@robobun

robobun commented May 22, 2026

Copy link
Copy Markdown
Collaborator
Updated 1:13 AM PT - May 23rd, 2026

@Jarred-Sumner, your commit a5bff0a has 1 failures in Build #57123 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31218

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

bun-31218 --bun

@coderabbitai

coderabbitai Bot commented May 22, 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

This PR makes trusted dependency tracking collision-safe by storing exact alias bytes instead of only hash presence, preventing unrelated packages from gaining trust through truncated-hash collisions. The change propagates through discovery APIs, validation checkpoints, diff tracking, and lockfile persistence.

Changes

Hash-collision-safe trusted dependency tracking

Layer / File(s) Summary
Type definitions and data structures
src/install/lockfile/Package.rs, src/install/lockfile.rs, src/install/lockfile/bun.lock.rs
New AddedTrustedDependency struct carries add_to_lockfile and name bytes; TrustedDependenciesSet and parsing/emission now store boxed name bytes and has_trusted_dependency validates exact-byte equality or legacy empty sentinel.
Trusted dependency discovery API
src/install/PackageManager/PackageManagerLifecycle.rs
find_trusted_dependencies_from_update_requests returns map entries containing name bytes; collection helper and recursive add helper store alias bytes into map entries.
PackageInstaller trust tracking
src/install/PackageInstaller.rs
trusted_dependencies_from_update_requests now maps truncated hashes to alias bytes; insertion sites record dependency alias bytes when marking trust via update requests.
PackageInstaller trust validation and lockfile recording
src/install/PackageInstaller.rs
Trust detection requires stored alias-byte equality; lockfile updates record alias bytes and consult added_trusted_dependencies name bytes when deciding trusted status.
Isolated Installer collision-safe checking
src/install/isolated_install/Installer.rs
Installer field stores alias bytes; Step::RunPreinstall verifies stored alias bytes equal current dep name; lockfile insertion uses boxed alias bytes for trusted entries.
Global store eligibility check
src/install/isolated_install.rs
Virtual-store eligibility check now retrieves the truncated-hash entry and verifies stored name equals the dependency name instead of using hash-only membership.
Lockfile text validation and parsing
src/install/lockfile/bun.lock.rs, src/install/lockfile.rs
Emitting and parsing trustedDependencies now require exact-name handling: emit only when stored name is empty sentinel or matches current name; parsing computes truncated hash from exact bytes and stores full name bytes, with UTF-8 and non-UTF-8 handling.
Binary lockfile (bun.lockb) migration loading
src/install/lockfile/bun.lockb.rs
Loader replaces unsafe bulk initialization with iterative insertions, inserting legacy hash-only entries with an empty-byte sentinel.
Lockfile diff generation and parsing
src/install/lockfile/Package.rs
Diff generation records AddedTrustedDependency { add_to_lockfile, name } for added trusted entries and records removed trusted entries as boxed name bytes across diff scenarios.
CLI trust command
src/runtime/cli/pm_trusted_command.rs
TrustCommand.exec inserts boxed name-byte payloads into lockfile.trusted_dependencies instead of unit values.
Regression tests for collision prevention
test/cli/install/bun-install-lifecycle-scripts.test.ts
Two new tests verify exact-name trust matching: one ensures a colliding package’s scripts are blocked unless its exact name is trusted; another ensures bun i --trust does not expand trust to truncated-hash collisions.

Suggested reviewers

  • RiskyMH
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: storing exact trusted dependency names instead of relying only on truncated hashes for membership checks.
Description check ✅ Passed The description covers what the PR does and verification approach, but lacks explicit test verification details in structured sections matching the template.
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: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/install/lockfile/Package.rs (1)

1297-1319: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't treat default trusted dependencies as hash-only in the explicit→default path.

Line 1298 and Line 1315 only check the truncated hash, so a user-added trusted dependency that collides with a default entry is treated as if it were the default package. In that transition the old custom entry is never removed, which can leave a package trusted after it has been deleted from trustedDependencies.

🩹 Suggested direction
                 for entry in default_trusted_dependencies::entries() {
-                    if !from_trusted_dependencies
-                        .contains(&(entry.hash as TruncatedPackageNameHash))
-                    {
+                    let hash = entry.hash as TruncatedPackageNameHash;
+                    let same_entry = from_trusted_dependencies
+                        .get(&hash)
+                        .is_some_and(|from_name| {
+                            from_name.is_empty() || &**from_name == entry.key
+                        });
+                    if !same_entry {
                         summary.added_trusted_dependencies.put(
-                            entry.hash as TruncatedPackageNameHash,
+                            hash,
                             AddedTrustedDependency {
                                 add_to_lockfile: false,
                                 name: Box::from(entry.key),
                             },
                         )?;
                     }
                 }

                 // removed
                 for (&from_trusted, from_name) in from_trusted_dependencies.iter() {
-                    if !default_trusted_dependencies::has_with_hash(u64::from(from_trusted)) {
+                    let is_default = default_trusted_dependencies::entries().iter().any(|entry| {
+                        entry.hash as TruncatedPackageNameHash == from_trusted
+                            && entry.key == &*from_name
+                    });
+                    if !is_default {
                         summary
                             .removed_trusted_dependencies
                             .put(from_trusted, from_name.clone())?;
                     }
                 }
🤖 Prompt for 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.

In `@src/install/lockfile/Package.rs` around lines 1297 - 1319, The code is only
comparing truncated hashes (TruncatedPackageNameHash) against
default_trusted_dependencies, causing user-added entries that collide on
truncated hash with a default to be treated as defaults and never removed;
update the logic in the loops around default_trusted_dependencies::entries(),
from_trusted_dependencies and default_trusted_dependencies::has_with_hash so you
verify full identity (compare the default entry.key/name against the stored
from_name) rather than only the truncated hash: when iterating
default_trusted_dependencies::entries() check if a matching truncated hash
exists in from_trusted_dependencies and also that the names match before
skipping add_to_lockfile, and when computing removals use a lookup that confirms
the default entry name differs (or use the full package identifier instead of
truncated hash) so user-added trusted dependencies that collide on hash are
still removed and recorded via summary.removed_trusted_dependencies.put.
🤖 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 `@src/install/isolated_install/Installer.rs`:
- Around line 111-116: The current trusted_dependencies_from_update_requests map
uses ArrayHashMap<TruncatedPackageNameHash, Box<[u8]>> which overwrites one
alias when two different exact names share the same truncated hash; change the
value to a collection of exact names (e.g., Vec<Box<[u8]>> or
HashSet<Box<[u8]>>) keyed by TruncatedPackageNameHash so inserts append rather
than replace, update all code that inserts into
trusted_dependencies_from_update_requests to push/insert into the collection,
and update lookup logic in RunPreinstall (and any place that writes back to
bun.lock / package.json) to iterate the stored exact names and perform the
byte-equality check against each stored alias before trusting or writing names
back.

In `@src/install/lockfile.rs`:
- Around line 120-121: The current TrustedDependenciesSet type stores a single
Box<[u8]> per TruncatedPackageNameHash which silently drops legitimate
trusted-name collisions — update the representation so each truncated hash can
map to multiple names (e.g., change the value from Box<[u8]> to a collection
type such as Vec<Box<[u8]>> or a small linked bucket/list) or switch to
full-name keys instead of truncated hashes; adjust all use sites that insert,
iterate, and lookup in TrustedDependenciesSet (and any helpers operating on
TruncatedPackageNameHash / ArrayHashMap) to handle multiple entries per hash and
perform exact byte-match checks against the per-hash collection when validating
trust.

In `@src/install/lockfile/bun.lock.rs`:
- Around line 1560-1571: The current logic inserts a single name per
TruncatedPackageNameHash into trusted_dependencies, which allows later inserts
to overwrite earlier ones on hash collisions; change the map value to a
collision bucket (e.g., HashMap<TruncatedPackageNameHash, Vec<Box<str>>> or
similar) and instead of trusted_dependencies.insert(...) push the name into the
vector bucket for both the s.is_utf8() and non-UTF8 branches; also update any
lookup code that checks trustedDependencies to iterate the bucket and perform
exact-name comparison (using the stored Box<str> names) so multiple distinct
names with the same truncated hash are all honored.

In `@src/install/lockfile/Package.rs`:
- Around line 1267-1285: When diffing explicit trusted-dependency sets, you
currently only test membership by hash (to_trusted_dependencies.contains(&hash)
/ from_trusted_dependencies.contains(&hash)), which misses replacements where a
different stored name collides to the same hash; change the membership check to
compare the stored name bytes as well (i.e., treat a match only if the
stored-name bytes are equal OR if the stored-name is the legacy empty-name
sentinel, treat that as a hash-only match for bun.lockb compatibility). Update
the loops that call summary.added_trusted_dependencies.put(...,
AddedTrustedDependency { ... }) and
summary.removed_trusted_dependencies.put(...) to use this name-aware comparison
so a replaced trusted package is emitted as a remove + add rather than ignored.

---

Outside diff comments:
In `@src/install/lockfile/Package.rs`:
- Around line 1297-1319: The code is only comparing truncated hashes
(TruncatedPackageNameHash) against default_trusted_dependencies, causing
user-added entries that collide on truncated hash with a default to be treated
as defaults and never removed; update the logic in the loops around
default_trusted_dependencies::entries(), from_trusted_dependencies and
default_trusted_dependencies::has_with_hash so you verify full identity (compare
the default entry.key/name against the stored from_name) rather than only the
truncated hash: when iterating default_trusted_dependencies::entries() check if
a matching truncated hash exists in from_trusted_dependencies and also that the
names match before skipping add_to_lockfile, and when computing removals use a
lookup that confirms the default entry name differs (or use the full package
identifier instead of truncated hash) so user-added trusted dependencies that
collide on hash are still removed and recorded via
summary.removed_trusted_dependencies.put.
🪄 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: f268e24d-4c25-401e-8731-9df8d0034640

📥 Commits

Reviewing files that changed from the base of the PR and between 346ce08 and ab5b022.

📒 Files selected for processing (10)
  • src/install/PackageInstaller.rs
  • src/install/PackageManager/PackageManagerLifecycle.rs
  • src/install/isolated_install.rs
  • src/install/isolated_install/Installer.rs
  • src/install/lockfile.rs
  • src/install/lockfile/Package.rs
  • src/install/lockfile/bun.lock.rs
  • src/install/lockfile/bun.lockb.rs
  • src/runtime/cli/pm_trusted_command.rs
  • test/cli/install/bun-install-lifecycle-scripts.test.ts

Comment thread src/install/isolated_install/Installer.rs Outdated
Comment thread src/install/lockfile.rs
Comment thread src/install/lockfile/bun.lock.rs Outdated
Comment thread src/install/lockfile/Package.rs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/install/lockfile/Package.rs (1)

1309-1337: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the explicit→default trusted-dependency diff name-aware too.

Line 1316 and Line 1333 still decide membership by truncated hash alone. If an explicit trusted dependency collides with a default trusted dependency, removing trustedDependencies from package.json can emit neither the removal of the old name nor the addition of the default one. That leaves the old colliding name looking unchanged to downstream trust/lockfile update logic.

Suggested direction
             if let (Some(from_trusted_dependencies), None) = (
                 from_lockfile.trusted_dependencies.as_ref(),
                 to_lockfile.trusted_dependencies.as_ref(),
             ) {
                 // added
                 for entry in default_trusted_dependencies::entries() {
-                    if !from_trusted_dependencies
-                        .contains(&(entry.hash as TruncatedPackageNameHash))
-                    {
+                    let hash = entry.hash as TruncatedPackageNameHash;
+                    let already_trusted = from_trusted_dependencies
+                        .get(&hash)
+                        .is_some_and(|from_name| {
+                            from_name.is_empty() || &**from_name == entry.key
+                        });
+                    if !already_trusted {
                         // although this is a new trusted dependency, it is from the default
                         // list so it shouldn't be added to the lockfile
                         summary.added_trusted_dependencies.put(
-                            entry.hash as TruncatedPackageNameHash,
+                            hash,
                             AddedTrustedDependency {
                                 add_to_lockfile: false,
                                 name: Box::from(entry.key),
                             },
                         )?;
                     }
                 }

                 // removed
                 for (&from_trusted, from_name) in from_trusted_dependencies.iter() {
-                    if !default_trusted_dependencies::has_with_hash(u64::from(from_trusted)) {
+                    let still_trusted = default_trusted_dependencies::entries().iter().any(|entry| {
+                        entry.hash as TruncatedPackageNameHash == from_trusted
+                            && (from_name.is_empty() || &**from_name == entry.key)
+                    });
+                    if !still_trusted {
                         summary
                             .removed_trusted_dependencies
                             .put(from_trusted, from_name.clone())?;
                     }
                 }
🤖 Prompt for 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.

In `@src/install/lockfile/Package.rs` around lines 1309 - 1337, The current diff
only compares truncated hashes (from_trusted_dependencies and
default_trusted_dependencies::has_with_hash) and misses cases where an explicit
trusted dependency hash collides with a default entry but has a different name;
update both the "added" and "removed" branches to be name-aware: when iterating
default_trusted_dependencies::entries() in the added branch, check whether
from_trusted_dependencies contains an entry with the same hash AND the same name
(compare entry.key) before creating AddedTrustedDependency; likewise, when
iterating from_trusted_dependencies in the removed branch, if
default_trusted_dependencies contains the same hash but a different name, treat
it as a rename: put the old name into summary.removed_trusted_dependencies and
also ensure you add the default name into summary.added_trusted_dependencies
with add_to_lockfile = false (or if the hash is absent entirely, behave as
before); use default_trusted_dependencies::entries() or a lookup by hash to get
the default name for these comparisons and updates (affecting
summary.added_trusted_dependencies.put,
summary.removed_trusted_dependencies.put, and the logic around
default_trusted_dependencies::has_with_hash).
🤖 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.

Outside diff comments:
In `@src/install/lockfile/Package.rs`:
- Around line 1309-1337: The current diff only compares truncated hashes
(from_trusted_dependencies and default_trusted_dependencies::has_with_hash) and
misses cases where an explicit trusted dependency hash collides with a default
entry but has a different name; update both the "added" and "removed" branches
to be name-aware: when iterating default_trusted_dependencies::entries() in the
added branch, check whether from_trusted_dependencies contains an entry with the
same hash AND the same name (compare entry.key) before creating
AddedTrustedDependency; likewise, when iterating from_trusted_dependencies in
the removed branch, if default_trusted_dependencies contains the same hash but a
different name, treat it as a rename: put the old name into
summary.removed_trusted_dependencies and also ensure you add the default name
into summary.added_trusted_dependencies with add_to_lockfile = false (or if the
hash is absent entirely, behave as before); use
default_trusted_dependencies::entries() or a lookup by hash to get the default
name for these comparisons and updates (affecting
summary.added_trusted_dependencies.put,
summary.removed_trusted_dependencies.put, and the logic around
default_trusted_dependencies::has_with_hash).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f7a59d89-a1ad-4e62-8b8c-5cc4cc87c9ba

📥 Commits

Reviewing files that changed from the base of the PR and between 122f48a and bb7db2f.

📒 Files selected for processing (1)
  • src/install/lockfile/Package.rs

Comment thread src/install/lockfile.rs Outdated

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

I didn't find any bugs, but this hardens the lifecycle-script trust gate (what decides whether postinstall scripts run) and threads a new name-equality invariant through both installers, both lockfile formats, and the diff path — worth a human pass, especially with CI currently red on the build-rust jobs.

Extended reasoning...

Overview

This PR closes a fail-open hash-collision in trustedDependencies / --trust: membership was previously decided by a truncated 32-bit name hash alone, so a crafted package name colliding with a trusted entry would have its lifecycle scripts run. The fix changes TrustedDependenciesSet and the --trust subtree map from ArrayHashMap<TruncatedPackageNameHash, ()> to ArrayHashMap<TruncatedPackageNameHash, Box<[u8]>> and requires the stored name bytes to match at every lookup. It touches the hoisted installer (PackageInstaller.rs), isolated installer (isolated_install.rs, isolated_install/Installer.rs), lockfile text parser/stringifier (bun.lock.rs), binary lockfile loader (bun.lockb.rs), diff summary (lockfile/Package.rs), has_trusted_dependency (lockfile.rs), and bun pm trust (pm_trusted_command.rs). Legacy bun.lockb entries get an empty-bytes sentinel that keeps hash-only matching for back-compat. Two regression tests use precomputed wyhash-truncation collisions to verify the colliding package's scripts stay blocked.

Security risks

This is the gate that decides whether arbitrary install-time scripts execute, so it is directly security-sensitive. The change moves the failure mode from fail-open to fail-closed, which is the right direction. The residual risks a reviewer should sanity-check are: (1) every lookup site now does the byte-equality check (I see it applied in has_trusted_dependency, both installers' --trust checks, the global-store eligibility check, the diff, and the bun.lock stringifier — looks complete); (2) the empty-sentinel back-compat path in has_trusted_dependency and bun.lock.rs only ever matches entries produced by the binary-lockfile loader; and (3) the bun.lock.rs parser's new UTF-8/UTF-16 branch agrees with the hash that was previously computed via as_string_hash_utf8.

Level of scrutiny

High. This is a deliberate security boundary change in production install code, threading a new invariant through ~10 files and two lockfile formats. It's well-scoped and well-tested, but it is exactly the category (security-sensitive, multi-site invariant) where a second pair of human eyes is appropriate rather than bot-only approval.

Other factors

  • CodeRabbit's four concerns were all about the pre-existing single-slot-per-hash layout; Jarred explained the fail-closed tradeoff and they're resolved. The diff-comparison concern was additionally addressed in bb7db2f.
  • My earlier inline note on lockfile.rs:3374 flags a pre-existing unsynchronized read in the isolated installer's Task::run; it's not introduced by this PR and is marked as a separate follow-up.
  • CI (Build #56957) is currently red on several build-rust / build-cpp jobs for bb7db2f — likely infra/toolchain (the failure is a -no-pie clang warning and generic ci.ts build failures rather than test failures), but worth confirming green before merge.
  • The bug-hunting system found no issues.

Comment thread src/install/lockfile/bun.lock.rs Outdated
@robobun

robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator

🔧 Status — both review items are handled:

  1. bun.lock.rs transient arena — cleaned up on the branch (now f6caa6d).
  2. Unsynchronized trusted_dependencies read in the isolated installer → fixed in install: synchronize trusted dependency lookups in the isolated installer #31245. A regression test for --trust + linker = "isolated" (plus an alternative no-lock-on-read variant) is on farm/ad8e5e20/isolated-trusted-deps-race, ready to cherry-pick into install: synchronize trusted dependency lookups in the isolated installer #31245 if wanted.

CI on a5bff0a (#57123): every test lane that has run is green — the earlier windows-11-aarch64 failure cleared on retry, and darwin-26-aarch64 / darwin-14-x64 have since passed. The single remaining red is darwin-14-aarch64-test-bun, which expired waiting for a macOS agent and never ran; one retry of that job once an agent frees up and the build is green. Nothing in the diff is failing.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Delete your slop comments.

Jarred-Sumner and others added 6 commits May 23, 2026 02:43
Store the name each trusted-dependency entry was registered under and
require it to match at lookup time, instead of relying on the truncated
32-bit name hash alone. Applies to trustedDependencies from package.json
and bun.lock, the --trust subtree set, and the install diff summary.
Entries loaded from a legacy binary lockfile have no stored name and
keep matching by hash, preserving existing behavior for those projects.
@Jarred-Sumner Jarred-Sumner force-pushed the claude/hardening-fix-lockfile-rs-3359-27 branch from 613d511 to a5bff0a Compare May 23, 2026 02:43

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/runtime/shell/states/Expansion.rs:390 — Wrapping every interpolated ! as {!} means N consecutive ! bytes produce N sequential brace groups, but the glob matcher's BraceStack is BoundedArray<Brace, 10> and sequential groups consume one slot each (push happens before the recursive call returns), so the 11th {!} overflows the stack and the whole pattern returns no-match. For something like echo prefix${'!'.repeat(11)}* against a file named prefix!!!!!!!!!!!x this is a regression — pre-PR the non-leading ! bytes matched literally. Since glob_match_impl already treats a non-leading ! as a literal, only the first ! (when pattern.is_empty()) actually needs the {!} wrap; subsequent ones can be emitted bare.

    Extended reasoning...

    What the bug is

    neutralize_glob_metachars (Expansion.rs:390) wraps every non-meta ! byte from interpolated/quoted data as a one-branch brace group {!}. The rationale in the comment is correct as far as it goes — [!] would parse as a negated class, and a bare leading ! would flip the matcher's negation loop in r#match (matcher.rs:143-146). But it doesn't account for the matcher's brace-stack capacity: N sequential {!} groups in a single path component require brace-stack depth N, and the stack is capped at 10.

    The code path

    In glob_match_impl (matcher.rs:358-366), encountering a { whose open_brace_idx is not already on brace_stack does return match_brace(...). match_brace finds the branch and calls match_brace_branch (line 458). match_brace_branch then:

    1. Pushes a Brace { open_brace_idx, branch_idx } onto brace_stack (line 522), returning false if the BoundedArray<Brace, 10> push fails (lines 525-527).
    2. Recursively calls glob_match_impl (line 534).
    3. Only pops at line 543, after the recursive call returns.

    Inside that recursive glob_match_impl, after matching the literal ! and skip_branch()-ing past }, the next byte is the next sequential {, at a different glob_index. It's not on brace_stack, so we again return match_brace(...) → another push. The pop for group k doesn't happen until group k+1's recursion has fully returned, so N sequential {!} groups require brace-stack depth N.

    BunGlobWalkerZ::init_with_cwdbuild_pattern_components only splits the pattern on path separators (no brace pre-expansion), so the whole {!}{!}…{!}* component reaches match_pattern_slowcrate::r#match (GlobWalker.rs:1755) intact.

    Step-by-step proof

    Take await Bun.$echo prefix${'!'.repeat(11)}*`` in a directory containing prefix!!!!!!!!!!!x:

    1. current_out = b"prefix!!!!!!!!!!!*"; meta_offsets records only the trailing literal * (offset 17).
    2. neutralize_glob_metachars emits prefix{!}{!}{!}{!}{!}{!}{!}{!}{!}{!}{!}* — eleven sequential one-branch groups.
    3. The glob walker hands this single component to r#match. glob_match_impl matches prefix literally, then hits the first { at glob_index 6 and recurses through match_brace_branch, pushing brace_stack[0].
    4. After matching ! and skipping past }, glob_index is at the second {; recurse again, push brace_stack[1]. Repeat.
    5. At the 11th {, brace_stack.push(...) on the BoundedArray<Brace, 10> fails; match_brace_branch returns false (lines 525-527), which propagates up as a no-match for the whole pattern.
    6. The shell prints no matches found: prefix!!!!!!!!!!!* even though prefix!!!!!!!!!!!x exists.

    Before this PR, step 2 didn't happen — the raw ! bytes reached glob_match_impl and, being non-leading, fell through the _ => break to_else arm (matcher.rs:384) and matched literally. So for non-leading ! runs this is a regression. (For leading ! runs, e.g. ${'!'.repeat(11)}*, pre-PR was also broken differently — the negation loop at matcher.rs:143 consumed them — so that exact case is not a regression, just differently wrong.)

    Why existing code doesn't prevent it

    The matcher's BRACE_BRANCH_BUDGET (10000) limits alternatives, not nesting depth, so it doesn't help here. The new test "injected leading ! does not negate" only uses a single !, so it doesn't exercise this. The doc comment's claim that the matcher "provably treats [the wrapped form] as that literal character" is correct for any individual group but doesn't hold once 11+ groups must be on the stack simultaneously.

    Impact and fix

    Edge case (11+ ! bytes from interpolation in a single path component adjacent to a literal glob), and the failure is benign fail-closed (no-match error showing the original word, no crash, no security regression). But it's a correctness flaw in newly-added neutralization code with a one-line fix: only wrap ! when pattern.is_empty(); otherwise emit it bare, since glob_match_impl already treats a non-index-0 ! as a literal character. Something like:

    b'!' if pattern.is_empty() => pattern.extend_from_slice(b"{!}"),
    b'!' => pattern.push(b'!'),

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

I didn't find any issues beyond what's already resolved, but since this hardens the lifecycle-script trust boundary across both installers and both lockfile formats (with the bun.lockb empty-name sentinel as a compat path), it's worth a human sign-off rather than a bot approval.

Extended reasoning...

Overview

This PR closes a fail-open in trusted-dependency checks: membership was previously decided by a truncated 32-bit name hash alone, so a crafted package name that collided with a trusted entry's hash would have its lifecycle scripts run. The fix changes TrustedDependenciesSet from ArrayHashMap<TruncatedPackageNameHash, ()> to ArrayHashMap<TruncatedPackageNameHash, Box<[u8]>> and requires the stored name bytes to match at every lookup. The change is threaded through PackageInstaller, the isolated installer, find_trusted_dependencies_from_update_requests, the diff summary (AddedTrustedDependency), bun.lock parse/stringify, bun.lockb load (empty-name sentinel for hash-only legacy entries), and bun pm trust. Two regression tests use precomputed wyhash-truncation collisions to verify the colliding package's scripts stay blocked and that only the intended name is persisted.

Security risks

The code being changed is the security boundary — it gates whether postinstall/preinstall scripts execute. The direction is strictly hardening (fail-open → fail-closed on hash collision). The bun.lockb sentinel keeps legacy hash-only matching for entries loaded from the binary format, which is intentional for compatibility but means projects still on bun.lockb don't get the hardening until they migrate. The remaining single-slot-per-hash limitation (two legitimately trusted colliding names → one fails closed) was discussed and accepted as pre-existing and ~2⁻³²-per-pair. I don't see a way for this change to grant trust where it wasn't granted before.

Level of scrutiny

High. This is production-critical install-path code that decides whether arbitrary package scripts run on a user's machine, touches 10 files across both linker modes and both lockfile formats, and introduces a sentinel value with special-case semantics in several places (name.is_empty() checks in has_trusted_dependency, the bun.lock stringifier, and the diff). The implementation looks correct and consistent, but the blast radius of a mistake here is "runs untrusted scripts" or "silently stops running trusted ones", so it deserves a human reviewer rather than bot approval.

Other factors

All four CodeRabbit threads and Jarred's own "dead arena" note are resolved (the bun.lock parser now transcodes straight into the owned Box<[u8]>, and the diff now compares stored names per 50684b3). My earlier note on the pre-existing unsynchronized trusted_dependencies read in the isolated installer is being handled in #31245. Robobun reported all executed CI lanes green on 7b0c89f (macOS lanes expired waiting for agents); there have been four small follow-up commits since. The bug-hunting system found nothing. Net: I have no blocking concerns, but I'm deferring per the security-sensitive-code guideline.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants