FormData: throw when serializing an S3-backed entry instead of sending an empty part - #38364
FormData: throw when serializing an S3-backed entry instead of sending an empty part#38364robobun wants to merge 3 commits into
Conversation
…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.
|
Warning Review limit reached
Next review available in: 13 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 3:07 AM PT - Aug 14th, 2026
✅ @robobun, your commit 82ad489fd6ba5294c9345e8287687f1b28636387 passed in 🧪 To try this PR locally: bunx bun-pr 38364That installs a local version of the PR into your bun-38364 --bun |
|
Status: ready for review. Reproduced on the released build (1.4.0) by appending an Tests: the new Review follow-ups: a3c33a1 quotes the entry name via |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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), sofrom_dom_form_datadrops the joiner and callers propagate the pending exception. !blob.is_s3()guard onresolve_size()— verified it only skips the zero-write; the store match below still hitsData::S3.name.to_slice()lifetime: theZigStringSlicelocal outlives theformat_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: 0server 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.
Problem
formData.append(name, s3file)followed bynew Response(formData),new Request(url, { body })orfetch(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 toS3Client.file(),Bun.file("s3://..."), slices of either, andnew File([s3file], name).FormDataContext::on_entryinsrc/runtime/webcore/Blob.rsbuilds the multipart body synchronously. Itsstore::Data::S3arm was a TODO that pushed nothing after the headers, while theFilearm reads the file and theBytesarm pushes the bytes.blob.resolve_size()first, which for an S3 store sets the entry's size from "unknown" to 0 (formData.get(name).sizewent fromNaNto0after serializing).Fix
S3arm now marks the serialization failed and throws aTypeErrornaming 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 throughbun_core::fmt::quote, so a"or newline in it is escaped rather than breaking the message. This is the same failure path theFilearm already uses for an unreadable file, so the constructors throw andfetch()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.TypeErroris what the neighbouring body-init failures use (Body::from_jsfor a disturbed stream); the fetch spec also maps unreadable request bodies to aTypeErrorrejection. 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 forBun.file()parts and explicitly leaves S3 entries on this buffered serializer, so it would get this error too.test/js/web/html/FormData-multipart-serialization.test.ts(newdescribeat 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;sizebecomes 0) and pass with this change.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 underBUN_JSC_validateExceptionChecks=1;cargo fmt --check.Fileread error; the two changes touch adjacent lines, so whichever lands second needs a small rebase.Background
Blob's bytes live in aStore, which is one ofBytes(in memory),File(a path or fd, read on demand) orS3(a bucket key, downloaded on demand).S3Client.file()andBun.file("s3://...")both return blobs with anS3store;.slice()andnew File([blob])share the source blob's store.Blob::from_dom_form_datais the only multipart serializer: every body init that receives aFormData(theResponseandRequestconstructors andfetch()) calls it synchronously and gets back one in-memory blob holding the whole body.FormDataContext::on_entryis invoked once per entry and appends that entry's part to aStringJoiner.failed, throws on the global object andfrom_dom_form_datadrops 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.sizereportsNaNonly 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