Skip to content

Bun.write(file, file): check the source before opening the destination - #38186

Open
robobun wants to merge 1 commit into
mainfrom
farm/31b8bafb/bun-write-check-source-before-truncating-dest
Open

Bun.write(file, file): check the source before opening the destination#38186
robobun wants to merge 1 commit into
mainfrom
farm/31b8bafb/bun-write-check-source-before-truncating-dest

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.write(dest, Bun.file(dir)) rejects with That doesn't work on folders, but dest is 0 bytes afterwards. Same with a directory fd as the source, with a FIFO source on Linux (Non-regular files aren't supported yet), and with a Bun.file(dest) destination; a destination that did not exist is left behind as an empty file.
  • Cause: CopyFile::do_open_file in src/runtime/webcore/blob/copy_file.rs opened the source and right after it the destination with O_CREAT | O_WRONLY | O_TRUNC. The source fstat, the directory check and the Linux source-kind dispatch (where the FIFO rejection comes from) all ran later, in run_async.
  • fs.copyFile and cp refuse the same inputs without touching the destination, and Bun.write with a missing source already did, because the source open happened first.

Fix

  • run_async now opens the source (if it is a path), fstats it, rejects a directory, picks the Linux copy syscall, and only then calls open_destination(). The position of that call is the fix; the checks themselves are the existing ones.
  • do_open_file<IOWhich> is split into open_source / open_destination. Its Both mode encoded the old order; the dest-open failure path now closes a Bun-opened source through the existing do_close().
  • pick_copy_syscall is the dispatch block's three conditions, unchanged, returning None for the unsupported case so it can be evaluated before the destination exists; the dispatch block now matches on its result.
  • The macOS clonefile attempt, size clamp, preallocate and copy code are unchanged apart from indentation. The dead if destination is Fd {} block and the redundant system_error.is_some() check in the rewritten region are removed.
  • Windows default path (uv_fs_copyfile) never touches the destination for an unusable source (probed with a directory, a directory fd and a missing source), so it is unchanged.
  • Windows read/write fallback (prepare_read_write_loop) had the same order: under BUN_FEATURE_FLAG_DISABLE_UV_FS_COPYFILE=1 a missing source truncated the destination. It now opens the source first, and closes it before a mkdirp retry because copyfile() re-runs from the top. It still cannot recognise a directory source at open time (GetFileType reports directories as files); with a path source that fallback is only reachable under the test flag, so that is left alone.
  • Verification:
    • test/js/bun/io/bun-write.test.js, Bun.write(dest, Bun.file(unusable source)) leaves the destination alone: directory source for {path, Bun.file} destination x {path, fd} source, directory source with an absent destination, FIFO source (Linux only; macOS copies FIFOs through its read/write fallback), missing source. The six directory/FIFO cases fail on the released build and pass with this change. The missing-source case passes on POSIX either way; it is there for the Windows fallback, where it failed before (the file re-runs itself with the flag on Windows).
    • Windows x64 debug build: the file passes in both modes.
    • cargo check -p bun_runtime for aarch64-apple-darwin and x86_64-unknown-freebsd, cargo clippy -p bun_runtime, cargo fmt --check: clean.
  • Related open PRs, not duplicates: Bun.write: don't destroy a file copied onto itself; honour source BunFile slice #32859 (copying a file onto itself) moves the truncation to an explicit ftruncate after the source fstat, which would still create an absent destination and still truncate before the Linux FIFO rejection. Bun.write(file, Bun.stdin): splice a pipe source into any destination on Linux #36692 would make Linux copy FIFO sources; if it lands, the FIFO case here becomes a successful copy and that test goes away. blob: copy the source slice window in Bun.write(file, file.slice()), not the whole file #31515 (source slice windows) contains the same Windows prepare_read_write_loop reorder as part of its Windows plumbing but leaves the POSIX order alone; whichever lands second has a trivial conflict in that one Windows function.

Background

  • A file-to-file Bun.write is handled by CopyFile (POSIX) and CopyFileWindows, not by the generic blob write path. CopyFile::run_async runs on the work pool and reports through self.system_error: set it and return, and then() rejects the promise with it.
  • Each side is either a path, which Bun opens and closes itself, or a caller-supplied fd (Bun.stdout, Bun.file(fd)), which Bun never opens, truncates or closes. That is why both opens are conditional on *_fd == Fd::INVALID and why do_close() only closes what Bun opened.
  • On Linux the copy uses copy_file_range (regular to regular), splice (pipe to pipe) or sendfile (regular file, char device or socket source), each with a read/write fallback; any other source kind is rejected. The choice depends only on the source's st_mode and the destination store's cached mode, so it can be made before the destination is opened.
  • destination_file_store.mode is 0 unless that BunFile was stat'ed earlier (.size, .lastModified, or an fd store like Bun.stdout); pick_copy_syscall keeps the existing rule that 0 counts as a regular file.
Local note on an unrelated test in the same file

should work when copyFileRange is not available > on large files exceeds the 5 s default per-test timeout in my environment with both the released and the patched build (it moves 256 MB through the disk; CI runs the file with a much larger per-test timeout). #37792 is already about that test.


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/io/bun-write.test.js

CopyFile opened the destination with O_CREAT | O_TRUNC and only then
fstat'ed the source, so a directory source ("That doesn't work on
folders"), a source kind Linux can't copy ("Non-regular files aren't
supported yet"), or a failing fstat rejected the write after the
destination had already been emptied (or created).

Open the source, fstat it, and pick the Linux copy syscall first; the
destination is opened only once the source is known to be usable. The
Windows read/write fallback had the same order and now opens the source
first as well.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 29 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 60fd482b-1d89-46f9-89cb-8c0214e5a5e1

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and 819807e.

📒 Files selected for processing (2)
  • src/runtime/webcore/blob/copy_file.rs
  • test/js/bun/io/bun-write.test.js

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 reviewed this PR and didn't find any bugs. Because it reorders the open/validate/open sequence across four platform branches in CopyFile::run_async (a core Bun.write path), a human look would still be worthwhile.

Checked: open_source/open_destination/pick_copy_syscall are the old do_open_file<IOWhich> and Linux dispatch bodies verbatim; every new early-return path (fstat fail, ISDIR, pick_copy_syscall → None, open_destination fail) calls do_close() so the Bun-opened source fd is released; Retry::Fail still sets system_error via mkdir_if_not_existsset_system_error. The macOS clonefile guard (both pathlikes are Path) is equivalent to the old both fds INVALID — the block is only de-indented. On Windows, read_write_loop.close() before mkdirp() correctly resets must_close_source_fd/source_fd so the copyfile() re-entry re-opens the source without leaking; prepare_pathlike only sets must_close on success, so throw() after a failed source open is a no-op close.

Extended reasoning...

Overview

Fixes a data-loss bug in file-to-file Bun.write: when the source turns out to be unusable (a directory, or on Linux a FIFO → regular file), the destination was already opened O_CREAT|O_TRUNC and left as a 0-byte file. The fix reorders run_async to open the source, fstat it, run all source-rejection checks, and only then open the destination. do_open_file<IOWhich> is split into open_source/open_destination; the Linux syscall-choice conditions are hoisted into pick_copy_syscall so the FIFO rejection can happen before the destination exists. The Windows read/write fallback (prepare_read_write_loop) gets the same reordering, plus a read_write_loop.close() before the mkdirp retry so the already-opened source fd doesn't leak when copyfile() re-enters. Tests cover the {path, Bun.file} × {path, fd} directory-source matrix, an absent destination, a Linux FIFO source, and a missing source (which also exercises the Windows fallback via the file's self-rerun under BUN_FEATURE_FLAG_DISABLE_UV_FS_COPYFILE=1).

Security risks

None. This is local file I/O ordering; no untrusted-input parsing, no permission or auth logic. The destination-open flags and mode are unchanged.

Level of scrutiny

High — CopyFile::run_async is the production path for Bun.write(BunFile, BunFile) on every POSIX target, with distinct cfg branches for macOS (clonefile/fcopyfile), Linux/Android (copy_file_range/splice/sendfile), FreeBSD, and a separate Windows implementation. Most of the ~250-line Rust diff is de-indentation of the macOS clonefile block (previously nested under if both fds INVALID) and moving code into helpers verbatim, but the reordering itself needs the fd-lifecycle and error-path audit that REVIEW.md calls for.

Other factors

I traced every new early-return in run_async and confirmed each releases the Bun-opened source via do_close() (which is guarded on pathlike being a Path and the fd being valid, so caller-supplied fds are untouched). The Retry::Fail arm in open_destination no longer closes the source inline, but its only caller now calls do_close() on Err, and mkdir_if_not_exists still sets system_error on that path so then() rejects. The removed if system_error.is_some() { return; } was dead (every system_error-setting path in the old do_open_file also returned Err), and the removed empty if let Fd(_) = dest.pathlike {} block did nothing. The macOS clonefile guard change from both fds INVALID to both pathlikes are Path(_) is equivalent given the two if let Fd(fd) assignments immediately above. On Windows, prepare_pathlike only sets must_close after a successful open, so the new source-first order doesn't leak on failure, and the added close() before mkdirp() handles the one case where the source was opened but the destination open failed with ENOENT. Tests use tempDir, close their fds in finally, hold the FIFO open r+ so Bun's O_RDONLY open doesn't block, and assert exact rejection messages plus untouched destination content. The PR description documents fail-before verification and cross-target cargo check. Given the size and the four-way platform branching, deferring rather than approving.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. blob: copy the source slice window in Bun.write(file, file.slice()), not the whole file #31515 - Contains the same CopyFileWindows::prepare_read_write_loop reorder (source opened before the destination, with the same read_write_loop.close() before mkdirp) for the same reason: a missing/unreadable source should reject without touching the destination.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:32 AM PT - Aug 13th, 2026

@robobun, your commit 819807e075ec23026b019c50b6acfe28f67f51b1 passed in Build #94716! 🎉


🧪   To try this PR locally:

bunx bun-pr 38186

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

bun-38186 --bun

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #31515: that PR adds source slice windows and, as part of its Windows plumbing, reorders prepare_read_write_loop the same way this one does (source opened first, read_write_loop.close() before the mkdirp retry). It does not change the POSIX CopyFile order, which is what this PR is about: on POSIX the destination was opened with O_TRUNC before the source fstat, the directory check and the Linux source-kind dispatch, so those rejections emptied (or created) the destination. The overlap is that one Windows function; whichever lands second has a trivial conflict there. Noted in the description.

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