Skip to content

FormData: preserve filename when parsing a zero-byte multipart file part - #36435

Open
robobun wants to merge 4 commits into
mainfrom
farm/598ecec6/multipart-empty-file-name
Open

FormData: preserve filename when parsing a zero-byte multipart file part#36435
robobun wants to merge 4 commits into
mainfrom
farm/598ecec6/multipart-empty-file-name

Conversation

@robobun

@robobun robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Problem

Parsing a multipart/form-data body where a file part has a non-empty filename but an empty body produces a File whose .name is undefined. Bun cannot round-trip its own serialization of an empty File:

const ct = { headers: { "content-type": "multipart/form-data; boundary=B" } };
const part = body => `--B\r\nContent-Disposition: form-data; name="z"; filename="z.txt"\r\nContent-Type: text/plain\r\n\r\n${body}\r\n--B--\r\n`;

(await new Response(part(""),  ct).formData()).get("z").name;  // undefined (should be "z.txt")
(await new Response(part("a"), ct).formData()).get("z").name;  // "z.txt"

const fd = new FormData();
fd.append("e", new File([], "empty.log", { type: "text/plain" }));
(await new Response(fd).text()).includes('filename="empty.log"');  // true, serializer emits it
(await new Response(fd).formData()).get("e").name;                 // undefined (should be "empty.log")

Node returns the filename in all of these cases.

Cause

The multipart parser builds a Blob from the part body and passes it to DOMFormData along with the filename. When JS reads the entry, toJS() calls Blob__setAsFile() to attach the filename to the Rust-side Blob. Blob__setAsFile wrote the name onto the blob's byte store via bytes.stored_name, but a zero-byte Blob has no store (Blob::create_with_bytes_and_allocator only allocates one when len > 0), so the name was dropped.

Fix

When Blob__setAsFile is given a non-empty name and the blob has no store, create an empty Bytes store to carry the name. This is the same thing the File constructor (jsdom_file_construct) already does for new File([], name).

Verification

FormData > multipart parse of a zero-byte file part > preserves filename with Response
FormData > multipart parse of a zero-byte file part > preserves filename with Request
FormData > multipart parse of a zero-byte file part > round-trips an empty File through Response.formData()

All three fail on main with Received: undefined and pass with this change. Full FormData.test.ts (152 tests) passes.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/html/FormData.test.ts
bun test v1.4.0 (026a33b90)

test/js/web/html/FormData.test.ts:
(pass) FormData > should be able to append a string [3.46ms]
(pass) FormData > should be able to append a Blob [7.81ms]
(pass) FormData > should be able to set a Blob [6.98ms]
(pass) FormData > should be able to set a string [2.32ms]
(pass) FormData > should get filename from file [3.78ms]
(pass) FormData > should use the correct filenames [4.89ms]
76 |             : new Request("http://x/", { method: "POST", headers, body });
77 | 
78 |         const empty = (await make(part("")).formData()).get("f") as File;
79 |         expect(empty).toBeInstanceOf(File);
80 |         expect(empty.size).toBe(0);
81 |         expect(empty.name).toBe("empty.txt");
                                ^
error: expect(received).toBe(expected)

Expected: "empty.txt"
Received: undefined

      at <anonymous> (/workspace/bun/test/js/web/html/FormData.test.ts:81:28)
(fail) FormData > multipart parse of a zero-byte file part > preserves filename with Response [13.29m
... (truncated)

release without fix: 21 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/web/html/FormData.test.ts:
(pass) FormData > should be able to append a string [0.06ms]
(pass) FormData > should be able to append a Blob [0.15ms]
(pass) FormData > should be able to set a Blob [0.08ms]
(pass) FormData > should be able to set a string [0.04ms]
(pass) FormData > should get filename from file [0.05ms]
(pass) FormData > should use the correct filenames [0.05ms]
76 |             : new Request("http://x/", { method: "POST", headers, body });
77 | 
78 |         const empty = (await make(part("")).formData()).get("f") as File;
79 |         expect(empty).toBeInstanceOf(File);
80 |         expect(empty.size).toBe(0);
81 |         expect(empty.name).toBe("empty.txt");
                                ^
error: expect(received).toBe(expected)

Expected: "empty.txt"
Received: undefined

      at <anonymous> (/workspace/bun/test/js/web/html/FormData.test.ts:81:28)
(fail) FormData > multipart parse of a zero-byte file part > preserves filename with Response [0.43ms]
76 |             : new Request("http://x/", { method: "POST", headers, body });
77 | 
78 |         const empty = (await make(part("")).formData()).get("f") 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/html/FormData.test.ts
bun test v1.4.0 (026a33b90)

test/js/web/html/FormData.test.ts:
(pass) FormData > should be able to append a string [3.32ms]
(pass) FormData > should be able to append a Blob [7.96ms]
(pass) FormData > should be able to set a Blob [6.73ms]
(pass) FormData > should be able to set a string [2.60ms]
(pass) FormData > should get filename from file [4.14ms]
(pass) FormData > should use the correct filenames [5.79ms]
(pass) FormData > multipart parse of a zero-byte file part > preserves filename with Response [13.87ms]
(pass) FormData > multipart parse of a zero-byte file part > preserves filename with Request [4.44ms]
(pass) FormData > multipart parse of a zero-byte file part > round-trips an empty File through Response.formData() [15.13ms]
(pass) FormData > multipart parse of a zero-byte file part > preserves the filename argument on an empty Blob entry [2.82ms]
(pass) FormData > should parse multipart/form-data (simple) with Response [13.81ms]
(pass) FormData > should roundtrip multipart/form-data (simple
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 941ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/115] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[1/115] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m gimli v0.34.0
�[1m�[92m   Compiling�[0m object v0.39.1
�[1m�[92m   Compiling�[0m addr2line v0.27.0
�[1m�[92m   Compiling�[0m std v0.0.0 (/root/.rustup/toolchains/nightly-2026-07-20-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std)
�[1m�[92m   Compiling�[0m proc_macro v0.0.0 (/root/.rustup/toolchains/nightly-2026-07-20-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/proc_macro)
�[1m�[92m   Compiling�[0m bitflags v2.11.1
�[1m�[92m   Compiling�[0m memchr v2.8.0
�[1m�[92m   Compiling�[0m libc v0.2.186
�[1m�[92m   Compiling�[0m konst_macro_rules v0.2.19
�[1m�[92m   Compiling�[0m stru
... (truncated)
diff hotspot
src/runtime/webcore/Blob.rs       | 10 ++++++++
 test/js/web/html/FormData.test.ts | 48 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 58 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                               reads  edits  tests
src/runtime/webcore/Blob.rs            8      3      0
test/js/web/html/FormData.test.ts      3      3      0

Parsing a multipart/form-data body that contains a file part with a
filename but an empty body produced a File whose .name was undefined.

The multipart parser builds a Blob from the part body and hands it to
DOMFormData along with the filename. When JS later reads the entry,
toJS() calls Blob__setAsFile() to attach the filename to the Rust-side
Blob. Blob__setAsFile wrote the name onto the Blob's byte store, but a
zero-byte Blob has no store, so the name was silently dropped.

When there is no store, create an empty Bytes store to carry the name,
matching what the File constructor already does for new File([], name).
@robobun

robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green; ready for review.

Repro (fails on main, passes here):

bun bd test test/js/web/html/FormData.test.ts -t "zero-byte file part"

CI build #85678: no failures in FormData.test.ts or anything this diff touches. The red lanes are a pre-existing worker-transfer-terminate-stress ASAN abort on main plus a handful of unrelated flaky tests that each passed alone or on retry.

Comment thread src/runtime/webcore/Blob.rs Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. FormData/Blob: 5 multipart serialization divergences from the spec, still present on 1.4.0 canary #35439 - Bug 5 (empty Blob loses .name) and Bug 2 (explicit filename argument ignored) in this issue are caused by the same Blob__setAsFile code path this PR fixes
  2. HTML form multipart/form-data with file upload not giving null while file is not attached by the user #16096 - Empty file input submitted via HTML form produces a zero-byte file part; this PR ensures the parsed File retains its filename instead of becoming detached/broken

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #35439
Fixes #16096

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Multipart file part handling

Layer / File(s) Summary
Zero-byte file parsing and round-trip validation
test/js/web/html/FormData.test.ts
Adds tests for filename preservation when parsing empty multipart file parts through Request and Response, plus serialization and deserialization of empty File objects.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title is concise and accurately describes the main fix: preserving filenames for zero-byte multipart file parts.
Description check ✅ Passed The description covers the problem, cause, fix, and verification steps, which matches the template's intent despite different headings.

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

@robobun

robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

On the linked issues:

@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 — targeted fix that mirrors the existing jsdom_file_construct empty-store pattern.

What was reviewed:

  • Confirmed the new branch in Blob__setAsFile is byte-for-byte the same store-creation pattern as jsdom_file_construct (Blob.rs:5627-5636), so ownership/refcount semantics are already proven.
  • Checked both C++ call sites of Blob__setAsFile in blob.cpp — the path_str is copied via to_owned_slice(), no borrow escapes.
  • Verified the !path_str.is_empty() guard intentionally leaves filename="" alone (per the #16096 note).
  • Structured-clone deserialize path was checked for the same bug class and ruled out.
Extended reasoning...

Overview

This PR fixes a bug where parsing a zero-byte multipart file part loses the filename. The change adds a 10-line else if branch to Blob__setAsFile in src/runtime/webcore/Blob.rs: when the blob has no store (the zero-byte case) and a non-empty filename is supplied, it creates an empty Bytes store to carry stored_name. Four new test cases in test/js/web/html/FormData.test.ts cover Request/Response parsing, round-tripping through Response.formData(), and FormData.append(name, emptyBlob, filename).

Security risks

None. This is purely about attaching a filename string to an already-constructed empty Blob. No untrusted-size arithmetic, no new parsing, no allocation sized from external input.

Level of scrutiny

Low-to-medium. The Rust change is a verbatim copy of the pattern already used in jsdom_file_construct (lines 5627-5636) for new File([], name) — same StoreRef::from(Store::new(...)), same init_empty_with_name, same ThreadSafeRefCount::init(), same MimeType::NONE. Since that pattern is already exercised on every new File([], "name") call, the memory-ownership semantics are proven. The branch is only reachable when this.store() is None, so store.set(Some(...)) cannot leak a prior store. path_str.to_owned_slice().into_boxed_slice() copies the C++-owned BunString into a Rust-owned Box<[u8]> freed by Bytes::Drop, matching the existing branch's ownership comment.

Other factors

Tests are well-constructed: they include a 1-byte control case to prove the assertions isolate the zero-byte path, they cover both Request and Response, and the PR description confirms all three fail on main with Received: undefined. The comment-cop feedback about the long comment was addressed (trimmed to one line). The author correctly declined to add Fixes #16096 since that concerns filename="", which this PR intentionally leaves unchanged via the !path_str.is_empty() guard. A finder flagged structured-clone deserialize as a possible sibling site with the same bug class; verifiers ruled it out.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #37680 stops Blob__setAsFile (and the File constructor) from writing names into the byte store at all and puts them on the per-Blob name field instead, which makes the zero-byte case here work without creating a store (it also removes init_empty_with_name, so the two will conflict). Both repros from this description pass on that branch, and it carries a parsed zero-byte part test. If #37680 lands first this one should not be needed.

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