Skip to content

Bun.write: don't destroy a file copied onto itself; honour source BunFile slice - #32859

Closed
robobun wants to merge 3 commits into
mainfrom
claude/farm/877abf2e/bun-write-file-copy-data-loss
Closed

robobun wants to merge 3 commits into
mainfrom
claude/farm/877abf2e/bun-write-file-copy-data-loss

Conversation

@robobun

@robobun robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator

Two silent data-loss bugs in Bun.write's file→file path, both resolving successfully while writing the wrong bytes.

Fixes #20462
Fixes #22456

Repro

// 1) self-copy truncation (#20462)
await Bun.write("/tmp/f.txt", "twenty-one bytes here");
await Bun.write("/tmp/f.txt", Bun.file("/tmp/f.txt"));   // resolves 0
(await Bun.file("/tmp/f.txt").text()).length;            // 0, file destroyed

// 2) source slice ignored
await Bun.write("/tmp/src.txt", "ABCDEFGHIJKLMNOPQRSTUVWXYZ");
await Bun.write("/tmp/dst.txt", Bun.file("/tmp/src.txt").slice(10, 20)); // resolves 26
await Bun.file("/tmp/dst.txt").text();                   // "ABCDEFGHIJKLMNOPQRSTUVWXYZ" (whole file)

Cause

CopyFile::do_open_file opens the destination with O_TRUNC before any same-inode check, so when source and destination resolve to the same file the open truncates the data before it is read.

write_file_with_source_destination passed destination_blob.offset / .size to CopyFile::create, so a source slice's window was never seen. Because blob.size also holds a stat size cached by resolve_size() (via .exists()/.size), a previously stat'd destination BunFile also acted as a cap (#22456). run_async treated the passed size as an end offset and never seeked the source fd.

Fix

  • Open the destination without O_TRUNC. After both fds are open, fstat the destination: if it is the same inode as the source, resolve with the file's size as a no-op; otherwise ftruncate(dest, 0) for regular-file path destinations.
  • Compute a slice_len for both source and destination blobs (treating a size that equals the store's cached stat, or 0 with an unstat'd store, as "whole file" so only an explicit .slice() bounds the copy), and cap the copy at min(source_slice_len, dest_slice_len). Seek the source fd to the source offset.
  • On macOS, use the bounded read/write loop for sliced sources since fcopyfile ignores the fd position; set read_len after fcopyfile.

Windows uses a separate CopyFileWindows path that this change does not touch; the new tests are describe.skipIf(isWindows). #31515 has the Windows slice plumbing.

Verification

New tests in test/js/bun/io/bun-write.test.js under Bun.write > file to file:

  • self-copy by path and via a hardlink leave the file intact
  • slice(a,b), slice(0,n), slice(a) each write exactly the slice (over a pre-existing larger destination)
  • the Bun.write do short write when copying file #22456 reproduction: a stat'd BunFile reused as a destination receives the full source
  • a stat'd BunFile used as an unsliced source is not capped at its cached size
  • a source whose .exists() ran before the backing file existed copies the whole file

All except the last two fail on main and pass with this change. The existing Bun.file(fd).slice(...) destination-cap tests also still pass.


no test proof · iteration 11 · 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

@coderabbitai

coderabbitai Bot commented Jun 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Fixes Bun.write file-to-file copying by selecting source ranges correctly, handling same-inode and sliced copies, explicitly truncating destinations, and adding regression tests for copy correctness.

Changes

File-to-file copy correctness

Layer / File(s) Summary
Source range selection
src/runtime/webcore/Blob.rs
CopyFile::create now receives the source blob offset and size for file-to-file copies.
Copy execution handling
src/runtime/webcore/blob/copy_file.rs
Copy handling detects same inodes, sets source offsets for slices, bounds macOS sliced copies, explicitly truncates destinations, and removes O_TRUNC from destination opening.
Bun.write regression coverage
test/js/bun/io/bun-write.test.js
Tests cover complete copies, self-writes, hardlinks, sliced sources, reused destinations, changing source sizes, and sources created before their files exist.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the two main fixes: preventing file self-copy truncation and honoring source BunFile slice boundaries during writes.
Description check ✅ Passed The description fully addresses the template with a clear explanation of what the PR does and how verification was performed, including reproductions and test coverage.
Linked Issues check ✅ Passed The PR directly fixes two linked issues (#20462 and #22456) with clear reproductions and targeted test cases that validate each bug is resolved.
Out of Scope Changes check ✅ Passed All changes remain within scope: file-copy logic improvements for the POSIX and macOS paths, test additions for the fixed scenarios, and explicit deferral of Windows support.

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

@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:18 AM PT - Aug 4th, 2026

❌ @robobun, your commit 6487977 has 1 failures in Build #88816 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32859

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

bun-32859 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Bun.write on itself causes content to be empty #20462 - Bun.write on itself causes content to be empty — exact match for Bug 1 (self-copy truncation via O_TRUNC before same-inode check)
  2. Bun.write do short write when copying file #22456 - Bun.write does short write when copying file — exact match for Bug 2 (destination blob size used as max_length instead of source size)

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #20462
Fixes #22456

🤖 Generated with Claude Code

@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 - Also fixes Bun.write(dest, Bun.file(src).slice(a, b)) writing the whole file instead of the sliced window, modifying the same Blob.rs and copy_file.rs code paths

🤖 Generated with Claude Code

Comment thread test/js/bun/io/bun-write.test.js Resolved
Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
Comment thread test/js/bun/io/bun-write.test.js Resolved
Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
Comment thread src/runtime/webcore/blob/copy_file.rs Resolved

@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: 3

🤖 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/runtime/webcore/blob/copy_file.rs`:
- Around line 1032-1035: The comment in copy_file’s slice-handling branch is too
long and exceeds the repository’s 3-line limit. Shorten the explanatory comment
in the bounded read/write path inside copy_file so it conveys the same point in
at most 3 lines, keeping the reference to fcopyfile and the
slice/non-truncatable destination behavior but removing any extra wording.
- Around line 928-940: The destination handling in copy_file’s overwrite path
should fail closed instead of ignoring identity/truncation errors. Update the
same-file check around bun_sys::fstat and the truncation call on
self.destination_fd so that any fstat or ftruncate failure returns an error and
aborts the copy rather than continuing. Keep the logic localized to the
copy_file flow and the do_copy_same_file/do_close path so success is only
reported after destination identity and truncation are confirmed.

In `@test/js/bun/io/bun-write.test.js`:
- Around line 613-626: The current hardlink same-inode test only covers a
full-file copy, so it misses regressions in the sliced self-copy path handled by
do_copy_same_file in Bun.write/copy_file logic. Extend the Bun.write hardlink
test by adding a case where Bun.file(originalPath).slice(...) is written to the
hardlink alias, and assert both paths still contain the original content with no
truncation or corruption. Keep the test alongside the existing same-inode
coverage in bun-write.test.js and reference the existing hardlink setup and
Bun.write/Bun.file behavior.
🪄 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: 3159ce2c-a058-44eb-9cce-79037ba672a1

📥 Commits

Reviewing files that changed from the base of the PR and between df92f8f and 157f820.

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

Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
Comment thread test/js/bun/io/bun-write.test.js Resolved
Comment thread test/js/bun/io/bun-write.test.js Resolved
Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
Comment thread src/runtime/webcore/blob/copy_file.rs Resolved

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

⚠️ Outside diff range comments (1)
src/runtime/webcore/blob/copy_file.rs (1)

388-417: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use a bounded loop before Linux fallback writes escape the slice.

copy_file_using_read_write_loop(..., remain, ...) is not actually bounded: after consuming remain, it keeps reading to EOF. The new total_written.min(max_length) only fixes the reported count/truncation afterward, so sliced writes to pipes/sockets//dev/null-style destinations can still receive bytes past the slice when copy_file_range/sendfile falls back.

Suggested direction
-    #[cfg(any(target_os = "macos", target_os = "freebsd"))]
+    #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "freebsd"))]
     fn do_copy_bounded(&mut self, limit: u64) -> Result<u64, bun_core::Error> {

Then use it in each Linux fallback when !unknown_size, before calling the unbounded read/write helper:

+                    if !unknown_size {
+                        match self.do_copy_bounded(remain as u64) {
+                            Ok(written) => {
+                                total_written += written;
+                                return Ok(());
+                            }
+                            Err(err) => return Err(err),
+                        }
+                    }
                     match node_fs::NodeFS::copy_file_using_read_write_loop(

Also applies to: 455-477, 533-555, 593-615

🤖 Prompt for 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.

In `@src/runtime/webcore/blob/copy_file.rs` around lines 388 - 417, The
bounded-copy path in do_copy_bounded is needed to prevent Linux fallback
read/write loops from continuing past the requested slice; update the fallback
logic in copy_file_using_read_write_loop and the related Linux branches so they
stop after remain/max_length instead of reading to EOF. Use the existing
do_copy_bounded helper (and the unknown_size guard where applicable) to enforce
the limit before any unbounded read/write helper is called, ensuring
pipes/sockets and similar destinations never receive bytes beyond the slice.
🤖 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.

Outside diff comments:
In `@src/runtime/webcore/blob/copy_file.rs`:
- Around line 388-417: The bounded-copy path in do_copy_bounded is needed to
prevent Linux fallback read/write loops from continuing past the requested
slice; update the fallback logic in copy_file_using_read_write_loop and the
related Linux branches so they stop after remain/max_length instead of reading
to EOF. Use the existing do_copy_bounded helper (and the unknown_size guard
where applicable) to enforce the limit before any unbounded read/write helper is
called, ensuring pipes/sockets and similar destinations never receive bytes
beyond the slice.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a326fd57-5715-4a01-8890-fc8ed26db733

📥 Commits

Reviewing files that changed from the base of the PR and between 157f820 and de4e389.

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

Comment thread src/runtime/webcore/Blob.rs Resolved
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Another report of the source-slice half of this landed today; I put together a minimal variant before spotting this PR. Branch farm/c73aece3/bun-write-file-slice has the CopyFileWindows piece (routes offset > 0 through the seek + read/write loop and caps the read at size, so the slice tests don't need to be skipped on Windows) in case it's useful to fold in here or into #31515. It also keeps the existing dest.slice(0, N) cap via source.size.min(dest.size) rather than removing that test, though dropping the cap as you've done is probably the cleaner semantics long-term given #22456.

Comment thread src/runtime/webcore/Blob.rs Resolved
@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: rebased onto current main, ~140 insertions, all review threads resolved.

bun-write.test.js passes on every lane across builds 88782 and 88816. The only CI reds on these builds are unrelated main breaks:

  • test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts JSC ExceptionScope assertion on x64-asan (build 88782)
  • test/js/bun/cron/in-process-cron.test.ts File Watcher EAGAIN panic on ubuntu aarch64 (build 88816)

Both reported to triage.

The slice_len heuristic at Blob.rs:4758 is asymmetric: for a source it widens to whole-file whenever size <= store.max_size (any cached-stat desync); for a destination only on an exact size == store.max_size match. .slice(0, n) on a stat'd parent where n coincides with the cached stat is indistinguishable from an unsliced stat'd blob; closing that needs an explicit is-sliced marker on Blob, deferred as out of scope.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #31515 (open since May 28, already reviewed) fixes the same sliced-source copy window, covers Windows as well, and was just rebased onto main. The part of this PR that #31515 does not cover is Bun.write(x, Bun.file(x)) truncating the source before it is read (the same-inode case). If #31515 lands first, this one should be rebased on top of it and reduced to that fix.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This hsould be approximately 20% of the code. Delete.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Cut to the minimal fix in 5dcf6ba: 127 insertions (was 474).

Kept:

  • same-inode check (no-op instead of truncate)
  • source offset/size passed to CopyFile, with cached-stat size treated as whole-file
  • max_length calc fix + source-fd seek
  • macOS: read/write loop for sliced sources (fcopyfile ignores fd position); read_len set after fcopyfile
  • 7 tests covering the two issues plus the slice matrix via it.each

Dropped: slice-onto-self, bounded copy helper, FreeBSD/Linux-fallback slice handling, and the tests that exercised those. None of that was in the original reports and all of it is no worse than current behaviour (the slice was ignored entirely before).

@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: 2

Caution

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

⚠️ Outside diff range comments (1)
src/runtime/webcore/blob/copy_file.rs (1)

933-961: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

FreeBSD's sliced-source copy still does unbounded I/O before truncating.

Passing 0 here selects copy_file_using_read_write_loop's unbounded "read to EOF" fallback (per its contract in src/runtime/node/node_fs.rs:4871-4989, where a bounded loop runs while remain > 0 then falls through to an unbounded loop that reads until EOF "we don't know the size / so we just go forever until we get an EOF"), even though self.max_length is already a concrete bound by this point (recomputed at line 828 for all platforms before reaching this block). The final content ends up correct because of the subsequent truncate, but for a small slice of a very large source file, this reads/writes the entire remainder of the source before discarding most of it — unlike the macOS branch just above, which correctly bounds the loop to self.max_length.

♻️ Proposed fix
                 match node_fs::NodeFS::copy_file_using_read_write_loop(
                     bun_core::ZStr::EMPTY,
                     bun_core::ZStr::EMPTY,
                     self.source_fd,
                     self.destination_fd,
-                    0,
+                    self.max_length as usize,
                     &mut total_written,
                 ) {
🤖 Prompt for 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.

In `@src/runtime/webcore/blob/copy_file.rs` around lines 933 - 961, Update the
FreeBSD call to NodeFS::copy_file_using_read_write_loop in the sliced-source
copy path to pass self.max_length as the read/write bound instead of 0. Preserve
the existing total_written tracking, error handling, and final truncation
behavior.
🤖 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/runtime/webcore/blob/copy_file.rs`:
- Around line 803-825: Update the same-inode handling in the copy flow to set
read_len using the existing offset/max_length clamp used later, so sliced
self-copies report only the slice length. Handle failures from bun_sys::fstat
and bun_sys::ftruncate explicitly by propagating an error or otherwise failing
closed, rather than skipping truncation or discarding the result; preserve the
early return for confirmed same-inode files.
- Around line 847-850: Handle the result of bun_sys::set_file_offset in the
offset setup within the copy flow, instead of discarding it. If repositioning
self.source_fd fails, propagate the error and stop the copy before reading,
while preserving the existing regular-file and positive-offset conditions.

---

Outside diff comments:
In `@src/runtime/webcore/blob/copy_file.rs`:
- Around line 933-961: Update the FreeBSD call to
NodeFS::copy_file_using_read_write_loop in the sliced-source copy path to pass
self.max_length as the read/write bound instead of 0. Preserve the existing
total_written tracking, error handling, and final truncation behavior.
🪄 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: 0e637af3-3781-4071-9d19-749f91c9b74c

📥 Commits

Reviewing files that changed from the base of the PR and between de4e389 and b66da11.

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

Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
Comment thread src/runtime/webcore/Blob.rs Resolved
Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
Comment thread test/js/bun/io/bun-write.test.js Resolved
Comment thread src/runtime/webcore/Blob.rs Resolved

@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: 2

🤖 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/runtime/webcore/Blob.rs`:
- Around line 4802-4805: The source-length logic around
source_store.data.as_file().max_size must preserve whether BunFile.slice() was
explicitly bounded instead of inferring unsliced state from offset and size.
Carry an explicit unsliced/bounded marker through the call, ensure explicit
slice(0, cachedSize) remains capped at the cached size, and ensure explicit
empty slices return zero even when cached_max_size is MAX_SIZE. Add regression
coverage for these empty and boundary cases.

In `@src/runtime/webcore/blob/copy_file.rs`:
- Line 918: Update the bounded-copy cleanup around ftruncate in the relevant
copy operation to retain its syscall result instead of discarding it. When
truncation fails, close both descriptors and propagate a catchable error; only
report success after truncation succeeds, while preserving existing cleanup
behavior for successful truncation.
🪄 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: f99dc224-6ed3-48e2-8fd9-8ccc81a49876

📥 Commits

Reviewing files that changed from the base of the PR and between b66da11 and db1315f.

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

Comment thread src/runtime/webcore/Blob.rs Resolved
Comment thread src/runtime/webcore/blob/copy_file.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 bugs found in this pass, but deferring to a human: this reworks the core Bun.write file→file copy path (removes O_TRUNC, adds same-inode detection, changes which blob's offset/size drives the copy), drops the destination-slice-cap semantics along with its test, and overlaps with open PR #31515. The whole-file-vs-slice heuristic in Blob.rs is acknowledged as imperfect pending an explicit is-sliced bit — worth a maintainer sign-off on that trade-off and on the semantic change.

Checked: the same-inode/ftruncate(0) block only runs for path-backed destinations and is gated on S_ISREG, so fd destinations and /dev/null are unaffected; the macOS sliced branch now truncates to read_len (fixes the empty-source-slice extend); the new failed-stat disjunct in the source-len heuristic covers the ENOENT-then-created case; new tests cover self-copy, hardlink, three slice shapes, and both stale-stat cases.

Extended reasoning...

Overview

The PR touches three files: src/runtime/webcore/Blob.rs (the write_file_with_source_destination call site — switches from passing destination blob offset/size to source blob offset/size, with a three-disjunct heuristic to distinguish an explicit slice from a cached stat), src/runtime/webcore/blob/copy_file.rs (removes O_TRUNC from destination open flags; adds an fstat-based same-inode check that no-ops instead of truncating; adds explicit ftruncate(dest, 0) for regular-file destinations; fixes the max_length computation to use saturating_sub(offset).min(max_length); seeks the source fd; on macOS, routes sliced sources through a read/write loop instead of fcopyfile), and test/js/bun/io/bun-write.test.js (deletes one destination-slice-cap test, adds a describe.skipIf(isWindows) block with 8 new tests).

Security risks

None identified. This is local file I/O with no auth, crypto, network, or untrusted-input parsing. The paths and fds come from the calling JS, which already had full filesystem access. The change does not widen any attack surface.

Level of scrutiny

High. Bun.write is a core, widely-used API and the two bugs being fixed are silent data-loss bugs — the fix itself must not introduce new ones. The PR went through ~15 review iterations in which multiple real regressions were found and fixed (Windows test gating, Linux read/write-fallback overshoot, macOS read_len not set after fcopyfile, ftruncate(0) on non-regular destinations, cached-source-stat cap, empty-source-slice ftruncate extend, ENOENT-stat source). That history alone signals the code is subtle enough to warrant human eyes on the final shape.

Other factors

  • Semantic change: Bun.write(Bun.file(dst).slice(0, N), src) previously capped the write at N bytes; it now writes the whole source. The only test asserting the old behavior was deleted. The PR description now mentions this, and the rationale (same mechanism as #22456, and the old code applied dst.offset as a source seek so it was never coherent) is sound, but it's a user-visible behavior removal that a maintainer should ratify.
  • Heuristic limitation: the source-len heuristic in Blob.rs cannot distinguish .slice(0, n) where n == store.max_size from a cached stat, and now also treats {offset:0, size:0, max_size:MAX_SIZE} as whole-file. The author agreed the real fix is an explicit is-sliced bit on Blob, deferred as out of scope. That's reasonable, but it's a design call.
  • Overlap with #31515: an open, already-reviewed PR fixes the same sliced-source window and additionally covers Windows. A robobun comment flagged that if #31515 lands first, this PR should be reduced to just the same-inode fix. A human should decide the merge order.
  • Discarded syscall results: fstat(dest), ftruncate(dest, 0), and set_file_offset(src) failures are silently ignored. This was raised by CodeRabbit, and the author intentionally dropped fail-closed handling per a scope-reduction directive. On a just-opened regular-file fd these effectively cannot fail, and pre-PR O_TRUNC never surfaced its own errno either, so it's not a regression — but it's the kind of trade-off a maintainer should confirm.
  • No CODEOWNERS hit and no outstanding unresolved threads; all inline comments are marked resolved.

Jarred-Sumner pushed a commit that referenced this pull request Aug 4, 2026
… fd (#36758)

## Repro

```sh
head -c 200000 /dev/zero | tr '\0' S > /tmp/src.bin
printf 'DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD' > /tmp/dst.bin
BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1 bun -e '
  const fs = require("fs");
  const fd = fs.openSync("/tmp/dst.bin", "r+");
  console.log(await Bun.write(Bun.file(fd).slice(0, 5), Bun.file("/tmp/src.bin")));
  fs.closeSync(fd);
'
wc -c /tmp/dst.bin
# Linux (fallback): 200000 (expected 30; whole source over-copied then ftruncated)
# macOS:            5      (expected 30; fcopyfile rewrote from 0 then ftruncate(5))
```

Same for `Bun.stdout` under `>>` redirection: the pre-existing bytes in
the redirected file are gone after the write.

## Cause

`CopyFile::run_async` dispatched fd-backed destinations through the same
path as path destinations.

- **macOS:** `fcopyfile(COPYFILE_DATA)` rewrites the destination from
offset 0, and when `stat.st_size > max_length` Bun then calls
`ftruncate(dest, max_length)` to trim the over-copied tail. Both are
safe when Bun opened the destination with `O_CREAT|O_TRUNC`, but for a
caller-supplied fd they discard whatever the file already contained.
- **FreeBSD** and the **Linux read/write fallback** (kernel < 4.5,
`EXDEV`, `ENOTSUP`, `EINVAL`, or `BUN_CONFIG_DISABLE_COPY_FILE_RANGE`):
`copy_file_using_read_write_loop` reads the source to EOF regardless of
`max_length` and then `ftruncate(dest, total_written)` sets the file
length, with the same effect.

The Linux `copy_file_range`/`sendfile`/`splice` happy path already
copies exactly `max_length` bytes and does not truncate, so this only
shows up there when the kernel syscall is unavailable for the fd pair.

## Fix

Add `read_write_loop_capped`, a bounded `read()`/`write()` loop that
transfers exactly `cap` bytes (or to EOF) and never touches the
destination's length. Route every fd-backed destination through it:

- macOS/FreeBSD `run_async`: take the `fcopyfile`/`ftruncate` branch
only when `destination_file_store.pathlike` is a path.
- Linux `do_copy_file_range`: the three identical fallback sites are
factored into `read_write_fallback`, which keeps the existing
copy-to-EOF + `ftruncate` for path destinations and uses the bounded
loop for fd destinations.

## Verification

Two tests in `test/js/bun/io/bun-write.test.js` under
`Bun.write(Bun.file(fd), Bun.file(path)) does not truncate the fd` spawn
a child with `BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1` so they fail-before
on every POSIX lane:

```
=== system bun ===
(fail) preserves bytes past the slice window in an r+ fd
  resolved: "200000", content: 200000 x "S"   (expected "5", "SSSSS" + 25 x "D")
(fail) preserves pre-existing bytes when stdout is redirected with >>
  resolved: "1000", content: 1000 bytes        (expected "100", 110 bytes)

=== this branch ===
(pass) preserves bytes past the slice window in an r+ fd
(pass) preserves pre-existing bytes when stdout is redirected with >>
```

The existing `Bun.write(Bun.stdout, Bun.stdin) copies the whole pipe`
test is widened from Linux-only to every POSIX lane, covering the
bounded loop's EOF-termination branch on macOS/FreeBSD. `cargo check -p
bun_runtime --target {aarch64,x86_64}-apple-darwin` and `--target
x86_64-unknown-freebsd` pass.

## Related

Found during review of #36692 (which routes a FIFO source through
`splice` on Linux; the `bun_opened_dest` guard it adds to the same three
fallback sites is subsumed by `read_write_fallback` here). #32859
reworks the same block to honour source slices and #33715 sets
`read_len` after `fcopyfile`; both are complementary and will need a
small rebase against whichever lands first.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 4 · 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

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
springmin pushed a commit to springmin/bun that referenced this pull request Aug 4, 2026
… fd (oven-sh#36758)

## Repro

```sh
head -c 200000 /dev/zero | tr '\0' S > /tmp/src.bin
printf 'DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD' > /tmp/dst.bin
BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1 bun -e '
  const fs = require("fs");
  const fd = fs.openSync("/tmp/dst.bin", "r+");
  console.log(await Bun.write(Bun.file(fd).slice(0, 5), Bun.file("/tmp/src.bin")));
  fs.closeSync(fd);
'
wc -c /tmp/dst.bin
# Linux (fallback): 200000 (expected 30; whole source over-copied then ftruncated)
# macOS:            5      (expected 30; fcopyfile rewrote from 0 then ftruncate(5))
```

Same for `Bun.stdout` under `>>` redirection: the pre-existing bytes in
the redirected file are gone after the write.

## Cause

`CopyFile::run_async` dispatched fd-backed destinations through the same
path as path destinations.

- **macOS:** `fcopyfile(COPYFILE_DATA)` rewrites the destination from
offset 0, and when `stat.st_size > max_length` Bun then calls
`ftruncate(dest, max_length)` to trim the over-copied tail. Both are
safe when Bun opened the destination with `O_CREAT|O_TRUNC`, but for a
caller-supplied fd they discard whatever the file already contained.
- **FreeBSD** and the **Linux read/write fallback** (kernel < 4.5,
`EXDEV`, `ENOTSUP`, `EINVAL`, or `BUN_CONFIG_DISABLE_COPY_FILE_RANGE`):
`copy_file_using_read_write_loop` reads the source to EOF regardless of
`max_length` and then `ftruncate(dest, total_written)` sets the file
length, with the same effect.

The Linux `copy_file_range`/`sendfile`/`splice` happy path already
copies exactly `max_length` bytes and does not truncate, so this only
shows up there when the kernel syscall is unavailable for the fd pair.

## Fix

Add `read_write_loop_capped`, a bounded `read()`/`write()` loop that
transfers exactly `cap` bytes (or to EOF) and never touches the
destination's length. Route every fd-backed destination through it:

- macOS/FreeBSD `run_async`: take the `fcopyfile`/`ftruncate` branch
only when `destination_file_store.pathlike` is a path.
- Linux `do_copy_file_range`: the three identical fallback sites are
factored into `read_write_fallback`, which keeps the existing
copy-to-EOF + `ftruncate` for path destinations and uses the bounded
loop for fd destinations.

## Verification

Two tests in `test/js/bun/io/bun-write.test.js` under
`Bun.write(Bun.file(fd), Bun.file(path)) does not truncate the fd` spawn
a child with `BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1` so they fail-before
on every POSIX lane:

```
=== system bun ===
(fail) preserves bytes past the slice window in an r+ fd
  resolved: "200000", content: 200000 x "S"   (expected "5", "SSSSS" + 25 x "D")
(fail) preserves pre-existing bytes when stdout is redirected with >>
  resolved: "1000", content: 1000 bytes        (expected "100", 110 bytes)

=== this branch ===
(pass) preserves bytes past the slice window in an r+ fd
(pass) preserves pre-existing bytes when stdout is redirected with >>
```

The existing `Bun.write(Bun.stdout, Bun.stdin) copies the whole pipe`
test is widened from Linux-only to every POSIX lane, covering the
bounded loop's EOF-termination branch on macOS/FreeBSD. `cargo check -p
bun_runtime --target {aarch64,x86_64}-apple-darwin` and `--target
x86_64-unknown-freebsd` pass.

## Related

Found during review of oven-sh#36692 (which routes a FIFO source through
`splice` on Linux; the `bun_opened_dest` guard it adds to the same three
fallback sites is subsumed by `read_write_fallback` here). oven-sh#32859
reworks the same block to honour source slices and oven-sh#33715 sets
`read_len` after `fcopyfile`; both are complementary and will need a
small rebase against whichever lands first.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 4 · 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

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun rebase or close

@robobun
robobun force-pushed the claude/farm/877abf2e/bun-write-file-copy-data-loss branch from db1315f to 54f8516 Compare August 4, 2026 07:59
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main as a single commit (54f8516). Resolved a conflict in the macOS block, which now uses do_read_write_loop_capped for sliced sources instead of the older overshoot-then-truncate approach; that brings the diff to 125 insertions. All 8 file to file tests pass locally, cross-target compile is clean.

Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
@robobun
robobun force-pushed the claude/farm/877abf2e/bun-write-file-copy-data-loss branch 2 times, most recently from 31c657b to 83df194 Compare August 4, 2026 08:18
Comment thread src/runtime/webcore/Blob.rs Resolved
Comment thread src/runtime/webcore/Blob.rs Resolved
Comment thread src/runtime/webcore/Blob.rs Resolved
…File slice

Fixes #20462
Fixes #22456

Bun.write(p, Bun.file(p)) opened the destination with O_TRUNC before
reading the source, so when they were the same inode the file was
truncated to zero bytes and the call resolved 0.

Bun.write(dest, Bun.file(src).slice(a, b)) passed the destination
blob's offset/size to CopyFile, so the source slice was ignored and
the whole file was written; the destination's cached size also acted
as a cap (#22456).

Open the destination without O_TRUNC. After both fds are open, fstat
the destination: if it is the same inode, resolve with the file's
size as a no-op; otherwise ftruncate a regular-file destination to 0.
Pass the source blob's offset/size (treating a size that matches the
store's cached stat, or 0 with an unstat'd store, as whole-file) and
seek the source fd before copying. On macOS, use the bounded
read/write loop for sliced sources since fcopyfile ignores the fd
position.
@robobun
robobun force-pushed the claude/farm/877abf2e/bun-write-file-copy-data-loss branch from 83df194 to be367ef Compare August 4, 2026 09:01
Comment thread src/runtime/webcore/blob/copy_file.rs Resolved
Comment thread src/runtime/webcore/Blob.rs Resolved
Comment thread test/js/bun/io/bun-write.test.js 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 new findings on 6487977. All prior review threads are resolved — the .lastModified desync test was added, and the two remaining items (sliced self-copy resolving with the full stat size, and the ungated size == cached disjunct matching an explicit f.slice(0, f.size) on a stat'd fd destination) are the acknowledged residuals of the deferred is-sliced marker. Deferring to a maintainer for that trade-off and for the cross-platform copy-path changes rather than auto-approving.

What was reviewed:

  • slice_len heuristic against every stat/desync path I'd previously traced; be367ef's is_source gating covers them.
  • The three #36758 fd-destination tests still pass with the new min(slice_len(src), slice_len(dst)) cap.
  • O_TRUNC → post-open ftruncate ordering: only applied to path destinations after the same-inode check, gated on ISREG.
Extended reasoning...

Overview

The PR fixes two silent data-loss bugs in Bun.write's file→file path (#20462 self-copy truncation, #22456 stale-stat cap) and makes source BunFile slices actually bound the copy. It touches src/runtime/webcore/Blob.rs (a new slice_len heuristic deciding whether blob.size is a real slice bound or a cached stat), src/runtime/webcore/blob/copy_file.rs (drops O_TRUNC from the destination open, adds a post-open same-inode check + ftruncate(0), seeks the source fd, and reworks the macOS branch to use the capped read/write loop for sliced sources), and adds nine tests to test/js/bun/io/bun-write.test.js.

Security risks

None identified. The change is local to file-to-file copy semantics; no parsing of untrusted input, no auth/crypto/permissions surface. The inode comparison uses st_dev+st_ino from fstat on already-open fds, which is the standard same-file check.

Level of scrutiny

High. This is a data-integrity path where a wrong branch silently writes the wrong bytes and resolves successfully. The slice_len heuristic went through four review-driven revisions today alone (rebase drop of the #36758 fd-cap, .lastModified desync, .slice(0,0) on unstat'd dest, missing test coverage), and its remaining hole — size == cached also matches an explicit .slice(0, f.size) on a stat'd fd destination, technically regressing #36758's cap for that shape — cannot be closed without adding an explicit "was sliced" marker to Blob, which was deferred as out of scope. The change also fans out across Linux (copy_file_range/sendfile/splice), macOS (clonefile/fcopyfile), and FreeBSD branches, each with different fast-path semantics.

Other factors

  • All 19 of my prior inline threads are resolved; the last requested change (the .lastModified desync test) landed in 6487977.
  • Two acknowledged residuals are explicitly "leaving for the maintainer": the sliced-self-copy read_len value, and the size == cached destination hole. Both are strict improvements over main (which destroyed the file / ignored source slices entirely), but whether to ship the heuristic-with-known-hole vs. do the structural is-sliced fix is a design call.
  • The maintainer engaged this morning ("rebase or close") and should see the final state.

Given the data-loss surface, platform fan-out, and the explicit maintainer-decision residual, this is not a change I'm comfortable auto-approving.

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #43388. It carries the same-file fix from this PR against current main, without the slice() heuristic. #41209 covers the slice() bounds with an explicit flag on the Blob.

@robobun robobun closed this Sep 18, 2026
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.

Bun.write on itself causes content to be empty Bun.write do short write when copying file

2 participants