install: bound tar-header preallocation and remove temp dir on failed extraction - #36542
install: bound tar-header preallocation and remove temp dir on failed extraction#36542robobun wants to merge 5 commits into
Conversation
WalkthroughChangesTarball extraction now bounds preallocation from archive entry sizes. Extraction failures clean temporary directories through an RAII guard. Regression tests cover oversized tar entries and invalid gzip input. Tarball extraction hardening
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
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:
TempExtractionDirdrop ordering:extract_destination(inner block) drops before the guard on early return, so the fd is closed beforedelete_treeon Windows.- Guard armed before
make_open_path(per RAII guidance);delete_treeon a not-yet-created or already-renamed dir is benign (let _). file_buffer.len()bound inextract_to_diris sound — entry body is inline in the decompressed buffer;i64::try_fromcan't overflow under the existing 2 GiB decompress cap.- The boringssl
is_safe_alt_namerestoration 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 Errinside the extraction block,extract_destination(inner scope) drops beforetmpdir_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()usesmem::forget; the guard owns only a copiedFd(which isCopy, non-owning per the field docs) and a&ZStr, so nothing leaks.- If
move_to_cache_directoryfails after the rename succeeded (e.g.,open_at/get_fd_path_zfailure), the guard'sdelete_treetargets the now-absent temp path and no-ops — the cache copy is not touched. - Tests look sound: they isolate
$TMPDIR/cache per run, assert exactleaked: [], and bound total tree size well below the 16 MiB declared header. Thefile:dep forces the buffered path so the guard is what's tested.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
TempExtractionDirguard arm/commit ordering vs. all early-return paths inextract(); drop order relative toextract_destination(fd closes beforedelete_treeon Windows).move_to_cache_directoryfailure-after-rename: guard'sdelete_treeon the now-absenttmpnameis a discarded ENOENT, no double-free of the renamed cache folder.- Preallocation caps are strictly tightening (can only reduce
fallocate); theBun.Archiveunder-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'sdelete_treeruns — necessary on Windows. commit()usescore::mem::forget; the guard holds only a borrowedFdand 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.
|
CI on build #86466 (209c6d7, rebased over #36541): the only hard-red failure is The diff is green; ready for review. |
|
@robobun fix conflicts |
e5ed01c to
d65e6cc
Compare
|
@robobun fix clippy |
|
Clippy fixed in 9ccb361: |
|
Updated 6:04 PM PT - Jul 31st, 2026
❌ @robobun, your commit 209c6d7 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 36542That installs a local version of the PR into your bun-36542 --bun |
There was a problem hiding this comment.
LGTM — the reworded file_buffer.len() comment addresses my earlier note, and the post-rebase/clippy changes are mechanical.
What was reviewed:
TempExtractionDirdrop ordering on error paths:extract_destinationlives in the inner block so it closes before the guard'sdelete_treeruns (matters on Windows);delete_treeis fd-relative totemp_dirwith a randomizedtmpname, so it can only remove the directory this extraction created.commit()disarm viaself.name = Nonecorrectly no-ops the Drop; guard is armed aftertmpnameis generated and a post-rename failure inmove_to_cache_directoryjust 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 forBun.Archiveon 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.
|
@robobun fix conflicts |
… 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).
9ccb361 to
209c6d7
Compare
|
Rebased onto b4aa3a0 (#36541). Only conflict was the Both new tests pass after the rebase. The |
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
src/install/TarballStream.rssrc/install/extract_tarball.rssrc/libarchive/lib.rstest/cli/install/bun-install-streaming-extract.test.ts
There was a problem hiding this comment.
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:
TempExtractionDirdrop ordering vs.extract_destination— the inner-block fd closes before the guard'sdelete_tree, so Windows can remove the dir on the?-propagation path.commit()timing vs.move_to_cache_directorypost-rename failures — after a successful renametmpnameis gone, so a later error'sdelete_tree(tmpname)is a harmless ENOENT; the Windows retry path usestmpname+"tmp"and doesn't collide.- Preallocation caps only tighten the previous unbounded
entry.size(); no correctness impact sincepreallocate_fileis 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.
Problem
A 155-byte
.tgzcan fill the disk. If a tarball entry's ustar header declares a size far larger than the body that follows (e.g.77777777777octal = 8 GiB - 1),bun installon Linuxfallocates the full declared size before the truncated body is detected. The extraction then fails cleanly witherror: Fail extracting tarball, but the temp extraction directory (under$TMPDIRor<cache>/.tmp/) is never removed. Each retry leaks another fully-allocated copy.The temp-directory leak applies to every buffered extraction failure (zlib/decompression errors, truncated input, rename-into-cache failures), not just header size lies: the buffered
ExtractTarball::extractpath creates the temp directory up front and returns early on any error without removing it.Cause
Archiver::extract_to_dir/TarballStream::begin_entrycallbun_sys::preallocate_file(fd, 0, entry.size())on Linux.entry.size()is the attacker-controlled ustar header field, andfallocatemode 0 allocates real blocks.ExtractTarball::extractcreates its temp directory viamake_open_path, then returns on every error path (decompression,Archiver::extract_to_dir,move_to_cache_directory) without removing it. The streaming extractor already cleans up inTarballStream::finish; the buffered path (used forfile:tarballs and any registry tarball below the 2 MiB streaming threshold) does not.Fix
Archiver::extract_to_dircaps preallocation atfile_buffer.len(): a tar entry's body is stored inline in the already-decompressed archive stream, so the body cannot exceed that length.TarballStream::begin_entrycaps preallocation at 64 MiB. The stream length is not known at header time, so this is a fixed ceiling; the streaming path already deletes its temp directory on failure, so this only bounds the transient disk spike.ExtractTarball::extractwraps its temp directory in aTempExtractionDirRAII guard thatdelete_trees on drop, committed only aftermove_to_cache_directorysucceeds. The temp-directory cleanup here overlaps with install: don't leak package extraction temp directories into $TMPDIR #33979, which addresses the broader concurrent-publish race; this PR covers the error-path leak with an RAII guard per review direction and can be rebased over whichever lands first.Verification
New tests in
test/cli/install/bun-install-streaming-extract.test.tsunderbuffered extract: malformed tarball cleanup:tar header size far exceeding body fails cleanly without leaking temp disk: afile:tarball with a 16 MiB header lie and 100-byte body, installed twice with an isolated$TMPDIR/cache; asserts the install fails, the temp directory is empty afterward, and total disk under tmp+cache stays below 1 MiB.a tarball that fails to decompress does not leak its temp directory: afile:dep whose bytes are not a gzip stream; asserts the install fails and the temp directory is empty.Both fail on main (leaked
.xxxxxxxx-1.pkgdirectory containing a 16 MiBbig.binon Linux) and pass with the fix. The rest ofbun-install-streaming-extract.test.tsandbun-install-tarball-integrity.test.tspass unchanged.The first commit restores the
is_safe_alt_namehelper insrc/boringssl/lib.rsso main builds; it was removed by #36252 but a caller was added by #36165. Drop it if the dedicated fix lands first.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