Skip to content

fix(formdata): multipart parser corrupts parts located past 4 GiB - #30609

Open
robobun wants to merge 1 commit into
mainfrom
farm/fc08f495/formdata-multipart-4gb-offsets
Open

fix(formdata): multipart parser corrupts parts located past 4 GiB#30609
robobun wants to merge 1 commit into
mainfrom
farm/fc08f495/formdata-multipart-4gb-offsets

Conversation

@robobun

@robobun robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

req.formData() on a multipart body larger than 4 GiB returns corrupted field names/filenames for any part whose header sits past the 4 GiB mark. The part is then unreachable by name:

const form = await req.formData();
[...form.keys()]
// -> ["file", "\^@\^@\^@\^@\^@\^@\^@\^@\^@\^@\^@\^@\^@\^@\^@\^@\^@"]
form.get("description_field")
// -> null

This is the remaining half of #21490. The original symptom there — a 3.5 GB file coming through as 1.5 GB (exactly 2³¹ bytes lost) — was caused by Field.value being stored as a Semver.String, and was fixed as a side effect of #27483 (landed in 1.3.11) when value was switched to a plain slice.

Root cause

for_each_multipart_entry still stored name, filename, and content_type as bun_semver::String, which packs a slice as Pointer { off: u32, len: u32 } bitcast into a u64 with bit 63 used as a tag. That gives:

  • len: effectively u31 (bit 31 is the tag) → silent truncation at 2 GiB
  • off: u32 truncation → silent wrap at 4 GiB

So once a part's Content-Disposition header sat past byte 2³², the offset wrapped and slice(buf) returned bytes from the middle of the preceding file body.

Fix

In src/runtime/webcore/FormData.rs, store name, filename, and content_type as plain &'a [u8] borrows on the existing lifetime-parameterized Field<'a>, the same way value already is. The subslicer and the buf callback parameter are no longer needed, and no unsafe is introduced.

Also drop a leftover u32 round-trip in ArrayBuffer::from_bytes / from_boxed_slice (src/jsc/array_buffer.rs) — the target fields are already usize, so the cast just panicked on >4 GiB buffers in debug builds (hit on the client side when serializing a FormData containing a >4 GiB Bun.file()).

Earlier revisions of this PR carried the identical fix in FormData.zig / array_buffer.zig; those files were removed from the tree in #32621 (the Zig porting references), so the PR is now Rust-only.

Verification

New test in test/js/web/html/FormData.test.ts constructs a 4 GiB + 256 byte multipart body with a text field and a second file part after the big file, and asserts the trailing parts round-trip correctly. Spawned in a subprocess and skipped on Windows / machines with < 16 GiB effective memory (process.constrainedMemory() aware).

before after
keys ["big_upload", "", ""] ["big_upload", "description_field", "second_attachment"]
get("description_field") null "this part lives past the 4 GiB mark"
second file name undefined "also_past_4gb.txt"

Gate: test fails with src/ stashed, passes with the fix. cargo check / cargo clippy / cargo fmt --check clean; the adjacent FormData tests (control-character Content-Type, duplicate numeric field names) still pass.

Fixes #21490
Related: #27483, #27443


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

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:04 PM PT - Aug 15th, 2026

🔄 @robobun, the build for your commit cc060d91 (Build #98444) was cancelled — waiting for the next build...

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 seconds

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: ce35d5e8-f8a5-4bd8-a27f-8f7a535053c7

📥 Commits

Reviewing files that changed from the base of the PR and between bcab5ed and cc060d9.

📒 Files selected for processing (3)
  • src/jsc/array_buffer.rs
  • src/runtime/webcore/FormData.rs
  • test/js/web/html/FormData.test.ts

Walkthrough

FormData multipart parsing switches from buffer-offset bun.Semver.String slices to direct []const u8 slices for name, filename, and content_type. Public signatures are updated accordingly. ArrayBuffer.fromBytes initialization is simplified. A conditional regression test validates multipart parsing when headers are positioned past 4 GiB.

Changes

FormData multipart parsing with raw slices and large-offset support

Layer / File(s) Summary
FormData public API contracts
src/runtime/webcore/FormData.zig
FormData.Field.filename and FormData.Field.content_type change from bun.Semver.String to []const u8; forEachMultipartEntry iterator signature changes to fn (Ctx, []const u8, Field).
Entry handler and content-type checks
src/runtime/webcore/FormData.zig
Internal multipart entry handler accepts name: []const u8, reads field.filename and field.content_type as raw slices, and checks content-type presence via field.content_type.len > 0.
Multipart header parsing with raw slices
src/runtime/webcore/FormData.zig
Header parsing loop initializes name/filename as []const u8, extracts Content-Disposition params into raw slices, parses/trims content-type into a raw slice, normalizes Field, and invokes the iterator with (ctx, name, field).
ArrayBuffer direct byte initialization
src/jsc/array_buffer.zig
ArrayBuffer.fromBytes assigns .len and .byte_len directly from bytes.len without intermediate u32 casts.
Large-offset multipart parsing regression test
test/js/web/html/FormData.test.ts
Adds a conditional test (skipped on Windows or systems with <16 GiB RAM) that spawns a subprocess constructing a multipart body with headers beyond 2^32 bytes, calls request.formData(), and validates parsed keys, file metadata, and sizes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Changes successfully address issue #21490 by fixing corrupted field names/filenames for multipart parts with headers past 4 GiB by converting from bun.Semver.String to raw []const u8 slices.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the 4 GiB multipart parsing issue: FormData.zig header field refactoring, ArrayBuffer.fromBytes optimization, and comprehensive regression test.
Title check ✅ Passed The title clearly identifies the multipart parser corruption that the pull request fixes for parts located past 4 GiB.
Description check ✅ Passed The description explains the problem, root cause, fix, verification, test limits, and related issues, although it does not use the template headings.

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

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

🤖 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/FormData.zig`:
- Line 332: The parsing loop in FormData.zig currently exits early when both
filename and name are found (condition using header_chunk.len > 0 and (filename
== null or name.len == 0)), which skips subsequent headers like Content-Type and
leaves field.content_type empty; update the loop(s) (the header parsing while
using header_chunk, filename, name and the similar blocks around the other
occurrences) to continue scanning all header lines until header_chunk.len == 0
(i.e., remove the early-exit dependency on filename/name), and ensure that when
a "Content-Type" header is encountered you set field.content_type appropriately
so file parts retain their content type (apply same change to the other similar
blocks referenced).
🪄 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: 605edc8f-a6a8-4e88-9564-83e94b6b122e

📥 Commits

Reviewing files that changed from the base of the PR and between 3bf4b33 and 82448fb.

📒 Files selected for processing (3)
  • src/jsc/array_buffer.zig
  • src/runtime/webcore/FormData.zig
  • test/js/web/html/FormData.test.ts

Comment thread src/runtime/webcore/FormData.zig Outdated
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(formdata): prevent silent truncation of files >= 4GB in req.formData() #27443 - Also replaces bun.Semver.String with []const u8 in the multipart FormData parser to fix 4 GiB corruption; PR fix(formdata): multipart parser corrupts parts located past 4 GiB #30609 is a more complete fix covering additional fields

🤖 Generated with Claude Code

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

🤖 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 `@test/js/web/html/FormData.test.ts`:
- Around line 2-3: The test currently uses os.totalmem() to decide skipping
which checks installed RAM; change the skip gating to use the effective memory
ceiling instead (e.g., use v8.getHeapStatistics().heap_size_limit or process
resource limits or a new harness helper) so the FormData.test.ts skip is
deterministic under cgroup/job memory caps; locate the check that imports
totalmem and replace the logic that references totalmem with a call to
v8.getHeapStatistics().heap_size_limit (or the harness helper) and base the
skip/skipIf on that value (update any references to totalmem in the test file
accordingly).
🪄 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: 1c8fdad6-7d2c-4275-b642-19cc32e4e870

📥 Commits

Reviewing files that changed from the base of the PR and between 82448fb and 4b2f6ed.

📒 Files selected for processing (1)
  • test/js/web/html/FormData.test.ts

Comment thread test/js/web/html/FormData.test.ts Outdated
Comment thread src/runtime/webcore/FormData.zig 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 — mechanical bun.Semver.String[]const u8 widening that mirrors what Field.value already went through, with a gated regression test.

Extended reasoning...

Overview

This PR touches three files: src/runtime/webcore/FormData.zig (multipart parser), src/jsc/array_buffer.zig (one-line cast removal), and test/js/web/html/FormData.test.ts (new regression test). The core change converts Field.filename, Field.content_type, and the local name from bun.Semver.String (which packs offset/length into u32 with a tag bit) to raw []const u8 slices, matching what was already done for Field.value in #27483. The subslicer, the buf callback parameter, and the unused string type alias are dropped accordingly. The ArrayBuffer.fromBytes change removes a vestigial @as(u32, @intCast(...)) whose targets (.len, .byte_len) are already usize.

Security risks

The multipart parser handles untrusted request bodies, but this change is strictly a type widening — slices that previously round-tripped through a lossy u32 offset/length encoding now stay as native fat pointers into the same input buffer. No new parsing logic, no new indexing arithmetic, no allocation changes. The fromBytes change removes a debug-build panic on >4 GiB inputs without altering release behavior. I see no injection, OOB, or auth surface introduced.

Level of scrutiny

Low–medium. The transformation is mechanical (.isEmpty().len == 0, .len().len, .slice(buf) → direct slice, subslicer.sub(x).value()x) and follows the exact pattern already applied to Field.value. Grep confirms forEachMultipartEntry and FormData.Field are only referenced within FormData.zig, so the callback signature change is self-contained.

Other factors

The bug-hunting system found no issues. All inline review threads are resolved: the os.totalmem() gating concern was addressed in 8de77d5 with process.constrainedMemory(), and both CodeRabbit and my own note about the pre-existing Content-Type early-exit on line 332 were acknowledged as out of scope (the diff there is purely .len().len). The new test is appropriately gated (skipped on Windows / <16 GiB) and runs in a subprocess. The musl build failures in CI appear to be infrastructure-related rather than caused by this diff. No CODEOWNERS apply.

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

CI status

The diff is green.

Build #72460 (4c84809, current HEAD)

72 lanes pass, cargo clippy passes. 1 failed lane (windows-2019-x64-baseline), two error annotations — both fleet-wide:

Test Also failing on
test/napi/napi.test.ts (Windows 2019 x64-baseline) #72457
test-worker-message-port-transfer-terminate.js (debian 13 x64-asan) #72455, #72456, #72457, #72458

Neither intersects the multipart parser. The new test in this PR ("multipart parser handles parts at offsets > 4 GiB") does not appear in any failure output and has passed on every lane where it runs.


The PR touches only src/runtime/webcore/FormData.rs (&'a [u8] borrows replacing u32-packed bun_semver::String), src/jsc/array_buffer.rs (dead-cast removal), and the memory-gated regression test. No unsafe. All review threads resolved; claude[bot] LGTM'd every revision.

Ready for merge.

@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (post-Rust-rewrite) and ported:

  • src/jsc/array_buffer.rs: from_bytes/from_owned_bytes drop the u32::try_from().expect() round-trip (fields were already usize)
  • src/runtime/webcore/FormData.rs: Field.filename/content_type changed from bun_semver::String (u32 offset/len) to *const [u8]; for_each_multipart_entry iterator now passes name: &[u8] directly, drops SlicedString and the buf: &[u8] back-reference

Gate verified: without the .rs change, parts past 4 GiB parse with empty names and null values (u32 offset wrap); with it, all parts round-trip. .zig kept as reference.

(Unrelated: should increment reference count of the name string on Blob times out on my debug+ASAN container both with and without this change — 100k-iteration GC stress, not touched by this diff.)

@robobun
robobun force-pushed the farm/fc08f495/formdata-multipart-4gb-offsets branch from 1dc0c33 to 31d5a69 Compare May 14, 2026 17:28
Comment thread test/js/web/html/FormData.test.ts 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.

No issues found and all prior feedback addressed — the fix is a clean mechanical swap from bun.Semver.String to native slices following the pattern Field.value already uses. Deferring to a maintainer only because this touches the multipart body parser (untrusted-input path) across both the Zig reference and the freshly-ported Rust implementation, and the latter adds new unsafe raw-pointer derefs (same contract as the pre-existing field.value, but worth a human glance).

Extended reasoning...

Overview

This PR fixes #21490: req.formData() corrupts part names/filenames for any multipart part whose header sits past byte 2³² in the body. Root cause is that forEachMultipartEntry stored name/filename/content_type as bun.Semver.String, which packs offset/length into a u32/u31 pair and silently wraps. The fix swaps these to raw []const u8 (Zig) / *const [u8] (Rust) slices into the input buffer — exactly the pattern Field.value already uses since #27483. It also drops a leftover u32 round-trip cast in ArrayBuffer.fromBytes/from_bytes whose target fields are already usize. A new memory-gated, Windows-skipped regression test in FormData.test.ts constructs a 4 GiB + 256 B body and asserts the trailing parts round-trip.

Files: src/runtime/webcore/FormData.{zig,rs}, src/jsc/array_buffer.{zig,rs}, test/js/web/html/FormData.test.ts.

Security risks

The multipart parser handles untrusted HTTP request bodies, so this is a security-adjacent code path. However, the change does not alter parsing logic, boundary handling, or header tokenization — it only changes how already-parsed subslices are stored (native fat pointer instead of u32-packed offset). The fix strictly removes a silent integer truncation; it cannot introduce new OOB reads because the slices are direct subslices of input produced by the same indexing as before. The Rust side's new unsafe { &*field.content_type } / &*field.filename derefs follow the identical SAFETY contract already in place for field.value (always either the b"" static or a subslice of the caller-owned input, valid for the iterator call). No auth/crypto/permissions code is touched.

Level of scrutiny

Moderate. The transformation is mechanical and the diff is small, but it spans (a) a parser on the untrusted-input path and (b) two parallel implementations — the Zig reference and the recently-landed Rust port. The Rust change adds three unsafe blocks. While each mirrors the pre-existing field.value: *const [u8] pattern and the TODO(port): lifetime comment explains why &'a [u8] isn't used yet, I'd prefer a maintainer confirm the Rust lifetime story rather than rubber-stamp it.

Other factors

  • forEachMultipartEntry / for_each_multipart_entry have no external callers (grep confirms both are file-local), so the iterator signature change is self-contained.
  • No CODEOWNERS apply to the touched paths.
  • All prior review feedback (CodeRabbit's cgroup-memory gate, my require("os") → static import nit, the pre-existing Content-Type early-exit) has been addressed or explicitly scoped out, and all inline threads are resolved.
  • CI was green on the previous push except for an unrelated fleet-wide Windows test-http-should-emit-close-when-connection-is-aborted flake; build #54342 for the latest commit is in progress.
  • The bug-hunting system found no issues on the current revision.

@robobun
robobun force-pushed the farm/fc08f495/formdata-multipart-4gb-offsets branch from d11a1e1 to fdb922c Compare May 21, 2026 17:54

@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 — mechanical Semver.String → raw-slice swap that mirrors what #27483 already did for Field.value, plus a dead u32 cast removal; all prior nits addressed.

Extended reasoning...

Overview

Touches src/runtime/webcore/FormData.{zig,rs} (multipart parser), src/jsc/array_buffer.{zig,rs}, and test/js/web/html/FormData.test.ts. The core change converts Field.{filename,content_type} and the per-part name from bun.Semver.String / bun_semver::String (which packs offset/len as u32 with bit 63 stolen as a tag) to raw []const u8 / *const [u8] slices into the input buffer. The iterator callback signature drops the now-unused buf back-reference and SlicedString subslicer. The array_buffer change removes a vestigial @as(u32, @intCast(...)) / u32::try_from().expect() round-trip on fields that were already usize. A memory-gated, subprocess-isolated regression test exercises a 4 GiB+ multipart body.

Security risks

None introduced. The multipart parser does process untrusted HTTP bodies, but this change doesn't alter parsing logic or bounds computation — it only changes how already-computed subslices are stored (native fat pointer instead of a packed u32 offset/len that silently wrapped at 4 GiB). The slices point into the same caller-owned buffer with identical bounds; the only behavioral delta is that offsets past 2³² no longer truncate. The Rust *const [u8] + unsafe { &* } pattern is the same idiom Field.value already uses (ported in #27483), so no new unsafe surface is being introduced — two more fields adopt an existing, documented invariant.

Level of scrutiny

Low–medium. The transformation is mechanical and follows an established precedent (Field.value underwent the identical conversion in #27483). I verified via grep that forEachMultipartEntry / for_each_multipart_entry and the Field struct have no consumers outside these two files, so the signature change is fully self-contained. The array_buffer hunk is a strict simplification (the target fields are usize; the cast was a debug-build panic waiting to happen and a no-op in release). No CODEOWNERS cover these paths.

Other factors

All review threads are resolved: my pre-existing Content-Type early-exit note was acknowledged as out of scope; CodeRabbit's cgroup-memory gate suggestion was applied (process.constrainedMemory?.() || totalmem()); my require("os") → static-import nit was fixed in fdb922c. The bug-hunting system found nothing this round. CI on the prior HEAD was green modulo a fleet-wide fetch-tcp-keepalive flake unrelated to this diff, and the new regression test passed on every lane where it ran.

@robobun
robobun force-pushed the farm/fc08f495/formdata-multipart-4gb-offsets branch from fdb922c to 28f675b Compare May 24, 2026 10:23

@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 — mechanical Semver.String → raw slice swap matching the existing Field.value pattern; all prior feedback addressed.

Extended reasoning...

Overview

This PR fixes #21490 by changing how the multipart FormData parser stores part metadata. forEachMultipartEntry previously stored name, filename, and content_type as bun.Semver.String (which packs offset/length into u32 fields, wrapping at 4 GiB), and now stores them as raw []const u8 / *const [u8] slices into the input buffer — exactly the same representation Field.value already uses since #27483. The buf callback parameter and SlicedString subslicer become unnecessary and are dropped. A separate one-line change in array_buffer.{zig,rs} removes a vestigial u32 round-trip on fields that are already usize. A new regression test in FormData.test.ts constructs a >4 GiB multipart body in a subprocess and asserts trailing parts round-trip correctly; it's gated on process.constrainedMemory()/totalmem() ≥ 16 GiB and skipped on Windows.

Security risks

None introduced. The multipart parser handles untrusted HTTP bodies, but this change strictly removes a lossy u32 packing step — the same subslices of the same caller-owned input buffer flow through the same logic, just without offset truncation. No new dereferences of attacker-controlled offsets; the Rust unsafe { &*field.filename } pattern is identical to the pre-existing unsafe { &*field.value } pattern with the same SAFETY invariant (points into the caller-owned input or b"").

Level of scrutiny

Low-to-medium. The diff is a mechanical type substitution (.slice(buf) → direct use, .isEmpty().len > 0, .len().len) following an already-landed pattern from #27483. forEachMultipartEntry / for_each_multipart_entry and FormData.Field have no callers outside the two modified files, so the signature change is fully self-contained. The array_buffer change is a strict simplification (removing @as(u32, @intCast(x)) / u32::try_from(x).expect() as usize where x is already usize and the target field is usize).

Other factors

All four review threads are resolved: the CodeRabbit loop-condition comment and my own pre-existing-bug note were correctly scoped out (the .len().len change is purely mechanical, the early-exit behavior is unchanged from main); the cgroup-memory gate was addressed with process.constrainedMemory?.(); and my require("os") nit was fixed with a static import. The author's CI summary reports the diff green across 65+ lanes with only a fleet-wide fetch-tcp-keepalive flake unrelated to this change. No CODEOWNERS cover these paths. The bug-hunting system found no issues.

@robobun
robobun force-pushed the farm/fc08f495/formdata-multipart-4gb-offsets branch from 28f675b to 65dce7d Compare May 27, 2026 17:21

@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 issues found and both earlier nits are addressed — deferring only because this touches the multipart body parser (untrusted input) across both the Zig and Rust implementations, which I'd rather a human sign off on.

Extended reasoning...

Overview

This PR fixes #21490 by changing how forEachMultipartEntry stores part metadata (name, filename, content_type): from bun.Semver.String / bun_semver::String — which packs offset/length into 32-bit fields and silently wraps past 4 GiB — to native []const u8 / *const [u8] slices. It applies the same change in parallel to src/runtime/webcore/FormData.zig and src/runtime/webcore/FormData.rs, drops the now-unused subslicer/buf plumbing, removes a redundant u32 round-trip in ArrayBuffer.fromBytes / ArrayBuffer::from_bytes (target fields are already usize), and adds a memory-gated regression test in test/js/web/html/FormData.test.ts.

The transformation is mechanical and follows the exact pattern already applied to Field.value in #27483: subslicer.sub(x).value()x, .len().len, .isEmpty().len == 0 / .is_empty(), .slice(buf) → direct use. The Rust side adds the required per-block SAFETY: comments for the *const [u8] derefs, and the Default impl keeps them initialised to b"" so they are always valid fat pointers. The control-character Content-Type guard from the recent hardening PR is preserved.

Security risks

Multipart parsing operates on untrusted HTTP request bodies, so it is security-adjacent. However, this PR does not introduce or alter any parsing logic, bounds checks, or allocations — it only widens the storage representation of already-computed subslices from u32-packed offsets to native fat pointers. The array_buffer change removes a debug-build panic on >4 GiB buffers without changing release behaviour. I see no new injection, OOB, or lifetime exposure; the Rust *const [u8] fields borrow into the caller-owned input for exactly the duration of the iterator callback, matching the existing Field.value contract.

Level of scrutiny

Medium-high. The change itself is locally verifiable and semantically equivalent modulo the bug being fixed, but it spans the production multipart parser in two coordinated language implementations and changes the public forEachMultipartEntry iterator signature. That combination is past my threshold for auto-approval of code on an untrusted-input path.

Other factors

The bug-hunting system found nothing. Both of my earlier inline comments (the pre-existing Content-Type early-exit, scoped out as orthogonal; and the require("os") → static import nit) are resolved, as is CodeRabbit's cgroup-aware memory-gate suggestion. CI is reported green across 71 lanes with only expired darwin agents outstanding. The new test is sensibly gated (process.constrainedMemory() with totalmem() fallback, skip on Windows) and runs in a subprocess. No CODEOWNERS match the touched paths.

@robobun
robobun force-pushed the farm/fc08f495/formdata-multipart-4gb-offsets branch from 65dce7d to 8500af0 Compare June 5, 2026 20:39

@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 — mechanical type swap (Semver.String → raw slice) mirroring the existing Field.value pattern, plus a gated regression test; all prior review threads resolved and CI green.

Extended reasoning...

Overview

This PR fixes #21490 by changing FormData.Field.filename/content_type and the multipart iterator's name parameter from bun.Semver.String / bun_semver::String (which packs offset/length as u32 and silently wraps past 4 GiB) to native []const u8 / &[u8] slices. The same fix was already applied to Field.value in #27483; this completes it for the remaining three fields. It also drops a vestigial u32 round-trip in ArrayBuffer.fromBytes / ArrayBuffer::from_bytes where the target fields are already usize. A memory-gated subprocess regression test in FormData.test.ts verifies parts past the 4 GiB mark round-trip correctly.

Security risks

The multipart parser handles untrusted HTTP bodies, but this change adds no new parsing logic — it only changes how subslices of the input buffer are represented (native fat pointer instead of u32 offset+len packed into a u64). All slices still point into the same caller-owned input buffer with the same bounds; in the Rust port the borrow checker enforces this via the 'a lifetime on Field. The pre-existing printable-ASCII guard on content_type (header-injection hardening) is preserved verbatim. Net effect is a correctness improvement: the old code could hand back bytes from the wrong offset within the buffer.

Level of scrutiny

Moderate. The transformation is mechanical and follows the exact pattern already established for Field.value in the same struct: subslicer.sub(x).value()x, .slice(buf) → direct use, .len()/.isEmpty().len/.is_empty(). forEachMultipartEntry/for_each_multipart_entry is only called from within the same file (verified via grep), so the iterator signature change has no external callers to break. The array_buffer change is a no-op simplification (removing u32::try_from(x).expect() as usize where x: usize).

Other factors

All prior review threads (CodeRabbit's Content-Type early-exit note, the cgroup-aware memory gate, and the static-import nit) are resolved — the early-exit is correctly identified as pre-existing and out of scope, and the test gate now uses process.constrainedMemory() with a totalmem() fallback. The bug-hunting pass found nothing. The robobun CI summary reports zero test failures on the current HEAD with only infra-expired darwin agents red. No CODEOWNERS cover these paths.

@robobun
robobun force-pushed the farm/fc08f495/formdata-multipart-4gb-offsets branch from 8500af0 to 2c59d42 Compare June 26, 2026 19:20
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main. Two things changed shape:

Verified post-rebase: cargo check / clippy / fmt --check clean, bun bd builds, and the new 4 GiB test plus the adjacent FormData tests (control-character Content-Type, duplicate numeric names) all pass. PR description updated.

@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 — clean mechanical fix replacing the u32-packed bun_semver::String with lifetime-checked &'a [u8] borrows, plus a trivially-correct cast removal in array_buffer.rs.

Extended reasoning...

Overview

This PR touches three files: src/runtime/webcore/FormData.rs (switches Field.filename/content_type and the name parameter from bun_semver::String — which packs offset/length into 32 bits — to plain &'a [u8] borrows, matching Field.value which already had that type), src/jsc/array_buffer.rs (drops a vestigial u32::try_from(len).expect(...) as usize round-trip whose target fields are already usize), and test/js/web/html/FormData.test.ts (adds a memory-gated subprocess regression test for parts past 4 GiB). The Zig half of the fix was dropped after the porting-reference sources were removed from the tree, so the diff is now Rust-only with no unsafe.

Security risks

The multipart parser ingests untrusted request bodies, but this change strictly narrows the attack surface: it replaces a u32 offset that silently wrapped at 4 GiB (causing slice(buf) to read garbage from elsewhere in the buffer) with proper lifetime-checked Rust slices. No new parsing logic, header handling, or allocation paths are introduced. The adjacent control-character Content-Type rejection test is reported still passing. The array_buffer.rs change is a pure no-op for <4 GiB inputs and only removes a debug-build panic for larger ones. I see no auth/crypto/permissions surface here.

Level of scrutiny

Low-to-moderate. The fix is mechanical and follows the pattern already established for Field.value in #27483/#31783 — the struct was already lifetime-parameterized as Field<'a>, so the new fields slot in without any borrow-checker gymnastics. for_each_multipart_entry has exactly one caller (to_js_from_multipart_data::Wrapper::on_entry), updated in the same diff, and the dropped buf/subslicer were only ever used to reconstitute the now-direct slices. The array_buffer.rs hunk is a four-line identity simplification.

Other factors

All prior review threads (CodeRabbit's cgroup-memory gate, my static-import nit, the pre-existing Content-Type early-exit) are resolved; the early-exit was correctly scoped out as orthogonal and pre-existing. The new test is well-isolated (subprocess, process.constrainedMemory()-aware skip, 120s timeout) and the bug-hunting system found nothing on this revision. No CODEOWNERS cover these paths. The author's CI summary shows green with only fleet-wide flakes unrelated to FormData.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (a554586). The only conflict was in FormData.test.ts#33398 appended a USVString conversion of lone surrogates suite at the same spot as the 4 GiB test; kept both. No source conflicts.

Verified post-rebase: cargo check / clippy / fmt --check clean, bun bd builds, the 4 GiB regression test passes, and the new USVString tests pass alongside it.

@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 — mechanical type change (bun_semver::String&'a [u8]) matching the existing value field, plus a dead u32 round-trip removal; all prior feedback addressed.

Extended reasoning...

Overview

This PR touches three files:

  • src/runtime/webcore/FormData.rs: switches Field.filename/content_type and the name parameter of for_each_multipart_entry from bun_semver::String (u32-packed offset/length) to plain &'a [u8] borrows, matching the shape Field.value already had. Drops the now-unused SlicedString subslicer and the buf: &[u8] back-reference on the iterator callback. The one caller (Wrapper::on_entry) is updated in the same file; grep confirms no other callers.
  • src/jsc/array_buffer.rs: removes u32::try_from(bytes.len()).expect("int cast") as usize round-trips in from_bytes/from_owned_bytes — the target fields are already usize, so the cast just panicked on >4 GiB buffers in debug builds.
  • test/js/web/html/FormData.test.ts: adds a memory-gated (process.constrainedMemory() || totalmem() >= 16 GiB, non-Windows) subprocess test that builds a 4 GiB+256 B multipart body and asserts the trailing parts' names/filenames/contents round-trip.

Security risks

None introduced. The multipart parser handles untrusted network input, but this change adds no parsing logic — it only changes how already-computed subslices are stored and passed to the callback. The old bun_semver::String encoding silently truncated offsets at 2³² and lengths at 2³¹; the new &'a [u8] is a direct lifetime-checked borrow with native-width bounds, so the change strictly removes a corruption vector. No unsafe is introduced; borrowck enforces that the slices don't outlive the input buffer.

Level of scrutiny

Low-medium. The Rust change is a mechanical type substitution following the pattern already established for Field.value (which was moved to &'a [u8] in #27483/#31783). The array_buffer.rs change is a trivial dead-cast removal. The test is well-structured (subprocess-isolated, cgroup-aware skip gate, drains pipes concurrently, asserts stdout before exit code) and follows repo conventions.

Other factors

  • No bugs found by the bug hunting system.
  • All four prior review threads (CodeRabbit ×2, my own ×2) are resolved: the memory gate now uses constrainedMemory(), the require("os") is a static import, and the pre-existing Content-Type early-exit was correctly scoped out as orthogonal.
  • The author's CI status comment reports the diff green with only fleet-wide unrelated TLS failures.
  • The public for_each_multipart_entry signature changes, but grep shows the only caller is in the same file.

@robobun
robobun force-pushed the farm/fc08f495/formdata-multipart-4gb-offsets branch from a554586 to 860984c Compare July 8, 2026 22:31
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (860984c) and squashed to a single commit to stop the import-line churn conflicting on every round. One semantic change to absorb:

The other conflict was just the test-file import line (#33622 added isASAN/isDebug); consolidated.

Verified post-rebase: cargo check / clippy / fmt --check clean, bun bd builds, and the 4 GiB regression test + the adjacent FormData tests (control-character Content-Type, USVString conversion) all pass.

@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 — mechanical bun_semver::String&'a [u8] swap matching the existing value field, plus a no-op cast removal.

Extended reasoning...

Overview

Three files touched:

  • src/runtime/webcore/FormData.rs: Field.filename / Field.content_type change from bun_semver::String (u32-packed offset/len) to plain &'a [u8] borrows, matching the existing value: &'a [u8] field. The for_each_multipart_entry iterator signature drops the buf: &[u8] back-reference and passes name as a raw slice; the SlicedString subslicer is deleted. Net: fewer lines, no unsafe, one indirection removed.
  • src/jsc/array_buffer.rs: removes u32::try_from(len).expect(...) as usize round-trips in from_bytes / from_owned_bytes. The target fields were already usize, so this only ever added a debug-build panic on >4 GiB buffers.
  • test/js/web/html/FormData.test.ts: adds a memory-gated (constrainedMemory() || totalmem() ≥ 16 GiB, non-Windows) subprocess regression test that constructs a 4 GiB + 256 B multipart body and asserts parts past 2³² bytes round-trip.

Security risks

None. This widens an internal offset representation from u32 to native slice pointers, fixing silent wrap/corruption on large bodies. No new parsing logic, no new attack surface; the control-character Content-Type guard and boundary length check are unchanged.

Level of scrutiny

Medium — production request-body parsing code — but the change is a mechanical type substitution that follows the exact pattern already established for Field.value in #27483/#31783, and strictly simplifies (drops subslicer.sub(...).value() indirection and the buf callback param). The array_buffer.rs hunk is trivially correct. The remaining use bun_semver; is still live via Map<'a> = ArrayHashMap<bun_semver::String, ...>.

Other factors

  • Bug hunter found nothing on the current revision.
  • All prior review threads (CodeRabbit's cgroup-memory gate, my require("os") nit, the pre-existing Content-Type early-exit note) are resolved; the author addressed each with a commit or a scoping justification.
  • CI green on the latest rebase per the robobun status comment (only red lane was a darwin artifact-download infra timeout).
  • PR has been rebased four times since May with no semantic drift; the Rust diff is now smaller than the original Zig one because #31783 already lifetime-parameterized Field<'a>.

@robobun
robobun force-pushed the farm/fc08f495/formdata-multipart-4gb-offsets branch from 860984c to 4c84809 Compare July 13, 2026 18:29
@robobun

robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (4c84809). The only conflict was for_each_multipart_entry's return type — #33909 switched it to crate::Result<()> (per-crate thiserror enums); kept that with my iterator signature. cargo clippy and fmt clean, build succeeds, and the 4 GiB regression test + the adjacent control-char test pass.

@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 — mechanical type widening that follows the existing value: &'a [u8] pattern in the same struct.

Reviewed: Field<'a> now stores filename/content_type as native &'a [u8] borrows instead of bun_semver::String (u32-packed offset/len) — the borrow checker enforces they don't outlive input, and the only caller of for_each_multipart_entry is in the same file. The array_buffer.rs change drops a u32::try_from().expect() as usize round-trip on fields that are already usize — no release-build behavior change, just removes a debug panic on >4 GiB. Test is subprocess-isolated, memory-gated via process.constrainedMemory(), and drains pipes concurrently. All prior review threads (Content-Type early-exit is pre-existing, cgroup-aware skip, static os import) are resolved.

Extended reasoning...

Overview

This PR fixes silent data corruption in req.formData() when a multipart body exceeds 4 GiB: part metadata (name, filename, content_type) was stored as bun_semver::String, which packs a slice as {off: u32, len: u32} with bit 63 as a tag — so offsets past 2³² wrapped and slice(buf) returned garbage from the middle of the preceding file body.

Three files touched:

  • src/runtime/webcore/FormData.rs (~30 lines): Field<'a>.filename/content_type change from bun_semver::String to &'a [u8], matching the existing value: &'a [u8] field. The for_each_multipart_entry iterator drops the buf back-reference and subslicer helper since callers now receive slices directly. Verified this function has exactly one caller (Wrapper::on_entry, same file).
  • src/jsc/array_buffer.rs (4 lines): from_bytes/from_owned_bytes drop u32::try_from(len).expect("int cast") as usize where len/byte_len are already usize. Pure dead-cast removal.
  • test/js/web/html/FormData.test.ts (~90 lines): new subprocess test constructing a 4 GiB + 256 byte multipart body with trailing parts past the 2³² mark.

Security risks

The multipart parser handles untrusted request bodies, so this is security-adjacent. However, the change does not touch any bounds-checking, allocation, or parsing logic — it only changes how already-computed subslices of the input buffer are stored (native &'a [u8] borrow vs. a lossy packed offset). The Rust borrow checker now statically enforces that these slices cannot outlive input, which is strictly stronger than the previous SlicedString indirection. No unsafe is introduced. The array_buffer.rs change only removes a debug-build panic path; release builds already truncated silently via as usize after the (successful in release) u32::try_from, so behavior is unchanged there — actually, no: u32::try_from(x).expect() panics in both debug and release when x > u32::MAX. So this removes a reachable panic on >4 GiB buffers, which is a correctness improvement (the fields were already usize-wide, the cast was vestigial from the Zig port).

Level of scrutiny

Medium-low. The fix is the canonical Rust idiom (borrow with a lifetime parameter) applied to two fields whose sibling value already uses that exact shape on the same struct. There is no design decision here — the u32 packing was simply wrong for this use case, and native slices are the obvious replacement. The single caller is updated in lockstep. cargo check/clippy/fmt are reported clean across four rebases.

Other factors

  • No bugs found by the bug hunting system on the current revision.
  • All prior review threads resolved: my earlier inline comments (pre-existing Content-Type early-exit, require("os") → static import) were addressed or acknowledged as out-of-scope; CodeRabbit's cgroup-memory-gate concern was fixed via process.constrainedMemory().
  • Test follows repo conventions: bunExe()/bunEnv, await using proc, concurrent pipe drain, stderr/stdout asserted before exitCode, memory/platform skip gate with a stated reason.
  • use bun_semver; remains because Map<'a> (unrelated to this fix) still names bun_semver::String.
  • The PR has been open two months with four clean rebases and no human reviewer has raised concerns; the diff has only gotten smaller (Zig half deleted when the porting references were removed, raw-pointer + SAFETY comments dropped when Field gained a lifetime parameter upstream).

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Triage note: #21490 was closed as fixed by #27483, which covers the single large file case. The variant this PR fixes is still present on current main (165dc9f): FormData.rs still stores name / filename / content_type as bun_semver::String, and array_buffer.rs still has the u32 casts. With only this PR's test applied to main it fails with the symptom from the description:

keys: ["big_upload", "", ""]        (expected ["big_upload", "description_field", "second_attachment"])
description_field: null

(took about 20 seconds under a debug build here.) Staying open; the branch currently conflicts with main and needs a rebase.

@robobun
robobun force-pushed the farm/fc08f495/formdata-multipart-4gb-offsets branch from 4c84809 to 317c345 Compare August 15, 2026 17:27
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (317c345). One conflict in Field: #36184 narrowed the fields to pub(crate) on the same lines where this PR changes their types; kept both. With #36184's dead-code sweep, the use bun_semver import in FormData.rs has no remaining users once these fields switch to slices, so it's removed too (the crate dependency stays; the CLI modules still use it).

#34362's case-insensitive form-data / HTAB parsing changes merged cleanly alongside this. Post-rebase: cargo clippy / fmt --check clean, build succeeds, the 4 GiB regression test passes, and all 11 adjacent multipart tests (including #34362's) pass.

for_each_multipart_entry stored part name, filename, and content-type as
bun_semver::String, which packs offset and length into 32-bit fields
(with bit 31 of length stolen as a tag). For any part whose header sat
past 4 GiB in the request body, the offset wrapped and the parser read
garbage, so field names came back as bytes from the middle of the
preceding file body and the part was unreachable by name.

This is the remaining half of the bug behind #21490. The file body
itself (Field.value) was already switched to a raw slice in #27483,
which fixed the 2 GiB length truncation, but name/filename/content_type
were still u32-indexed.

Switch all Field slices to plain &'a [u8] borrows on the existing
lifetime-parameterized Field<'a> and drop the subslicer. Also remove a
leftover u32::try_from(len).expect() as usize round-trip in
ArrayBuffer::from_bytes/from_boxed_slice whose target fields are usize;
it panicked on >4 GiB buffers in debug builds on the client-side
FormData serialization path.

Fixes #21490
@robobun
robobun force-pushed the farm/fc08f495/formdata-multipart-4gb-offsets branch from 317c345 to cc060d9 Compare August 15, 2026 17:33

@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 — mechanical type change from u32-packed bun_semver::String to &'a [u8] borrows.

What was reviewed:

  • Field<'a> and for_each_multipart_entry callback signature: only caller is Wrapper::on_entry in the same file; .slice(buf) sites map 1:1 to direct slice use.
  • array_buffer.rs from_bytes/from_owned_bytes: target fields are already usize, so dropping the u32::try_from().expect() round-trip is a pure widening.
  • Test: subprocess-isolated, drains stdout/stderr/exited concurrently, cgroup-aware skip via process.constrainedMemory(), exit code asserted last.
  • Prior inline feedback (static os import, pre-existing Content-Type early-exit) confirmed addressed/out-of-scope.
Extended reasoning...

Overview

Three files: src/runtime/webcore/FormData.rs (~30 lines) switches Field.filename / Field.content_type and the local name in for_each_multipart_entry from bun_semver::String (u32 offset+len packed into a u64) to plain &'a [u8] borrows on the existing lifetime parameter, matching how Field.value already works. The SlicedString subslicer and the buf: &[u8] callback back-reference become dead and are removed along with the use bun_semver import. src/jsc/array_buffer.rs drops a leftover u32::try_from(len).expect() as usize round-trip in from_bytes/from_owned_bytes where the target fields were already usize. test/js/web/html/FormData.test.ts adds a memory-gated subprocess test that builds a 4 GiB + 256 B multipart body and asserts parts past the 4 GiB mark round-trip.

Security risks

None. The parser already operated on the full input via native &[u8] slices for boundary scanning and body extraction; this change only moves three metadata fields off a lossy u32-offset representation onto the same native slice type. No new unsafe, no new allocation, no user-controlled arithmetic introduced.

Level of scrutiny

Low-to-medium. The Rust change is a type substitution whose correctness follows from the fact that &[u8] carries usize pointer+len — every .slice(buf) call site is replaced by the slice itself, and .len() == 0 / .is_empty() map directly. I grep-verified for_each_multipart_entry and Field have no callers outside this file, so the callback signature change is fully self-contained. The array_buffer.rs hunk is a dead-cast removal (target type was already usize).

Other factors

All prior review threads are resolved: CodeRabbit's cgroup-memory gating suggestion was applied (process.constrainedMemory?.() || totalmem()), my earlier require("os") nit was addressed with a static import, and the pre-existing Content-Type early-exit was correctly scoped out (unchanged loop condition, only .len() == 0.is_empty() here). robobun reports the branch green on CI with only fleet-wide flakes unrelated to the multipart parser. The test follows harness conventions (bunExe/bunEnv, await using, concurrent pipe drain, stderr/content asserted before exit code) and is skip-gated on Windows and <16 GiB effective memory.

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 Server Uploads Stop at 1,58 GB Despite Configured Higher Limit

1 participant