Skip to content

tar extraction: no disk allocation from the entry header, file length from libarchive - #36542

Closed
robobun wants to merge 10 commits into
mainfrom
farm/413059c4/install-tarball-size-lie
Closed

robobun wants to merge 10 commits into
mainfrom
farm/413059c4/install-tarball-size-lie

Conversation

@robobun

@robobun robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • On Linux, bun install and Bun.Archive.extract() pass a tar header's size to fallocate. A 159-byte tarball allocates 8 GiB before error: Fail extracting tarball.
  • A sparse entry that ends in a hole loses its tail: the loops drop the length libarchive returns with ARCHIVE_EOF (src/libarchive/lib.rs:261).
  • A write the file system refuses (full disk, size limit) puts a file's end over its start, and extract() resolves.

Fix

  • lib::EntryWriter writes every entry: each block with pwrite or a seek, the length from the ARCHIVE_EOF offset. On Windows it marks the file sparse first (FSCTL_SET_SPARSE).
  • A block it cannot place or write fails the entry.
  • No disk write or allocation is sized from the header. Verified: 12 tests, red on main (one only on ext4), in bun-install-streaming-extract.test.ts, archive.test.ts, bun-create.test.ts.
  • Self-reviewed: 4 concerns raised, 3 addressed. Kept TempExtractionDir, which install: don't leak package extraction temp directories into $TMPDIR #33979 has as a closure.

Background

Downsides

  • Entries over 1 MB lose the fallocate that 990f53f tuned. Measured: no gain (65 MB file: 60.8 ms without, 69.1 ms with).
  • A hole still becomes real bytes in memory (Bun.Archive.files(), bun create) and on disk (--backend=copyfile, no sparse files).
  • An entry that cannot be placed or sized now fails (before: a short file, exit 0). With a glob, an archive cut in a member's padding loses it.
Notes

Reproduce (Linux, GNU tar)

# 1. A header that declares more than the archive holds.
bun -e '
const { gzipSync } = require("zlib");
const oct = (n, w) => n.toString(8).padStart(w - 1, "0") + "\0";
function header(name, size) {
  const b = Buffer.alloc(512, 0);
  b.write(name, 0, 100); b.write(oct(0o644, 8), 100); b.write(oct(0, 8), 108); b.write(oct(0, 8), 116);
  b.write(oct(size, 12), 124); b.write(oct(0, 12), 136); b.fill(" ", 148, 156); b.write("0", 156);
  b.write("ustar\0", 257); b.write("00", 263);
  let s = 0; for (let i = 0; i < 512; i++) s += b[i]; b.write(oct(s, 8), 148);
  return b;
}
const pj = Buffer.from(JSON.stringify({ name: "pkg", version: "1.0.0" }));
require("fs").writeFileSync("pkg.tgz", gzipSync(Buffer.concat([
  header("package/package.json", pj.length), pj, Buffer.alloc(512 - pj.length),
  header("package/big.bin", 8 * 1024 ** 3 - 1), Buffer.alloc(100, 120), Buffer.alloc(412), Buffer.alloc(1024),
])));'
printf '{"name":"root","dependencies":{"pkg":"file:./pkg.tgz"}}' > package.json
mkdir -p tmp && TMPDIR=$PWD/tmp bun install; du -sh tmp   # main: 8.1G

# 2. Sparse members.
mkdir -p src/package && cd src
printf '{"name":"pkg","version":"1.0.0"}' > package/package.json
printf hello > package/small-tail.bin && truncate -s 512K package/small-tail.bin
printf hello > package/big-tail.bin && truncate -s 64M package/big-tail.bin
tar --sparse -czf ../pkg.tgz package && cd ..
bun install && stat -c '%n %s bytes %b blocks' node_modules/pkg/*.bin

Do not run a build without this change on a sparse member with a large size or offset unless a file size limit is set (ulimit -f). The fallocate call does not stop for a signal. The test for the 17 TiB offset sets such a limit.

Measurements

One archive of 20 sparse members: the old GNU format and PAX 1.0, five layouts (data then hole, hole then data, data-hole-data-hole, data-hole-data, hole only), 300000 and 3000000 bytes. The .tgz is 1086 bytes, the members are 33,000,000 bytes long, and they hold about 100 KB of data. Each cell is wrong members / allocated bytes. The last commit does not change these paths.

path main (367d939) first revision (6008648) this PR GNU tar 1.35
bun install, file: (buffered) 6 / 30,085,120 12 / 106,496 0 / 106,496 0 / 106,496
bun install, registry (streaming) 6 / 30,076,928 6 / 30,072,832 0 / 98,304
Bun.Archive.extract() 6 / 30,081,024 12 / 106,496 0 / 106,496
Bun.Archive.extract() with a glob 0 / 33,062,912 0 / 33,062,912 0 / 106,496
  • Two 70 MiB sparse members in a 338-byte .tgz, streaming extractor: main 0 wrong and 146,808,832 bytes allocated. The first revision 2 wrong, each 67,108,864 bytes long. This PR 0 wrong and 8,192 bytes allocated.
  • The header that declares 8 GiB - 1 for a 100-byte body: bun install exits 1 and the temporary directory is empty. Bun.Archive.extract() rejects and leaves a 1536-byte file, the bytes that the archive holds after the header.
  • The wrong members on main are the three layouts that end in a hole, at the size below 1 MB. The fallocate for an entry over 1 MB also set the file length, and that hid the bug on Linux. By a read of the code, main loses the tail at every size on macOS and Windows, where nothing preallocates.
  • One PAX 1.0 sparse member with a 512-byte chunk at offset 4 GiB (a 3,584-byte tar), Bun.Archive.extract(): the merge base allocates 4,295,036,928 bytes in 594 ms, and 4,294,975,488 bytes in 2363 ms with a glob (it writes the zeros). This PR allocates 4,096 bytes in 2 ms on both paths.
  • The same member at offset 1 TiB on the merge base: fallocate ran for 19 minutes, took all 273 GiB that were free, did not stop for SIGKILL, and blocked rm of the file until the disk was full.
  • The same member at offset 17 TiB, above the ext4 file size limit, under ulimit -f: main and the earlier head of this PR wrote zeros up to the limit (33,554,432 bytes at a 32 MiB limit). This PR rejects in 5 ms and writes nothing.
  • A healthy 6,000,000-byte member in a gzip archive, where no file can grow past 4 MiB (ulimit -f). main: extract() resolves, the file has 4,194,304 bytes, and the bytes at offset 0 are those of offset 4,193,792. A streamed registry install prints Resolved, downloaded and extracted [3] and exits 0. This PR: extract() rejects with ReadError, and the 4,194,304 bytes that stay are the start of the member. The install exits 1 with EFBIG extracting tarball for "pk", and the next install from the same cache gives the whole file.
  • A map with chunks out of order, overlapping, or past the real size (old GNU and PAX 1.0, 300000 bytes): both extract() paths now give the same file, each block at its offset. On main the glob path writes an out-of-order map in stored order (wrong content) and drops a member whose map overlaps.

The preallocation, end to end

Two release builds of this branch. B adds back if size > 1_000_000 { fallocate(fd, 0, 0, size) } in the buffered extractor. A2 is the same binary as A, to show the noise. Runs alternate A, A2, B. Medians. The machine has ext4 under overlayfs, 93% full, under load.

workload measure A (this PR) A2 (same binary) B (with fallocate)
Bun.Archive.extract(), one 65 MB member, 31 runs extract ms 60.8 61.5 69.1
extract and fsync ms 115.1 120.4 116.3
CPU ms 124.5 119.2 127.9
extents in the file 74 71 107
Bun.Archive.extract(), ten 6.5 MB members, 31 runs extract ms 59.0 59.9 65.9
extract and fsync ms 217.3 204.3 205.2
extents for each file 2.0 2.0 3.7
bun install of a file: tarball, one 65 MB member, 21 runs wall ms 341.0 328.2 345.5
system CPU ms 84.6 83.2 92.2
extents in the file 48 43 109
bun install of a file: tarball, ten 6.5 MB members, 21 runs wall ms 364.9 332.6 339.4
extents for each file 7.0 7.3 6.5
  • With fallocate the extract call is 7 to 8 ms slower, and bun install of the 65 MB member uses about 8 ms more system CPU. The difference between A and A2 there is about 1 ms.
  • The wall time of bun install and the times with fsync differ by less than A and A2 do.
  • The file does not get fewer extents with fallocate on this disk.
  • 990f53f raised the threshold from 4096 bytes to 1 MB. The benchmark linked beside preallocate_file writes one buffer with one call, on ext4 on NVMe. Extraction writes blocks of up to 64 KB. I have no such machine. A maintainer decides if the call comes back, and EntryWriter is the one place for it.

Cause in detail

  • libarchive's tar reader returns ARCHIVE_EOF from archive_read_data_block with the offset set to the logical size of the entry (archive_read_support_format_tar.c:734). For every tar variant this equals archive_entry_size(). Every reader in bun enables only tar and gzip.
  • libarchive's archive_read_data_into_fd calls pad_to with that offset. On a file that is only an lseek. Its disk writer sets the length: _archive_write_disk_finish_entry calls ftruncate(a->fd, a->filesize) and has no fallocate. Its Windows disk writer sends FSCTL_SET_SPARSE when the map has a hole of 4096 bytes or more (archive_write_disk_windows.c:1015).
  • fallocate(fd, 0, 0, size) allocates blocks and sets the length. ftruncate sets only the length, and the range reads as zeros.
  • The port tracked final_offset as the end of the last block that was yielded. On the pwrite path the tail ftruncate could not run, because the two offsets it compared were the same value.
  • fallocate(fd, 0, 0, size) entered in 8fca3f2 (2022). Its result was discarded, so it never reported a full disk.
  • The loops kept one value for two things: the end of the last pwrite, and the file position. After a failed pwrite the fallback compared the offset of the block with that value, found them equal, and called write() with no seek. The file position was still 0. So the rest of the member went over its start. EntryWriter keeps end and cursor apart, seeks, and the refused write fails the entry. docs/runtime/archive.mdx says that extract() throws when the disk is full.
  • The zero fill came with the port of archive_read_data_into_fd, which also writes to pipes. bun opens each output file itself. A seek on such a file fails only for an offset that the file system cannot hold.

What this change adds for a caller that never hits the bug

  • For each dense entry: the same pwrite calls, one fallocate fewer when the entry is over 1 MB, and no ftruncate (the ARCHIVE_EOF offset equals the last byte written). No allocation is added. EntryWriter is three words on the stack or in TarballStream, and one more byte on Windows.
  • Windows: a dense entry sends no FSCTL_SET_SPARSE. The call is made once for a file, before its first hole.
  • Bun.Archive.extract() with a glob now writes each block straight from libarchive's buffer. Before, it copied through a 64 KB stack buffer.
  • bun create: package.json is read into the buffer that Plucker already has. A file up to 64 KB takes one archive_read_data call, as before.
  • Five real packages (typescript@5.6.3, @esbuild/linux-x64@0.24.0, @swc/core-linux-x64-gnu@1.7.40, lodash@4.17.21, react@18.3.1) with release builds of the merge base and of this branch before the last commit: 1214 files, and each has the same path and sha256 in both. Three of the tarballs take the streaming extractor.
  • Peak memory of bun install for one 256 MiB dense member, 8 interleaved runs for each binary: 246 to 248 MiB with this change, 244 to 250 MiB without.
  • Binary size on Linux x64, release, size: text 80,675,787 bytes with this branch and 80,676,555 bytes with the merge base (bf42a52), 768 bytes less. data 110,424 and bss 1,822,992 in both.
  • Binary size on Windows: one more import (DeviceIoControl) and one small function. Not measured, because I cannot build for Windows here.
  • Not available in this environment: strace, perf, valgrind, bloaty, hyperfine. The syscall counts above come from the code.

Tests

  • test/cli/install/bun-install-streaming-extract.test.ts: buffered extract: failed extraction (2 tests, the temporary directory) and sparse tar members (buffered, streaming up to 3 MB, streaming 65 MiB), and one test for a refused write: a streamed registry install under a 4 MiB file size limit must exit 1, and the next install from the same cache must give the whole file. The streaming tests send the body in two pieces and assert Streamed in the output. The first piece ends inside the data of package.json. The gzip stream starts with a stored block for that, so the place does not depend on the compressor.
  • test/js/bun/archive.test.ts: sparse members (without a glob, with a glob, a header that declares 16 MiB for 100 bytes, and a chunk at 17 TiB). The last test runs the child with a 32 MiB file size limit, so a build that writes zeros stops there. It is skipped on Windows. One more test extracts a gzip archive with a 6,000,000-byte member under a 4 MiB limit: extract() must reject, and what stays of the file must be its start.
  • test/cli/install/bun-create.test.ts: a template whose package.json header declares 2 GiB. The test compares the peak memory of bun create with that of a child that does nothing.
  • The tests build the sparse members by hand, so they need no tar binary. GNU tar lists and extracts the same bytes correctly.
  • They compare the content of each file with its map on every platform. They compare allocated blocks on Linux and on Windows. The limit is 64 KiB for each chunk and 1 MiB more, because NTFS rounds a chunk up to 64 KiB. Both checks pass on Windows x64 and Windows aarch64. The three test files pass on Linux (glibc, musl, ASAN) and macOS too.
  • On main all 12 fail on Linux on ext4. The 17 TiB test needs a file system that cannot seek to that offset: on tmpfs main passes it. The tests with a file size limit are skipped on Windows.
  • The six fixtures in test/js/bun/fixtures/sparse-tars/ are not sparse members. Each is typeflag 0 with the whole file stored. That is why the tail bug was not seen. The comment there now says so.

Self-review

The review ran on the head before the last commit. It said to keep the writer and raised four points.

  • Windows: finish() extended a file that nothing marked sparse, so NTFS would allocate the declared size. Tighten ffi pointer bounds, sparse archive extraction, and the Windows default trust store #31581 compiled that call out on Windows and named the mark as a follow-up. Done here: FSCTL_SET_SPARSE before the first hole. I cannot run Windows here. The allocation check in the tests now runs there, and it passes on Windows x64 and Windows aarch64.
  • The removal of the preallocation had no timing of bun itself. Done: the table above. It still needs a maintainer's yes.
  • The body said that nothing is sized from the header. That was wrong for memory and for a copy. Done: see Downsides and the next section.
  • TempExtractionDir repeats a guard that install: don't leak package extraction temp directories into $TMPDIR #33979 has as a closure, together with the leak on the success path. Not done: the type stays here. Whichever PR merges second drops its copy.

I found the zero fill after that review. The last source commit has no review of that kind. CI checks it on every platform, and I checked the malformed maps above by hand on a debug build with ASAN. The commit after it changes tests only.

What a program can see besides the fixes

  • An entry that cannot be placed, written or sized fails the extraction. main gave a short file and exit 0.
  • extract() with a glob, for an archive that is cut inside the zero padding after the data of a member: main keeps that member, because its loop stops after the declared bytes. This PR drops it and resolves with one less, because the shared writer reads the entry to its end. Without a glob both reject.
  • A sparse map without the closing entry that GNU tar writes: the file gets the real size that the header declares. On main it ended at its last data block.

Not in this PR

  • Memory. archive_read_data returns a hole as zeros, so a reader that holds a member in memory holds its holes. Bun.Archive.files() on a 1,536-byte tar with a 256 MiB hole: 540 MiB peak memory, 548 MiB on main. bun create reads a template's package.json with the same call. bun install reads the extracted package.json of a tarball dependency whole: a 512 MiB hole gives 1,545 MiB peak memory. At 32 MiB the merge base and this PR both peak at about 105 MiB.
  • A copy. A 172-byte tarball with an 8 MiB hole, bun install: with the default hardlink the file in node_modules takes 0 blocks (16,384 on main). With --backend=copyfile it takes 16,384 blocks, because the copy writes the zeros. The copy in the cache takes 0.
  • A file system without sparse files (FAT, exFAT, HFS+): ftruncate and a write past the end allocate there. Not measured, no such file system here.
  • The streaming extractor fails the install when a piece of the body ends inside a sparse map. The libarchive patch that lets a read resume does not cover the map reads. main does the same. The tests send the sparse members in one piece.
  • A plain member whose pax header gives a real size below the stored bytes: the streaming extractor keeps the stored bytes, and the buffered one writes nothing when the real size is 0. main does the same.
  • Bun.Archive.extract() with a glob deletes a file whose write failed and resolves with a smaller count. Without a glob it rejects. main does the same. Bun.Archive: reject when a header read fails, with one in-memory tar reader and a named policy #43242 is the open PR for the error policy of that path.
  • A successful install can leave a directory in the temporary directory when the cache entry already exists and the rename uses RENAME_EXCHANGE. install: don't leak package extraction temp directories into $TMPDIR #33979 covers that.
  • The streaming extractor still removes its temporary directory by hand in TarballStream::finish. TempExtractionDir ignores a delete_tree error, as that code does.

Found on the way

buffered extract does not hold the decompressed local tarball in memory (from #36541) can fail for a reason outside the code under test. On Linux the peak memory that is reported for a child is never below the peak of the process that spawned it. /bin/true reports 27 MiB from a small parent and 727 MiB after the parent allocated 700 MiB. The test failed once in 17 runs of the file here, and it passed in the 16 others and alone.

The first revision of this PR

The first revision capped the preallocation at the input length in the buffered extractor and at 64 MiB in the streaming extractor. That removed the only thing that set the length of a large entry that ends in a hole, so such an entry lost its tail at every size. Its two tests observed only the temporary directory.


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

@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Walkthrough

Changes

Archive extraction now uses a shared writer for offset-based writes and sparse files. Archive reads into vectors use bounded incremental reservations. Temporary extraction directories are removed when extraction or cache migration fails. Tests cover sparse members, truncated archives, and failed installs.

Changes

Archive extraction and sparse-file handling

Layer / File(s) Summary
Archive writing and bounded reads
src/libarchive/lib.rs, src/sys/lib.rs, src/sys/windows/mod.rs, src/bun_core/string/MutableString.rs
Libarchive adds offset-aware file writing and bounded reads. Windows adds sparse-file support. The public MutableString::inflate method is removed.
Extraction consumer integration
src/install/TarballStream.rs, src/runtime/api/Archive.rs
TarballStream and filtered disk extraction delegate archive writes to libarchive helpers.
Temporary extraction cleanup
src/install/extract_tarball.rs
A temporary-directory guard removes the directory unless the cache move succeeds.
Archive extraction regression coverage
test/cli/install/bun-create.test.ts, test/cli/install/bun-install-streaming-extract.test.ts, test/js/bun/archive.test.ts
Tests cover sparse GNU and PAX members, truncated archives, allocation bounds, and temporary-directory cleanup after failed installs.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 79e7e

The changes add test coverage for archive extraction. No merge-blocking issue was found in the reviewed ranges.

🚥 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 summarizes the main change: tar extraction no longer allocates disk space from the entry header and uses libarchive’s offset for file length.
Description check ✅ Passed The description explains the problem, fix, trade-offs, and verification in detail. It does not use the template’s exact headings, but it provides the required information about what the PR does and ho…

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I didn't find any bugs. Deferring to human review because this touches security-sensitive tarball extraction (untrusted-registry DoS mitigation) and overlaps with #33979 on temp-dir cleanup design — worth a maintainer sign-off on the 64 MiB streaming cap and on which PR's cleanup approach lands.

What was reviewed:

  • TempExtractionDir drop ordering: extract_destination (inner block) drops before the guard on early return, so the fd is closed before delete_tree on Windows.
  • Guard armed before make_open_path (per RAII guidance); delete_tree on a not-yet-created or already-renamed dir is benign (let _).
  • file_buffer.len() bound in extract_to_dir is sound — entry body is inline in the decompressed buffer; i64::try_from can't overflow under the existing 2 GiB decompress cap.
  • The boringssl is_safe_alt_name restoration matches the call site and is a straight build fix.
Extended reasoning...

Overview

Five files: src/libarchive/lib.rs and src/install/TarballStream.rs cap Linux fallocate preallocation (to file_buffer.len() and 64 MiB respectively) so an attacker-controlled ustar size field can't allocate gigabytes of real disk before the truncated body is detected. src/install/extract_tarball.rs adds a TempExtractionDir RAII guard so every buffered-extract error path removes the temp directory instead of leaking it per attempt. src/boringssl/lib.rs restores a helper removed by #36252 that #36165 still calls (unrelated build fix). Two new tests exercise both the size-lie and bad-gzip failure paths with an isolated $TMPDIR and assert no leaked directories and bounded disk usage.

Security risks

This is itself a security hardening (disk-fill DoS via malicious registry tarball). I checked that the guard's delete_tree cannot escape the temp root: it deletes parent/tmpname where tmpname is generated by FileSystem::tmpname from a validated basename, not attacker input. The preallocation cap only reduces the argument to fallocate, so it cannot introduce a new correctness issue (writes past the preallocated size just extend the file normally). No new user-facing surface.

Level of scrutiny

Medium-high. Package extraction from untrusted registries is security-sensitive, and the RAII guard changes control flow on every error path in the buffered extractor. The 64 MiB streaming ceiling is a policy constant a maintainer should confirm. The PR description explicitly flags overlap with #33979 on temp-dir cleanup — that coordination is a human decision.

Other factors

  • Drop ordering checked: on early return Err inside the extraction block, extract_destination (inner scope) drops before tmpdir_guard (outer scope), so the Windows open-handle-blocks-delete concern is handled.
  • The guard mirrors the existing cleanup in TarballStream::finish (Dir::borrow(&temp_dir).delete_tree(tmpname)), so it's not a novel pattern.
  • commit() uses mem::forget; the guard owns only a copied Fd (which is Copy, non-owning per the field docs) and a &ZStr, so nothing leaks.
  • If move_to_cache_directory fails after the rename succeeded (e.g., open_at/get_fd_path_z failure), the guard's delete_tree targets the now-absent temp path and no-ops — the cache copy is not touched.
  • Tests look sound: they isolate $TMPDIR/cache per run, assert exact leaked: [], and bound total tree size well below the 16 MiB declared header. The file: dep forces the buffered path so the guard is what's tested.

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

Beyond the inline nit, I also checked: the TempExtractionDir guard's mem::forget in commit() leaks nothing (struct holds only a copied non-owning Fd and a borrowed &ZStr); delete_tree on an already-renamed/non-existent tmpname is a silent no-op (delete_tree_open_initial_subpath returns Ok(None) on ENOENT), so post-rename error paths in move_to_cache_directory don't misbehave; and both preallocation caps are strictly tighter than before, so legitimate large entries just under-preallocate rather than fail. Deferring to a human given this is security-sensitive extraction of untrusted registry content and the noted overlap with #33979.

Extended reasoning...

This PR touches the package manager's tarball extraction path (attacker-controlled input from registries), which is security-sensitive per the review guidelines. The changes look correct and well-scoped — the RAII guard is armed before any fallible work and disarmed only after the rename succeeds, and the fallocate caps can only reduce disk usage vs. the previous unbounded behavior — but a maintainer should sign off on the interaction with #33979 and the security-relevant behavior change.

Comment thread src/libarchive/lib.rs Resolved

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

Thanks for the comment fix in e5ed01c — the reworded invariant now accurately covers both the pre-decompressed and compressed-input callers. I didn't find further issues, but deferring to a maintainer given this is security-sensitive package-extraction code and the PR description notes overlap with #33979's temp-dir handling that whoever merges should be aware of.

What was reviewed:

  • TempExtractionDir guard arm/commit ordering vs. all early-return paths in extract(); drop order relative to extract_destination (fd closes before delete_tree on Windows).
  • move_to_cache_directory failure-after-rename: guard's delete_tree on the now-absent tmpname is a discarded ENOENT, no double-free of the renamed cache folder.
  • Preallocation caps are strictly tightening (can only reduce fallocate); the Bun.Archive under-preallocation is best-effort only.
Extended reasoning...

Overview

This PR hardens bun install tarball extraction against two related issues: (1) an attacker-controlled ustar header size field being passed unbounded to Linux fallocate, allowing a tiny .tgz to allocate gigabytes of real disk before the truncated body is detected; and (2) the buffered ExtractTarball::extract path leaking its temp extraction directory on every error return. It touches src/libarchive/lib.rs and src/install/TarballStream.rs (preallocation caps), src/install/extract_tarball.rs (RAII TempExtractionDir guard), src/boringssl/lib.rs (unrelated build-fix restoring is_safe_alt_name), and adds two regression tests.

Security risks

The changed code handles untrusted registry/file: tarballs. The preallocation changes are strictly monotonic tightening — size.min(bound) can only reduce the fallocate argument, never increase it — so no new attack surface is introduced there. The RAII guard adds a delete_tree(temp_dir, tmpname) on error paths; tmpname is a fresh random name generated by FileSystem::tmpname immediately before the guard is armed and is never derived from tarball contents, so there's no risk of deleting an attacker-controlled path. I traced the failure-after-rename case in move_to_cache_directory: once the rename succeeds, tmpname no longer exists under temp_dir, so a subsequent error triggers a harmless ENOENT from delete_tree (discarded via let _ =) rather than deleting the cache folder.

Level of scrutiny

High — this is the package installer's untrusted-input handling path. The individual changes are small and mechanically sound, but the surrounding control flow (move_to_cache_directory with its Windows retry loop, POSIX renameat_concurrently_a, multiple error exits) is intricate enough that a maintainer familiar with the install subsystem should confirm the guard's placement, particularly given the PR itself flags overlap with #33979.

Other factors

  • My prior inline comment (misleading invariant in the file_buffer.len() bound comment) was addressed in e5ed01c with an accurate reword; the thread is resolved.
  • Drop ordering on error inside the extraction block is correct: extract_destination (declared after the guard) drops first, closing the dir fd before the guard's delete_tree runs — necessary on Windows.
  • commit() uses core::mem::forget; the guard holds only a borrowed Fd and a &ZStr, so nothing leaks.
  • The boringssl change is a mechanical restore of a helper removed by #36252 while a caller from #36165 still exists; unrelated to the main fix but necessary for the branch to build.
  • Tests are hermetic (isolated TMPDIR/cache), drain pipes concurrently, and were shown to fail on main / pass with the fix in the PR evidence.

@robobun

robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review at 79e7e5d. That is 7ca8779 plus one commit that changes tests only. What changed is in #36542 (comment).

CI on the last source commit, 7ca8779 (build 122628): 181 of 182 jobs pass. The one red job is a shard of debian 13 x64-asan, where test/js/bun/spawn/spawn.test.ts fails. That test fails on main too, and this change does not touch it. The three test files of this PR pass on Linux (glibc, musl, ASAN), macOS (x64, aarch64) and Windows (x64, aarch64). On Windows that includes the check that a hole takes no disk.

Two decisions are for a maintainer (the PR body has the numbers):

How this was reproduced (Linux x64, release build of main 367d939):

  • The reported disk-fill. A 159-byte file: tarball has a tar header that declares 8 GiB - 1 for a 100-byte body. bun install fails with error: Fail extracting tarball from pkg and leaves 8.1G in the temporary directory. Bun.Archive.extract() on the same bytes rejects with ReadError and leaves the file in the destination directory.
  • Sparse members. tar --sparse members that end in a hole lose their tail when they are 1 MB or less. Larger ones keep their length, and each hole becomes real blocks: a 377-byte tarball installs with 536,940,544 bytes allocated.
  • A chunk that the file system cannot seek to. A 3,584-byte tar has one sparse chunk at 17 TiB, above the ext4 file size limit. With a 32 MiB file size limit on the process, Bun.Archive.extract() writes 33,554,432 bytes of zeros and then rejects. The limit is what stops it.
  • A write that the file system refuses. A gzip archive has one healthy member of 6,000,000 bytes. Where no file can grow past 4 MiB, Bun.Archive.extract() resolves with a 4,194,304-byte file whose bytes at offset 0 are those of offset 4,193,792. A streamed registry install of the same member prints Resolved, downloaded and extracted [3] and exits 0.

The commands are in the PR body, under Notes.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts

@robobun
robobun force-pushed the farm/413059c4/install-tarball-size-lie branch from e5ed01c to d65e6cc Compare July 31, 2026 11:17
@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto b7fef25 and dropped the boringssl commit (already fixed on main by #36540). Conflicts resolved; diff is now just the tarball extraction changes and tests.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix clippy

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Clippy fixed in 9ccb361: TempExtractionDir::commit now disarms via Option::take instead of mem::forget, and the streaming preallocation bound uses .clamp(). cargo clippy --workspace --all-targets -- -D warnings is clean locally.

@robobun

robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:23 AM PT - Oct 3rd, 2026

❌ @robobun, your commit 79e7e5d has 2 failures in Build #123253 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36542

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

bun-36542 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — the reworded file_buffer.len() comment addresses my earlier note, and the post-rebase/clippy changes are mechanical.

What was reviewed:

  • TempExtractionDir drop ordering on error paths: extract_destination lives in the inner block so it closes before the guard's delete_tree runs (matters on Windows); delete_tree is fd-relative to temp_dir with a randomized tmpname, so it can only remove the directory this extraction created.
  • commit() disarm via self.name = None correctly no-ops the Drop; guard is armed after tmpname is generated and a post-rename failure in move_to_cache_directory just delete_trees a now-nonexistent path.
  • Preallocation caps are strictly tighter than before (.clamp(0, 64 MiB) / .min(file_buffer.len())), so no regression for legitimate entries beyond the documented best-effort under-preallocation for Bun.Archive on compressed input.
Extended reasoning...

Overview

Bounds attacker-controlled fallocate in both tarball extraction paths (Archiver::extract_to_dir caps at file_buffer.len(), TarballStream::begin_entry caps at 64 MiB) and adds a TempExtractionDir RAII guard around the buffered extractor's temp directory so failed extractions no longer leak per-attempt directories in $TMPDIR. Two new tests exercise the size-lie and bad-gzip failure paths with an isolated $TMPDIR/cache and assert no leaked directories and bounded disk usage.

Security risks

This is security-sensitive code (untrusted tarball extraction), but the change is purely defensive: the preallocation bound is only ever tighter than before, and the new cleanup path is scoped to Dir::borrow(&temp_dir).delete_tree(tmpname) where tmpname is the randomized .{hex}-{n}.{suffix} name this extraction generated — it cannot resolve to .., empty, or an absolute path, and delete_tree is fd-relative. No new attack surface; strictly reduces the disk-fill vector described in the PR.

Level of scrutiny

Medium-high — package-manager extraction of untrusted input. I traced the guard's lifetime against every early-return in extract(): the invalid-name return happens before the guard is armed; make_open_path failure runs delete_tree on a possibly-nonexistent path (harmless); extract_destination is declared inside the inner block so it drops (closes) before tmpdir_guard on any ? unwind, which matters for Windows directory removal; a post-rename failure inside move_to_cache_directory leaves the guard armed but tmpname no longer exists under temp_dir, so delete_tree is a no-op.

Other factors

My earlier inline note on the file_buffer.len() invariant comment was addressed in d65e6cc (comment now distinguishes pre-decompressed vs compressed-input callers). Changes since then are mechanical: rebase dropping the boringssl build-fix commit, and clippy fixes (Option-based disarm instead of mem::forget, .clamp() instead of chained .max().min()). The maintainer is actively engaged. Tests are verified fail-on-main / pass-on-PR under ASAN in the PR evidence block. The PR notes overlap with #33979 for the broader concurrent-publish race, but this RAII guard is self-contained and composes with either landing order.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts

@robobun
robobun force-pushed the farm/413059c4/install-tarball-size-lie branch from 9ccb361 to 209c6d7 Compare August 1, 2026 00:35
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto b4aa3a0 (#36541). Only conflict was the node:fs import line in the test file. Since #36541 now hands compressed bytes to Archiver::extract_to_dir for tarballs over 64 MB, I also updated the file_buffer.len() bound comment to describe the invariant without naming specific callers, and refreshed two test comments that referenced the removed zlib pre-decompress step.

Both new tests pass after the rebase. The buffered extract does not hold the decompressed local tarball in memory test from #36541 fails locally here (maxRSS assertion off by a 1024x unit factor), but that reproduces on b4aa3a0 without this PR's changes applied.

@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/install/TarballStream.rs`:
- Around line 896-904: Add coverage for the streaming extraction path using a
streamed tarball containing an entry whose declared size exceeds
PREALLOCATE_CEILING. Assert that extraction bounds disk preallocation to the
configured ceiling, and ensure the test does not use a local file: dependency or
only exercise buffered extraction.
🪄 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: 02fa6a47-4d6f-4d96-b40e-a0bedfbb18b6

📥 Commits

Reviewing files that changed from the base of the PR and between bcd46e8 and 209c6d7.

📒 Files selected for processing (4)
  • src/install/TarballStream.rs
  • src/install/extract_tarball.rs
  • src/libarchive/lib.rs
  • test/cli/install/bun-install-streaming-extract.test.ts

Comment thread src/install/TarballStream.rs Resolved

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

No issues found — my earlier comment on the file_buffer.len() invariant was addressed in the reworded comment, and the post-#36541 rebase version is accurate for both pre-decompressed and compressed-input callers. Deferring to human review since this is security-sensitive install-path code (untrusted tarball input → disk allocation) and a maintainer is already engaged.

What was reviewed:

  • TempExtractionDir drop ordering vs. extract_destination — the inner-block fd closes before the guard's delete_tree, so Windows can remove the dir on the ?-propagation path.
  • commit() timing vs. move_to_cache_directory post-rename failures — after a successful rename tmpname is gone, so a later error's delete_tree(tmpname) is a harmless ENOENT; the Windows retry path uses tmpname+"tmp" and doesn't collide.
  • Preallocation caps only tighten the previous unbounded entry.size(); no correctness impact since preallocate_file is advisory.
Extended reasoning...

Overview

Three focused changes across four files: (1) Archiver::extract_to_dir caps Linux fallocate at file_buffer.len() instead of the attacker-controlled ustar header size; (2) TarballStream::begin_entry caps the same at a fixed 64 MiB since the stream length is unknown at header time; (3) ExtractTarball::extract wraps its temp extraction directory in a TempExtractionDir RAII guard so every error return (? on decompression, extraction, or rename-into-cache) removes the partially-extracted tree instead of leaking it in $TMPDIR. Two new tests exercise a header-size-lie tarball and a non-gzip tarball via the buffered file: path with an isolated tmp/cache.

Security risks

This is a DoS mitigation in the package installer's untrusted-input path. The changes are strictly defensive: the preallocation bounds only ever reduce the value passed to fallocate (best-effort, so under-preallocation is harmless), and the drop guard only fires on error paths where the temp directory would previously have leaked. I traced the guard's delete_tree scope — it is confined to temp_dir/tmpname where tmpname is the freshly-generated random name from FileSystem::tmpname, so it cannot reach outside the extraction it created. On the success path commit() disarms before Drop; on post-rename failures inside move_to_cache_directory the directory has already been renamed away and delete_tree is a no-op ENOENT.

Level of scrutiny

High — this is production install-path code handling untrusted registry/file: tarballs, with Windows-specific rename/delete semantics in play. The individual changes are small and mechanical, but the interaction between the new drop guard, the existing extract_destination handle lifetime, and the Windows move_opened_file_at retry loop needed tracing. I verified Rust drop order closes extract_destination before the guard runs delete_tree, and that the Windows retry's tempdest (= tmpname + "tmp") is disjoint from the guard's tmpname.

Other factors

My earlier 🟡 inline comment (the file_buffer.len() bound comment overstated its invariant for compressed-input callers) was addressed twice — once in e5ed01c and again after the #36541 rebase changed which callers pre-decompress. The current wording is accurate. The new tests follow harness conventions (tempDir, bunEnv, concurrent pipe drain, isolated $TMPDIR/cache) and assert on both the failure exit and the absence of leaked directories/disk. The PR notes overlap with #33979; the RAII approach here is compatible with either landing order. A maintainer is actively engaged on the thread, so deferring rather than approving.

… extraction

A tarball whose ustar header declares a size far larger than the body
that follows (e.g. a 155-byte tgz with one entry claiming 8 GiB) made
bun install fallocate the declared size on Linux before the truncated
body was detected, and the failed extraction's temp directory was never
removed. Repeated attempts filled $TMPDIR with one fully-allocated
copy per run.

Archiver::extract_to_dir now caps the preallocation at the length of
the decompressed tar buffer (a tar entry's body is stored inline in the
archive stream, so it cannot exceed that). The streaming extractor caps
at 64 MiB since the stream length is not known at header time. The
buffered ExtractTarball::extract path wraps its temp directory in a
TempExtractionDir RAII guard that removes it on every error path and is
committed once the directory has been renamed into the cache.
TempExtractionDir::commit disarms via Option::take instead of
core::mem::forget (forbidden per PORTING.md), and the streaming
preallocation bound uses clamp instead of max().min().
Package extraction now hands compressed bytes to libarchive for tarballs
whose gzip ISIZE trailer reports over 64 MB, so the file_buffer.len()
bound is no longer always the decompressed length there; the comment now
describes the invariant without naming callers. Test comments updated to
match (libarchive fails to open the input, not a separate zlib reader).
@robobun

robobun commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main bf42a52 (was 209c6d7, now 6008648).

  • The conflict was in test/cli/install/bun-install-streaming-extract.test.ts only. Main appended a test at the end of the file, where this pull request appends its describe block. The resolution keeps both.
  • No source line of this pull request changed. The three src/ hunks are identical before and after the rebase.
  • Checked on a debug build of the rebased branch: all 27 tests of that file pass. The 2 tests of buffered extract: malformed tarball cleanup fail on a debug build of main bf42a52.

@robobun
robobun force-pushed the farm/413059c4/install-tarball-size-lie branch from 209c6d7 to 6008648 Compare September 30, 2026 20:32

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/libarchive/lib.rs — pre-existing: a bun create <user>/<repo> template tarball whose package.json header claims a huge size (e.g. 8 GiB) still makes bun allocate and zero-fill that many bytes of RAM, crashing the process. The plucker branch at src/libarchive/lib.rs:1784 calls plucker_.contents.inflate(size) with the raw header size; MutableString::inflate is Vec::resize(amount, 0), which aborts on allocation failure and memsets on success. This is the same attacker-controlled-size class the PR bounds for fallocate two lines below. Fix: bound the plucker allocation too, e.g. cap size at file_buffer.len() (or a fixed ceiling) before inflate, and grow from actual read_data results.

    Why this was flagged

    A GitHub template tarball for bun create (src/runtime/cli/create_command.rs:483 builds pluckers for package.json) contains a package.json entry whose ustar size field is 77777777777 octal with a short body. In Archiver::extract_to_dir, size at src/libarchive/lib.rs:1770 is that header value; the plucker hash matches and plucker_.contents.inflate(size)? runs at lib.rs:1784, then resize(cap, 0) at 1786. MutableString::inflate (src/bun_core/string/MutableString.rs:267) is self.list.resize(amount, 0), an infallible allocation plus an 8 GiB memset, so the process is OOM-killed or aborts instead of reporting a truncated archive. The new prealloc = size.min(file_buffer.len()) at lib.rs:1839 only bounds the fallocate below this branch; the plucker path still uses the unbounded size. The base branch behaves the same, so this is pre-existing, but it is the same attacker-controlled header-size class the PR sets out to close in this function.

    Verification: bun create <user>/<repo> fetches a template tarball whose package.json header declares a huge size with a short body. In src/libarchive/lib.rs, size at 1770-1772 is the raw header value, and line 1784 plucker_.contents.inflate(size)?; runs before the new cap; MutableString::inflate is self.list.resize(amount, 0), so the process aborts or memsets 8 GiB before read_data at 1789. The PR's bound at 1839 does not cover 1784.

Comment thread src/install/TarballStream.rs Resolved
Comment thread src/install/extract_tarball.rs Resolved
Comment thread src/install/extract_tarball.rs Resolved
@robobun

robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Do not merge this as it is. With the preallocation cap, a member of a tar --sparse tarball that ends in a hole loses its tail, and the command exits 0.

Repro (Linux x64, GNU tar 1.35):

mkdir -p t/src/package t/p && cd t
echo '{"name":"pk","version":"1.0.0"}' > src/package/package.json
truncate -s 300000 src/package/small.bin; truncate -s 3000000 src/package/big.bin
printf DATA | dd of=src/package/small.bin conv=notrunc status=none
printf DATA | dd of=src/package/big.bin conv=notrunc status=none
tar --sparse -czf pk.tgz -C src package
echo '{"name":"proj","dependencies":{"pk":"file:../pk.tgz"}}' > p/package.json
(cd p && bun install >/dev/null 2>&1; echo "bun install: exit $?"; stat -c '%n %s' node_modules/pk/*.bin)
bun -e 'await new Bun.Archive(await Bun.file("pk.tgz").bytes()).extract("ex")'; stat -c '%n %s' ex/package/*.bin

Sizes after bun install and after Bun.Archive#extract() (the two give the same numbers):

build big.bin (3,000,000) small.bin (300,000)
main 5a183c1 (debug) and 1.4.3-canary.1+367d939d9 3000000 4096
this pull request at 6008648 (debug) 4096 4096
GNU tar 3000000 300000

Cause: read_data_into_fd (src/libarchive/lib.rs) extends the file only to the end of the last data block. libarchive reports the real size of a sparse entry through the offset that archive_read_data_block returns at EOF, and its own archive_read_data_into_fd pads the file to that offset after the loop (vendor/libarchive/libarchive/archive_read_data_into_fd.c). On main, a member over 1,000,000 bytes gets its length from the fallocate of the declared size, on Linux only. This pull request caps that preallocation at the input length, so the length is lost. Smaller members lose their tail on main too.

The fix belongs in this pull request, with a test for a sparse member that ends in a hole (buffered install, streamed install and Bun.Archive#extract()).

@robobun
robobun marked this pull request as draft September 30, 2026 22:21
@robobun

robobun commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Do not merge this revision. It loses data. I converted the PR to a draft.

The preallocation cap in this PR truncates a tar entry that ends in a hole (a GNU sparse entry, tar --sparse). One tarball, three extractors. Each cell is apparent bytes / allocated 512-byte blocks.

entry GNU tar 1.35 main (release build of 367d939) this PR (6008648)
small-tail.bin: 5 bytes, then a hole to 512 KiB 524288 / 8 4096 / 8 4096 / 8
big-tail.bin: 5 bytes, then a hole to 64 MiB 67108864 / 8 67108864 / 131080 4096 / 8
mid.bin: 8 MiB, one byte at 0 and one at 8000000 8388608 / 16 8388608 / 16392 8003584 / 16
lead.bin: a hole, then 5 bytes at the end of 4 MiB 4194304 / 8 4194304 / 8192 4194304 / 8

Bold marks a value that differs from GNU tar. Each file with a wrong length also has an md5 that differs from the GNU tar output. bun install exits 0 in every case. Bun.Archive.extract() gives the same lengths.

Cause

  • libarchive returns the logical end of an entry together with ARCHIVE_EOF (archive_read_support_format_tar.c:734). Upstream archive_read_data_into_fd pads the file to that offset (archive_read_data_into_fd.c:169). The port ignores it. Archive::read_data_into_fd in src/libarchive/lib.rs and TarballStream::close_output_file leave the file at the end of the last block that carried data.
  • On Linux, the fallocate(fd, 0, 0, size) for an entry over 1 MB hides this. Mode 0 allocates the blocks and also sets the file length to the header size. That call is the disk-fill in the report too: it turns each hole into real blocks, and it allocates the declared size before a short body is detected.
  • This PR caps the fallocate and does not repair the tail. The truncation that main has for an entry of 1 MB or less then applies to each size.

Reproduce

On Linux:

mkdir -p src/package && cd src
printf '{"name":"pkg","version":"1.0.0"}' > package/package.json
printf hello > package/small-tail.bin && truncate -s 512K package/small-tail.bin
printf hello > package/big-tail.bin && truncate -s 64M package/big-tail.bin
tar --sparse -czf ../pkg.tgz package && cd ..
printf '{"name":"root","dependencies":{"pkg":"file:./pkg.tgz"}}' > package.json
bun install
stat -c '%n %s bytes %b blocks' node_modules/pkg/*.bin

main prints:

node_modules/pkg/big-tail.bin 67108864 bytes 131080 blocks
node_modules/pkg/small-tail.bin 4096 bytes 8 blocks

tar -xzf pkg.tgz gives 67108864 bytes / 8 blocks and 524288 bytes / 8 blocks.

Next

The rework repairs the tail first, so that the file length comes from libarchive and not from the preallocation. Then it removes the dependency on the header size. The two tests in this revision observe only the temporary directory. The rework adds tests that compare the length and the allocated blocks with the table above. I will post here when it is ready for review.

robobun and others added 2 commits October 1, 2026 08:08
…rom libarchive

A tar entry header declares a size. Nothing proves that the archive holds
that many bytes, and for a sparse entry it never does.

The buffered extractor, the streaming extractor and the glob path of
Bun.Archive.extract() each had a write loop of their own. On Linux the first
two called fallocate() with the header size. That allocated the declared size
before a short body was detected, and it turned each hole of a sparse entry
into real blocks. It also hid a second defect: each loop left the file at the
end of the last block that carried data and dropped the length that libarchive
returns with ARCHIVE_EOF. An entry that ends in a hole lost its tail unless the
fallocate had already set the length.

- lib::EntryWriter writes the data of one entry for all three loops. A block
  goes to the offset libarchive gives it. The file gets its length in
  finish(), from the offset returned at the end of the entry's data.
- No extraction path calls preallocate_file. The caps on it are gone with it.
- TarballStream holds an EntryWriter in place of its copy of the block writer.
- Archive::read_data_to_vec reads an entry into memory in steps. The Plucker
  that bun create uses for package.json no longer allocates the header size.

Tests build GNU sparse members by hand, in the old GNU format and in PAX 1.0,
and compare each extracted file with its map and, on Linux, its allocated
blocks.
@robobun robobun changed the title install: bound tar-header preallocation and remove temp dir on failed extraction tar extraction: size nothing from the entry header, take the length from libarchive Oct 1, 2026
The Plucker in Archiver::extract_to_dir was its last caller. Archive::read_data_to_vec
is used only inside bun_libarchive, so it is pub(crate).
@robobun

robobun commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

The head moved from 6008648 to 21138f9. The PR stays a draft until the self-review is done.

What changed

  • 004800c: one lib::EntryWriter writes each entry for the buffered extractor, the streaming extractor and Bun.Archive.extract() with a glob. It takes the file length from the offset libarchive returns with ARCHIVE_EOF. The fallocate is removed from both extractors, and the caps from the first revision go with it. bun create reads package.json from a template tarball in steps. TempExtractionDir is unchanged.
  • 99ef8d2: formatting, by autofix.
  • 21138f9: removes MutableString::inflate, which lost its last caller.

The title and the body describe the PR as it is now.

What I verified

On Linux x64 with a debug build of 21138f9. The repro is the one from the comment above (truncate -s 300000 small.bin, truncate -s 3000000 big.bin, tar --sparse -czf).

path big.bin small.bin
bun install, file: tarball (buffered extractor) 3000000 bytes, 8 blocks 300000 bytes, 8 blocks
bun install, registry tarball (streaming extractor) 3000000 300000
bun install, registry tarball with BUN_FEATURE_FLAG_DISABLE_STREAMING_INSTALL=1 3000000 300000
Bun.Archive.extract() 3000000 bytes, 8 blocks 300000 bytes, 8 blocks
Bun.Archive.extract() with { glob: "**" } 3000000 bytes, 8 blocks 300000 bytes, 8 blocks
  • In each row both files are byte-identical to the source files (cmp).
  • 20 sparse members (the old GNU format and PAX 1.0, five layouts, 300000 and 3000000 bytes): 0 wrong on each of those paths. They take 106,496 bytes on disk for 33,000,000 bytes of length, the same as GNU tar 1.35. main has 6 wrong and 30,085,120 bytes. The first revision has 12 wrong.
  • Two 70 MiB sparse members through the streaming extractor: 0 wrong, 8,192 bytes on disk. The first revision wrote each at 67,108,864 bytes.
  • The header that declares 8 GiB - 1 for a 100-byte body: bun install exits 1 with Fail extracting tarball, and the temporary directory is empty. Bun.Archive.extract() rejects and leaves a 1536-byte file. main leaves 8.1G in the temporary directory.
  • 9 new or changed tests in test/cli/install/bun-install-streaming-extract.test.ts, test/js/bun/archive.test.ts and test/cli/install/bun-create.test.ts. All 9 fail on main, 6 fail on the first revision, and all pass here. The tests build the sparse members by hand, so they use no tar binary.
  • Suites on the head: archive.test.ts 111 pass, bun-install-streaming-extract.test.ts 30 pass, bun-create.test.ts 22 pass, bun-install-tarball-integrity.test.ts 16 pass, bun-publish.test.ts 46 pass, bun-pm-diff.test.ts 46 pass. cargo clippy has no finding in the touched files, and rust:check-all passes for 12 targets.
  • Two runs on this machine failed for reasons outside this change, and each passes when run again. One bun-publish.test.ts run lost its local registry (ECONNREFUSED), and a build without this change does the same. One bun-pm-diff.test.ts run timed out in a tarball entry larger than 64 MiB is read whole, which takes 2.3 to 4.5 s alone on both builds, against a 5 s limit.

Not measured on my side

  • macOS and Windows: I have no machine for them. CI ran the tests there, and they pass on Linux (glibc and musl), macOS and Windows. The tests compare content on every platform and allocated blocks on Linux only.
  • Binary size, added after the first version of this comment: size on a release build of the merge base (bf42a52) and of 21138f9 gives the same numbers: text 80,676,555, data 110,424, bss 1,822,992.

The review also found that bun create sized a memory buffer from the header of package.json in a template tarball. That is fixed in 004800c, with a test in bun-create.test.ts.

…s on Windows

EntryWriter wrote zeros up to a block's offset when pwrite and lseek both
failed. ext4 cannot seek past 16 TiB, so a sparse map with a chunk above that
made the writer fill the disk. A block that neither call can place now fails
the entry. WriteStrategy loses its lseek flag.

On Windows, NTFS gives a hole real clusters unless the file is marked sparse.
EntryWriter now sends FSCTL_SET_SPARSE before a seek or an ftruncate leaves a
range that no block fills. bun_sys gets set_sparse() for that.

Tests: a chunk at 17 TiB, run under a file size limit. The allocation checks
now run on Windows too. The streamed sparse tests split the body at a fixed
place in package.json and not at half of a deflate stream.
@robobun robobun changed the title tar extraction: size nothing from the entry header, take the length from libarchive tar extraction: no disk allocation from the entry header, file length from libarchive Oct 2, 2026
@robobun

robobun commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

The head moved to 7ca8779. The PR stays a draft until CI is green on it.

What changed since 21138f9

  • EntryWriter no longer writes zeros to reach the offset of a block. When pwrite and a seek both fail, the entry fails. Before, a sparse map with a chunk above the ext4 file size limit (16 TiB) made the writer write zeros up to that offset. main has the same fallback.
  • On Windows, EntryWriter sends FSCTL_SET_SPARSE before a seek or an ftruncate leaves a hole. Without the mark, NTFS allocates clusters for the hole.
  • Tests: one new test (a chunk at 17 TiB, with a 32 MiB file size limit on the child). The allocation check now runs on Windows too. The streamed sparse tests split the body at a fixed place inside package.json.
  • TempExtractionDir and the rest of the change are as before.

What I verified (Linux x64, debug build with ASAN)

  • test/js/bun/archive.test.ts: 112 pass, 1 skip. test/cli/install/bun-install-streaming-extract.test.ts: 30 pass. test/cli/install/bun-create.test.ts: 22 pass. test/cli/install/bun-install-tarball-integrity.test.ts: 16 pass.
  • Sparse members of 300000 and 3000000 bytes are whole in the four paths: bun install of a file: tarball, a streamed registry tarball, Bun.Archive.extract(), and the same with a glob. A streamed 65 MiB member is whole too.
  • A header that declares more than the archive holds allocates nothing, fails, and leaves no temporary directory.
  • On a release build of main, the 10 new or changed tests fail.
  • cargo check passes for x86_64-pc-windows-msvc and aarch64-pc-windows-msvc. I cannot run Windows here. CI runs the allocation check there.

Still true after this change

A hole becomes real bytes in memory for Bun.Archive.files() and bun create, and on disk with --backend=copyfile or on a file system without sparse files. The PR body has the numbers.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/bun/archive.test.ts Resolved
Comment thread src/runtime/api/Archive.rs Resolved
Comment thread src/install/TarballStream.rs Resolved
A full disk, a quota or a file size limit refuses a write in the middle of a
file. Two tests put a 6,000,000-byte member under a 4 MiB file size limit.
Bun.Archive.extract() must reject and leave only the start of the file. A
streamed registry install must fail and leave no package in the cache.

The test for a header that declares too much now asserts the ReadError
rejection. Two install spawns no longer pipe a stdout that nothing reads.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)

🔵 Trivial · Replace the polling loop with a condition-driven… · bun-install-streaming-extract.test.ts:1366

test/cli/install/bun-install-streaming-extract.test.ts:1366
📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the polling loop with a condition-driven wait.

The loop calls Bun.sleep(5) to poll for the extraction directory. The test guidelines say to never wait for time to pass and to wait for the condition instead. The loop also runs readdirSync(tmp) every 5 ms, and it throws if tmp is removed.

The !exited guard bounds the loop. The code comment explains why the ordering is needed. Use fs.watch on tmp, or add a signal from the child, if a deterministic wait is practical.

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Review comment at @test/cli/install/bun-install-streaming-extract.test.ts at
line 1366:
Replace the polling loop that checks for a `.sparse-pkg` entry in `tmp` with an
event-driven wait, using `fs.watch` or a child-process signal; preserve the
`exited` guard and required ordering, and handle `tmp` being removed while
waiting.

Source: Coding guidelines


🤖 Prompt to fix review comments
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Outside diff comments:
Review comments at @test/cli/install/bun-install-streaming-extract.test.ts:
- Line 1366: Replace the polling loop that checks for a `.sparse-pkg` entry in
`tmp` with an event-driven wait, using `fs.watch` or a child-process signal;
preserve the `exited` guard and required ordering, and handle `tmp` being
removed while waiting.

After applying the fix, consider running `coderabbit review --agent` for local
review. Visit https://docs.coderabbit.ai/cli?utm_source=ghpr

ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 604cf501-3395-4101-a7d6-9999014c93fa
📥 Commits

Reviewing files that changed from the base of the PR and between 7ca8779 and 79e7e5d.

📒 Files selected for processing (2)
  • test/cli/install/bun-install-streaming-extract.test.ts
  • test/js/bun/archive.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.

@robobun

robobun commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

The head moved to 79e7e5d. The new commit changes two test files and no source file.

What changed since 7ca8779

  • Two new tests for a write that the file system refuses in the middle of a file (a full disk, a quota, a file size limit). Each puts a 6,000,000-byte member under a 4 MiB file size limit.
    • test/js/bun/archive.test.ts: Bun.Archive.extract() of a gzip archive must reject, and what stays of the file must be its start.
    • test/cli/install/bun-install-streaming-extract.test.ts: a streamed registry install must exit 1 with EFBIG extracting tarball for "pk", and the next install from the same cache must give the whole file.
  • The two review nits: the test for a header that declares too much asserts the ReadError rejection, and two install spawns no longer pipe a stdout that nothing reads.
  • The PR body now names this defect. On main the fallback after a failed pwrite calls write() with no seek while the file position is still 0. The file is cut, its start holds its end, extract() resolves, and a streamed install exits 0 and leaves that package in the cache. EntryWriter (already in the previous head) seeks and fails the entry.

What I verified (Linux x64)

  • Debug build with ASAN of this head: test/js/bun/archive.test.ts 113 pass, 1 skip. test/cli/install/bun-install-streaming-extract.test.ts 31 pass.
  • Release build of main: both new tests fail. extract() resolves with a 4,194,304-byte file whose bytes at offset 0 are those of offset 4,193,792. The install prints Resolved, downloaded and extracted [3].
  • The test for a chunk at 17 TiB fails on main only where the file system cannot seek to that offset (ext4). On tmpfs main passes it. The body says so now.

Also stated in the body now

With a glob, an archive that is cut inside the zero padding after the data of a member loses that member (main keeps it). Without a glob both reject.

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Coordination with #44509 (what Bun.Archive#extract() rejects with).

#44509 now sits on this branch: its base is farm/413059c4/install-tarball-size-lie, and it no longer changes the pwrite fallback. The split:

  • This PR holds the writer (EntryWriter), the fix for a write() after a failed pwrite in the buffered and the streaming extractor, and the tests for that fix.
  • Bun.Archive: extract() rejects with the failed syscall or libarchive's message #44509 holds the error of extract(): read_data_into_fd returns the bun_sys::Error of a failed call on the output file, extract_to_dir returns it with the entry path, and Archive.rs rejects with a SystemError or the libarchive message. One effect outside Bun.Archive: the buffered install prints EFBIG extracting tarball from <pkg> where it prints Fail.

What I measured for the failed pwrite, to save a run here. Linux x64, file size limit set with ulimit -f:

case 1.4.3-canary.1 this branch at 7ca8779 (debug)
extract() of a .tar.gz with one 131072-byte entry, limit 102400 bytes resolves with 1, the file has 102400 bytes and its first 64 KiB are not the entry's rejects (ReadError)
streaming bun install, one 128 KiB entry, limit 96 KiB exit 0, Saved lockfile, the scrambled file is installed exit 1, error: EFBIG extracting tarball for "stream-pkg" (at byte 131341 of 131341), nothing installed
buffered bun install, same tarball exit 1, error: Fail extracting tarball from stream-pkg the same

The limit has to sit between one 64 KiB block and the entry size, or main also fails and the test proves nothing. ulimit -f counts 512-byte blocks in dash and 1024-byte blocks in bash, so the test below reads the unit first.

The streaming install test I removed from #44509

It goes in test/cli/install/bun-install-streaming-extract.test.ts and uses that file's buildTarball and makeRegistry. On this branch it passes. On 1.4.3-canary.1 it fails: the install exits 0.

// `ulimit -f` makes a write fail without a full disk. It counts blocks of 512
// bytes in dash and in a shell in POSIX mode, and of 1024 bytes in bash.
let fileSizeLimitUnit: Promise<number> | undefined;
async function probeFileSizeLimitUnit(): Promise<number> {
  using dir = tempDir("file-size-limit-unit", {});
  const script = `
    const fs = require("node:fs");
    const fd = fs.openSync(process.env.PROBE, "w");
    let written = 0;
    try {
      while (written < 4096) written += fs.writeSync(fd, Buffer.alloc(64));
    } catch {}
    console.log(written);
  `;
  await using proc = Bun.spawn({
    cmd: ["/bin/sh", "-c", 'ulimit -f 1 && exec "$@"', "sh", bunExe(), "-e", script],
    env: { ...bunEnv, PROBE: join(String(dir), "probe") },
    stdout: "pipe",
    stderr: "inherit",
  });
  const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
  expect(exitCode).toBe(0);
  return Number(stdout);
}

test.skipIf(isWindows)("streaming extract fails when a write is refused in the middle of an entry", async () => {
  // The gzip filter hands the entry over in blocks of 64 KiB. The limit lets
  // the first block through and fails the second. From offset 0, the second
  // block fits under the limit.
  const big = Buffer.alloc(128 * 1024);
  let seed = createHash("sha256").update("file-size-limit").digest();
  for (let off = 0; off < big.length; off += 32) {
    seed.copy(big, off);
    seed = createHash("sha256").update(seed).digest();
  }
  const limit = 96 * 1024;
  const { tgz, shasum, integrity } = buildTarball([
    { path: "package.json", body: Buffer.from(JSON.stringify({ name: "stream-pkg", version: "1.0.0" }) + "\n") },
    { path: "big.bin", body: big },
  ]);
  await using reg = await makeRegistry(tgz, shasum, integrity, 4096);
  using dir = tempDir("streaming-extract-file-size-limit", {
    "package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "stream-pkg": "1.0.0" } }),
    "bunfig.toml": Bun.TOML.stringify({ install: { registry: reg.url } }),
  });
  const unit = await (fileSizeLimitUnit ??= probeFileSizeLimitUnit());
  expect([512, 1024]).toContain(unit);

  await using proc = Bun.spawn({
    cmd: ["/bin/sh", "-c", `ulimit -f ${limit / unit} && exec "$@"`, "sh", bunExe(), "install", "--verbose"],
    cwd: String(dir),
    env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache"), BUN_INSTALL_STREAMING_MIN_SIZE: "1024" },
    stdout: "pipe",
    stderr: "pipe",
  });
  const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

  expect(stderr).toMatch(/error: EFBIG extracting tarball for "stream-pkg" \(at byte \d+ of \d+\)/);
  expect(existsSync(join(String(dir), "node_modules", "stream-pkg"))).toBe(false);
  expect(exitCode).toBe(1);
});

#44509 keeps its own tests for the refused write. They assert the error (EFBIG, write, the path) that only exists with #44509, so they do not fit here.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants