Skip to content

Bun.Archive: extract() with a glob rejects at a file or directory entry that the file system refuses - #46646

Open
robobun wants to merge 4 commits into
mainfrom
robobun/a7c8abf7/archive-glob-extract-errors
Open

robobun wants to merge 4 commits into
mainfrom
robobun/a7c8abf7/archive-glob-extract-errors

Conversation

@robobun

@robobun robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • extract(dir, { glob }) resolves although an entry was not written: 0 entries into a read-only directory. extract(dir) rejects, and docs/runtime/archive.mdx:233 promises a throw.
  • extract_to_disk_filtered (src/runtime/api/Archive.rs:1333-1373) does Err(_) => continue after a failed make_path or openat. install: fix regressions and older bugs found after the package manager consolidation #45307 made its header and write arms reject.
  • An automated check found this. No user reported it.

Fix

  • A refused file or directory entry rejects, as in install: fix regressions and older bugs found after the package manager consolidation #45307. A file opens first and makes its parent only on ENOENT. A directory entry needs a directory at its name.
  • The rejection is the error of the refused system call (code, syscall, path). Files get the owner-write bit, so a second extract can write them.
  • Verified: test/js/bun/archive.test.ts, 19 new tests. On main 12 fail as root, 16 as uid 65534. The whole file passes.
  • Self-reviewed: 27 concerns raised, 25 addressed. Rejected: symlinks created last. It drops a link in silence.

Background

Downsides

  • With a glob, 28 of 41 probed results change for a user that is not root. 8 reject where extract(dir) resolves (Notes).
  • A 0444, 0555 or 0400 member is written as 0644, 0755 or 0600. extract(dir) still rejects with ReadError.
  • Cost: one failed openat per directory that the archive does not list. One fstatat per directory entry that exists. 1,280 bytes of .text.
Notes

Decisions to sign off. No maintainer has ruled on these. Each line says where to revert it.

What the rejection looks like. Linux, a user that is not root.

failure rejection with a glob
a file holds the name of the parent directory ENOTDIR: not a directory, open '<dest>/d/a.txt'
a directory holds the name of a file entry EISDIR: illegal operation on a directory, open '<dest>/b.txt'
the destination is read-only, nested file EACCES: permission denied, mkdir '<dest>/d'
the destination is read-only, top-level file EACCES: permission denied, open '<dest>/b.txt'
a read-only file holds the name EACCES: permission denied, open '<dest>/b.txt' (the file keeps its content)
a file name of 300 bytes ENAMETOOLONG: name too long, open '<dest>/<name>'
a file holds the name of a directory entry EEXIST: file already exists, mkdir '<dest>/x'
no free inodes, at a directory entry ENOSPC: no space left on device, mkdir '<dest>/x'

path is the destination as the caller passed it, joined with the name that the refused call was given. A failed mkdir, of a directory entry or of the parent of a file entry, names mkdir and the directory on every platform. On Windows bun_sys makes a directory with an open call, and its error names that call and carries no path. When the parent directory cannot be made, the rejection is that mkdir error and not the ENOENT of the open after it. The entries before the refused one stay on disk, and the ones after it are not written. extract(dir) stops in the same way.

Not changed. These still resolve.

  • A symlink entry that cannot be made is skipped by both call forms (Archive.rs, the SymLink arm, and create_deferred_symlinks in src/libarchive/lib.rs). extract(dir) counts it, a glob does not. A rejection there fails on a file system without symlinks. The arm is the code of main, with its EPERM | ENOENT retry. A test pins the name-taken case.
  • extract(dir) without a glob resolves and counts a directory entry that it cannot make on POSIX (src/libarchive/lib.rs, the Directory arm of extract_to_dir). TarballStream::make_directory of bun install drops it too. Both are outside this PR.
  • Hard links, a name that does not fit a path buffer, and a name that is_safe_path refuses are skipped by rule.
  • The write arm of this loop and every rejection of extract(dir) are still a bare ReadError. The draft Bun.Archive: extract() rejects with the failed syscall or libarchive's message #44509 is the change for that.

The 8 probed rows where the glob form rejects and extract(dir) resolves (uid 65534, of 41 scenarios).

  • Five directory entries that cannot be made: a lone x/ and a lone x/y/ into a read-only destination, d/ over a regular file d, a/b/ below a regular file a, x/ with no free inodes. extract(dir) resolves and counts each one, and nothing is on disk.
  • Three entries below a symlink of the same archive: l -> nope then l/a.txt, l -> b.txt then l/a.txt, l -> nope then l/sub/. With a glob the symlink is made in archive order, so the entry below it cannot be written. extract(dir) makes symlinks last: the entry becomes a file in a real directory l, and the link is dropped without an error. On main the glob form resolved and dropped the entry. A test pins one row.
  • As root, 22 of 41 glob results change, and 6 reject where extract(dir) resolves.
  • Of the 28 changed results, 19 now settle and write exactly as extract(dir), and 1 settles alike with another mode (a 0400 member: 0600 with a glob, 0644 without).

Measurements. Local release builds of main 5c6d4e24cd and of this change on top of it, Linux x64. strace, perf, valgrind and bloaty are not installed here. Syscalls come from a ptrace logger (PTRACE_SYSCALL with PTRACE_GET_SYSCALL_INFO). Allocator calls come from gdb breakpoints on the mimalloc entry points, on the work-pool thread, from the start of the extract job to its return.

  • Syscalls of one extract(dir, { glob: "**" }) into a fresh destination, main to PR:
    • 200 files in 4 directories, no directory entries: mkdirat 201 to 5, openat 201 to 205.
    • the same tree with directory entries: mkdirat 205 to 5, openat 201 to 201.
    • 200 files in 200 directories: mkdirat 201 to 201, openat 201 to 401.
    • a second extract of the tree with directory entries: mkdirat 205 to 5, openat 201 to 201, fstatat 0 to 4.
    • After the PR each count equals the count of extract(dir), but for the 4 fstatat. extract(dir) is the same on both builds.
  • Allocator calls of the job: 200 files in 4 directories mi_malloc 199 to 7 and mi_free 6,272 to 6,080. 200 files in 200 directories mi_malloc 3 to 203 and mi_free 6,076 to 6,276.
  • Refused entry, system calls before the rejection: 1 (a file in the way), 2 (read-only destination), 2 (refused directory entry, of which 1 fstatat).
  • File modes with a glob, umask 022: 3 of 6 probed modes change (0444 to 0644, 0555 to 0755, 0400 to 0600). 0600, 0644 and 0755 do not.
  • Release .text: 82,231,705 to 82,232,985 bytes (llvm-size). run_on_pool for ExtractContext 4,111 to 4,494 bytes. The new ExtractResult::refused and mkdir_error are 402 and 152 bytes. The stripped binary is 107,808,480 bytes on both.
  • The extract() job allocation: 152 to 160 bytes (llvm-objdump). Both are in the 160-byte bin of mimalloc (vendor/mimalloc/src/page-queue.c, mi_bin). The error is boxed for that reason.

Weighed.

  • One loop for both call forms, either way round. Through extract_to_dir the glob form takes that loop's modes (| 0o666: a 0600 member is written as 0644), its counts (it counts entries that it did not make) and its symlink rules, and that loop still resolves after a refused directory entry or symlink. It needs a maintainer's agreement first, and this PR does not block it.
  • Symlinks created last with create_deferred_symlinks, as extract_to_dir does. It removes the three symlink rows above, and it drops the link without an error in each of them. The open archive: refuse symlinks in the destination with openat2(RESOLVE_NO_SYMLINKS) and O_NOFOLLOW_ANY #41750 carries that decision.
  • A bare ReadError for the new exits, as extract(dir) gives. The caller then cannot tell which entry to exclude, and main gave it every other entry.
  • The retry set of extract_to_dir (EACCES | EPERM | ENOENT). A missing parent answers ENOENT on POSIX and on Windows (src/errno/windows_errno.rs), so the file arm retries on ENOENT only.

Other open PRs on this function. The first one merges cleanly today. The others conflict with main.

Tests.

  • New block "an entry that the file system refuses", 19 tests. On the canary of main: 12 fail, 3 pass, 4 skip as root, and 16 fail, 3 pass as uid 65534. The 3 that pass pin what must not change (a glob that leaves the refused entry out, a directory entry below directories the archive does not list, a symlink entry whose name is taken). On the debug build of this PR: 15 pass, 4 skip as root, and 19 pass as uid 65534.
  • Eight mutations of the new code each fail at least one test of the block as both users: skip after the second open, skip after the first open, ignore the parent mkdir error, count a directory entry whose name is taken without the check, reject a directory that exists, no make_path on ENOENT, no owner-write bit, no destination in path.
  • test/js/bun/archive.test.ts: 138 pass, 5 skip, 0 fail as root, and 142 pass, 1 skip, 0 fail as uid 65534. bun run rust:check-all: 12 targets ok. cargo clippy -p bun_runtime: no warning in the changed files.
  • Windows and macOS were not run here. The first CI run of this PR passed the file rows on Windows with the codes that the tests expect (ENOENT, EEXIST, EPERM). It failed the two directory rows that run there: the rejection named open as the system call. The second commit makes the directory arm name mkdir, and the second CI run passed on every lane. The third commit does the same for the parent of a file entry, with a row that also runs on Windows. The Windows lanes of the third CI run passed with that row.

Review history. The design review picked the in-place edit of the arms over the larger designs above. The self-review of the first build asked for three code changes. Two are in: the directory arm follows one rule (a directory at the name, or a rejection), and the rejection carries the system error. The third, symlinks created last, is rejected for the reason under Weighed. It also asked for the row table of the tests, the rows that pin the retry path, and this list of what is left.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/archive.test.ts

…ry that the file system refuses

extract_to_disk_filtered skipped a file or directory entry when its
mkdir or open failed, and the promise resolved with a smaller count.
extract() without a glob rejects in the same cases for a file entry.

- File entry: open first. If the parent directory is missing, make it
  and open once more. A failure rejects with the error of that system
  call (code, syscall, path).
- Directory entry: mkdir first. The entry is made when mkdir succeeds or
  when a directory holds the name. Anything else rejects.
- A file written with a glob gets the owner-write bit, so a later entry
  of the same name and a second extract can open it again.
- A symlink entry that cannot be made is still skipped, as without a
  glob.
- crate::Error::ReadError and OpenError had this one user and are gone.
@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:45 AM PT - Oct 24th, 2026

❌ @robobun, your commit 8f3961e has 2 failures in Build #131882 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46646

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

bun-46646 --bun

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green, and CI is red on lanes that this PR does not touch. This PR replaces the draft #44731.

CI.

  • test/js/bun/archive.test.ts has not failed on any lane since the Windows fix in b3b09c3485. The run on that commit passed on every lane.
  • The last code commit (983e6c7bc6) changes only the error of a failed parent mkdir. Linux, Windows and macOS arm64 passed it.
  • Build 131736 failed on one macOS x64 shard, in module-graph-isolation.test.ts and node-http-uaf.test.ts.
  • Build 131882 is the same tree. Two macOS jobs failed. One arm64 shard stopped before any test ran: the download of the build artifact timed out after 120 s. One x64 shard failed in node-http-uaf.test.ts again. That test also fails on main (build 131841).

How I reproduced it. Linux x64, the canary of main (5c6d4e24c), as a user that is not root. Root ignores directory permissions.

  • new Bun.Archive({ "d/a.txt": "A", "b.txt": "B", "e/g.txt": "G" }).extract(dest, { glob: "**" }) into a destination of mode 0555 resolves with 0, and nothing is on disk.
  • With a regular file d in the destination, the same call resolves with 2, and d/a.txt is missing.
  • Without glob, both calls reject with ReadError.
  • On this branch the two glob calls reject with EACCES: permission denied, mkdir '<dest>/d' and ENOTDIR: not a directory, open '<dest>/d/a.txt'.
  • The cases that do not depend on the user are tests in test/js/bun/archive.test.ts.

@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 31aedda and 8f3961e.

Files selected for processing: 2
  • src/runtime/api/Archive.rs
  • 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; 0 remain after this review.

Walkthrough

Filtered archive extraction now returns structured results. Filesystem refusals during directory and file creation produce system errors with paths relative to the extraction root. The JavaScript extraction promise rejects with converted system errors. Tests cover refusal conditions, parent creation, glob filtering, repeated writes, and symlink cases.

Changes

Filtered extraction

Layer / File(s) Summary
Extraction results and error propagation
src/runtime/api/Archive.rs, src/runtime/error.rs
Filtered extraction returns ExtractResult. Archive-open, directory-open, and header-read failures return ReadError. System errors are converted for promise rejection. The public Error enum no longer contains ReadError or OpenError.
Filesystem entry handling
src/runtime/api/Archive.rs
Directory extraction creates missing parents. File extraction adds owner-write permission and retries after creating missing parents. Filesystem refusals return system errors, and failed file-data writes return ReadError.
Extraction behavior tests
test/js/bun/archive.test.ts
Tests cover filesystem failures, glob filtering, parent creation, repeated writes, and symlink cases.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: Normal

Merge Risk: Minimal · up to 8f396

The filtered extraction change has no established merge-blocking issue; normal checks remain appropriate.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: glob-based archive extraction now rejects refused file or directory operations.
Description check Passed The description explains the problem, implementation, scope, trade-offs, and verification results. It uses headings different from the repository template, but it includes equivalent information for b…
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.
Full details: Description check

Explanation

The description explains the problem, implementation, scope, trade-offs, and verification results. It uses headings different from the repository template, but it includes equivalent information for both required sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

…ry on every platform

Windows makes a directory with an open call, so a refused directory
entry reported open as its system call there. The directory arm now
reports mkdir, as on POSIX. The test expects the Windows error code of
a directory entry below a file (ENOENT).

@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 findings, I also checked the normalized_buf[name_len] = 0 write in the Directory arm (bounded by the earlier raw_pathname.len() >= normalized_buf.len() guard, and normalization never lengthens the path) and the removal of crate::Error::ReadError/OpenError (no remaining callers in src/).

Extended reasoning...

The change rewires the glob path of Bun.Archive#extract in src/runtime/api/Archive.rs to surface refused mkdir/open calls as ErrnoException rejections, adds an owner-write bit to extracted file modes, and deletes two unused Error variants in src/runtime/error.rs, with a new table-driven test suite. It touches archive-to-filesystem path handling, which is a security-relevant surface, though the existing is_safe_path gate is unchanged. The confirmed inline findings (mode change for read-only members, Windows syscall/path parity for the parent-mkdir failure, and the still-silent symlink arm) plus the author-flagged open design decisions mean a human should weigh in.

Findings marked 🟡 are optional suggestions and need no follow-up push.

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

  • 🟣 src/runtime/api/Archive.rs — pre-existing: a caller using extract(dir, { glob }) whose symlink entry is refused still gets a resolved promise with the link silently missing, while file and directory refusals now reject. The SymLink arm at src/runtime/api/Archive.rs:1421-1432 does continue on every errno (EACCES, EROFS, ENOSPC, EEXIST) and only retries EPERM/ENOENT. Fix: in this same loop, return ExtractResult::refused for a symlink refusal on any errno other than the ones the arm deliberately tolerates, keeping the EPERM skip the author relies on for file systems without symlinks, so a read-only or full destination fails loudly for every entry kind.

    Why this was flagged

    An archive whose matching entries include a symlink is extracted with extract(dest, { glob: "**" }) into a destination that is read-only or out of space, through ExtractContext::do_run -> extract_to_disk_filtered (src/runtime/api/Archive.rs:722-729). bun_sys::symlinkat at src/runtime/api/Archive.rs:1421 fails with EACCES or ENOSPC; the arm hits else { continue; } at src/runtime/api/Archive.rs:1431 and the loop goes on. The promise resolves with a count that omits the link and no error, exactly as on the base branch, while the File and Directory arms in the same match (src/runtime/api/Archive.rs:1365 and 1397) now reject via ExtractResult::refused. The PR text names this as a decision, citing EPERM on file systems without symlinks; that reason covers EPERM only, not EACCES, EROFS, ENOSPC or EEXIST, which the arm swallows as well.

    Verification: Pre-existing. Trigger: extract(dest, { glob }) where a matching symlink entry hits any errno other than EPERM/ENOENT. At src/runtime/api/Archive.rs:1421-1432 every refusal falls to continue, so the function reaches ExtractResult::Success(count) at line 1443 and the promise resolves. The test at archive.test.ts:2512-2517 pins exactly this. The base's SymLink arm is byte-identical.

Comment thread src/runtime/api/Archive.rs Resolved
Comment thread src/runtime/api/Archive.rs Resolved
…ntry cannot be made

On Windows the error of a failed mkdir names an open call and carries no
path, so a file entry whose parent directory could not be made rejected
with the open call and the path of the file. Both arms now build that
error in one place: the system call is mkdir, and the path is the
directory.
@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Reply to the review of b3b09c3485.

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants