Skip to content

FormData: throw when serializing an S3-backed entry instead of sending an empty part - #38364

Open
robobun wants to merge 3 commits into
mainfrom
farm/33100eed/formdata-s3-part-throws
Open

FormData: throw when serializing an S3-backed entry instead of sending an empty part#38364
robobun wants to merge 3 commits into
mainfrom
farm/33100eed/formdata-s3-part-throws

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • formData.append(name, s3file) followed by new Response(formData), new Request(url, { body }) or fetch(url, { body }) serializes the entry as its part headers followed by an empty body. Nothing throws, no S3 request is made, fetch() gets its 200 and the receiver stores a 0-byte file. Applies to S3Client.file(), Bun.file("s3://..."), slices of either, and new File([s3file], name).
  • Cause: FormDataContext::on_entry in src/runtime/webcore/Blob.rs builds the multipart body synchronously. Its store::Data::S3 arm was a TODO that pushed nothing after the headers, while the File arm reads the file and the Bytes arm pushes the bytes.
  • Same arm also called blob.resolve_size() first, which for an S3 store sets the entry's size from "unknown" to 0 (formData.get(name).size went from NaN to 0 after serializing).

Fix

  • The S3 arm now marks the serialization failed and throws a TypeError naming the entry: FormData entry "report" is an S3 file, which cannot be read while serializing the body. Read it first: formData.append(name, new Blob([await s3file.bytes()]), filename). The name goes through bun_core::fmt::quote, so a " or newline in it is escaped rather than breaking the message. This is the same failure path the File arm already uses for an unreadable file, so the constructors throw and fetch() rejects, and the partially built body is freed as before.
  • resolve_size() is skipped for S3 entries, so a rejected entry keeps its unknown size.
  • A TypeError is what the neighbouring body-init failures use (Body::from_js for a disturbed stream); the fetch spec also maps unreadable request bodies to a TypeError rejection. Reading the object here synchronously is not an option (network on the JS thread), and a streaming path is a separate feature: fetch: stream FormData bodies that contain Bun.file() parts #35792 adds one for Bun.file() parts and explicitly leaves S3 entries on this buffered serializer, so it would get this error too.
  • Verified with test/js/web/html/FormData-multipart-serialization.test.ts (new describe at the end: Response, Request, fetch() against a local server that must receive nothing, the four S3 blob shapes listed above, the quoting of plain, non-ASCII, " and CRLF entry names, and the size side effect). All eleven new tests fail on the released build (empty part is produced; size becomes 0) and pass with this change.
  • Also run with the debug build: the rest of that file, test/js/web/html/FormData.test.ts, FormData-file-error-leak.test.ts, test/js/bun/http/fetch-file-upload.test.ts, form-data-set-append.test.js; the repro under BUN_JSC_validateExceptionChecks=1; cargo fmt --check.
  • fetch: reject a FormData body whose Bun.file() cannot be read with a TypeError #37514 reworks how this function reports the File read error; the two changes touch adjacent lines, so whichever lands second needs a small rebase.

Background

  • A Blob's bytes live in a Store, which is one of Bytes (in memory), File (a path or fd, read on demand) or S3 (a bucket key, downloaded on demand). S3Client.file() and Bun.file("s3://...") both return blobs with an S3 store; .slice() and new File([blob]) share the source blob's store.
  • Blob::from_dom_form_data is the only multipart serializer: every body init that receives a FormData (the Response and Request constructors and fetch()) calls it synchronously and gets back one in-memory blob holding the whole body. FormDataContext::on_entry is invoked once per entry and appends that entry's part to a StringJoiner.
  • On failure the context sets failed, throws on the global object and from_dom_form_data drops the joiner and returns an empty blob; the callers check for the pending exception and propagate it (fetch() turns it into a rejected promise).
  • Blob::resolve_size() fills in a blob's size lazily (stat for files); S3 sizes are only known after a network round trip, so for an S3 store it writes 0, and .size reports NaN only while the size is still the unresolved sentinel.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/html/FormData-multipart-serialization.test.ts

…g an empty part

Blob::from_dom_form_data builds the multipart body synchronously. For an
entry backed by an S3 store it wrote the part headers and no content, so
new Response(formData), new Request() and fetch() all succeeded and the
receiver stored an empty file. The serializer now throws a TypeError
naming the entry, and no longer resolves the entry's size first, which
for an S3 blob replaced the unknown size with 0.
@coderabbitai

coderabbitai Bot commented Aug 14, 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: 13 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: 04f0c6ef-8f86-48c0-81bf-116697e1275a

📥 Commits

Reviewing files that changed from the base of the PR and between f7ad274 and 82ad489.

📒 Files selected for processing (2)
  • src/runtime/webcore/Blob.rs
  • test/js/web/html/FormData-multipart-serialization.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:07 AM PT - Aug 14th, 2026

@robobun, your commit 82ad489fd6ba5294c9345e8287687f1b28636387 passed in Build #95514! 🎉


🧪   To try this PR locally:

bunx bun-pr 38364

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

bun-38364 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on the released build (1.4.0) by appending an S3Client.file() to a FormData and serializing it via new Response(), new Request() and fetch() against a local server: all three produced the part headers followed by an empty body and the server received the request, and formData.get(name).size went from NaN to 0 afterwards. With this branch all three throw/reject with the TypeError described above and nothing is sent.

Tests: the new describe block at the end of test/js/web/html/FormData-multipart-serialization.test.ts (eleven cases, all fail on the released build, pass with this change).

Review follow-ups: a3c33a1 quotes the entry name via bun_core::fmt::quote and adds the new File([s3file], name) shape; 82ad489 trims the two comments in the S3 arm.

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

LGTM — replaces a silent-data-loss TODO with the same self.failed = true + throw pattern the adjacent File read-error arm already uses; well-tested. The two inline nits are cosmetic and non-blocking.

Extended reasoning...

Overview

Two files touched. In src/runtime/webcore/Blob.rs, the store::Data::S3(_) arm of FormDataContext::on_entry — previously a TODO that pushed nothing after the part headers — now sets self.failed = true and throws a TypeError naming the entry, exactly mirroring the adjacent store::Data::File read-error path (lines 3976-3979). The resolve_size() call above it is gated on !blob.is_s3() so a rejected entry keeps its unknown (NaN) size instead of being zeroed. In test/js/web/html/FormData-multipart-serialization.test.ts, a new describe block adds six tests covering Response/Request/fetch(), three S3 blob shapes via test.each, and the size side-effect.

Security risks

None. This turns a silent 0-byte upload into a synchronous TypeError before any network I/O. No new parsing of untrusted input, no auth/crypto/permissions surface. The error message interpolates the FormData entry name, which is already user-controlled and only ever displayed back to the same user.

Level of scrutiny

Low-to-medium. The native change is ~10 lines and follows the exact failure-path pattern already established in the same function for unreadable files: I confirmed from_dom_form_data checks context.failed after the forEach, drops the joiner (freeing pushed slices), and returns an empty blob (Blob.rs:976-982); callers check for the pending exception. on_entry early-returns on self.failed (line 3899) so subsequent entries after the S3 one are skipped and no double-throw occurs. blob.is_s3() (webcore_types.rs:426) checks the same store::Data::S3 discriminant the match arm uses, so the guard and the arm are consistent.

Other factors

The tests are hermetic (dummy S3 endpoint at 127.0.0.1:1, no S3 request is made; local Bun.serve({port: 0}) for the fetch case), assert the exact error class and message, and verify the negative contract (receivedBodies stays empty). The PR description states all six new tests fail on the released build and pass with the change, and that BUN_JSC_validateExceptionChecks=1 was run. The two inline findings are both nits: bun_fmt::quote for the entry name would render "/CRLF-containing names more cleanly, and the test.each matrix omits the new File([s3file], name) shape the description names — neither affects correctness of the fix and can be addressed in a follow-up if desired.

Comment thread src/runtime/webcore/Blob.rs
Comment thread test/js/web/html/FormData-multipart-serialization.test.ts
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.

LGTM — both follow-ups (bun_core::fmt::quote for the entry name, the new File([s3file], ...) test row) landed and the comment trims look right.

What was reviewed:

  • The S3 arm now mirrors the File-read-error arm exactly (self.failed = true + throw), so from_dom_form_data drops the joiner and callers propagate the pending exception.
  • !blob.is_s3() guard on resolve_size() — verified it only skips the zero-write; the store match below still hits Data::S3.
  • name.to_slice() lifetime: the ZigStringSlice local outlives the format_args! borrow, and its Owned variant frees any UTF-16→UTF-8 allocation on drop.
  • Test hermeticity: the S3 endpoint is never dialed; the fetch case uses a local port: 0 server and asserts nothing was received.
Extended reasoning...

Overview

Two files. In src/runtime/webcore/Blob.rs, the store::Data::S3(_) arm of FormDataContext::on_entry — previously a TODO that pushed nothing after the part headers — now sets self.failed = true and throws a TypeError naming the entry (via bun_core::fmt::quote) with a concrete remedy in the message. A one-line guard skips blob.resolve_size() for S3 stores so a rejected entry's size stays NaN instead of being zeroed. In test/js/web/html/FormData-multipart-serialization.test.ts, a new describe block adds eleven tests covering Response/Request/fetch, four S3-blob shapes, four entry-name quoting cases, and the size side-effect.

Security risks

None. The change converts silent data loss (0-byte upload accepted by the receiver) into an explicit TypeError before any network I/O. No new parsing, no new trust boundaries; the S3 credentials in the test are dummies and the endpoint (127.0.0.1:1) is never contacted because serialization fails first.

Level of scrutiny

Low-to-medium. The runtime change is ~7 lines and structurally identical to the neighbouring store::Data::File error path (lines 3974–3977): set failed, throw on the global, fall through to the trailing \r\n push, and let from_dom_form_data drop the joiner and return an empty blob when context.failed is set. throw_type_error returns a JsError that is intentionally discarded via let _ =, matching the File arm's let _ = global_this.throw_value(...). The ZigString::to_slice()ZigStringSlice::slice()fmt::quote(&[u8]) chain type-checks against the definitions in bun_core::string and bun_core::fmt, and the temporary is bound to a local so it outlives the borrow inside format_args!.

Other factors

I reviewed an earlier revision of this PR and left two nits (use bun_fmt::quote; add the new File([s3file], ...) test row). Both were addressed in a3c33a1, and 82ad489 trimmed the two comments the comment-cop bot flagged. All four inline threads are resolved. The bug-hunting system found nothing on the current revision. Tests are hermetic (local Bun.serve({ port: 0 }), no real S3), assert exact error class and message, and cover the variant matrix the PR description enumerates. The behavior change — throwing where the code previously produced silently-wrong output — is the conservative choice pending an async streaming path (#35792), and the error message tells the user exactly how to work around it.

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