Skip to content

Bun.file().slice(): reject delete()/writer()/write(), fix exists(); cap chardev slice reads - #35822

Open
robobun wants to merge 5 commits into
mainfrom
claude/farm/20557d44/file-slice-read-clamp-write-reject
Open

Bun.file().slice(): reject delete()/writer()/write(), fix exists(); cap chardev slice reads#35822
robobun wants to merge 5 commits into
mainfrom
claude/farm/20557d44/file-slice-read-clamp-write-reject

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

Three file-backed Blob.slice() defects:

// 1. buffered read of a character-device slice over-reads to the next 64 KiB multiple
(await Bun.file('/dev/urandom').slice(0, 1_000_000).bytes()).length
// => 1048576, expected 1000000

// 2. delete()/write()/writer() on a sliced Bun.file() act on the whole file
await Bun.write('/tmp/a', '0123456789');
await Bun.file('/tmp/a').slice(2, 5).delete();
// => /tmp/a is gone; same for .write('x') (file becomes 'x') and Bun.write(slice, ...)

// 3. exists() on a slice returns false for a file that exists
await Bun.file('/tmp/a').slice(2, 5).exists()
// => false (stat() on the same slice succeeds)

Why

Over-read: ReadFile::do_read_loop caps each read by max_length.saturating_sub(read_off), but read_off is initialised to 0 and never advanced on the POSIX path (the only read_off += is on the Windows ReadFileUV branch). A chardev slice sizes its initial buffer at min(max_length, 4096), so the loop lands on the 64 KiB stack buffer and appends whole 65536-byte reads until buffer.len() >= max_length without the cap ever shrinking.

Whole-file mutation on a slice: .slice() dupe()s the parent's pathlike store and records only offset/size. WriteFile opens O_WRONLY|O_CREAT|O_TRUNC and never threads the offset; do_unlink calls unlink(path); writer() opens the same path at position 0. validate_writable_blob only rejected Bytes-backed destinations, so a file-backed slice reached every destructive path.

exists() false on a slice: get_exists_sync tests ISREG(file.mode) || ISFIFO(file.mode), where file.mode is filled in by resolve_size(). A slice has a concrete size, so the size == MAX_SIZE gate skipped resolve_size() and mode stayed 0.

How

  • Advance self.read_off += read_amount per iteration in do_read_loop, matching the Windows path. remaining_buffer's cap now shrinks correctly and the final buffer is exactly max_length bytes.
  • Add Blob.is_sliced_view: Cell<bool>, set by get_slice_from and preserved by dupe(), the manual clone literal, and structured-clone serialization (SERIALIZATION_VERSION bumped to 5). validate_writable_blob and write_file_internal both reject when it is set, with the message hoisted to a single const. This covers Bun.write(), .write(), .writer(), .delete()/.unlink(), and Bun.Image#write(). An explicit flag avoids the false positive that keying on size != MAX_SIZE would have after .size is read on an un-sliced Bun.file().
  • Stat the file in get_exists_sync when seekable is still None, gated on !size_was_unresolved so a nonexistent un-sliced path still issues exactly one stat.

Verification

$ bun bd test test/js/bun/util/bun-file.test.ts
23 pass (the slice-is-read-only, structuredClone, exists(), and chardev-length groups all fail on main)
$ bun bd test test/js/bun/io/bun-write.test.js -t 'file-file'
the existing sliced-destination case now asserts the TypeError instead of the truncated output

blob.test.ts (46 tests), blob-write.test.ts, bun-file-read.test.ts, structured-clone-blob-file.test.ts and blob-file-name-ownership.test.ts pass with the debug build. The four timeouts in bun-write.test.js reproduce identically on an unpatched debug/ASAN build.

The streaming face of the chardev slice bug (sliced .stream() never closes after delivering max_size) is covered by #31680. #35815 overlaps on the sliced-destination rejection (different shape, offset != 0 only).


no test proof · iteration 5 · 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 Jul 25, 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: 11 minutes

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: f1a1d41e-140a-4293-8220-08f851ff4a10

📥 Commits

Reviewing files that changed from the base of the PR and between 2a5855b and 78eecc1.

📒 Files selected for processing (5)
  • src/jsc/webcore_types.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/blob/read_file.rs
  • test/js/bun/io/bun-write.test.js
  • test/js/bun/util/bun-file.test.ts

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:46 PM PT - Jul 27th, 2026

@robobun, your commit 78eecc1 has 2 failures in Build #83732 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35822

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

bun-35822 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Bun.write: reject a sliced Bun.file() destination instead of truncating the whole file #35815 - Rejects sliced Bun.file() write destinations (same sliced-write-clobber fix)
  2. Bun.file().slice(): enforce the slice window on unknown-size sources (chardev over-read + stream hang) #31680 - Enforces slice window on chardev reads (same chardev over-read fix)
  3. Honor Blob slice offsets on non-seekable sources #33510 - Honors Blob slice offsets on non-seekable sources (same chardev over-read fix)

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

On the duplicate flags:

The read_off advance in do_read_loop (the buffered over-read half) is not covered by any of the three.

Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated

@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 moving the check into write_file_internal — that closes the Bun.Image#write gap I flagged. I'm not auto-approving because this introduces a new user-facing throw (API decision: reject vs. implement positional writes) and overlaps with three open PRs (#35815/#31680/#33510) that a human should reconcile. The two comment-cop flags on the doc comment at L5310-5315 are also still outstanding.

Extended reasoning...

Overview

Two fixes for file-backed Blob.slice(): (1) a one-line read_off += read_amount in ReadFile::do_read_loop so chardev slice reads stop at exactly max_length instead of the next 64 KiB multiple, mirroring the existing Windows ReadFileUV path; (2) a new reject_sliced_file_destination guard called from write_file_internal and get_writer that throws a TypeError when the destination blob has offset != 0 on a file-backed store, preventing silent O_TRUNC clobber. Tests cover boundary sizes (1/4095/4096/4097/65535/65536/65537/1_000_000) and all three write entry points plus a false-positive guard (.size read stamping blob.size).

What changed since my last review

Commit 3f7b320 addressed my prior nit: the check moved from three JS entry points into write_file_internal's per-blob validation block, so Bun.Image#write and any future callers are covered. get_writer retains its own call since it doesn't route through write_file_internal. The redundant entry-point calls were removed.

Security risks

None identified. The read-side fix strictly reduces bytes returned (no over-read of adjacent memory — the over-read was of the device stream, not a buffer). The write-side change refuses an operation that previously silently destroyed data, which is a safety improvement.

Level of scrutiny

Medium. The read_off advance is mechanically obvious and mirrors Windows. The write-side rejection is a user-facing API behavior change — code that previously (incorrectly) succeeded now throws. That's the right call given silent data loss, but throw-vs-implement-pwrite is a design decision worth a human sign-off.

Other factors

  • Three flagged overlapping PRs; the author's reply explains the non-overlap for #31680/#33510 and partial overlap with #35815, but a maintainer should decide landing order.
  • The comment-cop bot flagged the 6-line doc comment on reject_sliced_file_destination twice; those are unaddressed. It reads to me as legitimate why-documentation (invariant + intentional unlink() exemption) rather than a workaround justification, but the repo's own linter disagrees.
  • Verified the read_off cast is sound: read_amount: usize ≤ 64 KiB per iteration, well within SizeType.
  • Checked that keying only on offset != 0 (not size) avoids the .size-read false positive the test covers, and that slice(0, n) destinations still pass through (offset 0) — those still O_TRUNC but at least write from position 0, which is arguably intentional.

@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 moving the check into write_file_internal — that closes the Bun.Image#write gap I flagged earlier, and blob.writer() keeping its own call is right since it doesn't route there. I didn't find any correctness issues in this revision, but deferring to a maintainer for the API-surface decision (new synchronous TypeError on sliced destinations) and to reconcile with the three overlapping open PRs (#35815 / #31680 / #33510).

What was reviewed:

  • read_off += read_amount in do_read_loop — mirrors the Windows ReadFileUV::on_read advance; remaining_buffer's max_length.saturating_sub(read_off) cap now shrinks correctly.
  • reject_sliced_file_destination placement — now inside write_file_internal's per-blob block (covers Bun.write, blob.write, Image#write, S3 callers unaffected since gated on store::Data::File) plus get_writer.
  • Checked that keying on offset != 0 alone means .slice(0, N) destinations still pass through and O_TRUNC — deliberate per the description to avoid .size-read false positives, but worth a maintainer nod.
Extended reasoning...

Overview

Two independent fixes to file-backed Blob.slice(): (1) a one-line advance of self.read_off in the POSIX ReadFile::do_read_loop so the per-read cap max_length - read_off actually shrinks, fixing chardev slice reads that overshot to the next 64 KiB multiple; (2) a new reject_sliced_file_destination helper that throws a TypeError when a file-backed blob with offset != 0 is used as a write destination, called from write_file_internal and blob.writer(). Tests added for both in bun-file.test.ts (chardev boundary matrix over 1/4095/4096/4097/65535/65536/65537/1_000_000) and blob-write.test.ts (three entry points + a false-positive guard for unsliced files after .size is read).

Since my previous review, the author moved the sliced-destination check from three per-entry-point call sites into the shared write_file_internal helper (3f7b320), which now also covers Bun.Image#write. The doc comment on the helper was trimmed to two lines (0f3c199) after comment-cop flagged it. All prior threads are resolved.

Security risks

None identified. The read-side change tightens a bound (reads fewer bytes than before, never more). The write-side change adds validation that fails closed. No untrusted-input parsing, no auth/crypto surface.

Level of scrutiny

Moderate. The read_off fix is a one-liner that mirrors the existing Windows path and is straightforwardly correct. The write-side change is small but introduces a new user-facing error on a Bun-native API — that's an API-surface decision (throw now vs. implement positional writes later) that a maintainer should sign off on, per the repo's "API design" review guidance.

Other factors

  • Overlapping PRs: the duplicate-finder flagged #35815 (same sliced-destination rejection, placed in write_file_internal), #31680 (streaming face of the chardev bug), and #33510 (non-seekable source offsets). The author's overlap analysis is coherent, but a human needs to decide which combination to merge/close.
  • Intentional gap: .slice(0, N) as a destination (offset 0, size set) still passes the check and will O_TRUNC + write past N. The description explains this is deliberate to avoid false-positiving after .size stamps blob.size, and there's a regression test for that. Reasonable tradeoff, but worth maintainer confirmation.
  • Test evidence: the PR body's "release without fix" gate output was actually built from 31edb2b (this PR's first commit), so it doesn't demonstrate release-build failure on true main — a gate-tooling artifact, not a code concern. The ASAN-without-fix run does show 3 failures as expected.

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

The earlier nit (Bun.Image#write bypassing the guard) is addressed — the check now lives in write_file_internal and get_writer. I didn't find further issues, but this adds a new user-facing throw on a previously-silent path and overlaps with #35815/#31680/#33510, so leaving the merge-vs-consolidate call to a maintainer.

Checked: read_off advance matches the Windows ReadFileUV path and remaining_buffer's cap now shrinks per iteration; the new guard keys on offset != 0 and store::Data::File(_) only, so S3 destinations and .size-stamped unsliced files are unaffected (covered by the new test); unlink()/delete() remain unguarded as stated.

Extended reasoning...

Overview

Two independent fixes to file-backed Blob.slice():

  1. read_file.rs (1 line): advance self.read_off += read_amount per iteration in do_read_loop, so remaining_buffer's max_length - read_off cap actually shrinks. Mirrors the existing Windows ReadFileUV::on_read path.
  2. Blob.rs (~15 lines): new reject_sliced_file_destination helper throwing a TypeError when a file-backed blob with offset != 0 is used as a write destination. Called from write_file_internal (covers Bun.write, blob.write, Bun.Image#write) and get_writer (covers blob.writer()).

Tests cover the boundary lengths for the chardev over-read (/dev/zero, POSIX-only) and all three write entry points plus the .size-read false-positive.

Security risks

None identified. The read-side change tightens an over-read (returns fewer bytes than before, never more). The write-side change fails closed where the previous behavior was silent data loss.

Level of scrutiny

Medium. The mechanical changes are small and well-tested, but write_file_internal is a shared entry point for Bun.write, blob.write, and Bun.Image#write, and the new throw is a user-visible API behavior change (previously-silent code now throws synchronously). REVIEW.md flags user-facing API surface changes as needing maintainer sign-off, and the error message codifies that positional writes are out of scope for Bun.write ("use pwrite/write" instead).

Other factors

  • My prior inline comment (Image#write bypass) was addressed in 3f7b320 by moving the guard into write_file_internal; verified only two call sites remain (Blob.rs:1715, Blob.rs:5016).
  • The guard checks store::Data::File(_) explicitly, so S3-backed destinations reaching write_file_internal from S3File.rs/S3Client.rs are unaffected.
  • Three overlapping open PRs (#35815 same reject shape, #31680 streaming face, #33510 non-seekable source offsets) — a maintainer should decide which combination to land rather than an auto-approval racing them.
  • Comment-cop feedback was addressed (doc comment trimmed to two lines in 0f3c199).

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: build 83732 is 192/196 green. The remaining failures are unrelated to this diff: no-orphans.test.ts (perl daemon reaping timeout on darwin-x64, same flake as build 81654) and serve.test.ts request-body backpressure EPIPE (also fails on main), plus four [flaky]-tagged tests in install/bake/fetch-leak/websocket-server. None of the edited test files (bun-file.test.ts, bun-write.test.js, blob.test.ts, structured-clone-blob-file.test.ts) failed anywhere.

Ready for review.

robobun added 2 commits July 28, 2026 01:06
A byte-range slice of a Bun.file() keeps the parent's pathlike store, so
.delete() unlinked the whole file and .writer()/.write()/Bun.write() opened
it with O_TRUNC and wrote at offset 0, ignoring the window. A slice's
.exists() also returned false because resolve_size() is gated on
size == MAX_SIZE and a slice has a concrete size.

Add Blob.is_sliced_view, set in get_slice_from, and reject it in
validate_writable_blob (covers do_unlink, get_writer, do_write, and
Bun.write via write_file) and in write_file_internal for the callers
that bypass validate_writable_blob. exists() now stats the file when
the store has never been resolved.
@robobun
robobun force-pushed the claude/farm/20557d44/file-slice-read-clamp-write-reject branch from 2c90c5a to 6f06ec6 Compare July 28, 2026 01:09
Comment thread src/jsc/webcore_types.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
@robobun robobun changed the title Cap chardev slice reads at the requested length; reject sliced Bun.file() write destinations Bun.file().slice(): reject delete()/writer()/write(), fix exists(); cap chardev slice reads Jul 28, 2026
Comment thread src/jsc/webcore_types.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked per discussion: the guard now lives in validate_writable_blob keyed off a new Blob.is_sliced_view flag set in get_slice_from, so .delete(), .writer(), .write() and Bun.write(dest=slice, ...) all throw for any slice (including slice(0, n)). Also fixes the slice().exists() false negative. The chardev read_off fix and test are retained from the previous revision.

Supersedes #35815 (which keyed off offset != 0 only and left .delete()/.writer() untouched).

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/runtime/webcore/Blob.rs:5028-5032 — The if blob.is_sliced_view.get() { return Err(global_this.throw_invalid_arguments(format_args!("A sliced Bun.file()..."))) } block appears verbatim here and again in validate_writable_blob (L5331-5335). Consider extracting the ~140-char message to a const SLICED_VIEW_MSG: &str (or a small throw_sliced_view(global) helper) and using it at both sites — REVIEW.md: "The second time a multi-line block appears in your diff, extract a named helper and use it at EVERY parallel site." Tests assert on the message substring, so a future wording tweak that only touches one site would silently drift.

    Extended reasoning...

    What

    This diff introduces the identical 5-line rejection block in two places:

    write_file_internal (Blob.rs:5028-5032):

    if blob.is_sliced_view.get() {
        return Err(global_this.throw_invalid_arguments(format_args!(
            "A sliced Bun.file() is a read-only byte-range view; delete() and write() would affect the whole file. Use the un-sliced Bun.file() instead."
        )));
    }

    validate_writable_blob (Blob.rs:5331-5335):

    if blob.is_sliced_view.get() {
        return Err(global_this.throw_invalid_arguments(format_args!(
            "A sliced Bun.file() is a read-only byte-range view; delete() and write() would affect the whole file. Use the un-sliced Bun.file() instead."
        )));
    }

    Byte-for-byte identical, including the ~140-character error string.

    Why it matters

    REVIEW.md, Code style & idioms reviewers enforce: "The second time a multi-line block appears in your diff, extract a named helper and use it at EVERY parallel site." Both occurrences were added by this PR (the second one was added when the Bun.Image#write review comment moved a copy of the check into write_file_internal), so the rule applies directly.

    The tests in test/js/bun/util/bun-file.test.ts assert on message content — expect((err as Error).message).toContain("sliced Bun.file()") — at four call sites. If a future wording change touches only validate_writable_blob (which covers .delete()/.writer()/.write()/Bun.write), the write_file_internal copy (which covers Bun.Image#write / Bun.s3.file().write) would silently drift and the tests would not catch it, since the tests only exercise the validate_writable_blob paths.

    Step-by-step

    1. Both blocks are added in this diff (neither existed before).
    2. They are structurally identical: same guard (blob.is_sliced_view.get()), same throw helper (throw_invalid_arguments), same literal message.
    3. A reviewer changing the message in one place has no compile-time signal that the other exists; rg for the string would find both, but the repo convention is to make that unnecessary by construction.
    4. The two sites cannot be collapsed to one call site — write_file_internal is reached by callers (Bun.Image#write, S3) that bypass validate_writable_blob, per the comment at L5025-5027 — so the deduplication has to be at the message/helper level, not the check-placement level.

    Fix

    Either:

    const SLICED_VIEW_READONLY: &str = "A sliced Bun.file() is a read-only byte-range view; delete() and write() would affect the whole file. Use the un-sliced Bun.file() instead.";

    and use format_args!("{SLICED_VIEW_READONLY}") (or format_args!("{}", SLICED_VIEW_READONLY)) at both sites, or lift the whole guard into a small helper:

    fn reject_sliced_view(global: &JSGlobalObject, blob: &Blob) -> JsResult<()> {
        if blob.is_sliced_view.get() {
            return Err(global.throw_invalid_arguments(format_args!(SLICED_VIEW_READONLY)));
        }
        Ok(())
    }

    and call reject_sliced_view(global_this, blob)?; in both write_file_internal and validate_writable_blob.

    Impact

    None on behavior — pure maintainability. Not blocking.

  • 🟡 src/runtime/webcore/Blob.rs:5328-5335 — The new is_sliced_view field is copied by dupe_with_content_type and the L3351 struct literal, but is not written by _on_structured_clone_serialize (Blob.rs:714-782) nor set by on_structured_clone_deserialize (Blob.rs:4131-4307). So structuredClone(Bun.file(p).slice(2, 5)) — or the same via postMessage to a Worker — yields a file-backed Blob with offset/size restored but is_sliced_view = false, and .delete()/.write()/.writer() on that clone pass this guard and clobber the whole file. Not a regression (pre-PR the clobber happened in-thread too), but REVIEW.md's "new struct field → audit every serialization pair" applies; safest fix is bumping SERIALIZATION_VERSION to 5 and writing the flag byte.

    Extended reasoning...

    What the bug is

    The PR adds Blob.is_sliced_view: Cell<bool> and threads it through Default, dupe_with_content_type, and the manual struct literal at Blob.rs:3351, then keys validate_writable_blob (L5331) and the write_file_internal guard (L5028) off it. But the structured-clone serialization pair was missed: _on_structured_clone_serialize writes version, offset, content_type[/_was_set], store_tag, [size (v4) + store payload], is_jsdom_file, last_modified, [name] — and never is_sliced_view. on_structured_clone_deserialize reconstructs the file-backed blob via find_or_create_file_from_path (which defaults is_sliced_view to false), then restores offset (L4279) and size (L4280-4284), but never sets is_sliced_view.

    The specific code path

    await Bun.write(p, "0123456789");
    const clone = structuredClone(Bun.file(p).slice(2, 5));
    await clone.delete();          // unlinks the whole file
    // or: await clone.write("XY") — truncates the whole file to "XY"

    The same applies to worker.postMessage(Bun.file(p).slice(2, 5)) followed by .delete()/.write()/.writer() on the received value.

    Why the existing guards don't catch it

    Both new guards test blob.is_sliced_view.get() alone. The deserialized blob has offset = 2, size = 3, store = Data::File(pathlike), and is_sliced_view = false (from Default), so:

    • validate_writable_blob (L5325-5335): store is File, not detached, not Bytes, is_sliced_view is false → Ok(()).
    • write_file_internal (L5022-5030): store is File, not detached, is_sliced_view is false → proceeds to open with O_TRUNC.
    • do_unlink: validate_writable_blob passes → file.unlink(pathlike) removes the whole path.

    Step-by-step proof

    1. Bun.file(p).slice(2, 5)get_slice_from dupes the parent, sets offset=2, size=3, is_sliced_view=true (L1978-1981).
    2. structuredClone(...)_on_structured_clone_serialize writes version=4, offset=2, content_type, store_tag=File, size=3 (L761), store payload (path), is_jsdom_file=0, last_modified. is_sliced_view is not written.
    3. on_structured_clone_deserialize reads back the same fields; at L4207/L4229 it calls Blob::new(find_or_create_file_from_path(...)), which returns a Blob with is_sliced_view: Cell::new(false) from Default.
    4. L4279 sets blob.offset = 2; L4280-4284 sets blob.size = 3 (since file_size = Some(3) != MAX_SIZE). is_sliced_view is never touched.
    5. clone.delete()validate_writable_blob sees is_sliced_view == false → passes → whole file unlinked.

    Impact and severity

    Same silent-clobber class this PR closes, on the structured-clone / postMessage path. Not a regression — before this PR, .delete()/.write() on a sliced Bun.file() clobbered the whole file in-thread too, so the structured-clone path is no worse than it was. It's a gap in the same-class coverage on a narrow path (clone a sliced file-backed blob, then mutate the clone). REVIEW.md: "New enum variant or struct field → audit every switch on the discriminant, every constructor/clone site, every hasher/serialization pair."

    How to fix

    Bump SERIALIZATION_VERSION to 5 and write/read is_sliced_view as a u8 after is_jsdom_file (matching the v3/v4 precedent). The alternative — reconstructing the flag on deserialize as offset != 0 || file_size.is_some_and(|s| s != MAX_SIZE) — has the same false-positive the PR body called out for the JS-side check: if the user read .size on an un-sliced Bun.file() before cloning, self.size is already the resolved stat size (not MAX_SIZE) at L761, so the heuristic would wrongly mark the un-sliced clone read-only. The version bump avoids that.

Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs
Comment thread src/runtime/webcore/Blob.rs
…void double-stat in exists()

- Bump SERIALIZATION_VERSION to 5 and write/read is_sliced_view so a
  structuredClone/postMessage of a sliced Bun.file() stays read-only for
  delete()/write()/writer() on the receiving side. Added tests for both the
  sliced clone (must throw) and an un-sliced clone after .size was read
  (must still write).
- Hoist the duplicated error message to SLICED_VIEW_READONLY_MSG so both
  throw sites cannot drift.
- Gate the exists() fallback stat on !size_was_unresolved so a nonexistent
  un-sliced Bun.file() still issues exactly one stat, not two.

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

The three follow-ups from the last pass (structured-clone serialization of is_sliced_view, the double-stat on ENOENT, and the duplicated error string) are all addressed in 78eecc1 and I have nothing further. Deferring to a human on the API-shape decision — making every sliced Bun.file() a hard read-only destination (including slice(0, n) and argless slice()) is a user-visible behavior change with a SERIALIZATION_VERSION bump, and it overlaps three open PRs (#35815 / #31680 / #33510) that a maintainer will want to reconcile.

What was reviewed

  • v5 serialize/deserialize ladder: is_sliced_view byte is written after the v3 name block and read only when version > 4; the == 3<= 4 gate change is behavior-preserving for v3/v4 payloads.
  • get_exists_sync: the !size_was_unresolved gate keeps the un-sliced ENOENT path at one stat; the data_mut().as_file() reshuffle is guarded by the preceding matches!(File) check.
  • read_off += read_amount placement: runs before the use_stack copy and before the buffer.len() >= max_length break, so the remaining_buffer cap shrinks on the next iteration and the final buffer is exactly max_length.
  • expect(() => Bun.write(slice, …)).toThrow() in the tests is sound — validate_writable_blob throws synchronously at Blob.rs:5350, before any promise is created.
Extended reasoning...

Overview

Three related Bun.file().slice() fixes across five files: a new Blob.is_sliced_view: Cell<bool> field on the core type (webcore_types.rs) threaded through dupe(), the manual clone literal, get_slice_from, and structured-clone serialization (SERIALIZATION_VERSION 4 → 5); guards in validate_writable_blob and write_file_internal that reject a sliced file-backed destination for .delete()/.write()/.writer()/Bun.write()/Bun.Image#write(); a one-line self.read_off += read_amount in the POSIX ReadFile::do_read_loop so the per-read cap actually shrinks; and a get_exists_sync fallback stat so a sliced file's .exists() returns true. ~160 lines of new tests plus one existing test rewritten to assert the new throw.

Security risks

None material. The serialization change reads one extra byte on v5+ payloads via the existing bounds-checked reader (read_int_le::<u8>()?), so a truncated payload errors rather than reading past the buffer. The new guard fails closed (rejects write). No auth/crypto/permissions surface touched.

Level of scrutiny

Medium-high. The mechanical parts (read_off advance, exists() stat fallback) are small and well-tested, but the PR also (a) changes a user-facing API contract — code that previously called Bun.write(file.slice(0, n), src) and got a truncated copy now throws TypeError, (b) bumps the structured-clone wire-format version on Blob, and (c) adds a field to a core #[repr(C)] type. Those are design decisions a maintainer should sign off on rather than auto-merge.

Other factors

  • All three of my earlier findings on this PR are resolved in 78eecc1; inline threads are marked resolved.
  • Duplicate-PR bot flagged three overlapping PRs; the author acknowledged the overlap and this PR supersedes #35815 by design, but a human needs to decide which lands and close the others.
  • Test coverage is thorough (variant matrix over slice shapes, negative test that reading .size doesn't false-positive, structuredClone round-trip, boundary sizes for the chardev cap) and follows harness conventions (tempDir, test.concurrent, no network, skipIf(!isPosix) for /dev/zero).
  • One existing test in bun-write.test.js was rewritten (not deleted) to assert the new throw and that the source file is untouched — the intent-change is stated in a comment, which satisfies REVIEW.md's "never silently weaken an existing test" rule.

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