Skip to content

patch: refuse symlink and hard link escapes when applying patches - #32677

Closed
robobun wants to merge 1 commit into
mainfrom
farm/ec03c79e/patch-symlink-nofollow
Closed

robobun wants to merge 1 commit into
mainfrom
farm/ec03c79e/patch-symlink-nofollow

Conversation

@robobun

@robobun robobun commented Jun 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • PatchFile::apply resolved every target path (openat, unlinkat, renameat, fchmodat, and the read of the file being patched) relative to the package directory and let the kernel follow whatever it found. is_safe_patch_path is lexical (empty / absolute / .. / NUL), so a symlink, junction, or hard link already present in the directory redirected the operation, and the patch's bytes were written to (or the unlink/rename/chmod applied to) the link target outside the directory. Reproduction with the internal testing API:

    import { patchInternals } from "bun:internal-for-testing";
    fs.symlinkSync("/tmp/outside.txt", "pkg/link");
    patchInternals.apply(
      "diff --git a/link b/link\nnew file mode 100644\n--- /dev/null\n+++ b/link\n@@ -0,0 +1,1 @@\n+PWNED\n",
      "pkg",
    );
    // /tmp/outside.txt now contains "PWNED"
  • Scope, to be precise about what this does and does not close: the only production caller is patch_install.rs, which copies the package into a fresh temporary directory (InstallMethod::Copyfile, patch_install.rs:497) and applies the patch there. That copy never contains symlinks or extra hard links, so none of this is reachable from bun install today. This PR makes the applier enforce containment itself instead of relying on that caller property, so the applier's contract holds for any directory it is pointed at (the testing API, or a future caller that applies in place).

  • Separately, a pre-existing bug this PR also fixes: FileCreation used the new file's mode from the patch header as the mode for any parent directories it had to create, so new file mode 100644 produced a 0644 directory and the file creation inside it failed with EACCES for a non-root user (newdir/x.txt in a patch reproduces this on main). It is in this PR because the rewrite replaces that line; it is listed here, and tested in describe("creation"), as a behavior change rather than as containment.

Fix

  • resolve_target validates the path and walks its parent directory one component at a time from the package directory fd, opening each component O_RDONLY | O_DIRECTORY | O_NOFOLLOW (creating it with mkdirat first when the operation is allowed to create directories). It returns the verified parent fd plus the final component, and every operation in apply is now an *at call against exactly that pair, so no path is ever re-resolved by the kernel after it has been checked. All five operations (delete, rename, create, patch, chmod) go through it; apply attaches the patch path to whatever error comes back, so a failing multi-file patch names the file.
  • open_target_file opens the final component O_NOFOLLOW (plus O_NONBLOCK on POSIX so a FIFO cannot block the open) and then requires the opened inode to be a regular file with st_nlink == 1. A second link means the inode is reachable from outside the directory, so it is refused before anything is truncated; the file being patched is read and written back through this one fd, and creation defers truncation until after the check.
  • Lexical additions: : is rejected on Windows, because the Windows openat strips a drive prefix from a relative component, turning C:.. into ... (NUL rejection already landed on main in Robustness pass across install, css, ffi, crypto, spawn, shell, and node compat #36165.) Created parent directories are 0755.
  • Windows: openat with O_NOFOLLOW opens with FILE_OPEN_REPARSE_POINT since sys(windows): keep FILE_SYNCHRONOUS_IO_NONALERT when opening with O::NOFOLLOW #36193, so the walk and the final open pass O_NOFOLLOW on every platform. On Windows that opens the reparse point itself rather than failing, and fstat on that handle reports a regular file, so reject_reparse_point additionally checks each component's own attributes (NtQueryAttributesFile via the new bun_sys::get_file_attributes_at) before opening it. That check is by name rather than by handle, so on Windows containment is not atomic against a concurrent swap; on POSIX it is.
  • Error codes: a symlink or junction anywhere in the path is ELOOP on every platform (Linux reports ENOTDIR, Darwin ELOOP, FreeBSD EMLINK for the refused open; symlink_component_err normalizes them after confirming with lstat); a hard-linked target is EMLINK; a non-regular target or a rejected path is EINVAL.
  • Verification: test/js/bun/patch/patch.test.ts adds suites for symlinks (final and intermediate component, all five operations), hard links, Windows junctions, the NUL .. chain, the Windows drive-relative component, FIFO targets, and the directory mode; each rejection test fails on the unfixed applier. bun-install-patch.test.ts and patch-bounds-check.test.ts snapshots are updated for the error messages, which now include the file name. byte-search and dead-code-escapes source lints pass.

Background

  • openat-family syscalls take a directory fd and resolve the given path relative to it. With a single-component name and O_NOFOLLOW, the kernel refuses to go through a symlink at that name, and since the directory fd already pins the parent, the combination is the standard way to operate on "this entry, in this directory, and nothing else"; walking a multi-component path this way is what makes intermediate symlinks unreachable too.
  • st_nlink is the number of directory entries pointing at an inode. A freshly copied package has exactly one for every file, so a count above one means a second name for the same data exists somewhere, and writing through either name writes the other.
  • On Windows, symlinks and junctions are both "reparse points". FILE_OPEN_REPARSE_POINT makes an open return the reparse point itself instead of its target, which is why a successful open there has to be followed by an attribute check rather than treated as proof the entry is a plain file or directory.

no test proof · iteration 15 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-install-patch.test.ts test/js/bun/patch/patch.test.ts

@robobun

robobun commented Jun 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:19 PM PT - Aug 17th, 2026

✅ @robobun, your commit 0a35b56d5daf5274627f2507a68324f9626213cd passed in Build #100196! 🎉


🧪   To try this PR locally:

bunx bun-pr 32677

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

bun-32677 --bun

@coderabbitai

coderabbitai Bot commented Jun 24, 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

src/patch/lib.rs now uses verified parent and target file descriptors for patch operations, adds path and target-type rejection checks, and updates patch tests for escape cases. Documentation examples and the web APIs table were reformatted.

Changes

Patch symlink hardening

Layer / File(s) Summary
Verified apply operations
src/patch/lib.rs
PatchFile::apply now performs deletion, rename, creation, and mode-change operations through verified parent directories and relative file descriptors, and apply_patch uses the same parent-walking target open flow.
Safety checks and write-back
src/patch/lib.rs
apply_patch updates splice bounds error mapping and the write-back phase truncates through the verified fd, resets the file offset on Windows, and uses new helpers for symlink, reparse-point, hard-link, and non-regular target rejection; is_safe_patch_path also rejects interior NUL bytes.
Escape regression tests
test/js/bun/patch/patch.test.ts, test/cli/install/bun-install-patch.test.ts, test/internal/dead-code-escape-limits.json
The patch test suite adds fixture helpers and coverage for symlink, hard-link, junction, NUL-byte, and non-regular target rejection, alongside assertions that in-directory patching still succeeds; related snapshot normalization and dead-code config updates are included here.

Documentation formatting

Layer / File(s) Summary
Docs formatting updates
docs/guides/util/base64.mdx, docs/runtime/web-apis.mdx
Reformats the base64 warning example as a fenced TypeScript block and reflows the web APIs table header and body layout without changing the listed content.

Suggested reviewers

  • alii
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely identifies the main change: preventing symlink and hard-link escapes during patch application.
Description check ✅ Passed The description explains the problem, implementation, platform behavior, verification coverage, and related behavior change in sufficient detail.

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

Comment thread src/patch/lib.rs Outdated
Comment thread src/patch/lib.rs Outdated
Comment thread src/patch/lib.rs Outdated
Comment thread src/patch/lib.rs Outdated
Comment thread src/patch/lib.rs Outdated
Comment thread src/patch/lib.rs Outdated

@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

Caution

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

⚠️ Outside diff range comments (1)
src/patch/lib.rs (1)

270-286: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject intermediate symlinks before reading the source file.

lstatat() only avoids following the final component; read_file_alloc() can still follow an intermediate directory symlink and read a file outside patch_dir before open_beneath() later rejects the write. Run the same parent containment check, or open/read through a verified open_beneath(..., RDONLY, ..., false, ...) fd, before stat/read.

As per coding guidelines, “Security checks fail closed and cover every path to the protected effect.”

🤖 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/patch/lib.rs` around lines 270 - 286, Reject intermediate symlinks before
the file is read in the patch loading flow. The current `lstatat()` check in
`src/patch/lib.rs` only protects the final path component, while
`read_file_alloc()` can still traverse a symlink in a parent directory and read
outside `patch_dir` before later containment checks happen. Update the logic
around `lstatat`, `read_file_alloc`, and the `open_beneath` path handling so the
source is opened/read only after verifying the full parent chain or by using a
verified `open_beneath(..., RDONLY, ..., false, ...)` file descriptor.

Source: Coding guidelines

🤖 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/patch/lib.rs`:
- Around line 467-477: The Windows final-symlink rejection in open_beneath() is
currently gated by O_CREAT, so FileModeChange/chmod paths can still follow a
final symlink before it is checked. In src/patch/lib.rs, move the lstatat(...
ISLNK ...) check out of the flags & sys::O::CREAT condition so it runs whenever
cfg!(windows) is true, and keep rejecting symlinks before sys::openat is called;
apply the same fix to the related open_beneath path referenced by the comment.

---

Outside diff comments:
In `@src/patch/lib.rs`:
- Around line 270-286: Reject intermediate symlinks before the file is read in
the patch loading flow. The current `lstatat()` check in `src/patch/lib.rs` only
protects the final path component, while `read_file_alloc()` can still traverse
a symlink in a parent directory and read outside `patch_dir` before later
containment checks happen. Update the logic around `lstatat`, `read_file_alloc`,
and the `open_beneath` path handling so the source is opened/read only after
verifying the full parent chain or by using a verified `open_beneath(...,
RDONLY, ..., false, ...)` file descriptor.
🪄 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: 31033103-68ff-4abd-9bbf-b31db40d67a2

📥 Commits

Reviewing files that changed from the base of the PR and between 942c222 and 08dff4b.

📒 Files selected for processing (2)
  • src/patch/lib.rs
  • test/js/bun/patch/patch.test.ts

Comment thread src/patch/lib.rs Outdated
Comment thread src/patch/lib.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.

All the issues I raised across the earlier revisions look properly addressed now and the bug hunter found nothing new on 6a703f5 — but since this is new security-boundary code (symlink containment for the patch applier) with platform-specific branches that took several iterations to get right, I'd still like a human to give the final design a once-over before merge.

Extended reasoning...

Overview

This PR hardens PatchFile::apply in src/patch/lib.rs against symlink-based directory escapes. It adds three helpers — open_beneath, verify_parent_beneath_patch_dir, and verify_fd_beneath_patch_dir — and wires them into all five PatchFilePart arms (creation, patch, deletion, rename, mode change). apply_patch switches from fstatat/stat to lstatat and rejects symlink targets up front. ~150 lines of new Rust plus a 250-line non-Windows test suite, a snapshot normalizer tweak, and a dead-code-limits update.

Security risks

The change is the security boundary: it enforces that patch application cannot write/chmod/unlink/rename outside the package directory via symlinks. Getting it wrong reintroduces arbitrary file write through patchedDependencies. Across three prior review rounds I (and CodeRabbit) flagged seven distinct issues — a full rename/delete bypass, mkdir-before-verify leak, comparator looseness on macOS/POSIX, a ./-prefix regression, a Windows O_CREAT empty-file leak, and a Windows fchmod panic on HANDLE-backed fds — all of which the author fixed. The current revision looks correct to me on POSIX, and the Windows path is defended by an lstatat pre-check plus post-open canonical-path verification.

Level of scrutiny

High. This is newly-authored containment logic with platform-conditional behavior (cfg!(windows) drops O_NOFOLLOW and falls back to lstatat + post-open verify; FileModeChange uses path-based fchmodat on Windows vs fchmod(fd) on POSIX). The Windows branches have no direct test coverage (the new suite is gated !== "win32"), and the get_fd_path-based prefix comparison is subtle enough that a maintainer familiar with the sys layer should confirm the approach.

Other factors

The author was very responsive and each fix came with added test coverage where feasible. CI on the latest commit (6a703f5) hasn't reported yet; the prior commit's failures (Windows snapshot, dead-code-escapes) are exactly what 6a703f5 targets. No outstanding unresolved review threads. I'm deferring rather than approving solely because security-boundary code of this shape warrants human sign-off, not because of any specific remaining concern.

@robobun
robobun force-pushed the farm/ec03c79e/patch-symlink-nofollow branch from 6a703f5 to 0265a5e Compare June 25, 2026 01:11

@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 `@src/patch/lib.rs`:
- Around line 253-258: The patch application flow in lib.rs still reuses the
path after containment is checked, which allows a symlink swap between
verification and the later read/chmod steps. Update the patching logic around
the verified handle/path flow used by lstatat, read_file_alloc, open_beneath,
and fchmodat so that fstat/read/chmod operate on the already-open
descriptor/handle instead of reopening by path. Keep the containment check as a
guard, but perform the actual file access and chmod through the verified
fd/handle to avoid check-then-act races.
🪄 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: 12f1f6bf-85f7-477b-8b88-ace3811c2ab0

📥 Commits

Reviewing files that changed from the base of the PR and between 08dff4b and 0265a5e.

📒 Files selected for processing (4)
  • src/patch/lib.rs
  • test/cli/install/bun-install-patch.test.ts
  • test/internal/dead-code-escape-limits.json
  • test/js/bun/patch/patch.test.ts
💤 Files with no reviewable changes (1)
  • test/internal/dead-code-escape-limits.json

Comment thread src/patch/lib.rs Outdated
@robobun

robobun commented Jun 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status at 0a35b56 (current head)

Single commit on current main. @alii reviewed the approach, requested changes, and re-reviewed 0e8d71965b as ready to merge; this head is that plus a rebase, a rebase-forced change to how Windows chmod is performed, and one test assertion, all listed in the comment after his (his comment and the one following it).

What the head contains, briefly:

  • every patch operation resolves its target through resolve_target (component walk with O_DIRECTORY | O_NOFOLLOW, then *at calls against the verified parent and final component); opened targets must be regular files with one link; the patched file is read and written through one fd;
  • : rejected in patch paths on Windows (NUL rejection is already on main); O_NOFOLLOW on every platform plus the by-name reparse check on Windows;
  • separately stated fix: directories a patch creates are 0755 instead of inheriting the file mode;
  • errors name the failing file (per side for renames); snapshots updated accordingly.

All review threads are resolved. Locally: patch suites, install-patch and bounds-check snapshots, and the byte-search / dead-code-escapes source lints pass; cargo check clean on linux, windows, darwin; clippy clean. CI runs on 0a35b56d5d.

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

All seven findings from the earlier review rounds look properly addressed and I have nothing new to flag, but since this is security-sensitive containment logic (with Windows paths that the new test suite skips) I'd like a human to give it a final look.

Extended reasoning...

Overview

This PR hardens PatchFile::apply in src/patch/lib.rs against symlink-traversal escapes. It adds three helpers — open_beneath, verify_parent_beneath_patch_dir, and verify_fd_beneath_patch_dir — and routes all five patch-operation arms (creation, patch, deletion, rename, mode-change) through them so that neither a final-component symlink nor an intermediate directory symlink can cause writes/unlinks/renames/chmods outside the patch directory. apply_patch switches to lstatat and rejects symlink targets before reading. A ~250-line POSIX-only test suite in test/js/bun/patch/patch.test.ts exercises the rejection and happy paths; bun-install-patch.test.ts and dead-code-escape-limits.json get small follow-on tweaks.

Security risks

This is squarely security-sensitive: the patch applier runs on untrusted input (patch files referenced by patchedDependencies, applied to package contents that may themselves be attacker-supplied via the registry). The change is a strict hardening over the prior O_CREAT|O_WRONLY|O_TRUNC open with no O_NOFOLLOW, and across three review rounds I raised seven issues — the rename/delete bypass, the pre-verify mkdir leak, comparator looseness (case-insensitive and is_sep_any), the ./ regression, a Windows empty-file leak via FILE_OPEN_IF, and a Windows fchmod panic on HANDLE-backed fds — all of which are now addressed and resolved in the thread. The remaining residual risk is the acknowledged TOCTOU on Windows between the lstatat pre-check / containment verify and the subsequent path-based fchmodat, which is acceptable under the threat model (no concurrent local attacker) and still backed by the post-open canonical-path check.

Level of scrutiny

High. This is new containment logic on a write path reachable from bun install, with platform-divergent behaviour (POSIX O_NOFOLLOW vs. Windows NtCreateFile semantics, HANDLE-vs-CRT fd kinds). The iteration history — seven distinct bugs surfaced across three bot review passes plus one CodeRabbit finding — confirms the code is subtle enough to merit a human maintainer's sign-off rather than bot auto-approval.

Other factors

The new refuses to follow symlinks suite is gated behind process.platform !== "win32", so the Windows-specific branches (the lstatat pre-check, the nofollow = 0 path, and the path-based fchmodat fallback) have no direct CI coverage; the existing mode change > simple test is likewise POSIX-only. The current bug-hunting pass found nothing new, all prior inline comments are resolved, and no human reviewer has weighed in yet.

@robobun robobun changed the title patch: refuse to follow symlinks when applying patches patch: refuse symlink and hard link escapes when applying patches Jun 26, 2026
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked the containment design in 20f36e8 to address the review. Point by point:

  1. Read path follows symlinks out of the patch dir. Gone structurally: apply_patch now opens the target once with O_RDWR | O_NOFOLLOW relative to the verified parent fd and does the read, ftruncate, and write-back through that same fd. There is no path-resolved read left (read_file_alloc is deleted). Test: file patch through a symlink is rejected asserts the ELOOP and that the link target is untouched; the hunk is written so that a read-through-the-link implementation would have succeeded.

  2. Hard links. Every opened target is fstated and must be a regular file with st_nlink == 1; a planted hard link is rejected with EMLINK before any truncation. No false positives for real installs: bun install applies patches to a freshly copied package (InstallMethod::Copyfile), so targets always have one link. Tests: refuses to write through hard links (file patch, creation, mode change; runs on every platform including Windows).

  3. unlink/rename/mkdir check-then-act. All five operations now run relative to the verified parent directory fd (unlinkat(parent, base), renameat(from_parent, from_base, to_parent, to_base), mkdirat per component during the walk), so there is no re-resolution of a path between verification and effect.

  4. procfs dependency. Gone: get_fd_path/canonical-path comparison is deleted, so patching works in chroot/distroless/pid-namespaced environments. I did not take the openat2 route: RESOLVE_BENEATH still needs exactly this walk as a fallback (pre-5.6 kernels, seccomp profiles that reject openat2) and adds no containment on top of the per-component O_NOFOLLOW walk, so there is one mechanism on all POSIX platforms instead of two.

  5. Windows tests. Added refuses to traverse junctions on Windows (directory junctions need no privilege in CI): creation, patch, and deletion through an intermediate junction, a junction as the chmod target, plus positive tests for regular files, new directories, and mode changes. While writing them I found that the lstatat + ISLNK check the previous revision relied on never detected reparse points (libuv's handle fstat only reports links under lstat), so the Windows branch now uses NtQueryAttributesFile relative to the verified parent handle (new bun_sys::get_file_attributes_at), which reports the reparse point's own attributes like GetFileAttributesW. That check is path-based because O_NOFOLLOW cannot be used for the final file open on Windows, so the PR body states Windows containment is best-effort; the hard link check is handle-based and fully effective there.

Error codes are now uniform: any symlink/junction in the target path is ELOOP on every platform, a multiply-linked target is EMLINK.

Comment thread src/patch/lib.rs Outdated
@alii

alii commented Jun 29, 2026

Copy link
Copy Markdown
Member

@robobun bump — no commits since 6-26. The symlink fix leaves same-severity holes: (1) the READ path still follows symlinks out of the patch dir — apply_patch reads via read_file_alloc → plain RDONLY openat, no NOFOLLOW/containment; route it through open_beneath(...). (2) hardlinks wholly undefended (lstat sees a regular file, NOFOLLOW is a no-op) — reject nlink>1 on the opened fd or document out-of-scope. (3) unlink/rename/mkdir keep the check-then-act race; operate via the verified dirfd or openat2(RESOLVE_BENEATH). (4) containment hard-depends on /proc → chroot/distroless hard-fail; prefer openat2 on Linux. (5) Windows branch (NOFOLLOW dropped) has zero tests — add junction-based cases.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

@alii Thanks for the review. I think the five points describe the revision before the rework that landed on 2026-06-26 as 20f36e8 ("patch: resolve targets beneath the patch dir without procfs, reject hard links"). That is the "no commits since 6-26" commit, and it replaced the mechanism wholesale: open_beneath and read_file_alloc no longer exist at head, so neither do the code paths through them. Taking each point against the current head:

  1. Read path. read_file_alloc is gone. apply_patch resolves the target once with the component walk and opens it O_RDWR | O_NOFOLLOW via open_target_file (lib.rs:239), then reads through that same fd (lib.rs:250). The read and the write-back share one verified descriptor, so there is no separate path-resolved read left to follow a symlink.

  2. Hard links. open_target_file fstats the opened fd and rejects st_nlink > 1 with EMLINK before anything is truncated (lib.rs:537). This guards FileCreation, FilePatch, and FileModeChange. It is correct for legitimate targets because bun install always applies patches to a freshly copied package (InstallMethod::Copyfile in patch_install.rs), so real targets have exactly one link. Covered by the "refuses to write through hard links" suite (patch.test.ts:896).

  3. unlink / rename / mkdir. open_parent_beneath (lib.rs:420) walks the target's parent one component at a time, each opened O_RDONLY | O_DIRECTORY | O_NOFOLLOW (lib.rs:474), and every mutation then runs relative to that verified dirfd: unlinkat(parent.fd, base) (lib.rs:61), renameat(from_parent.fd, from_base, to_parent.fd, to_base) (lib.rs:89), and mkdirat(dir, component) inside the walk itself (lib.rs:478). There is no path re-resolution between the check and the effect.

  4. procfs / openat2. The procfs dependency is fully removed; get_fd_path and /proc no longer appear in the file, so chroot and distroless work. On openat2(RESOLVE_BENEATH): it is Linux 5.6+ only and is commonly denied by seccomp profiles (Docker's default filter among them), so it would still need exactly this walk as its fallback on every other path, and it adds no containment the walk does not already provide, since the walk is the same kernel-enforced per-component no-follow resolution. The portable walk is therefore the single mechanism rather than a second parallel one. The longer form of this is in the PR description.

  5. Windows tests. There is a Windows-only junction suite at patch.test.ts:974 covering file creation, file patch, deletion, and mode change through an NTFS junction, plus the positive cases. It ran and passed on all three Windows lanes (2019 x64, 2019 x64-baseline, 11 aarch64) in build 64940.

The only thing keeping the check red is unrelated to this diff: test/napi/napi.test.ts fails on the Windows lanes because napi_is_arraybuffer splits CRLF output with "\n". That test came from #32629, the failure is deterministic (it reproduced on two consecutive builds plus an in-build retry), and main has not run a Windows test matrix since that commit landed, so this branch is just the first one to surface it. Full details with links are in the CI status comment above. Happy to make any further changes you want here.

@alii

alii commented Jun 29, 2026

Copy link
Copy Markdown
Member

@robobun you're right — I verified all five against 20f36e8d and concede. My review (and the bump) critiqued the pre-rework revision; open_beneath/read_file_alloc/get_fd_path are gone at head and the component-walk rework closes each point:

  1. Read shares the verified fd via open_target_file (read_to_end at lib.rs:308) — no separate path-resolved read.
  2. st_nlink > 1 → EMLINK before truncate (lib.rs:660), covered by the hard-link suite (patch.test.ts:896).
  3. unlinkat/renameat/mkdirat all run relative to the per-component-walked dirfd — no re-resolution.
  4. procfs fully removed; openat2(RESOLVE_BENEATH) would need this same walk as its seccomp fallback anyway, agreed.
  5. Junction suite at patch.test.ts:974, green on all three Windows lanes.
    Withdrawing the five points. The only red is the unrelated napi CRLF flake from napi: napi_is_arraybuffer returns false for SharedArrayBuffer #32629. LGTM from my side — sorry for the stale bump.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for taking the time to re-verify, and no problem at all. Nothing further is needed from this side, so I will leave the branch as is rather than push and reset CI on a signed-off diff.

On the one remaining red: if it would help to land this on a green check, I am happy to put up a separate small PR for the napi_is_arraybuffer failure in test/napi/napi.test.ts. The Windows failure output shows the test splitting CRLF output on "\n", leaving a trailing \r on each compared line, so it is a test-only fix. Otherwise it is unrelated to this diff and safe to merge over.

@alii

alii commented Jun 29, 2026

Copy link
Copy Markdown
Member

@claude review

Comment thread src/patch/lib.rs Outdated
Comment thread src/patch/lib.rs Outdated
@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

@alii heads up: the @claude review you triggered found a real containment bypass in the head you approved, so please treat your LGTM on 20f36e8d as withdrawn. Good call running one more pass.

The bug. A path component of .. followed by a NUL byte (e.g. "..\0x") is not byte-equal to b"..", so both is_safe_patch_path and the walk's .. guard accepted it, but each component is handed to openat as a NUL-terminated C string, so the kernel truncates at the NUL and resolves a real ... Chained components walk straight out of patch_dir, and every operation (create, truncate, write, unlink, rename, chmod) then runs at the attacker's chosen path. It needs nothing on disk, only bytes in the .patch file, and it was a regression introduced by the component-walk rework itself (the pre-rework code passed the whole path as one C string, so the kernel truncated the lot and returned EISDIR).

Reproduced before fixing. On the unfixed head, a FileCreation for ".." + NUL + "1/.." + NUL + "2/.." + NUL + "3/outside/escaped.txt" reaches the C-string boundary inside the walk; a debug build aborts right there:

panic: ZStr::as_cstr: interior NUL would truncate the C view
  bun_patch::open_dir_component    src/patch/lib.rs:475
  bun_patch::open_parent_beneath   src/patch/lib.rs:440
  bun_patch::PatchFile::apply      src/patch/lib.rs:99

That assert is debug-only, so on release (and macOS) the same input is a silent .. ascent instead of an abort. Full detail is in the resolved review thread.

The fix (7dd4527). is_safe_patch_path now rejects any path containing a NUL byte, which is never valid in a filename, so no legitimate patch changes behavior; open_parent_beneath refuses NUL-bearing components as a backstop next to its existing .. backstop. The review's second point (a FIFO target blocking the O_RDONLY open before the !ISREG check could reject it) is also fixed by opening targets with O_NONBLOCK on POSIX. Three new subprocess-isolated tests cover the escape, a NUL in the final component, and the FIFO case; all three fail on the unfixed build and pass on the fixed one, and the full patch suite is green (45 pass, 0 fail).

The incremental diff is small (two guard clauses and one open flag, plus tests), so it should be a quick re-look when you have a moment. CI is running on the new head. Sorry for the churn right after your sign-off; the extra review pass clearly paid for itself.

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

7dd4527 addresses both findings from my last pass (NUL-byte .. escape and the FIFO O_NONBLOCK gap), and I found nothing new this round — deferring final sign-off to a human given this is security-critical containment code.

Extended reasoning...

Overview

This PR replaces the patch applier's path resolution with a per-component O_NOFOLLOW | O_DIRECTORY walk (open_parent_beneath) plus an fd-based target open (open_target_file) that enforces ISREG and st_nlink == 1, so symlink and hard-link escapes are refused before any write/truncate/chmod/unlink/rename. It touches src/patch/lib.rs (~250 lines net), adds a Windows get_file_attributes_at helper in src/sys/lib.rs, and adds ~600 lines of tests across POSIX symlinks, NTFS junctions, hard links, NUL-byte paths, and FIFOs.

What changed since my last review

My two outstanding inline comments (the 🔴 NUL-byte ..\0x component escape and the 🟡 FIFO blocking-open nit) are both addressed in 7dd4527:

  • is_safe_patch_path now rejects path.contains(&0), and open_parent_beneath independently rejects any component containing a NUL — defence in depth, with two new tests covering the ..\0 escape and a NUL in the final component.
  • open_target_file now adds O_NONBLOCK on POSIX so a FIFO target returns immediately for the !ISREG → EINVAL check, with a new test (mode change on a FIFO is rejected instead of hanging).

The bug-hunting system found nothing new at this revision.

Security risks

This is the security-relevant surface itself: containment of untrusted patch-file paths against the filesystem during bun install. The mechanism (per-component O_NOFOLLOW dirfd walk + fd-relative *at syscalls + fstat nlink/ISREG gate) is the standard correct shape on POSIX. Windows is documented best-effort via NtQueryAttributesFile reparse-point checks. The PR has been through eight rounds of review and a full rework; every issue I and others raised has been addressed in code with regression tests.

Level of scrutiny

High. This is newly-written filesystem containment for adversarial input, with platform-specific syscall semantics (Linux vs Darwin O_NOFOLLOW|O_DIRECTORY errno, Windows HANDLE-vs-CRT-fd, NtCreateFile reparse handling). It is not a mechanical change.

Other factors

  • A human reviewer (alii) gave LGTM on the revision immediately prior to 7dd4527; the only delta since then is the two targeted fixes for my findings plus their tests.
  • Test coverage is thorough across all five patch operations, all three platform families, and the specific bypass vectors raised in review.
  • The one acknowledged-and-deferred item (Windows CRT-fd leak in sys::fstat) is a pre-existing limitation in shared code, scoped for follow-up.

Given the security-critical nature, I'm not auto-approving; a human should give the final nod, but from my side the diff is clean.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

@alii closing the loop: the review you triggered has re-run against 7dd4527347 and reports both of its findings (the NUL-byte .. escape and the FIFO O_NONBLOCK gap) as addressed, with nothing new found this round; it defers the final sign-off to a human. All 12 review threads on the PR are resolved.

CI on 7dd4527347 is still running, but every patch suite that has reported so far is green on every lane, including the new NUL-traversal, FIFO, and Windows junction tests on the ASAN and Windows lanes. The only red lanes are unrelated to this diff and already documented in the CI status comment: the napi_is_arraybuffer CRLF failure from #32629 on Windows, a node-tls-connect > should have peer certificate failure on the Windows 2019 lanes that also does not touch this code, and a few retried flakes.

So this is ready for your re-look whenever you have a moment. The entire delta since the head you LGTM'd is the two NUL guard clauses, the O_NONBLOCK flag, and their tests.

@robobun
robobun force-pushed the farm/ec03c79e/patch-symlink-nofollow branch from 7dd4527 to f6923bc Compare June 29, 2026 17:16
@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

@alii one more small update, no code change: I rebased onto current main, so the head is now f6923bc401.

Reason: the last CI run was red on every single test lane, and both causes turned out to be test bugs that main has already fixed since this branch's base.

Neither has any connection to this PR, and none of the patch suites (including the new NUL, FIFO, and junction tests) failed on any lane of that build.

On the rebase itself: git range-diff reports all 7 commits as =, meaning the patch content you and the triggered review looked at is unchanged to the byte; only the base moved. No conflicts. Locally on the rebased tree the full patch suite set is green (93 pass, 0 fail) and cargo check is clean for linux, windows, and darwin.

CI is running on f6923bc401; with both of those upstream test fixes now included, it should finally come back green rather than needing the "these reds are unrelated" explanation.

Comment thread src/patch/lib.rs Outdated
@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

@alii one more small commit on top, 5695567b12, from the follow-up automated review: FileCreation was passing the new file's mode from the patch header as the create mode for any missing parent directories, so a standard new file mode 100644 patch produced 0o644 directories, which are not traversable, and the subsequent create inside them fails with EACCES for any non-root user. It is pre-existing behavior (the old mkdir_recursive_at_mode call did the same thing) and it never showed up because CI and the test suite run as root, which bypasses directory permission checks, while the one test covering the scenario happened to use 100755.

The fix is one token: created parents now use Some(0o755), exactly like the rename arm a few lines above already did. I reproduced it before fixing (the created directory really is 0o644 on the unfixed build) and the test now uses the common 100644 header and asserts the created directory is traversable and the created file is not executable, so it catches a regression with or without root. Full patch suite is green (93 pass, 0 fail) and review threads are all resolved.

Sorry for the drip of small commits while you have this on your plate; each one has been a concrete finding from the extra review passes, and I would rather land them before your final look than after.

Comment thread src/patch/lib.rs Outdated
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

@alii all five points are addressed in 165adf292b, which is now a single commit on current main (the earlier 11-commit history, the autofix docs hunks, and the old escape-limits path are gone with the rebase). Per-point replies are on the threads; the short version:

  • Rebase fallout: main's NUL check kept, : check and the walk use strings::contains_char / strings::split_any, redundant NUL test dropped, byte-search and dead-code-escapes lints pass.
  • The repeated block is one resolve_target returning (ParentDir, basename), used by five small per-operation functions; the dead re-check in the walk is deleted.
  • The directory-mode change is stated in the body as a separate pre-existing bug, is a named CREATED_DIR_MODE, and its test lives in describe("creation").
  • O_NOFOLLOW is passed on every platform; reject_reparse_point stays with its real rationale; the Windows paragraph in the body is rewritten.
  • Normalizer lines deleted and both snapshots updated; apply now attaches the patch path to errors (the helpers no longer touch the path), so a failing multi-file patch names the file.

The body also now says up front that the only production caller applies to a fresh copyfile'd temp directory, so this hardens the applier's own contract rather than closing something reachable from bun install.

Locally at this head: patch.test.ts, bun-install-patch.test.ts, patch-bounds-check.test.ts, and both source lints are green (95 pass), and cargo check is clean for linux, windows, and darwin. I also resolved the batch of identical comment-cop threads after a pass that removed or shortened the comments it flagged; the ones left are doc comments stating a precondition or a platform difference.

Comment thread src/patch/lib.rs Outdated
Comment thread test/js/bun/patch/patch.test.ts Outdated

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

Close. The containment code at this head holds up: is_safe_patch_path and the walk split on the same separators so a walk component can never be .., every syscall now runs against the verified (parent fd, basename) pair, open_target_file's NOFOLLOW-everywhere plus NONBLOCK-on-POSIX split matches what openat_windows_impl does with each flag (NOFOLLOW keeps FILE_SYNCHRONOUS_IO_NONALERT, NONBLOCK drops it), and the fd ownership in open_parent_beneath is right. Four of the five earlier points are resolved; the Windows NOFOLLOW one still has a stale copy in the test file, and the new error attribution names the wrong file when a rename's source fails. Fix those two and this is ready from my side. No conflict with main. Windows behaviour is unverified here, read only.

Comment thread test/js/bun/patch/patch.test.ts Outdated
Comment thread src/patch/lib.rs Outdated
@robobun
robobun force-pushed the farm/ec03c79e/patch-symlink-nofollow branch from 9a1a82b to 9768f2a Compare August 13, 2026 02:22
@robobun
robobun force-pushed the farm/ec03c79e/patch-symlink-nofollow branch from 9768f2a to 0e8d719 Compare August 13, 2026 02:33
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

@alii both remaining items are in 0e8d71965b: rename errors are attributed per side (source resolve -> from_path, destination resolve -> to_path, renameat itself -> from -> to), with the two rename tests now asserting the reported path in each direction, and the test-file comment is reworded to match reject_reparse_point.

One correction on my side: when I said the body had been rewritten, it had not actually taken effect. The gh pr edit failed on a token scope error and my verification grepped for a string the old body also contained. It is applied now (Problem / Fix / Background, including the copyfile'd-tempdir scope note, the directory-mode change as a separate fix, and the corrected Windows paragraph), so if you read the body earlier, it is worth one more glance.

Locally at this head the patch suites, the two snapshot files, and both source lints pass, and clippy is clean. Nothing else outstanding from my side.

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

Looks ready to merge from my side. Checked the two remaining points at 0e8d719: rename_file now attaches from_path or to_path to whichever resolve failed (and from -> to only when renameat itself fails), the two rename tests assert the reported path and still fail against main, and the junction comment in patch.test.ts now matches reject_reparse_point. Nothing else changed in this push; the branch is on today's main and the touched files have not moved there. Windows behaviour is still unverified here, read only.

Comment thread src/patch/lib.rs
Comment thread test/js/bun/patch/patch.test.ts
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the careful passes. One note so it is your call rather than mine: the automated review left a single cosmetic test nit on this head (the hard-link mode-change test asserts file content, which a chmod cannot change; its EMLINK assertion is the one doing the work). I have left the sha exactly as you reviewed it rather than push for that alone. If you would like it folded in before merging, say so and it is a two-line push; otherwise it can ride along with any later change to that file.

Comment thread src/patch/lib.rs Outdated
@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

@alii main moved under this and it stopped merging cleanly, so the head is now 594b974fdb. Three things differ from the 0e8d719 you signed off on, so you can re-check just these:

  1. Rebase onto current main. The only textual conflict was one line in dead-code-escape-limits.json; src/patch/lib.rs and patch.test.ts were untouched upstream.
  2. One change the rebase forced: main's dead-code pass removed the Windows sys::fchmodat that change_mode was using, and Windows sys::fchmod still panics on a HANDLE-backed fd. change_mode now does fd.make_libuv_owned() and then sys::fchmod on every platform (the conversion is the identity off Windows), so the platform split is gone and the chmod runs on the verified handle rather than by name. It is exercised on the Windows lanes by the junction suite's "still applies a mode change to a regular file". Related: main has since made Windows fstat stat the HANDLE directly, so the CRT-slot leak noted earlier in review no longer applies to open_target_file.
  3. The cosmetic test nit I mentioned, folded in since a push was needed anyway: the hard-link mode-change test now asserts the outside file's mode is unchanged (captured before the apply, so it is platform-independent) instead of its contents.

At this head: cargo check is clean for linux, windows, and darwin, clippy is clean, and the patch suites, both snapshot files, and both source lints pass locally (95 pass). CI is running on it.

Comment thread src/patch/lib.rs Outdated
…atches

Every operation in PatchFile::apply now resolves its target through
resolve_target: the parent directory is walked one component at a time
from the package directory fd with O_DIRECTORY | O_NOFOLLOW, and the
operation runs as an *at call against that fd and the final component,
so nothing is re-resolved by the kernel after being checked. Targets
that are opened must be regular files with a single hard link, and the
file being patched is read and written back through one fd. On Windows
each component's own attributes are also checked, since O_NOFOLLOW there
opens a reparse point rather than refusing it. A colon is rejected in
patch paths on Windows because the Windows openat strips a drive prefix
from a relative component.

Also fixes parent directories created for a new file being given the
file's mode (0644 directories are not traversable), and attaches the
patch path to apply errors so a multi-file patch names the failing file.
@robobun
robobun force-pushed the farm/ec03c79e/patch-symlink-nofollow branch from 594b974 to 0a35b56 Compare August 17, 2026 21:58
Comment thread src/patch/lib.rs
@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

@alii one refinement to item 2 from my previous comment, now at 0a35b56d5d: automated review pointed out that with the conversion done after arming the scopeguard, Windows would leak one CRT slot per mode change (the guard only NtClosed the HANDLE; the converted fd's close is what releases both). change_mode now converts first and arms the guard on the converted fd, closing the original HANDLE if the conversion fails. That is the whole delta from 594b974fdb; POSIX is unchanged (identity conversion) and the patch suite passes locally. Sorry for the extra sha; nothing else is pending from my side.

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

The CRT-fd-slot leak I flagged on the previous head is fixed — change_mode now converts with make_libuv_owned() before arming the guard (and closes the original fd on the Err arm), so the guarded close goes through uv_fs_close and releases both the CRT slot and the HANDLE. This run found nothing further. Since the last human sign-off was on 0e8d719 and the change_mode rewrite landed after it, a quick human re-check of that delta is still worth having before merge.

What was reviewed

  • change_mode fd ownership after make_libuv_owned(): guard is now on the Uv-kind fd; the Err(()) arm closes the System-kind fd, so no path leaks.
  • resolve_target / open_parent_beneath walk: ParentDir::drop releases each intermediate fd; patch_dir itself is borrowed (owned: false).
  • rename_file error attribution: per-side with_path on each resolve_target, with_path_dest only on renameat — matches the tests' err.path assertions.
  • nt_query_attributes_at refactor in src/sys/lib.rs: pure extraction; exists_at_type_nt behavior unchanged.
Extended reasoning...

Overview

Rewrites PatchFile::apply in src/patch/lib.rs to enforce containment when applying a patch: every operation now goes through resolve_target, which validates the path lexically and walks parent components one at a time with O_DIRECTORY | O_NOFOLLOW (creating them with mkdirat when allowed), then operates on the verified (parent_fd, basename) pair. open_target_file opens the final component O_NOFOLLOW | O_NONBLOCK, requires S_ISREG and st_nlink == 1, and returns the fd used for both read and write-back. src/sys/lib.rs gains get_file_attributes_at (a small refactor of the existing NtQueryAttributesFile body) for the Windows reparse-point check. ~600 lines of new tests cover symlink/junction/hardlink/FIFO/NUL/drive-relative escapes plus the fixed directory-mode bug; two snapshot files are updated for the new path-bearing error messages.

Security risks

This is defense-in-depth for the patch applier itself. The only production caller today applies to a fresh copyfile'd temp directory, so the escapes are not reachable from bun install; the change hardens the applier's contract. The Windows containment is a by-name attribute check before each open (documented as non-atomic vs. concurrent swap); on POSIX the walk is atomic per component. I did not find any way for the new code to widen the pre-PR attack surface.

Level of scrutiny

High — this is security-relevant path-containment logic in native code with per-platform branches, and it substantially rewrites five operation paths. It has already had multiple human review rounds; the last human sign-off was on 0e8d719. Since then the branch was rebased and change_mode was reworked to use make_libuv_owned() + fchmod on every platform (main removed Windows fchmodat), and then reworked once more to fix the CRT-slot leak I flagged on the intermediate head. Those two post-sign-off deltas to change_mode are the part a maintainer has not yet re-confirmed.

Other factors

The bug-hunting pass on the current head found nothing. My earlier inline finding (Windows CRT fd slot leak in change_mode) is addressed exactly as suggested — the guard is now armed on the post-conversion fd. All prior review threads (rename error attribution, stale Windows comments, hard-link mode-change test assertion) are resolved in the current diff. The one open comment-cop note on line 150 targets a two-line comment and looks like a heuristic false positive. Given the security-sensitive scope and the pending human re-check of the post-rebase delta, deferring rather than approving.

Jarred-Sumner added a commit that referenced this pull request Oct 14, 2026
### What does this PR do?

It consolidates 597 open package manager pull requests into one reviewed
change for Bun 1.5. Each was ported, taken in part, or left out with a
reason. This is the complete change log: `X -> Y` = symptom on main ->
behaviour now; a line without `->` names a symptom that is gone; dep =
dependency.

| absorbed | in part | not taken | obsolete | total | issues closed |
|---|---|---|---|---|---|
| 464 | 40 | 74 | 19 | 597 | 133 |

699 files, +150,684 / -85,553 (src 221 files +41,383 / -46,459, net
-5,076).

#### Behaviour changes someone will notice

1. Unknown long flags are errors in every package manager command and
bunx (main: `bun install --network-timeout 100000` adds the package
`100000`). Drop the flag; bunx: tool flags go after the tool's name. In
`BUN_OPTIONS` they are skipped.
2. `bun ci`/`--frozen-lockfile` fail (`note: dependencies in
package.json changed since bun.lock was saved`) when a dep of the root
or a workspace was added, removed, re-grouped or re-ranged. Equivalent
spellings, `file:` directory rows (warning),
`trustedDependencies`/`patchedDependencies` edits pass. Run `bun
install`, commit (#41931; refs #22689).
3. `file:` folders are hard-linked real files (hoisted: was a symlink
per file, their deps did not resolve). The folder's own `node_modules`
is not taken, except `bundleDependencies`. Linked, and trusted scripts
run, once, not on every install. Declare or bundle what the folder
needs; `link:` / a workspace for a live link; `--force` reruns scripts
(#40628, #37127; fixes #13379).
4. Hoisted: what the previous `bun.lock` placed and the new one does not
leaves `node_modules`, with what only it needed and its bins; not under
`--filter`, not what no lockfile placed (`bun prune`) (#29794, #44406).
5. One-time re-downloads: registry metadata (cache format bump), `file:`
/ URL tarballs, packages of registries on a port, path or http; `bunx`
starts a new cache.
6. Retries wait: a registry that refuses connections costs about 9 s
before the install fails (main: at once).
`BUN_CONFIG_HTTP_RETRY_BACKOFF=<ms>` sets the first wait.
7. Dep names over 255 bytes per component are refused (main: a
4,100-byte name in a manifest aborts), as are the names under Security.
8. Workspace's symlinked `node_modules` is replaced by a real directory,
like links at `@scope`, `.bin`, `.bun/**` (main wrote and deleted
through them). Only the root `node_modules` link is followed: link the
root, not members (#42039).
9. `"trustedDependencies": []` kept the default trusted list off only on
the install that created `bun.lock` -> always, also in a checkout
without the workspace that declares it; written to `bun.lock` (one line;
Bun 1.4 honours it, drops it at its next save) (#44765).
10. `!dir/**` in `workspaces` also removes `dir`, as npm; negated
entries work in every spelling (`!./pkg`, `!pkg/`) and position, add no
members; `bun run --filter`/`--workspaces` follow them (#43401, #43400;
fixes #43399).
11. `bun pm ls` lists what is on disk, exits 1 without `node_modules`;
isolated: a dep of the root only if its link exists (#38923; fixes
#38909).
12. Linker sticks: while `node_modules/.bun` exists, a command that
names no linker keeps the isolated layout (main: lays hoisted over it).
Back: `--linker=hoisted` or delete `node_modules`.
13. `NODE_ENV=development`/`test`: `bun install` reads the `.env` files
`bun run` reads, not the production ones.
14. `--provenance`, `--provenance-file`, `publishConfig.provenance`,
`NPM_CONFIG_PROVENANCE`, `provenance=true` in `.npmrc` are errors (main:
published without it). Unset them or publish with npm (#30522; refs
#18611).
15. `packageExtensions` adds a key to `bun.lock`, a trailing section to
`bun.lockb`, only when an extension added something; older versions load
both.
16. Fresh resolves pick differently (locked versions stay): see
Resolver; Overrides. glibc hosts stop installing `-musl` variants
(`--libc='*'` for both).
17. New `warn:` lines: incorrect peers, deprecated versions, skipped
required deps, stale / misplaced `patchedDependencies`, unreadable
config files, retries, unusable hashes.
18. Dep's script finds its own `.bin`, then the inherited `PATH`, then
shared `.bin`s, so an untrusted package's `node` bin cannot run inside a
trusted script. Cost: a global `node-gyp` wins over the project's, for
dep scripts only (#44488).
19. If `bun install` itself is killed while a trusted dep's script runs,
the next install calls the package installed (registry packages: as
main; `file:` folders, local tarballs: the rerun on every install hid
it). `bun install --force` (#42001).

#### Decisions that need the maintainer's confirmation

- Frozen installs start failing (2). Of 241 `bun.lockb` files from this
repo's release tags, 6 that pass on main fail here (4 real drift).
- A frozen install with only a foreign lockfile writes nothing: no
`bun.lock`, no package.json edit, one note (#38804). Reverses #38333.
- Changes 1, 9 (teams on 1.4 and 1.5 see the `[]` line come and go), 10,
11, 12 (removing `linker = "isolated"` no longer switches a tree), 14,
18.
- The `packageExtensions` lockfile key (15): without it an edited or
removed extension cannot be told from the package's own deps. #43104 /
#43209 are out for needing a key.
- Scripts that run now and did not: #41803 (its opposite #41866 is out),
#41845, #39443, #43913, #42001, #38780, `bunx --trust`. No longer: root
scripts under `--dry-run`; under frozen, scripts of an installed package
trusted after the lockfile was saved (main: every frozen install; now a
plain install once, frozen never, silently).
- No state file, so a killed install goes unnoticed (19). The complete
fix is one bit or list per project, read once per install.
- Tarball cache entries named by integrity; full manifests by package
name for `libc`; #38813; +1 `renameat` per linked package (#38783); a
token survives only an http -> https redirect on one hostname (#38001);
#39603 flips a row of #38867.
- Hoisted thread pool on macOS: on; CI only, no stress run.
- Open: mask URLs in source excerpts under a diagnostic (not done);
should `bun pm pack` follow a symlinked `node_modules` (it does,
#41925).

#### New features and flags

- Yarn Berry migration (on main since): here package.json is written
just before `bun.lock` and only if that is saved; `link:` stays `link:`;
`libc` is honoured (#40057)
- `link:./dir`, `link:../x`, absolute paths: symlink to that directory,
relative to the declaring package.json, as Yarn / pnpm; `link:foo` stays
a `bun link` name (#40025; fixes #4719, #5045)
- `packageExtensions` in the root package.json adds missing deps / peers
to registry packages, can mark a peer optional (#40053)
- `bun pm cache pack <file>`/`unpack <file>`: the cache entries a
lockfile needs as one file (CI); unpack adds what is missing, refuses a
damaged pack whole (#40012)
- `bun pm cache prune [--max-age <days>] [--dry-run]`: drops cache
entries older than 30 days, not the global store (#42592)
- `bun pm fetch`: downloads all deps (pinned git too) into the cache; no
`node_modules`, scripts or lockfile write (#29509)
- `bun publish --recursive`/`--filter <pattern>`: packs the selected
workspaces, publishes deps first, skips private and already published
ones; the first failure ends the run (#41292)
- `bun update --depth 0`: direct deps only, transitive ones stay locked
(#41720; fixes #41625)
- `bun prune --check`: exit 1 when anything would be removed (#39216)
- `minimumReleaseAgeExcludes` takes `name@version`; new
`--minimum-release-age-excludes a,b@1.2.3`; `bun audit fix` honours the
list (#37909, #42803; fixes #28967, #42799)
- `minimumReleaseAge` accepts `90m`, `12 hours`, `3d`, `2 weeks`;
`nan`/`inf` are config errors; `0` no longer fetches full manifests
(#30529)
- `libc`: on Linux only the glibc or musl variant of an optional native
package is installed; recorded in `bun.lock`; `--libc` overrides
(#38797; fixes #16282)
- `bunx --cwd <dir>` (#28669; fixes #28668)
- `bunx --env-file <file>` (main: `unrecognised dependency format:
@.env`); not `bun --env-file F x pkg` (#35599; fixes #12430, #17759)
- `bunx --minimum-release-age`, part of its cache key (#30751; fixes
#30748)
- `--env-file`, `--no-env-file`, bunfig `env = false` for all package
manager commands (main: `--env-file ci.env` added the package `ci.env`)
(#36672; fixes #12011, #31450)
- `npm-shrinkwrap.json` is migrated like package-lock.json (#38861)
- Deprecated versions are reported: one line per direct dep, one for the
rest, all with `--verbose` (#35457; fixes #6883)
- `bun add -d | --optional | --peer <pkg>` moves a package another group
lists; `--save-prod` moves it back (#36284; fixes #4852, #5714)
- `bun audit fix` reports and rewrites the `overrides` rule that holds a
vulnerable version (#38785)
- `bun pm pkg set`/`delete`: arrays (append, index), bracketed names
(`exports[./package.json]`) (#38025; fixes #22035)
- `--dry-run` for `bun link`, `unlink`, `pm version`, `pm cache rm`, `pm
trust`, `pm pkg`, `pm migrate` (main: they wrote) (#41690)
- `bun publish` honours `publishConfig.registry` and
`publishConfig["@scope:registry"]` (#38322; fixes #18670)
- Auto-install reads registries and credentials from `.npmrc` (main:
`@corp/x` came from the default registry) (#44754)
- `NPM_CONFIG_USERCONFIG` names the user `.npmrc`, as
`actions/setup-node` sets it (#38047)
- `Cache-Control: no-cache`, `no-store`, `max-age` under 300 s on
registry metadata (#41644)
- Workspace can depend on the root package with `workspace:*`, also for
pack / publish (#36594; fixes #2773, #5176)
- `"gypfile": false` turns off the implicit `node-gyp rebuild`; a
trusted tarball, git, `file:` package with `binding.gyp` and no scripts
is built like a registry one (#43913; fixes #43903)
- Scripts get `npm_package_name`/`version`/`json` of their own package,
`npm_config_local_prefix`, `npm_package_config_*`,
`npm_lifecycle_event`, `npm_lifecycle_script`; inherited `npm_package_*`
are dropped (#36690, #38110; fixes #12752)
- Hoisted linker links packages on up to 8 threads, whatever
`node_modules` holds (nothing, a part, other versions, `--force`) and
after a download; not on Windows; `file:`/`link:` / workspace / trusted
deps, bins and scripts stay on the main thread; everything in today's
order, scripts too; a cold install is no faster (#29496)

#### Security

- Registry URL that is not http(s) (`htps://`, no scheme): `pm view`,
`whoami`, `publish`, `audit`, `pm diff` sent the token there over plain
TCP; a bad `BUN_CONFIG_REGISTRY` fell back to another registry with the
token -> error, no request, on every route (#41662, #41773)
- Token, `_auth`, user or password with CR, LF, NUL could inject
headers, as could `--otp`, `--user-agent` -> refused (#37458, #41661)
- `token = "$NPM_TOKEN"` with the variable unset sent the literal text
-> warning, nothing sent (#41049)
- Passwords and tokens in registry, tarball, git URLs were printed in
errors, warnings, summaries, `--verbose`, `pm ls`/`why`/`view`/`diff`,
`audit`, migrations -> masked (#38977, #38997, #38817)
- Control characters from manifests, lockfiles, registry answers reached
the terminal (`pm view`, `licenses`, `untrusted`, `audit`, resolutions,
bin names, errors) -> escaped (#38536, #38631, #39358, #38525)
- Dep names `.bin`, `node_modules`, a bare `@scope`, with control
characters, empty or scope-only were installed or requested -> refused;
skipped with a warning in a third party's manifest (#44439, #38615,
#43331, #41735)
- Registry, git or tarball package could `link:` a directory, use a
local `.tgz` of the project or a `file:` path that leaves it -> refused
at resolve and install time (#39023, #38986, #43101)
- Dep's own `npm:` alias could replace a same-named direct dep of the
root; an alias in a refused or stale lockfile kept redirecting -> the
root's dep wins; loaders register no aliases (#44412, #44294)
- Frozen install asked the registry for a range that `bun.lock` links to
a workspace missing on disk (dependency confusion) -> fails without a
request (#43471)
- Global store: a trusted package's script could modify shared deps; `pm
trust` ran scripts in the store, saw nothing under isolated -> its
closure is project-local (#40239, #35106)
- Failing script of an optional workspace / `link:` dep deleted that
folder -> only what the linker placed goes (#43226)
- `bun prune`, `update`, `dedupe` removed packages behind a symlinked
workspace `node_modules` -> prune refuses (exit 1), the others skip it
(#44484; fixes #44669)
- `bun add -g`, `link`, `unlink` replaced or deleted foreign files in
the global bin directory -> only what Bun made (#39745)
- `bun pm cache rm` could delete the project, home or Bun's directory
(an empty `BUN_INSTALL_CACHE_DIR` meant the project) -> refused (#39750)
- Project's bunfig.toml could point `globalDir`/`globalBinDir` anywhere
-> ignored with a warning (#41775)
- `bunx` installed into the shared temp directory, where another user's
`node_modules` / package.json was loaded -> `<cache>/.bunx-<uid>`, 0700
(#44393, #31447)
- Applying a patch followed symlinks and wrote through hard links ->
refused (#32677)
- Tarball could make Bun allocate the disk or memory its headers claim;
sparse members keep their holes (#36542)
- `minimumReleaseAge`: an exact version passed when only the abbreviated
manifest was cached; so did a publish time that is not a date (#38659,
#43307)
- `BUN_CONFIG_NO_VERIFY=0` turned verification off, `=1` did not -> off
only for `1` or `true` (#38310)
- Manifest hash Bun cannot use skipped verification silently -> warning
(#38484)
- `bun audit` took a malformed registry answer for a clean audit ->
failed audit; a scoped registry is named and skipped (#38557)
- Pack: keys of a bundled dep that are not package names pulled in
outside files; `**/a/b` patterns, a BOM, trailing spaces disabled ignore
rules; a symlinked README was sent (#44902, #41531, #41871)
- `bun publish` published with `"private": "true"` or `1` -> any truthy
value is private (#41664)
- Auto-install read a package.json version from the wrong buffer, an
alias could redirect other packages (#42839)

#### Install core

- Killed or failed install left a half-linked package that later
installs accepted -> linked in a staging directory, renamed into place
(#38783; fixes #43256)
- Switching hoisted <-> isolated left a mixed tree -> whole tree
replaced, workspaces' `node_modules` too (#38779)
- Each linker switch kept a copy of the old tree in
`node_modules/.old_modules-<hex>` -> deleted after the install; `bun
prune` collects old ones
- `--dry-run` ran root lifecycle scripts and wrote `yarn.lock` (#38830)
- bunfig `[install] dryRun = true` was ignored -> honoured, also by
publish, `pm pack`, prune, dedupe, `audit fix` (#34979)
- Empty package.json (root or member) deleted the lockfile, exit 0 ->
error, lockfile kept (#39666)
- Download that failed for an optional dep was skipped (exit 0) although
another dep or a non-optional peer needs it -> `error: failed to
download <name>@<version>`, exit 1 (#43197, #43219; fixes #43214)
- `--offline` failed on what is below an optional dep it had skipped ->
only misses still reachable are errors (#43185)
- `--offline` exited 0 without a required dep of an optional package
installed in one place, missing in another
- Required direct dep left out for `os`/`cpu` was silent -> warning;
`bun add` reports it as skipped (#41956)
- `--production`/`--omit=dev` failed on a `workspace:`, `link:`, `file:`
devDependency whose target is missing -> left out (#36704; fixes #14134,
#15102)
- `--production` installed, and ran scripts of, a devDependency that is
also an optional peer; `bun prune --production` removes it (#42040)
- Commands that only read package.json opened it for writing -> work on
a read-only file (#38745)
- `$TMPDIR` on another file system than the cache kept a probe file per
install (#38859)
- `bun add`, `remove`, `update`, `patch` rewrote a package.json in which
nothing changed
- `Infinity`/`NaN` were written into package.json -> `null` (#41743)
- Relative `--cwd` was applied twice by `bun update`, `link`, `unlink`,
`why` (#31154; fixes #31152)
- Warnings from native code appeared at exit -> flushed when printed,
also from Workers (#43004; fixes #42989)
- `bun --bun run` worked only for the first user of a shared machine ->
`node` shim directory per user (#35565; fixes #7504)

#### Resolver and version picking

- Range picked a deprecated version (`@types/*` stubs under `*`) ->
prefers one that is not, like npm; a locked version stays until `bun
update` (#36630; fixes #7571, #25314)
- `bun add pkg` installed a deprecated `latest` -> highest release that
is not deprecated; `pkg@latest` still follows the tag (#40317; fixes
#40310)
- Missing or dangling `dist-tags.latest` failed add, install, `update
--latest`, hid the package from `outdated`/`update -i` -> highest
release, with a warning (#36671; fixes #12041)
- `*`, `npm:pkg`, `npm:pkg@*` skipped a prerelease `latest` -> follow
the tag, like npm (#39774)
- Wide range (`*`, `>=1`) nested the newest version -> shares a resolved
version that satisfies it; `bun update` no longer re-adds what `bun
dedupe` removed (#38770)
- `1.0.0 || ^2.0.0`, `=2.5.0 <2` resolved as their first exact version
(#30076)
- `>=0.5 0` was read as alternatives -> intersection, like npm (#32993)
- `^`, `~`, x-ranges admitted prereleases of their upper bound
(`pkg@>=5.0.0-alpha.0 <8` overrode `^4`) -> end at `<X.Y.Z-0` (#43102;
fixes #41371)
- Exact prerelease met a range that does not admit it (`overrides` keys)
(#43150; fixes #43138)
- `>1` meant `>1.0.0` -> `>=2.0.0`; `>1.2` -> `>=1.3.0`, like
node-semver (#43253)
- `<x`, `<*`, `>x`, `>*` matched everything -> nothing; `^x`, `~x` match
any version (#43258)
- Version number above 2^64-1 was read as 0 -> invalid (#42458)
- `Bun.semver.satisfies()` was true for `""`, `1.2.3.4`, `1.2.3 junk`
(#32998)
- `satisfies("1.0.0+build-1", "^1.0.0")` was false; `< =1` was misread
- `pkg@2x`, `1beta`, `1.2x` were read as ranges -> dist-tags (#35259;
fixes #19065)
- Words starting with `ssh`/`http` were read as URLs -> dist-tags
(#37133)
- Leading whitespace in a version literal (tag, folder, tarball, `npm:`,
`catalog:`) broke the install, lost a pin in `bun add --catalog`, or
panicked (#38331, #38309)
- Version published after the manifest was cached failed for up to 5
minutes -> manifest fetched again once (#39790)
- Root dep edited to another package (`npm:` alias) under a range the
old version fits kept the old package (#41774)
- Plain dep got a same-named `npm:` alias's package outside its range
(#43267)
- New dep that follows an `npm:` alias asked for a garbled name (`GET
/1.0.0no-deps - 404`) (#43515; fixes #23264)
- `file:` and `link:` on one directory became one package (#38114)
- `file:` folder in both `dependencies` and `devDependencies` was
duplicated (#41833)
- Editing package.json re-downloaded GitHub / tarball deps, re-fetched
git ones that `bun.lock` resolves (#37142)
- Registry package that bundles a `file:../x` dep failed to install
(#43125)
- Bundled dep's dep took a slot the bundling package resolves through
(#38848; fixes #29263)
- `bundleDependencies` were kept from one dependency group of a manifest
only (#38857)
- Tarball, folder or git package whose own `name` is invalid was
installed under it -> refused (#38633)
- Folder, tarball, git packages without a `name` got broken lockfile
rows -> named after their source (#38681; fixes #17060)
- Dangling `node_modules` symlink was a resolver error -> missing
module; isolated: a failed optional dep goes with the links to it
(#38756)

#### Hoisted linker

- overlayfs: a replaced package of a lower layer kept its old files
beside the new ones, exit 0
- Install over an existing `node_modules` hung when the OS refused the
pool's first thread
- `bun prune` left the `.old-<random>` of a killed install
- macOS: clonefile kept a file already at the destination -> replaced (a
nested package could hold two versions' files)
- Package was hoisted next to an out-of-range version of its peer
(#36300; fixes #20376)
- Dep nested under a package with `bundledDependencies` resolved the
bundled version (#43377; fixes #43351)
- After a version bump the old version of a transitive dep stayed at the
root (#41053)
- Required peer that a migrated lockfile left unbound stayed unbound
(#39343)
- Tree saved after a ranged peer gained a higher candidate differed from
the next install's (#38767)
- `file:` override on a registry package's or transitive dep was not
linked or resolved from the wrong directory -> from the project root,
linked with its deps (#38994, #44339; fixes #44299)
- One `file:` folder that cannot be hard-linked (EXDEV) switched all
later packages to copyfile (#40663)
- Failed optional dep left its `.bin` links (#44931)

#### Isolated linker, global store

- `node_modules` or entry that is a mount point (Docker volume) is kept
and reset in place
- `bun add -g` never linked bins into the global bin directory (#30451;
fixes #28597, #30450)
- Optional dep whose tarball cannot be downloaded or is corrupt failed
the install -> skipped with a warning, also `--offline` (#43229; fixes
#43212)
- `node_modules/.bun/node_modules` changed between installs of one
lockfile with `npm:` aliases -> links named by the dep, one layout
(#40356; fixes #40355)
- Packages that are peers of each other were installed twice (#29345;
fixes #29343)
- Missing `link:` target was accepted -> install fails, as hoisted
(#38045)
- Registry package's own `file:./vendor/x` dep failed -> linked from
inside the package (#38856)
- Workspace's scripts ran before the root's deps and bins were there ->
after everything else; a bin such a script writes is linked for every
dependent (#44868)
- Empty directory, or one without package.json, in a link slot stayed
(#41453)
- Store names of non-ASCII paths were garbled (#40719)
- Package used under its name and an alias got bins for one dependent
only (#44446)
- `--omit=peer` dropped a peer an ancestor provides; `bun prune
--omit=peer` agrees (#41416)
- Files deleted from a `file:` folder stayed in its copy (#37138)
- Links of a renamed or removed workspace stayed in `node_modules` (both
linkers) (#38511)
- `bun prune` left dangling links to removed optional / peer deps in
store entries (#42625)
- Switching the global install's linker removed `bun link` registrations
(#43582)
- Global store: a package whose peer is typed by the project's
`@types/*` was shared, TypeScript missed the types -> project-local
(#36796; fixes #36795)
- Ranged peer whose auto-installed version drops out of the saved tree
was linked to nothing (#38768)

#### Bins

- Hoisted: with `--silent` a failed bin link exited 0 and saved the
lockfile -> 1, nothing saved (#44997, #44958)
- Every install recreated `.bin` links, failed with `EEXIST` on a
read-only up-to-date tree -> a right link is left alone (per bin: chmod
1 -> 0, unlink 1 -> 0, symlink 2 -> 1) (#36701, #20067; fixes #14736)
- Several packages with one bin name: an arbitrary one won -> direct
deps first, then registry / git / tarball before workspace, `file:`,
`link:` packages, then by name (#31416, #41867)
- Isolated made bin targets 0777 -> `0777 & ~umask` (#41509)
- One bin that fails to link stopped the rest (#38972)
- `bin` entry whose value is not a string dropped the package's other
commands (#43159; fixes #43148)
- File system that refuses symlinks (EACCES / EPERM) failed the install
-> one warning, no `.bin` links (#40161)
- Bare `bun update -g`/`bun ci -g` linked no bins; a reinstalled global
package gets its command back (#43384)
- `bun pm migrate`/`pm trust` wrote uninitialised bin bytes into
`bun.lockb` (#37581)

#### bun.lock, bun.lockb

- Corrupt or crafted `bun.lockb` crashed or hung install, `pm ls`,
`why`, `outdated` -> validated at load, refused with a warning; files by
Bun 0.x are normalised and keep loading (#32753, #39720)
- Two strings whose hashes collide became one in the lockfile (#41642)
- Root `file:` or registry dep named like a workspace made Bun write a
lockfile it refuses (`Duplicate package path`) -> the member beats
`file:`; a registry range it does not satisfy gets the root slot
(#37245, #37248)
- Root alias of the registry package named like a workspace dropped the
workspace (#43567)
- `file:` paths from the lockfile were resolved from the wrong directory
after `bun update` or an override removal (#38850)
- Re-resolving install dropped packages `bun.lock` holds only through
optional peers (#39002)
- Package printed at several paths got its edges from the wrong row
(#39347)
- Re-saving moved a bundled peer off its entry's version, and a peer
nothing satisfies (#38837, #38892)
- `bun add` installed nothing when `bun.lock` repeats a dependency line
(#43271)
- `--lockfile-only` saved after an error; `--silent` failed on a
`bun.lock` it ignores (#44364)
- `bun.lock`, `bun.lockb`, `yarn.lock` ignored the umask (`yarn.lock`
0666) (#41808)
- `bun <path>.lockb` printed the directory's lockfile, read `.env`,
needed package.json -> prints the given file (git textconv works)
(#44870)
- `git+git://` resolutions did not load back from `bun.lock` (#38798)
- `bun pm migrate`/`pm trust` wrote another
`configVersion`/`patchedDependencies` than install; yarn entries without
`#sha1` (#41908; fixes #38193)
- Absolute or over-long workspace path in `bun.lock` / pnpm-lock.yaml
crashed or counted as pruned (#43405)
- `bun install --yarn` rewrote an unchanged `yarn.lock` every time

#### --frozen-lockfile, bun ci

- `bun ci` failed in a workspace when nothing changed (member with a
script and a `latest` row); one member's edit moved pins elsewhere ->
members are compared row by row, keep their pins (#44919; fixes #26070)
- Version the lockfile pins was blocked by `minimumReleaseAge` (#40032;
fixes #40031)
- After a migration with a root postinstall, or `pm migrate` to
`bun.lockb`, the next frozen install failed on the meta hash (#44501)
- With both `overrides` and `resolutions` only `overrides` applied ->
both; a lockfile without those rules fails frozen, 1.4's too (#38811)
- Direct dep pinned outside its unchanged range (one-sided merge) was
installed, frozen passed -> re-resolved; frozen fails (#43375; fixes
#43795)

#### Migrations (npm, pnpm, Yarn v1)

- Yarn Berry: a range `yarn.lock` has no key for is the workspace that
satisfies it; Yarn's own `packageExtensions`; `resolutions` keyed on an
alias; a name in several groups of a workspace; `catalog:` in an
extension; `%xx` in a folder name; clearer refusals (#45052)
- Yarn v1, npm: a dep the lockfile has no entry for is reported (#45052)
- npm: a scope registry with a path and no trailing slash gave
`InvalidNPMLockfile` (#38698)
- npm: a package's own `file:` directory was resolved from the project;
one that climbs out is skipped (#38990)
- npm: a `file:` directory copied by `--install-links` failed with
EISDIR (#43103)
- pnpm: `pnpm-workspace.yaml` was read only with a migratable lockfile;
`bun patch --commit` then segfaulted (#38754)
- pnpm: the `pnpm` block was removed from package.json -> kept, fields
copied (#38775)
- pnpm: `onlyBuiltDependencies` was lost -> `trustedDependencies`;
isolated reports blocked scripts (#38780)
- pnpm: importer rows differed from a package.json parse, the next
install rewrote `bun.lock` (#38791)
- pnpm: only used catalog entries were migrated (#38829)
- pnpm: non-string keys / values in `pnpm-workspace.yaml` crashed ->
scalars copied as written (`react: 18` -> `"18"`) (#44470)
- Read-only commands and failed installs wrote a migrated lockfile and
package.json edits -> only a command that saves a lockfile (#38804)
- Yarn v1: workspace packages were dropped when the root has no deps
(#38878)
- Yarn v1: `npm:` alias packages were named after the tarball URL
(#38948)
- Yarn v1: registry entries without `resolved` failed (#38985)
- Yarn v1: an entry without a buildable resolution aborted the migration
-> skipped with a warning (#39001)

#### Workspaces, catalogs

- Member without a `name` failed the install -> skipped (#38781; fixes
#6317)
- Dist-tag (`latest`, `""`) on an unpublished sibling got a 404 -> links
the workspace (#35468; fixes #4830)
- Empty `workspace:` spec linked the project into itself -> error; a
glob like `../*` made the root its own member (#41653)
- Workspace globs skipped symlinked member directories (#41567)
- Range linked to a workspace stayed linked after the root dropped it
(#43468)
- Workspaces of a package.json in `/` (or `C:\`) were not found (#43404)
- `workspaces` glob that fails named no path (#43517)
- `bun update <name>` rewrote `workspace:^`, `file:` and other
non-registry entries (#38847)
- `bun add member@workspace:^` lost the spelling (#38866)
- `bun update` printed no row, or another catalog's version, for a moved
catalog entry (#38763)
- `bun update -i` updated one of several catalogs defining a package ->
a row per catalog (#36446)
- Failed `catalog:` dep always suggested `bun add --catalog` -> only
where that works (#44638)
- `npm run <script> -w <ws>`/`--workspaces` inside a script recursed
forever -> runs the workspace's script (#33800; fixes #22719, #24838,
#33796, #41543)

#### Overrides, peers

- Auto-installed peer took the newest version of one asker -> newest
that everyone asking accepts (#36646; fixes #8292, #15711)
- `*` peers resolved by manifest arrival order -> after their siblings;
unprovided peers in a fixed order (#37713)
- `incorrect peer dependency` was checked against any copy in the tree,
hoisted only, every install -> the copy each dependent resolves, both
linkers, only when something was resolved (#41936; fixes #7403)
- Peer that a `file:`, `link:`, tarball or git dep provides was looked
up in the registry (#33156)
- Peer dependency on `bun` downloaded the `bun` npm package (80-170 MB)
-> optional (#39757)
- `minimumReleaseAge` blocking a peer or optional pin failed the install
-> warning (#38995)
- `bun update name@version` bound a peer row to a stale lockfile
leftover (#38901)
- Override replaced the link to a member of that name (#37271)
- Changing `overrides` / catalogs re-resolved rows a package no longer
owns (#38849)
- `bun update <name>` left rows of a `$name` override stale, frozen
passed (#43981; fixes #43975)
- `bun update --latest` moved versions an override selects; `-r` wrote
`"latest"` into package.json (#44485)
- `overrides`/`resolutions` that are not objects were ignored -> error
(#40729)
- Keys `foo@catalog:`, `my-foo@npm:foo[@^1]` (Yarn) were ignored with a
warning

#### Registries, credentials, .npmrc, bunfig

- `.npmrc` credentials follow npm's key walk: `//host/` covers paths
below it, `_auth` sent as written, a line reaches registries named by
`--registry` / env / `$VAR`, a tarball on another https host gets that
host's line; a sibling or look-alike path inherits nothing; `:443`/`:80`
keys keep working (#40423; fixes #28233, #30311, #30513, #40549)
- Registry URL without a trailing slash lost its last path segment
(#36294; fixes #5368, #14017, #16185, #20373)
- `user:pass@` in a registry URL from an env var was dropped (#38834)
- `user@`/`user:@` sent nothing -> Basic auth with an empty password
(#39063)
- URL credentials were sent percent-encoded -> decoded (`p%40ss` sends
`p@ss`) (#39603)
- `.npmrc`: `;`/`#` comments were part of the value; `ignore-scripts=1`,
`=True`, empty did nothing -> as npm (#41916)
- `.npmrc` keys and values kept Unicode whitespace (#41714)
- `--ca`/`--cafile` were shadowed by a config file's `cafile` -> one
setting, a later layer replaces both (#41898)
- `-g` commands ignored `globalDir` from `--config` and `~/.bunfig.toml`
(#35454; fixes #4553; refs #12886)
- `~` in `cache.dir`, `globalDir`, `globalBinDir`, `cafile` was a
literal directory (#36667; fixes #10152, #26715)
- `[install.cache] disable = true` still cached manifests (#36219)
- Unreadable `.npmrc` / bunfig.toml was skipped silently -> warning
(#41693)
- `bun publish`, `pm whoami`, `audit`, `pm view`, `pm diff` ignored
`http_proxy` and `NODE_TLS_REJECT_UNAUTHORIZED=0` (#38075, #38093)
- `bun pm whoami` sent a stub CI name -> the real vendor
(`ci/github-actions`) (#40805)

#### Network, retries

- Retries came at once, one network error halved concurrency -> 250 ms
doubling to 10 s with jitter, `Retry-After`, 429 / 408 retried,
certificate errors not (#40009)
- Timed-out request was retried silently -> a warning per retry (#35168)
- `BUN_CONFIG_MAX_HTTP_REQUESTS` was ignored (#38744)
- Help and docs said `--network-concurrency` defaults to 48 -> 64
(#38759)

#### Cache, tarballs, extraction

- Process could exit before the manifests it fetched were cached:
`--offline` right after an install could fail
- `file:` / URL tarballs were cached by path or URL, two projects or
branches got each other's package -> by the integrity in `bun.lock`
(#39016)
- `file:` / URL tarball deps were reinstalled on every run (hoisted) ->
verified by a `.bun-tag` (#35576; fixes #8260)
- `--force`, `bun update`, re-adding the same path / URL kept old bytes
or the old hash -> direct tarball deps are fetched again (#31868; fixes
#31864)
- Re-extraction leaked the displaced cache folder; extraction
directories stayed in `$TMPDIR` (#38702, #33979; fixes #33977; refs
#28062)
- Two registries on one host (port, path, http) shared cache entries
(#41636)
- Concurrent installs sharing a cache deleted each other's entries
(ENOENT without `RENAME_EXCHANGE`, NFS) -> first writer wins (#36229;
fixes #36227)
- Gzip trailer was not verified (since 1.3.14) -> a damaged tarball
fails; also `Bun.Archive` (#41503)
- Body that is not gzip installed as an empty package; a tarball without
package.json stayed in the cache
- Query string / fragment of a tarball URL ended up in the extraction
folder name (#39011)
- `github:` tarballs extracted symlink entries -> skipped, like npm
(#31481)
- Rewriting a cached manifest unlinked it first -> atomic replace
(#38916)
- `--no-verify` cached unchecked bytes under the lockfile's hash, for
all projects -> own key; isolated kept such a copy

#### git, GitHub, folder deps

- Cached git dep never saw tags / branches pushed later; `bun update`
follows moved refs (#35566; fixes #11548, #13769, #15336, #18947)
- `git+ssh://git@host:2222/..` lost its port in the ssh fallback
(#36932; fixes #36931)
- `deploy@host:org/repo.git` became `git@deploy@host` (#35026)
- `bun add name@git+ssh://host/repo.git` lost the alias for hosts other
than GitHub (#36687; fixes #13891)
- `github:o/r#feat%2Fx` silently installed the default branch; an
escaped owner / repo is an error (#43172)
- Optional git dep that cannot be cloned or lacks the ref failed the
install -> skipped with a warning, also when locked (#43076, #43352;
fixes #43350)
- Several `github:` / git refs on one commit, or a `file:` folder
declared by several packages, failed with EEXIST or wrote a lockfile Bun
refuses -> one package (#42640, #42545)
- Concurrent cold installs replaced each other's git cache folder
(#42656)
- `GIT_DIR` (git hooks) made install and `bun create` work on another
repository -> 13 git variables are not passed on (#41715)
- `file:` folders declared by git / tarball packages were resolved from
the project (#38816)
- Bare word or `git@host/path` was handed to `git clone` as a local
path; a URL could be read as an option
- `bun link -g`/`unlink -g` failed; `bun link -g <name>` removed the
registration -> `-g` is ignored (#33142; fixes #21397, #33141)

#### Lifecycle scripts, trust

- Hoisted: beyond `--concurrent-scripts`, the next script waited for all
running ones to end -> for one
- Failed or unstartable script left other packages in place whose
scripts never ran, and never would -> removed
- `trustedDependencies` entry added to a member after the first install
did nothing -> its blocked scripts run once (#41803)
- Isolated: a package trusted after it was installed never ran its
scripts (#41845)
- Package installed through an `npm:` alias could not be trusted
consistently -> by its real name in package.json, `--trust`, `pm trust`,
`bun.lock` (#39443)
- `bun pm untrusted`/`trust`/`ls --trusted` followed a stale lockfile,
knew nothing with `bun.lockb` -> read package.json (#41933, #34327)
- `pm untrusted`/`trust` listed and reran workspace members (#41513)
- Failing script of a package both optional and required deleted the
package, exit 0 -> install fails (#43218; fixes #43211)
- `--concurrent-scripts` did not apply to the isolated linker (#42445;
fixes #42421)
- bunfig `concurrentScripts` beat `--concurrent-scripts` (#42422)
- Node-gyp shim was unreachable when the temp directory falls back to
the cache (#38098; fixes #38079)
- `--help` said dep scripts never run -> `--ignore-scripts` also skips
trusted ones (#38893)
- `bun pm -g untrusted` suggested `bun pm trust` -> `bun pm -g trust`
(#44663)
- After a failed script, a half-deleted git / tarball / patched package
counted as installed

#### Security scanner, audit

- Scanner saw registry packages only -> every resolved dep (git, GitHub,
tarball, `file:`, `link:`), URLs without userinfo / query; a fatal
advisory stops them before install and scripts (#37590)
- `--production`/`--omit=dev` failed when the scanner is a devDependency
(#31031; fixes #31028)
- Scanner process loaded `.env` itself -> gets the install's
environment, follows `--env-file`, `--no-env-file`, `env = false`
(#44845)
- `bun audit fix` could downgrade into an older major (#39317)
- `bun audit --help` lacked `--production`, `--omit`, `--dry-run`,
`--cwd` (#38836)

#### add, remove, update, outdated, link

- Hoisted: `bun remove foo` deleted the `node_modules/foo` another
workspace has for `"foo": "npm:bar"`
- `link`, `install <pkg>`, `install --analyze`, `update -i`, `remove`
exited 0 after `Failed to install N packages` -> 1 (#44989)
- `bun add ../path` from below package.json, `bun install -g .` resolved
from the wrong directory -> the cwd (#43348, #36623; fixes #5682)
- `bun add <folder | tarball | git url>` of a declared package wrote it
twice (#43380; fixes #8031)
- First `bun add -g` used a package.json above the global directory
(`~/package.json`) (#30659; fixes #40683)
- `bun add --dry-run` in an empty directory created package.json
(#36694; fixes #13244)
- `bun remove` reordered package.json keys -> order, tabs, final newline
kept (#35586; fixes #7215, #9351)
- `bun remove -g` left dangling links in the global `node_modules/.bin`,
deleted foreign dangling links in the bin directory (#43403)
- `bun update <name>` blanked an optional / peer range no version
satisfies (#43195)
- `bun update` that only rewrites package.json said `(no changes)`
(#38918; fixes #38908)
- `bun update -i` repeated its prompt on mouse clicks and in short
terminals (#39683)
- `update -i`/`outdated` tables were sized in bytes -> display width
(CJK, emoji), cells escaped (#43333, #43236)
- `outdated`/`update -i` used the lockfile's ranges -> package.json's
(#42005)
- `bun outdated a b` needed all patterns to match -> union; `!pattern`
removes (#43309)
- `outdated`/`why` knew an aliased dep by one name -> either, both
printed (#43338)
- Coloured version diff dropped the build tag, left a tag that only
grows (`rc.1` -> `rc.10`) dim; long prerelease tags were garbled
(#38688, #38649)
- `minimumReleaseAge` error swapped range and name (#37895)
- `bun link`/`unlink --help` described a wrong `--save` default (#38915)

#### bun pm, why, info

- `bun pm pkg get` of several values altered one with a `}` in it, left
a key unescaped (#45061)
- `bun pm cache rm --cache-dir <dir>` cleared the global cache ->
`<dir>`; project config still cannot redirect it
- `bun pm bin -g`/`pm ls -g` failed before any global install -> work,
write nothing (#36622; fixes #6382, #7501, #13019)
- `bun pm cache rm` says when the global store goes with the cache; docs
name `--force` for stale manifests (#43667)
- `bun pm version` saw no repository in worktrees / submodules (`.git`
is a file) (#41754)
- `bun pm version prerelease` overflowed above 2^32; versions npm calls
invalid were accepted (#42459)
- `bun pm view pkg@tag`/`pm diff` used `latest` for an unknown dist-tag
-> error listing the tags (#42063)
- `bun pm`, `pm --help`, an unknown subcommand printed three help texts,
unindented, without `pm diff` and `ls -g` -> one (#39487, #43314,
#43098; fixes #43310)
- `bun pm bin -g` said `not in $PATH` for an entry with a trailing slash
(#28773; refs #28771)
- `bun why` printed a package's dependents at every occurrence
(exponential) -> once, repeats `*deduped` (#37922)
- `bun why name@version` failed for exact / scoped names, ignored a
workspace's version (#43284, #43290; fixes #43288)
- `bun why`: no-match error on stdout -> stderr (#43287; fixes #43285)
- `bun why`: branch left open after the last visible workspace dependent
(#43292; fixes #43291)
- `bun why`: a cycle back to the queried package is `*circular` at its
first repeat (#43300; fixes #43295)
- `bun why`: a root package.json without `name` was missing as a
dependent (#43277)
- `bun why`: a peer dependent was shown under the wrong copy (#41941)

#### pack, publish

- `workspace:`/`catalog:` versions came from `bun.lock` (stale, or
failed without one) -> the package.json files; no lockfile is read
(#38813; fixes #20477, #20829)
- `workspace:` aliases and directories were published as written ->
`npm:` aliases; the workspace's version (#38788, #38835)
- `!pattern` in `.npmignore` could not re-include files below an
excluded directory (#36699; fixes #15602)
- Relative `--destination`, `--filename`, `bun publish ./x.tgz` were
resolved from the package root -> the cwd (#38739, #38704)
- Failed pack left a partial tarball, `publish --dry-run` deleted an
existing one, a FIFO `--filename` hung -> built in memory, written once
complete (#38721)
- File names that are not UTF-8 were mangled, two became one entry
(#38715)
- File systems whose readdir reports `DT_UNKNOWN` lost entries (#38716)
- `bin` paths naming no file, `..`, a trailing slash in
`directories.bin` broke pack; an empty `bin` hid `directories.bin`
(#38720)
- File under several `bin` names was packed several times (#41291)
- Directory, bin or bundled dep that is there but unreadable was left
out silently -> exit 1 (#44400, #44873)
- `bun publish` accepted a `version` that is not semver -> refused after
the scripts; `v1.0.0` goes out as `1.0.0` (#43142)

#### bun patch

- `--commit` from a workspace member flipped the root package.json's
final newline to the member's
- Patch adding a file in a new directory failed with `EACCES` for
non-root users (#33573; fixes #13330, #33571, #38569)
- Hunks were applied by line number, wrote wrong text when the file had
moved; `-U0` hunks landed a line off -> placed by context like `git
apply`; `hunk #N does not apply` fails the install (#41733, #41695;
fixes #38879)
- Old-style diffs whose hunks delete lines starting with `-- a/` were
misparsed (#34870)
- Failed copy, missing cache entry, missing git or failed `git diff`
left a partial or no `node_modules/<pkg>` -> staged copy, everything put
back (#43188, #43160; fixes #43164)
- `bun patch` dropped the package's bundled and nested deps, `--commit`
recorded them as deleted (#43178)
- `bun patch` of a bundled dep deleted the bundled copy -> refused
(#43199)
- `bun patch` of a `file:` folder, `link:` or workspace package damaged
`node_modules` -> refused (#43223; fixes #43200)
- `bun patch <path>` replaced a folder with another version -> refused
when the version is not locked (#43367; fixes #43353)
- `npm:` alias could not be named by its package; a second `bun patch
<alias>` lost the first patch (#43382)
- Isolated: `bun patch <transitive>` made a stray `node_modules/<name>`
-> edits the store entry (#43383)
- `bun patch` replaced workspace links and symlinks above the package,
could write into the global store -> links stay; anything under the
cache is refused (#43406)
- `--commit` on a patched package wrote `.bun-tag` files and cache paths
into the patch, read `/etc/gitconfig` (#44349; fixes #19327, #44320)
- `--commit`: a new binary file became empty, a changed one was skipped,
exit 0 -> commit fails
- Member's `patchedDependencies` were ignored silently -> warning
(#39869)
- Isolated: `bun patch` replaced `node_modules/<pkg>` by a directory
that could not load its deps -> edits a copy in the store entry, the
link stays; global store: `--commit` restores the link (#43365)

#### bunx

- `bunx pkg@latest` relinked everything each run, a day-old tree was
refreshed every run -> once; served with a warning when the registry
gives no answer (#41226; fixes #23597, #41211)
- Several `bunx <pkg>` at once corrupted the shared install -> file lock
(#37851; fixes #37830)
- `directories.bin` was resolved from the wrong directory (#39096)

#### bun create

- Windows: a GitHub template silently overwrote files (its conflict list
was always empty) and wrote through junctions -> listed; `--force`
replaces the link
- `bun create <local template> <dir>` deleted an existing directory ->
merges, lists conflicts (links in the destination too), overwrites only
with `--force` (#36351; fixes #27948, #36341)
- `git init` / commit raced tasks and install -> finished first; the
first commit holds exactly the template (#41342; fixes #36953)
- `bun-create` tasks ran without a shell, not at all without deps,
failures ignored -> run like package.json scripts, with
`npm_package_name`; a failing task stops with its exit code (#41598,
#38955)
- Failed nested `bun install` was ignored -> exits with its code
(#38485)
- Absolute template path crashed -> local folder; `.`, `..`, an empty
name are refused (#41770)
- `bun create ./Component.tsx` overwrote your stylesheet,
`styles/globals.css`, `lib/utils.ts` -> kept and listed; `--force`
overwrites (#44668)
- Template's package.json indentation was lost (#36965; fixes #36964)
- Missing-export hint printed `{{` `}}` (#37847)

#### Auto-install

- Timers and I/O callbacks ran inside `require()`, `import()`,
`Bun.resolveSync()` while a package was fetched -> the thread waits; git
deps are no longer cloned at run time (#36159; fixes #43842)
- Second version (`pkg@2` after `pkg@1`) with the manifest in the disk
cache failed with `Unexpected` (#36659; fixes #10411)
- Failed lookup failed until the process exited -> a 404 stands as long
as a cached manifest (5 min, or its `Cache-Control`); any other failure
is asked again once the event loop has gone around (#42883)
- Failed download limited `fetch()` to 4 concurrent requests for the
rest of the process (#42945)
- `--prefer-offline` still asked the registry -> cached manifest
(#36776)
- Resolve errors did not say why (#38180)
- bunfig `install.prefer` worked for `bun run <file>` only -> also `bun
<file>`, `bun test`
- Dep whose name or version has a JSON escape fell back to `latest`

#### Completions, installer

- Second completions line was added to a `.zshrc` that already sources
`~/.bun/_bun` (#35561; fixes #10897)
- fish completions went to `/etc` when `~/.config/fish/completions` is
missing (#41659)
- install.ps1: the `another bun.exe is already in PATH` check never
fired (#38567)

#### Crashes, panics, hangs

- Hang until out of memory on a dependency cycle -> `dependency cycle
cannot be laid out in node_modules`, exit 1; a bundled back-edge
installs (#38976)
- Hang: a dangling `node_modules/@scope` symlink (hoisted)
- Hang: `--concurrent-scripts=0` -> the default (#42379)
- Hang: isolated, a tarball URL that cannot be requested (`ftp://`)
(#39672)
- Hang: isolated, cold cache, a git dep that is scp-form, a peer, from
`overrides` or spelled differently in `bun.lock`; after a failed clone /
checkout (#44448, #37156, #43075)
- Panic: paths that do not fit the path buffer in both linkers' walks,
`link:` targets, long linked names, `<alias>/package.json` ->
`ENAMETOOLONG`/`Invalid dependency name` (#37424, #37400, #38369,
#38571, #38575)
- Panic: over-long `--cafile`, `--cwd`, `add --filter`;
`$BUN_INSTALL`/`$HOME` with `-g`; bunx bin names; pnpm `link:`/`file:`
paths; progress bar names (#41702, #38413, #37394, #43537, #38558)
- Abort of all package manager commands, `bun run`, `bunx` in a cwd
within 13 bytes of `PATH_MAX` -> `ENAMETOOLONG`, or the file counts as
unreadable (#42502)
- Panic: `outdated`, `update`, `update -i` on a `bun.lock` without
`packages` (#41647)
- Segfault: package.json nested ~8,400 levels; a deep YAML alias chain
in pnpm / Berry files
- Panic: all `bunx` install flags at once
- Use-after-free in `bun add --trust` and at the end of an isolated
install (#31245, #39640)
- `Bad system call` on Android / Termux (`openat2`, `fchmodat2` under
seccomp) (#39084)

#### Windows

- Bins whose shebang uses `env -S` did not run (#31391; refs #22422,
#27924, #31387)
- `.bunx` shims broke when the package is on another drive (#38018)
- `bun remove` left `.bunx`/`.exe` shims (#35585; refs #11970)
- `bun install` failed in the root of a drive (#43437; refs #29273)
- `EINVAL: Failed to replace old lockfile` on exFAT / FAT32 (#33029;
refs #10169)
- Concurrently published cache entry was deleted; renames failed while a
virus scanner holds a new file -> retried for 5 s
(`BUN_INSTALL_WINDOWS_RENAME_RETRY_MS`) (#33884, #35568; refs #11250,
#28062)
- `bunx.exe` was written when the hard link fails -> `bunx.cmd` (#35686)

#### Performance

User-space instructions of `bun install` (`perf stat`, median of 3,
millions), generated project of N packages, loopback registry; warm =
nothing to do, relink = cache + lockfile, no `node_modules`.

|  | main N=400 | branch N=400 | main N=1600 | branch N=1600 |
|---|---|---|---|---|
| hoisted warm | 22.5 | 20.3 | 146.5 | 80.1 |
| hoisted relink | 24.5 | 21.6 | 150.3 | 83.3 |
| hoisted cold | 121.0 | 122.2 | 511.2 | 474.6 |

System calls per package (`strace -c`, slope between 100 and 300
packages of 31 files). Per run: a few more `lstat` (change 8), one read
per `file:` directory dep.

|  | main | this branch |
|---|---|---|
| hoisted cold | 215.96 | 211.95 |
| hoisted relink | 72.00 | 70.00 |
| hoisted warm | 4.00 | 4.00 |

Quadratic scans in the `bun.lock` workspace parse, `yarn.lock` printer
(#34537) and Yarn v1 migration (#37244) are gone; no allocator heap per
cached package.json (#44610).

|  | main | this branch |
|---|---|---|
| `bun pm hash`, 32,000 workspaces (ms) | 6,914 | 236 |
| `bun pm migrate`, Yarn v1, 20,000 entries (ms) | 24,210 | 270 |
| peak RSS of `bun install`, 2,100 workspaces (KB) | 90,840 | 34,728 |
| hoisted, 1,000 packages x 30 files, cache, no `node_modules`, 8 cores,
wall (ms) | 381 | 120 |
| same, 4 cores | 383 | 174 |
| same, 2 cores | 222 | 162 |
| same, `node_modules` lacks half, 8 cores | 140 | 54 |
| same, 4 cores | 174 | 65 |
| same, 2 cores | 161 | 107 |
| same, no cache, no `node_modules`, 8 cores | 363 | 394 |
| same, 4 cores | 418 | 401 |
| same, 2 cores | 477 | 541 |

#### Internal, no user-visible effect

- Earlier fold of 171 PRs is the first layer; 12 of them were taken out
again (#39403)
- One implementation each: trusted set, required-dep predicate,
failed-optional discard, bin linking, package.json walk, `--cwd`, cache
publish, specifier -> version (was 4), lockfile writer, registry request
- `pack()` consumes its context; one `binding.gyp` predicate; Yarn v1
migrator 2,032 -> 888 lines, no `unsafe` (#37670, #43912, #37539,
#38303, #37865)
- Debug builds: opt-in stack dump, `node` shim directory kept, manifest
cross-check deleted (#37335, #37384, #43170)
- Binary size pass (outlined helpers, shared flag tables, index sorts):
+464 KB -> see Performance; dead auto-install disk scan removed.
`unsafe` in src/install: 585 -> 540

#### Test-only PRs

- verdaccio replaced by a registry on `Bun.serve` (on main since):
#44055
- No public network: #35035 #35377 #35384 #36717 #36718 #36721 #36889
#38185 #38467 #39022 #39655 #40087 #40538 #40956 #42800 #42802 #43289
(fixes #43286)
- Concurrent, a cache per test: #38464 #38504 #40095 #40635 #39646
#40198 #40450 #39956 #39740 #41110
- Exact output / lockfile / layout (nested-overrides, bun-add-filter,
lockfile-sync, frozen-lockfile-pruned, pm-licenses, isolated-relink):
#39648 #39746 #40452 #40454 #40456 #41118
- Flake causes removed (stdin / pty races, racing ranges, cold manifest
cache, un-awaited matchers): #34175 #41401 #41591 #38858 #39190 #38627
#37333 #33279
- Faster (streaming-extract, git-deps, npm-arborist): #35915 #40447
#40977
- New guards (`..` tarball entries, Bun's own `bun.lock` passes frozen,
hoisted layouts, what a linker verifies): #37431 #42384 #43878 #41864
- Harness (`isRoot`, peak RSS, `pm trust` CPU test on Windows): #39232
#43192 #35626 (refs #11252)

#### Taken in part (what is in. Not: what is not)

- #19637: opt-in `bunx --trust` (runs the scripts of the package and its
whole closure). Not: trust by default
- #29794: change 4. Not: removing a stale copy no lockfile placed (`bun
prune`)
- #30522: change 14. Not: signing; #15601 open (refs #18611)
- #31327: a junk integrity in a v2+ `bun.lock` is an error. Not: pinning
a computed sha512; refusing an empty integrity under frozen
- #32022: 10 helpers redone on current code, 26 of 28 pinning tests.
Not: non-install parts
- #35253: `bun create` without a git identity stages the files, says the
commit was skipped. Not: a made-up author
- #35807: `npm_config_ignore_scripts=true` turns scripts off. Not:
`false`/`0` turning them on over a config file
- #36387: false `Workspace name already exists` on a hash collision.
Not: the same for `trustedDependencies` names (one stays blocked) (refs
#36386)
- #36973: docs of the store layout as it is. Not: a stability promise
- #38001: a token survives an http -> https redirect on one hostname.
Not: another port (still stripped) (refs #15516)
- #38832: its forced-arrival-order tests, expecting one copy (#38770).
Not: its two-copies policy
- #39505: cold generics -> runtime flags in summary printer, `pack`,
lockfile read. Not: typed arrays, coverage
- #39886: an override on a peer was lost behind a same-named root `npm:`
alias. Not: a peer without an override shares the alias (`react` ->
`npm:@preact/compat`)
- #40053: features, change 15. Not: `[install.packageExtensions]` in
bunfig; `pnpm.packageExtensions` without a migration; `catalog:` values
(ignored, warning)
- #40110: warning for a `patchedDependencies` key that matches no
installed version. Not: failing frozen
- #41724: moot for tarballs (named by integrity). Not: new names for
bare git clones
- #41759: a package.json in a sticky shared directory above the cwd
(`/tmp`) is ignored, with a warning. Not: the owner rule
- #41863: `pm pkg set --json` with a value that is not JSON is an error,
nothing is written. Not: strict JSON (`0x10`, `.5` are normalised)
- #41921: Linux: a script no longer outlives a killed install. Not:
signal forwarding; macOS
- #41925: symlinked bins / `directories.bin` are neither packed nor
listed. Not: race-proof opener, Windows, a symlinked `node_modules`
- #41931: change 2. Not: failing on trusted / patched edits or
equivalent spellings (refs #22689)
- #42001: after a failed script the next install reruns it and what it
blocked. Not: change 18
- #42030: a plain install re-reads a `file:` directory's package.json.
Not: failing frozen (warns)
- #42039: change 8; `bun prune` refuses a linked `.bun`. Not: the root
link (on purpose); a package's own directory on a warm run
- #42052: `bun pm view`/`info`: no project needed, several fields, npm
paths (`maintainers[0].name`), semver-sorted `versions`, `bin:`,
`DEPRECATED`. Not: new `--json` errors (refs #20673)
- #42139: npmrc.test.ts: isolation per case, concurrent. Not: exact pins
- #42400: `pm diff` compares a folder of this project as pack publishes
it. Not: folders of other projects
- #42597: nothing of the project's `.env` reaches install's `git`. Not:
proxy / TLS variables from `.env`
- #43067: the 255-byte name bound, the install cases. Not: the repo-wide
sweep
- #43375: which package owns an override row; direct deps outside their
range. Not: transitive rows
- #43556: `bun patch` never writes into the global store. Not: a
project-local copy that installs keep
- #43581: a registry package's peer / optional edge to a workspace
missing on disk is dropped. Not: the pre-flight report
- #43793: the walker looks at link entries; the `@scope` hang. Not:
materialising symlinked cache entries; #43788 unverified
- #44058: Windows: `node` shim next to bun.exe when `%TEMP%` is on
another drive (never run). Not: a missing `%TEMP%` (warning) (refs
#44033)
- #44406: change 4. Not: a copy no loaded lockfile names
- #44488: a dep's script finds its own `.bin`, then `PATH`, then shared
`.bin`: another package's bin cannot shadow `node`/`sh`. Not: bins per
script owner
- #44641: its install test and docs hunk. Not: 13 HTTP / fetch test
files
- #44793: Windows: the launcher of a `.cmd`/`.bat` bin (any case)
refuses cmd.exe special characters. Not: `bun run <bin>.cmd`
- #44798: a `node_modules` a git dep commits is dropped unless it
bundles (then without links). Not: bundled closure only
- #44847: a workspace's `node_modules` is reset when only the root's was
deleted. Not: moving stale directories aside; a marker

#### Not taken

- #20178: the isolated linker covers it; #6850 open
- #28515: prints the repo URL, no changelog
- #29512: `bun pm sbom`: own PR
- #30289: a `bun link` name would override every project
- #30340: checks after the fetch, fails open
- #30582: config surface without demand
- #30863 #40976 #43643: not install: install.sh, `bun upgrade`
- #31585 #41750 #43144 #43242 #44509: not install: `Bun.Archive`
- #31979 #37596 #37646 #37678 #39165 #40312: refactor nothing needs
- #33113: needs a Windows measurement
- #33972: masks a flake; cause gone with #37713
- #35105: distrusts the cache directory
- #35371: borrow refactor, no longer applies
- #35571: re-lays existing monorepos; #9838 open
- #35602: false positives, harmful advice
- #36081: needs the public registry
- #36464: stale; weakens lockfile parsing
- #36476: the next step after this PR
- #36556: reverses an earlier hardening
- #36557: its code is deleted (#44393)
- #36587: restores a dead warning
- #36631: contradicts a landed test; #7869 open
- #36682: the typo already gets a clear error
- #36688: a landed test pins the opposite; #13509 open
- #36696: own feature (config root for bunx / create); #13247, #5361
open
- #36888 #39741 #39742 #39957 #40639: test rewrite without measurable
gain
- #37143: cause removed by #44919
- #37551: part of #40284
- #37644 #38365 #39206 #39936 #40855 #44031 #44425: not install
(transpiler, `bun run`, `Bun.write`, pipe reader, repo-wide)
- #38741: lockfile v2 has shipped
- #38777: follows links out; main case gone with change 3
- #38810: turned down earlier; #10297 open
- #38814: draft; rows grow per path
- #39217: little demand
- #39499: codemod for an unlanded change
- #39724: refuses installable tarballs; see #41503
- #40045: 16 public JSON formats: own PR
- #40073: test flag that hides a race
- #40277: second store layout
- #40284: stacked on branches that have not landed (needs new task,
request and archive types in six other crates); follows separately
- #40300: Windows bunx.exe identity; #40298 open
- #40717: opposite of #36690
- #40753: loosens default-trust hardening
- #41764: subset of #43322
- #41866: opposite of #41803
- #43104: needs a new `bun.lock` key
- #43209: needs #43104's key
- #43322: breaking; realpath per workspace
- #44117: stacked on #44058
- #44621 #44809 #44813: draft
- #44622: changes patch hash and cache names; #25932 open

Obsolete (on main via the PR in brackets, or moot): #18582 (the Zig
build is gone) #35542 (#38333) #35603 (#41854) #36352 (verdaccio is
gone) #36670 (#38269) #36977 (#40010; `--tarball-dir`, #7956 stay open)
#37126 (#40734) #37137 (#38269) #37250 (#38333) #37616 (#40734) #37925
(#37426) #38648 (#40063) #38671 (#39229) #38687 (#40406) #38776 (#38796)
#38869 (#41302) #38891 (verdaccio is gone) #40766 (verdaccio is gone)
#41667 (#42881)

#### Fixed issues

Fixes #2773. Fixes #4553. Fixes #4719. Fixes #4830. Fixes #4852. Fixes
#5045. Fixes #5176. Fixes #5368. Fixes #5682. Fixes #5714. Fixes #6317.
Fixes #6382. Fixes #6883. Fixes #7215. Fixes #7403. Fixes #7501. Fixes
#7504. Fixes #7571. Fixes #8031. Fixes #8260. Fixes #8292. Fixes #9351.
Fixes #10152. Fixes #10411. Fixes #10897. Fixes #11548. Fixes #12011.
Fixes #12041. Fixes #12430. Fixes #12752. Fixes #13019. Fixes #13244.
Fixes #13330.
Fixes #13379. Fixes #13769. Fixes #13891. Fixes #14017. Fixes #14134.
Fixes #14736. Fixes #15102. Fixes #15336. Fixes #15602. Fixes #15711.
Fixes #16185.
Fixes #16282. Fixes #17060. Fixes #17759. Fixes #18670. Fixes #18947.
Fixes #19065. Fixes #19327. Fixes #20373. Fixes #20376. Fixes #20477.
Fixes #20829.
Fixes #21397. Fixes #22035. Fixes #22719. Fixes #23264. Fixes #23597.
Fixes #24838. Fixes #25314. Fixes #26070. Fixes #26715. Fixes #27948.
Fixes #28233.
Fixes #28597. Fixes #28668. Fixes #28967. Fixes #29263. Fixes #29343.
Fixes #30311. Fixes #30450. Fixes #30513. Fixes #30748. Fixes #31028.
Fixes #31152.
Fixes #31450. Fixes #31864. Fixes #33141. Fixes #33571. Fixes #33796.
Fixes #33977. Fixes #36227. Fixes #36341. Fixes #36795. Fixes #36931.
Fixes #36953.
Fixes #36964. Fixes #37830. Fixes #38079. Fixes #38193. Fixes #38569.
Fixes #38879. Fixes #38908. Fixes #38909. Fixes #40031. Fixes #40310.
Fixes #40355.
Fixes #40549. Fixes #40683. Fixes #41211. Fixes #41371. Fixes #41543.
Fixes #41625. Fixes #42421. Fixes #42799. Fixes #42989. Fixes #43138.
Fixes #43148.
Fixes #43164. Fixes #43200. Fixes #43211. Fixes #43212. Fixes #43214.
Fixes #43256. Fixes #43285. Fixes #43286. Fixes #43288. Fixes #43291.
Fixes #43295.
Fixes #43310. Fixes #43350. Fixes #43351. Fixes #43353. Fixes #43399.
Fixes #43795. Fixes #43842. Fixes #43903. Fixes #43975. Fixes #44299.
Fixes #44320.
Fixes #44669.

Expected fixed, first exercised by CI (Windows; close by hand): #28062
#11250 #11970 #29273 #10169 #22422 #27924 #31387 #11252 #28771 #44033
#12886

Covered by a PR taken in part (close by hand): #18611 #36386 #15516
#22689 #20673

#### Credits

- @Jarred-Sumner: #39403 (the earlier fold), #20067, #31245, #31416,
#33884, #37590, the design of #31447, part of #31327.
- @MarshallOfSound: #40057, #40025, #40053, #40012, #40009.
- @alii: #45052, #45051, #40423, #31481, #33279, helpers of #32022, half
of #39505, review findings in #35259 #35468 #35586 #38047.
- @dylan-conway: #19637 (opt-in), the order-dependent shapes of #36476
(tests here), the off-by-one fixes of #33113, the objection behind
#37138.
- @nikelborm: #41291.

### How did you verify your code works?

- Suite (offline, root): main's tests on main 146 files, 9,099 pass, 306
fail; this branch 174 files, 16,067 pass, 42 fail; 6,277 passing tests
are new, 268 that fail on main pass. Of the 42, 40 fail on main too; 1
needs Docker, 1 test deps.
- Absorbed = a test here covers the claim, passes here, fails on main.
Exceptions: test-only PRs, guards, performance PRs (measured); by
reading only: #39640 #37335 (no test possible), #34175 (Docker), #38858
#39190 #41401 #41591 (flakes), #40447 #39740, the refactors.
- Randomized differential: 600 generated projects (every dep kind, both
linkers, both lockfile formats) through installs, edits, prune, patch,
damage, kills; all output compared. First install succeeds: 545 -> 559.
Projects that violate an invariant:

|  | main | this branch |
|---|---|---|
| repeated install rewrites files | 265 | 10 |
| repeated install reports work | 101 | 2 |
| repeated install changes the tree | 22 | 3 |
| lockfile depends on manifest arrival order | 57 | 0 |
| exit code depends on the linker | 20 | 0 |
| writes outside the project | 21 | 0 |
| declared dep cannot be resolved | 13 | 1 |
| dep resolves to a wrong version | 4 | 0 |
| frozen / relink / cold cache fails on its own lockfile | 7 | 0 |
| package.json rewritten with the same bytes | 7 | 0 |
| crash or hang | 3 | 0 |
| dangling symlink | 2 | 0 |
| bin not executable | 2 | 2 |
| leftovers in the temp directory | 230 | 1 |
| tree or exit code after kill + reinstall differs (timing) | 3 | 4 |

- Credentials: 805 requests with main, this branch, npm 11.17.0: 534 as
main; 170 wider (npm too in 140, 30 deliberate); 82 narrower; 19 another
credential. 142 non-http(s) registry URLs x 16 routes: no request (main:
66).
- Secrets: a secret in every input of 698 projects x 6,490 commands, not
masked: data asked for on stdout, source excerpts under a diagnostic,
echoed arguments.
- Lockfiles: 90,000 mutated `bun.lockb` x 7 commands: no crash or hang.
- Version picking: npm-pick-manifest 11.0.3 over 200,000 generated
pairs: differs only for a dangling written `latest` and `^1.0.0-a.0`.
- Debug + ASAN: 9 files (1,253 tests); 200 rounds of #31245's race. Two
installs at once, 252 packages: tree right in 150 of 150 rounds, 2 of
300 exit 1 (main: 35 of 40 wrong, 69 of 80).
- Hoisted thread pool vs main thread, one binary: 704 generated
projects, 8,800 fuzz cases, CPU starvation x 4 backends, 432 injected
I/O errors, thread and fd limits, script order: equal.

Not verified:
- Windows, macOS: CI only. Android (#39084): a seccomp filter on Linux,
no device.
- By script, no test: `--trust` with an alias, an aborted upgrade leaves
no holes, two installs at once. overlayfs: a simulated `EXDEV`.
- Known limits: change 19.

Co-authored-by: nikelborm …
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

This is Claude (an AI assistant) writing on Jarred's behalf.

Included in #44968.

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