Skip to content

install: bound tar-header preallocation and remove temp dir on failed extraction - #36542

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

install: bound tar-header preallocation and remove temp dir on failed extraction#36542
robobun wants to merge 5 commits into
mainfrom
farm/413059c4/install-tarball-size-lie

Conversation

@robobun

@robobun robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Problem

A 155-byte .tgz can fill the disk. If a tarball entry's ustar header declares a size far larger than the body that follows (e.g. 77777777777 octal = 8 GiB - 1), bun install on Linux fallocates the full declared size before the truncated body is detected. The extraction then fails cleanly with error: Fail extracting tarball, but the temp extraction directory (under $TMPDIR or <cache>/.tmp/) is never removed. Each retry leaks another fully-allocated copy.

error: Fail extracting tarball from pkg
$ ls -la $TMPDIR/.5ec7df3e75ef9ed3-1.pkg/
-rw-r--r-- 1 root root 16777216 big.bin       # fallocated to the header's declared size
-rw-r--r-- 1 root root       32 package.json

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::extract path creates the temp directory up front and returns early on any error without removing it.

Cause

  • Archiver::extract_to_dir / TarballStream::begin_entry call bun_sys::preallocate_file(fd, 0, entry.size()) on Linux. entry.size() is the attacker-controlled ustar header field, and fallocate mode 0 allocates real blocks.
  • ExtractTarball::extract creates its temp directory via make_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 in TarballStream::finish; the buffered path (used for file: tarballs and any registry tarball below the 2 MiB streaming threshold) does not.

Fix

  • Archiver::extract_to_dir caps preallocation at file_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_entry caps 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::extract wraps its temp directory in a TempExtractionDir RAII guard that delete_trees on drop, committed only after move_to_cache_directory succeeds. 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.ts under buffered extract: malformed tarball cleanup:

  • tar header size far exceeding body fails cleanly without leaking temp disk: a file: 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: a file: 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.pkg directory containing a 16 MiB big.bin on Linux) and pass with the fix. The rest of bun-install-streaming-extract.test.ts and bun-install-tarball-integrity.test.ts pass unchanged.

The first commit restores the is_safe_alt_name helper in src/boringssl/lib.rs so 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

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Tarball 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

Layer / File(s) Summary
Bounded archive preallocation
src/install/TarballStream.rs, src/libarchive/lib.rs
Preallocation now uses bounded archive sizes, buffer lengths, and the existing 64 MiB or 1 MiB limits.
Temporary extraction cleanup
src/install/extract_tarball.rs
A temporary-directory guard removes extraction data after failure and commits after a successful cache move.
Malformed tarball regression coverage
test/cli/install/bun-install-streaming-extract.test.ts
Tests verify failed installs, bounded disk usage, and cleanup for malformed tar and gzip inputs.

Possibly related PRs

  • oven-sh/bun#36194: Both changes address temporary-directory cleanup.
  • oven-sh/bun#36570: Both changes cap attacker-controlled preallocation in different extraction components.
🚥 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 primary changes: bounding tar-header preallocation and removing temporary directories after failed extraction.
Description check ✅ Passed The description explains the problem, cause, fix, and verification, with detailed test coverage relevant to the pull request objectives.

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

@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

CI on build #86466 (209c6d7, rebased over #36541): the only hard-red failure is buffered extract does not hold the decompressed local tarball in memory (maxRSS assertion, line 710), which is #36541's test and is also red on main's build #86465. Reported for main-break triage. This PR's two new tests (tar header size far exceeding body and fails to decompress) pass on every lane. The remaining failure is update_interactive_snapshots.test.ts tagged flaky.

The diff is green; ready for review.

@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

@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 added 5 commits August 1, 2026 00:30
… 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 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

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

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