Skip to content

install: stop aborting on linked package names that do not fit the symlink buffers - #38571

Open
robobun wants to merge 3 commits into
mainfrom
farm/9e637c26/install-long-link-name
Open

install: stop aborting on linked package names that do not fit the symlink buffers#38571
robobun wants to merge 3 commits into
mainfrom
farm/9e637c26/install-long-link-name

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun install --linker hoisted aborts when a workspace (or link:) package has to be linked into node_modules under a name longer than 512 bytes:
    panic: range end index 600 out of range for slice of length 512
    
    Exit 134, the rest of the install is lost. Names of up to 512 bytes fail just that package with ENAMETOOLONG: failed linking dependency/workspace to node_modules for package ... / Failed to install 1 package (exit 1), and the installer accepts names up to one byte below the path buffer size (4095 bytes on Linux, 1023 on macOS), so every length in between aborts.
  • Cause: the POSIX branch of PackageInstall::install_from_link (src/install/PackageInstall.rs) copies the entry name into dest_name_buf: [u8; 512] to get a NUL-terminated string for symlinkat, without checking the length. At exactly 512 bytes the copy fits but the NUL does not, so a release build hands symlinkat a string that runs past the buffer and a debug build trips the ZStr::from_buf assertion.
  • The Windows branch has the same unchecked copy into a bigger buffer: the name is appended to the absolute node_modules path in a 98302 byte PathBuffer, so a name within a node_modules path length of the 98301 byte maximum aborts as well:
    panic: range end index 98327 out of range for slice of length 98302
    

Fix

  • POSIX: the copy is removed. The name is the tail of destination_dir_subpath, which is already NUL-terminated (alias_is_safe_install_target guarantees it is name or @scope/name), so that tail is passed to symlinkat directly. Nothing is left to bounds check: the file system rejects a name over NAME_MAX with ENAMETOOLONG, which is exactly what happens to the names of up to 512 bytes today, so longer names now fail the same way.
  • Windows: the name plus its NUL is checked against the space left behind the directory path before it is copied, and the package fails with ENAMETOOLONG under Step::LinkingDependency when it does not fit, the same failure the directory path check a few lines above produces. A destination that does not fit the buffer cannot exist on disk, so this is the error the OS would give for it.
  • Both changes only touch the failure path: a name that fits is linked from byte-identical arguments as before.
  • Verified:
    • test/cli/install/bun-workspaces.test.ts, describe("workspace packages whose name is too long to link"): an unscoped and a scoped (@scope/<name>, which links inside a separately opened scope directory) workspace member, with a 600 byte name on POSIX and a 98282 byte name on Windows. Both cases abort with the panics above on the unfixed binary on Linux (USE_SYSTEM_BUN=1) and on Windows (stock build of current main), and pass with bun bd test on Linux x64 and Windows x64.
    • The rest of bun-workspaces.test.ts (70 tests) and bun-link.test.ts pass with the debug build, except bun-link.test.ts "should link dependency without crashing", which fails on main with any debug build independently of this change (install: make the debug-build stack dump on package install failure opt-in #37335).
    • cargo check -p bun_install for x86_64-pc-windows-msvc and aarch64-apple-darwin, cargo clippy -p bun_install and cargo fmt --check are clean.

Not in this PR

  • A second bun install (with node_modules present) of a name within 13 bytes of the path buffer size (4083 to 4095 bytes on Linux) still aborts, from a different place: the verify step appends /package.json to the buffer the name was copied into (get_installed_package_json_source). Reported separately.
  • The progress bar's name buffer, which aborts on long names whenever the progress bar is shown: install: stop panicking when a progress bar name is longer than its buffer #38558.

Background

  • A hoisted install puts workspace members, link: packages and file: dependencies on the project root into node_modules as symlinks (junctions on Windows); install_from_link creates that one link. The link's name is destination_dir_subpath, the dependency's name as it appears under node_modules. It comes from package.json, and alias_is_safe_install_target (PackageInstaller.rs) only lets through a single component or @scope/name shorter than the path buffer.
  • ZStr is bun's borrowed NUL-terminated byte string, the type the syscall wrappers take. as_bytes_with_nul() is the bytes including the terminator and ZStr::from_slice_with_nul borrows a slice ending in one, so the tail of an existing ZStr is a ZStr without a copy.
  • PathBuffer is a stack buffer of the platform's maximum path length: 4096 bytes on Linux, 1024 on macOS, 98302 on Windows (32767 UTF-16 units at up to three UTF-8 bytes each, plus the NUL). NAME_MAX, the longest single component a file system accepts, is 255 bytes on Linux and macOS, so every name this change is about is rejected by the OS once it gets there.
Name lengths probed on the unfixed build (workspace member, CI=1 bun install --linker hoisted)
Linux x64 (1.4.0-canary.1, b7a043103)            unfixed                                          fixed
  300, 511, 512                                    ENAMETOOLONG: failed linking ..., exit 1         same
  513, 600, 1024, 4082                             panic: ... out of range for slice of length 512  ENAMETOOLONG: failed linking ..., exit 1
  @<600 a's>/pkg (long scope, short name)          ENAMETOOLONG (creating the scope dir), exit 1    same
  @s/<600 b's>   (short scope, long name)          panic: ... slice of length 512                   ENAMETOOLONG: failed linking ..., exit 1
  4096, 5000                                       error: refusing to install dependency with unsafe name, exit 1   same

Windows x64 (1.4.0-canary.1, eabb96de7), node_modules path 61 chars
  600, 98000                                       ENOENT: failed linking ..., exit 1               same
  98240, 98260, 98282, 98301                       panic: ... out of range for slice of length 98302, exit 3   ENAMETOOLONG: failed linking ..., exit 1
  @scope/<98282 a's>                               panic: ... slice of length 98302                 ENAMETOOLONG: failed linking ..., exit 1
  98302                                            error: refusing to install dependency with unsafe name, exit 1   same

…not fit the symlink buffers

install_from_link copied the node_modules entry name into a 512 byte stack
buffer on POSIX, so a workspace or link: package whose name was longer than
that aborted the whole install. The name is the tail of destination_dir_subpath,
which is already NUL-terminated, so pass that to symlinkat directly. On
Windows the name is appended to the absolute node_modules path in a
PathBuffer; check that it fits and fail the package with ENAMETOOLONG when it
does not.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8302147f-7389-4233-8ae7-515b403af5bd

📥 Commits

Reviewing files that changed from the base of the PR and between a5c4db7 and fd63e5a.

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

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: 880d2b20-844f-4d14-9740-f7c309a58f0a

📥 Commits

Reviewing files that changed from the base of the PR and between eabb96d and a5c4db7.

📒 Files selected for processing (2)
  • src/install/PackageInstall.rs
  • test/cli/install/bun-workspaces.test.ts

Walkthrough

The link installation path now uses NUL-terminated destination strings, validates Windows destination capacity, and passes destinations directly to Unix symlink creation. Workspace tests cover oversized scoped and unscoped package names.

Changes

Workspace linking

Layer / File(s) Summary
Destination handling
src/install/PackageInstall.rs
install_from_link derives a destination ZStr. Windows linking returns ENAMETOOLONG when the destination buffer is too small. Unix linking passes the ZStr directly to symlinkat.
Oversized workspace regression coverage
test/cli/install/bun-workspaces.test.ts
The test covers oversized scoped and unscoped workspace names and checks the diagnostic, failed-package output, and exit status.

Suggested reviewers: jarred-sumner, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix for linked package names that exceed symlink buffer capacity.
Description check ✅ Passed The description explains the problem, fix, verification, test coverage, and excluded issues, covering the template requirements.
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.

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:05 PM PT - Aug 14th, 2026

@robobun, your commit fd63e5a has some failures in Build #96377 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38571

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

bun-38571 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed; the diff is green, the remaining red checks are unrelated to this PR (details below).

  • Reproduced on the unfixed binary: a workspace member with a 600 byte name under bun install --linker hoisted aborts with panic: range end index 600 out of range for slice of length 512 on Linux x64 (1.4.0-canary.1), and with panic: range end index 98327 out of range for slice of length 98302 for a 98260 byte name on Windows x64 (stock build of current main). Names of up to 512 bytes already failed cleanly with ENAMETOOLONG.
  • With this branch both fail the one package with ENAMETOOLONG: failed linking dependency/workspace to node_modules for package ..., exit 1, on both platforms.
  • Tests: test/cli/install/bun-workspaces.test.ts, describe("workspace packages whose name is too long to link"), fails on the unfixed binary and passes with the debug build on Linux x64 and Windows x64.
  • CI: every Buildkite test lane that has run on fd63e5a passed; the two darwin 14 aarch64 lanes have been waiting for an agent. The previous build's only red lane was s3.test.ts on Windows 2019 (reported separately), the rest were retry-passed flakes, none of them in install. The red cargo clippy and mordant GitHub checks built the PR merge ref during the window in which main itself did not compile (sql_jsc, report_active_exception_as_unhandled, fixed on main by sql: fix the build after #32089 × #37275 (LISTEN/NOTIFY error path) #38628); the files involved are not touched here, and cargo clippy -p bun_install plus cargo check for the Windows and macOS targets are clean locally. A re-run after the main fix clears them.

@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 — replaces an unchecked 512-byte buffer copy with a borrow of the already-NUL-terminated tail, and adds the missing bounds check on the Windows path.

What was reviewed:

  • Verified subdir.map_or(0, |dir| dir.len() + 1) produces the same bytes as the old basename() for both shapes alias_is_safe_install_target admits (name and @scope/name), so the success path is unchanged.
  • Confirmed ZStr::len()/as_bytes() exclude the NUL, so the Windows offset + dest.len() >= dest_buf.len() check leaves room for the terminator and copy_from_slice(dest.as_bytes()) is byte-identical to before.
  • Test covers scoped and unscoped names on both platforms and was verified to abort on the unfixed build.
Extended reasoning...

Overview

The PR fixes a panic in PackageInstall::install_from_link (src/install/PackageInstall.rs) when a linked package name exceeds the stack buffer used to build the symlinkat/junction destination. On POSIX it removes the 512-byte dest_name_buf copy entirely — destination_dir_subpath is already a &ZStr, and alias_is_safe_install_target guarantees it is exactly name or @scope/name, so its tail (after the scope separator, if any) is passed directly to symlinkat. On Windows it adds a length check before appending the name to the absolute node_modules path in dest_buf: PathBuffer, returning ENAMETOOLONG under Step::LinkingDependency instead of panicking on the slice index. A regression test in bun-workspaces.test.ts covers scoped and unscoped names sized to overflow the old buffer on each platform.

Security risks

None. The change converts a user-reachable panic (DoS on adversarial package.json input) into the same recoverable per-package failure that shorter over-length names already produce. The input-validation gate (alias_is_safe_install_target, which rejects .., absolute paths, backslashes, NUL, and multi-component names) is unchanged and still runs before this code.

Level of scrutiny

Moderate. This is a small, mechanical fix in the package installer's link path — no new syscalls, no allocation changes, no unsafe added. I traced dirname::<Auto> on both platforms against the two admitted alias shapes to confirm the new tail-slice offset matches what bun_paths::basename returned before, and checked ZStr::len/as_bytes/from_slice_with_nul semantics to confirm the Windows copy and the new bounds check (>=, leaving room for the NUL at [offset + dest.len()]) are correct. The success path passes byte-identical arguments to symlinkat/symlink_or_junction.

Other factors

The PR description documents empirical verification (USE_SYSTEM_BUN=1 panics, bun bd passes) on Linux x64 and Windows x64, plus cargo check on macOS and Windows targets and clean clippy/fmt. The test follows harness conventions (test.concurrent.each, setupTest, await using on the spawned process, drains both pipes concurrently, asserts stderr/stdout before exit code, Buffer.alloc for the long string). Two adjacent overflow sites (get_installed_package_json_source and the progress bar) are explicitly called out as separate issues rather than silently left. No CODEOWNERS on src/install/, no prior human review comments.

Comment thread src/install/PackageInstall.rs Outdated
Comment thread src/install/PackageInstall.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.

LGTM — converts a user-reachable panic into the same ENAMETOOLONG failure that shorter over-limit names already produce.

What was reviewed

  • POSIX: verified dest tail-slice is equivalent to the removed basename copy for both name and @scope/name shapes (guaranteed by alias_is_safe_install_target); the debug_assert_eq! locks this in.
  • Windows: bounds check offset + dest.len() >= dest_buf.len() correctly reserves the trailing NUL byte; dest.as_bytes()/dest.len() on the new &ZStr match the old &[u8] semantics.
  • relative_buf_z is the existing helper that does exactly what the removed manual copy+NUL did for the target path.
  • The comment-cop bot feedback was addressed in fd63e5a (pushed after the bot fired on a5c4db7).
Extended reasoning...

Overview

Changes are confined to PackageInstall::install_from_link in src/install/PackageInstall.rs plus a two-case regression test in test/cli/install/bun-workspaces.test.ts. The fix removes an unchecked copy of the link entry name into a 512-byte stack buffer (POSIX) and adds a bounds check before the equivalent copy on Windows, so an over-length name fails the one package with ENAMETOOLONG instead of aborting the whole install with a slice-index panic.

Security risks

None. The change tightens handling of adversarial input (a package name from package.json): a name that previously panicked now surfaces as a per-package error, and a name that fits is passed through byte-identical arguments. No new privileges, no new syscalls, no new user-facing surface.

Level of scrutiny

Moderate — this is native install-path Rust with buffer arithmetic, but the change is a net simplification: one buffer copy is deleted (POSIX now borrows the already-NUL-terminated tail of destination_dir_subpath), one bounds check is added (Windows), and the target string is built with the existing relative_buf_z helper instead of a hand-rolled copy+NUL. I traced resolve_path::dirname::<platform::Auto> for both name and @scope/name on POSIX and Windows and confirmed the subdir.map_or(0, |dir| dir.len() + 1) offset yields exactly basename(dest_path), which the added debug_assert_eq! also enforces on every debug run. alias_is_safe_install_target rejects \\, :, \0, empty/./.. components, and anything but 1 or @-prefixed 2 components, so no other shapes reach this code. The Windows check offset + dest.len() >= dest_buf.len() correctly reserves room for the NUL written at dest_buf[offset + dest.len()].

Other factors

  • Tests cover both the unscoped and scoped (separately-opened @scope directory) branches on both platforms, and the PR description records USE_SYSTEM_BUN=1 failing / bun bd test passing on Linux and Windows.
  • The two comment-cop bot comments fired at 18:06:59Z on commit a5c4db7; commit fd63e5a (18:15:38Z) replaced the flagged paragraph comments with the one-line comment + debug_assert_eq! and switched to relative_buf_z, so that feedback is addressed.
  • The one CI failure so far (s3.test.ts on Windows 2019) is unrelated to install/linking.
  • REVIEW.md's "user-reachable failures are recoverable errors, never panics" is exactly what this enforces.

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