Skip to content

Throw DataCloneError when structured-cloning an S3File - #37905

Open
robobun wants to merge 4 commits into
mainfrom
farm/1df6cd02/s3file-structured-clone-datacloneerror
Open

robobun wants to merge 4 commits into
mainfrom
farm/1df6cd02/s3file-structured-clone-datacloneerror

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • structuredClone(s3file) throws TypeError: Unable to deserialize data. postMessage(s3file) returns, and the receiver gets messageerror. A 5-byte slice clones into 5 wrong bytes with no error.
  • Each attempt also sets size of the source to 0. Then file.text() reads 1 byte of 10 and client.write(key, file) uploads 1 byte. No user report exists.
  • Cause: _on_structured_clone_serialize (src/runtime/webcore/Blob.rs:726) tags an S3 store as Bytes, writes a File body, and calls resolve_size(), which sets the size to 0.

Fix

  • structured_clone_store_tag() returns None for an S3 store. The serialize hook then throws a DataCloneError before it writes a byte or calls resolve_size(). A Blob built from an S3File throws too.
  • Store::serialize becomes File::serialize, without the S3 and Bytes arms. The deserializer rejects a hand-built File record with an s3:// path. Only reconstruct file-backed Blobs from in-process structured clone #31329 would make this check redundant.
  • Verified: test/js/web/structured-clone-blob-file.test.ts (18 of 19 new tests fail without the fix). Notes list the other suites.
  • Self-reviewed: 15 concerns raised, 10 addressed. The other 5 concern Blob routes outside this PR.

Background

  • A Blob store is Bytes, File or S3. The clone record has no S3 tag and no field for credentials.
  • Considered: open the key with the receiver's credentials (possibly another bucket), or put the credentials in the record (a secret key in each buffer).

Downsides

  • No maintainer has confirmed the new contract in the docs: an S3File cannot be cloned. postMessage(s3file) now throws at the sender.
  • expect(s3file).toHaveLength() and toBeEmpty() still set size to 0 through resolve_size(). s3: leave the size of an S3 blob unknown in resolve_size #43934 changes that function.
  • Cost for other blobs: none found. Serialize swaps two store checks for one match. Deserialize moves one prefix compare.
Notes

Questions for a maintainer.

  1. Is "an S3File cannot be structured-cloned" the wanted contract? The two other designs are under Background. A throw is the least binding choice: a later change can still make the clone work.
  2. The s3:// check in the deserializer overlaps Only reconstruct file-backed Blobs from in-process structured clone #31329, which rejects every file record that comes from raw bytes. If Only reconstruct file-backed Blobs from in-process structured clone #31329 is the place for it, this PR can drop that hunk and its test.

Measured on a release build of main (1.4.3 canary, 367d939) and on a debug build of this branch. The S3 endpoint is a Bun.serve on loopback that serves a 10-byte object.

Act on file = client.file("x.txt") main main, after the act this branch
none (control) size is NaN, text() reads 10 bytes, client.write("copy.txt", file) uploads 10 bytes same
structuredClone(file) TypeError size is 0, text() reads 1 byte (Range: bytes=0-0), write() uploads 1 byte DataCloneError, then the same as the control
port.postMessage(file) returns, the receiver gets messageerror the same as the row above DataCloneError, then the same as the control

Other results on main, with no I/O:

  • structuredClone(file.slice(0, 5)) returns a Blob of the bytes 00 00 00 00 01, and size of the slice goes from 5 to 0. The reader takes the low 32 bits of the u64 size as the byte length. The other windows that were tried throw the TypeError, except slice(2, 7), which returns 3 wrong bytes.
  • serialize(file) returns 49 bytes that deserialize() rejects.
  • new Blob([file]), new File([file], "n.txt") and formData.get() of an appended file are not S3File objects. They share the S3 store, throw the TypeError, and get size 0.
  • A File record with the path s3://bucket/key, written by hand, deserializes to [object S3File].

On this branch each of these throws, and size does not change.

Why the clone is refused and not made to work. An S3 store is a key plus refcounted S3Credentials (endpoint, bucket, region, keys) and the upload options (ACL, storage class, requester pays, multipart settings). The old record carried only the key as the user typed it. A receiver that opens that key again uses the credentials of its own environment, and a bare key (client.file("key")) would open a local Bun.file("key"). A DataCloneError at serialize time is what the structured clone algorithm specifies for a platform object that cannot be serialized. Bun already throws it for a Response and, since #32799, for a detached ArrayBuffer.

A third design keeps the same behavior and makes the generated serialize hook return JsResult<()> (branch robobun/22c566ef/s3file-structured-clone). It changes src/codegen/generate-classes.ts and BlockList.rs too. This PR throws from the existing hook, as 52 other let _ = global.throw_*() sites in src/runtime and src/jsc do. CloneSerializer checks for a pending exception after each hook.

The two arms of Store::serialize that are gone. The S3 arm wrote a path record under the Bytes tag, which is the wrong record of this bug. The Bytes arm had no caller: the Blob serializer writes the bytes of its own window inline.

Self-review, the 5 concerns with no change here. Each one is about a route that this PR does not touch.

Related PRs.

Not changed on purpose.

  • Serialization for cross-process transfer (process.send) turns each Blob into {} before it reaches the Blob serializer. An S3File still arrives as {}, the same as Bun.file(). A test asserts this.
  • The S3 arm of Blob::resolve_size() (see Downsides).
  • new Blob(["x", s3file]) with more than one part. It is a Bytes store that holds only x, and it clones as before.

Receiver side. The deserializer passes check_s3 = false to find_or_create_file_from_path and rejects an s3:// path first, next to the checks for a NUL byte and a negative fd. No File store has such a path: Bun.file("s3://...") builds an S3 store.

Tests. The block "S3File structured clone" has 19 tests. It covers five ways to make an S3File, two slices (one of 5 bytes), three plain wrappers over an S3 store (new Blob([s3file]), new File([s3file], name), formData.get()), each entry point (structuredClone, serialize of bun:jsc and node:v8, postMessage of MessagePort and Worker, the for-storage flag), an S3File nested in a larger graph, the {} result for cross-process transfer, a matrix of the other stores that still clone, and the hand-built s3:// record. The rows assert that size of the source does not change. No test does S3 I/O.

Suites run with the debug build of this branch (merged with main at 7c96a12).

  • test/js/web/structured-clone-blob-file.test.ts: 61 pass. 18 fail on the release build of main.
  • test/js/web/workers/structured-clone.test.ts: 244 pass (with --timeout 180000, the 2 GiB ArrayBuffer tests need more than 5 s on a debug build).
  • test/js/web/workers/structuredClone-classes.test.ts: 57 pass.
  • test/js/web/structured-clone-fastpath.test.ts: 93 pass.
  • test/js/bun/jsc/bun-jsc.test.ts: 41 pass.
  • 16 files of test/internal/source-lints/: 103 pass.
  • The repro under BUN_JSC_validateExceptionChecks=1: no report.
  • cargo clippy -p bun_runtime --no-deps: clean. bun run rust:check-all x86_64-pc-windows-msvc: ok. tsc --noEmit in test/: no error in the test file.

The Blob serializer tagged an S3 store as Bytes while writing the File
shaped body for it, so structuredClone(), bun:jsc/node:v8 serialize() and
postMessage() of an S3File produced a record that failed to read back with
"Unable to deserialize data." (postMessage lost the message and threw on the
receiver instead).

An S3 store is bound to the credentials and options of the client that made
it, which the record has no form for, so refuse it at serialize time with a
DataCloneError DOMException. The store kind to wire tag mapping is now an
exhaustive match shared by the tag and the body, Store::serialize becomes
File::serialize (its S3 arm was the other half of this bug and its Bytes arm
was unreachable), and the deserializer rejects a File record naming an
s3:// path instead of minting an S3File under the receiver's environment
credentials, since no serializer writes one.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 397ee95b-cce5-480b-b381-82450468ad74

📥 Commits

Reviewing files that changed from the base of the PR and between c86045c and ef2bca7.


📒 Files selected for processing (1)
  • test/js/web/structured-clone-blob-file.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.



Walkthrough

Structured-clone serialization rejects S3-backed Blobs with a DataCloneError. File records use File serialization, and deserialization rejects records with S3 paths. Tests and documentation cover these behaviors.

Changes

S3File structured-clone handling

Layer / File(s) Summary
Clone serialization and file records
src/runtime/webcore/Blob.rs, src/runtime/webcore/blob/Store.rs, test/js/web/structured-clone-blob-file.test.ts, docs/runtime/s3.mdx
The clone hook selects a serialization tag and rejects S3-backed Blobs before writing a record. File serialization writes path or descriptor records. Tests cover S3File rejection and confirm other Blob types still clone. Documentation describes the DataCloneError and how to recreate a file reference in a worker.
Reject S3 paths during deserialization
src/runtime/webcore/Blob.rs, test/js/web/structured-clone-blob-file.test.ts
File-descriptor records disable S3 path checks. Path records containing s3:// are rejected. Tests verify that Bun and V8 reject crafted records.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to ef2bc

S3-backed files now fail standard structured cloning with DataCloneError, as documented; no concrete merge-blocking issue remains.

Pre-merge checks · Passed 4
Passed Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: rejecting structured cloning of S3File values with DataCloneError.
Description check Passed The description explains the problem, fix, design rationale, limitations, related work, and verification results. It does not use the exact template headings, but it provides the required information …
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.

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

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

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:17 PM PT - Oct 12th, 2026

✅ @robobun, your commit ef2bca7829a034180166cef1eccf22693b20f46a passed in Build #124906! 🎉


🧪   To try this PR locally:

bunx bun-pr 37905

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

bun-37905 --bun

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: merged with main at 7c96a12, head ef2bca7. Reproduced on a release build of main (1.4.3 canary, 367d939) with

const options = { accessKeyId: "a", secretAccessKey: "b", bucket: "b", endpoint: "http://127.0.0.1:1" };
const f = Bun.s3.file("key.txt", options);
structuredClone(f); // TypeError: Unable to deserialize data.
f.size; // 0 after the failed clone, NaN before it
structuredClone(Bun.s3.file("key.txt", options).slice(0, 5)); // no error: a Blob of the bytes 00 00 00 00 01

port.postMessage(f) returns, and the receiver gets messageerror. Against a loopback S3 stub that serves a 10-byte object, await f.text() then reads 1 byte and client.write(key, f) uploads 1 byte.

With this branch each of these throws a DataCloneError at the sender, and size does not change. In test/js/web/structured-clone-blob-file.test.ts, 18 of the 19 new tests fail on main, and all 61 tests of the file pass with the fix.

@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. It makes a user-facing API decision (S3File is not structured-clonable, throwing DataCloneError) and adds a security-relevant deserialize guard, so a maintainer sign-off on the design would be worthwhile.

What was reviewed:

  • structured_clone_store_tag() is exhaustive over DataTag; the as_file() unwrap in the non-Bytes branch is reachable only when the tag is File.
  • check_s3: false in the deserializer is safe because the new s3:// prefix guard runs first, matching the same case-sensitive check find_or_create_file_from_path uses.
  • The deleted Store::serialize Bytes arm was confirmed unreachable (the Blob serializer inlines the sliced window itself); the S3 arm was the bug.
  • throw_dom_exception from the void hook relies on CloneSerializer's post-hook RETURN_IF_EXCEPTION, which the PR description verified under BUN_JSC_validateExceptionChecks=1.
Extended reasoning...

Overview

The PR fixes structured cloning of S3File, which previously wrote a malformed wire record (S3 store tagged as Bytes with a File-shaped body) that every reader rejected with "Unable to deserialize data." The fix refuses to serialize S3 stores at all, throwing a spec-compliant DataCloneError at the sender. It touches src/runtime/webcore/Blob.rs (serialize hook, deserialize File arm, new structured_clone_store_tag helper), src/runtime/webcore/blob/Store.rs (Store::serialize collapsed to File::serialize, dead S3/Bytes arms removed), one doc sentence, and a substantial new test block.

Security risks

Two security-adjacent changes: (1) the deserializer now rejects File records whose path starts with s3://, closing a vector where a hand-crafted bun:jsc/node:v8 buffer could produce a live S3File signed with the receiving process's environment credentials; (2) the design explicitly avoids putting S3Credentials on the wire, which would otherwise embed secrets in every serialize() output. Both are hardenings, not new attack surface. The s3:// check is case-sensitive but so is the only consumer (find_or_create_file_from_path), and check_s3 is now false there anyway, so a mixed-case scheme just becomes a local file path.

Level of scrutiny

Medium-high. The wire-format code is subtle (tag/body must agree, unreachable arms were silently wrong before), and the change is user-visible: code that previously got a confusing deserialize-time TypeError now gets a serialize-time DataCloneError with a specific message. The refactor of Store::serialize → File::serialize deletes code, which I traced: the Bytes arm was genuinely unreachable because _on_structured_clone_serialize writes bytes inline for memory-backed stores and never called store.serialize for them.

Other factors

Test coverage is comprehensive — every S3File constructor, every clone entry point, nested graphs, the for-storage/for-transfer flags, a guard matrix that other Blob kinds still clone, and the crafted-record rejection. The PR description states 14/15 new tests fail on the unfixed build and the whole file plus adjacent structured-clone suites pass with it. The bug hunting system found nothing. I'm deferring rather than approving because the "refuse to clone" decision is a deliberate API stance a maintainer should confirm (the alternatives — carrying credentials on the wire or rebinding to the receiver's client — are argued against convincingly in the description, but it's still their call), and the deserialize guard touches untrusted-input handling near credentials.

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

For the maintainer who makes the design call: this PR makes "an S3File cannot be structured-cloned" a documented contract. There are two open questions.

  1. Is a DataCloneError at the sender the wanted contract? Two other designs make the clone work:

    • Open the key again on the receiver with its environment credentials. The clone then reads another bucket or endpoint when the file came from a configured S3Client, and a bare key like client.file("key") opens a local Bun.file("key").
    • Carry S3Credentials and the options in the record. postMessage() to a worker then works. Each serialize() buffer of bun:jsc and node:v8 then holds the secret key, and the record needs a new version.

    A throw is the least binding choice: a later change can still make the clone work. structuredClone() already throws a DataCloneError for a Response and, since structuredClone: throw DataCloneError for a detached ArrayBuffer #32799, for a detached ArrayBuffer.

  2. The s3:// check in the deserializer overlaps Only reconstruct file-backed Blobs from in-process structured clone #31329, which rejects every file record that comes from raw bytes. If Only reconstruct file-backed Blobs from in-process structured clone #31329 is the place for that check, this PR can drop the hunk and its test.

@robobun

robobun commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

This came up again in a structured-clone sweep, with one extra symptom worth folding into the description: the failed first attempt also mutates the source. _on_structured_clone_serialize calls resolve_size() on the non-Bytes path, which sets an S3 blob's unknown size (NaN) to 0, so a second structuredClone() of the same object parses the shifted record as an empty plain Blob instead of throwing, and worker.postMessage(f) delivers a messageerror the first time and an empty Blob the second time. Throwing before anything is written, as this PR does, fixes that too.

I had an equivalent branch before finding this PR: main...robobun/22c566ef/s3file-structured-clone. The one difference is that it makes the generated on_structured_clone_serialize hook return JsResult<()> (codegen maps Err to a pending exception) instead of throwing from a () hook, and its test also asserts that size stays NaN after a failed attempt. Happy to close that branch in favor of this one.

…oneerror

Conflicts in src/runtime/webcore/Blob.rs and src/runtime/webcore/blob/Store.rs. Main changed the string view and path types around the Blob structured clone code (Utf8Bytes, PathLike::owned, the JsThread receiver of FileExt::unlink). The resolution keeps those and carries the store tag parameter, File::serialize, and the s3:// record guard over unchanged.
The tests now clone a 5-byte S3 slice (it cloned into 5 wrong bytes with no
error), new Blob([s3file]), new File([s3file], name) and formData.get() of
an appended S3File. They also assert that a refused clone leaves the size
of the source unchanged.

The docs say that structured cloning is not supported, and no longer call
it the only exception. The comments this change adds in Blob.rs and
Store.rs are one line each.

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

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

Comment on lines +3899 to +3906
if path.starts_with(b"s3://") {
return Err(crate::Error::InvalidValue);
}
let mut dest = PathOrFileDescriptor::Path(PathLike::owned(path));
break 'file Blob::new(Blob::find_or_create_file_from_path(
&mut dest,
global_this,
true,
false,

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.

🔴 If any persisted serialized buffer (v8.serialize, bun:jsc serialize, forStorage records) was written by a Bun that predates S3 support, where Bun.file("s3://b/k") was a plain File store, deserializing it now fails with InvalidValue instead of producing a usable Blob. src/runtime/webcore/Blob.rs:3899 rejects any File record whose path starts with s3://, and Blob.rs:3906 passes check_s3 = false so even the base's fallback (an env-credential S3File) is gone. Fix: decide the stored-data contract explicitly: either bump SERIALIZATION_VERSION so old records are distinguishable, or map an s3:// File record to a plain local File store (what the pre-S3 writer meant) rather than a hard error, and document the rejection.

Why this was flagged

Records were written before S3 support existed, when Bun.file("s3://...") built an ordinary File store and the File arm of the serializer (now FileExt::serialize at src/runtime/webcore/blob/Store.rs:203-223) wrote the path verbatim. Such records are valid wire data: version <= 3, File tag, Path tag, s3://.... On the base branch the deserializer at src/runtime/webcore/Blob.rs:3903 passed check_s3 = true and find_or_create_file_from_path at Blob.rs:3380-3402 minted an S3File from env credentials, so the record at least deserialized. After this PR the prefix check at Blob.rs:3899 returns Err(crate::Error::InvalidValue) and the Fd/Path arms at Blob.rs:3886 and Blob.rs:3906 pass false, so the same bytes now fail with 'Unable to deserialize data.' No format version bump accompanies the wire-reader change (SERIALIZATION_VERSION unchanged). Remedy: version-gate or downgrade the rejection to a local File store rather than a hard error.

Verification: The deserializer accepts versions 1–4 as supported input (src/runtime/webcore/Blob.rs:3838-3858, 3865, 3924-3944), so legacy records are a supported population. For a File-tagged Path record the PR adds an unconditional reject at Blob.rs:3899-3901 and passes check_s3 = false at Blob.rs:3906. Nothing in the version check gates the new reject, so that record now errors.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I kept the rejection in this push. The evidence:

  • No Bun with S3 support writes a File record with an s3:// path. Bun.file("s3://...") builds an S3 store (construct_bun_file, src/runtime/webcore/Blob.rs:5453). The serializer has tagged an S3 store as Bytes since the first release with S3 (bun-v1.1.43, src/bun.js/webcore/blob.zig:347), never as File.
  • A record from an older Bun named a local relative path. Main does not give that meaning back: find_or_create_file_from_path (Blob.rs:3382) turns it into an S3File with the environment credentials of the receiver. So the choice is a hard error or a read from a place the writer did not name.
  • A version bump does not separate these records: each record that exists today, hand-built ones included, would keep the old behaviour.

A local File store for such a record is a valid alternative. #31329 goes the other way and rejects every file record that comes from raw bytes, so this PR stays with the rejection. The second question in #37905 (comment) asks a maintainer which PR owns this check. I leave this thread open for that answer.

Comment thread test/js/web/structured-clone-blob-file.test.ts Outdated
Comment thread src/runtime/webcore/Blob.rs
The block comment states the invariant in one line. The two slice rows are
named by their windows.

@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 new issues

No new issues were found in this update; 1 finding from earlier reviews is still open above.

Still open from earlier reviews (1):

  • 🔴 src/runtime/webcore/Blob.rs:3906 — If any persisted serialized buffer (v8.serialize, bun:jsc serialize, forStorage records) was written by a Bun that pred…

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

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.

1 participant