Skip to content

Blob: a copy of a File is a plain Blob (file.slice(), new Blob([file]), response.blob()) - #45401

Open
robobun wants to merge 10 commits into
mainfrom
robobun/14cd5d82/file-slice-plain-blob
Open

robobun wants to merge 10 commits into
mainfrom
robobun/14cd5d82/file-slice-plain-blob

Conversation

@robobun

@robobun robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #43691

Part of #35439. Stacked on #45373.

Problem

  • file.slice() returns a File with the whole file's name and lastModified, so each upload chunk goes out as filename="report.pdf". The File API returns a plain Blob.
  • Blob::dupe_with_content_type (src/jsc/webcore_types.rs:356) copies is_jsdom_file, name and last_modified. get_slice_from (src/runtime/webcore/Blob.rs:1800), new Blob([file]) and response.blob() use that copy.

Fix

  • The default copy of a File is a plain view of its bytes. Seven holders that return the File call dupe_with_identity(), and Body.blob() strips the identity.
  • A view has no name. name_detached stops the fallback to the store's path, so a slice of new File([Bun.file(p)], "public.csv") never reports p.
  • Verified: test/js/web/fetch/blob.test.ts, 23 of 26 new cases fail without the fix.
  • Self-reviewed: 7 concerns raised, 5 addressed. The Notes name the other 2.

Background

Downsides

  • file.slice(), new Blob([file]) and response.blob() lose the File's name and are not instanceof File.
  • No File involved: slice() 399 to 395 instructions, 1 more branch. Allocations +0, .text +1,536 bytes, Blob stays 104 bytes.
  • A structured clone record grows by 1 byte. An older Bun cannot read a version 5 record.
Notes

Stacking. The first five commits are #45373 (FormData entry names). Without it, a chunk that loses its name would go out as filename="", which some servers read as a text field (#22762). With it, the chunk goes out as filename="blob", the same as Node. When #45373 merges, I rebase this PR and only its own commits remain.

Question for a maintainer. Blob::dupe() changes meaning for a File source under 32 Rust call sites: the default copy is a byte view, and 7 holders ask for the exact copy. #30328 already makes this change for the brand on the same line. If the changed default is not wanted, the other shape is: dupe() stays exact, and slice (Blob.rs:1803), the Blob constructor fast path (Blob.rs:3113) and Body.blob() call a named plain copy. name_detached, the version 5 byte and the tests stay the same. I can make that change in this PR.

Before and after (repro from the report; Node v26.3.0 gives the "after" column)

Expression main this PR
file.slice(0, 5) instanceof File true false
file.slice(0, 5).name "report.pdf" undefined
file.slice(0, 5).lastModified 1234 the value of a plain Blob
structuredClone(file.slice(0, 5)) instanceof File true false
fd.append("chunk", file.slice(0, 5)), multipart part filename="report.pdf" filename="blob"
new Blob([file]) instanceof File true false
(await new Response(file).blob()) instanceof File true false
Bun.serve with new Response(file.slice(0, 5)) Content-Disposition: filename="report.pdf" no Content-Disposition
Bun.serve with new Response(file) filename="report.pdf" filename="report.pdf"
new File([Bun.file(p)], "public.csv").slice(0, 5).name "public.csv" undefined (never p)
(await new Response(new File([Bun.file(p)], "public.csv").stream()).blob()).name p undefined
Bun.file(p).slice(0, 5).name p p
fd.get("f") of an appended File: name, lastModified kept kept

The 32 dupe() call sites (grep -rnE '\.dupe\(\)|borrowed_view\(\)|dupe_with_content_type\(|Blob::dupe\(|dupe_with_identity\(' src --include=*.rs, without the S3 credential dupes and the definitions)

Keep the identity (dupe_with_identity()), 7 sites:

Site Holder
Blob.rs:4016 (Blob__dupe) FormData entries and fd.get(); the 3 C++ callers are blob.h:53, blob.h:63, blob.cpp:19
Body.rs:1016, Body.rs:1590 a Response or Request body and its clone(): the name is the Content-Disposition filename
ObjectURLRegistry.rs:43, :79 an object URL: the blob: loader reads the name
StaticRoute.rs:196, FileRoute.rs:167 a Response that is also a route gets its body back

Bytes only (the default copy), 25 sites. Nothing after them reads name, last_modified or the brand: the readers are get_name_string, get_file_name (called from RequestContext.rs:3872, get_loader and Any::get_file_name) and structured clone.

Sites Use
Blob.rs:1803 slice(): the result goes to JS as a plain Blob
Blob.rs:3113, :3122 a single Blob part: new Blob([file]) goes to JS as a plain Blob, new File([file], name) sets its own name
Blob.rs:4471, :6240 Bun.write of bytes onto a bytes Blob, and a stream read back as a Blob
Blob.rs:434, :598, :5570 file and S3 read tasks
Blob.rs:1328, :4391, :4392, :4407, :4408, :4792, node/types.rs:1771 Bun.write sources and destinations
node/types.rs:148, :169, :190 BlobOrStringOrBuffer inputs
shell/Builtin.rs:744, :765, shell/states/Cmd.rs:762, spawn/stdio.rs:494 shell redirects and Bun.spawn stdio
StaticRoute.rs:124, FileRoute.rs:181 route bodies
standalone_graph_jsc.rs:78 an embedded file: it is not a File, so the copy keeps its name

The array body literal (Blob.rs:3092, new Response([file])) moves the File into the body and keeps its identity. The new test pins its Content-Disposition filename.

Tests. With the source of #45373, 25 tests fail: 23 in test/js/web/fetch/blob.test.ts, 1 in test/js/web/html/FormData.test.ts and 1 in test/js/web/workers/worker_blob.test.ts. With the source of main, 38 fail. All pass with this PR (346 tests in the 4 files on the debug build).

Measurements. Release builds of #45373 (6e2f30e) and of this PR (734ff52). The instruction counts are a gdb stepi count of one native call after 30 warm-up calls. The counts repeat exactly. malloc calls and syscall instructions do not change on any row.

Native call Instructions Conditional branches Lock-prefixed
blob.slice(0, 5), plain Blob 399 -> 395 44 -> 45 1 -> 1
slice, Bun.file() 413 -> 407 47 -> 48 1 -> 1
slice, File 412 -> 402 45 -> 45 2 -> 1
fd.append(name, blob) 545 -> 554 70 -> 73 9 -> 9
fd.append(name, file) 549 -> 554 66 -> 68 10 -> 10
fd.set(name, blob) 701 -> 711 99 -> 102 12 -> 12
fd.set(name, file) 707 -> 713 96 -> 98 14 -> 14
fd.get(name) 527 -> 521 59 -> 61 6 -> 6
blob.text() 543 -> 543 66 -> 66 3 -> 3
blob.stream() 408 -> 412 44 -> 45 1 -> 1
new Response(blob) 659 -> 650 80 -> 80 1 -> 1
Response.blob() 489 -> 489 48 -> 49 0 -> 0
new Blob([blob]) 579 -> 572 69 -> 70 1 -> 1
Bun.file(p).name 304 -> 308 40 -> 41 1 -> 1

Self-review: the 7 concerns

  1. One PR mixed the FormData fix with the dupe() change. Addressed: FormData: name an unnamed Blob entry "blob", and let the filename argument name a File entry #45373 is separate and this PR is stacked on it.
  2. The body did not name the open PRs in the same code. Addressed below.
  3. The body did not ask the design question. Addressed above.
  4. A Blob rebuilt from an unread stream (new Response(file.stream()).blob(), or .blob() after .body was read) lost the guard and reported the disk path. Addressed: the stream sources carry name_detached. main has the same leak today.
  5. The array body site had no test. Addressed: the /array-body row.
  6. Blob__dupe keeps the identity for every C++ caller. Kept: its 3 callers are the FormData entry paths.
  7. resolveObjectURL(URL.createObjectURL(file)) still returns the File (Node returns a plain Blob). Kept: the cross-thread name test in worker_blob.test.ts needs a visible name on a resolved object URL. It is one line in resolve_and_dupe_to_js if a maintainer wants it.

Not changed here

Other open PRs in the same code


no test proof · iteration 11 · 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

FormData.append(name, blob, filename) stored the filename on the C++
wrapper, and the multipart serializer read it from there. get(),
getAll() and the iterators returned a dupe of the original blob, and
Blob__setAsFile kept the dupe's own name when it had one. So the
filename override reached the wire but not JS.

The entry now owns the name. Blob__setAsFile always sets it on the
dupe that toJS returns. A Blob with no name becomes a File named
"blob", as the create-an-entry algorithm says, so its part serializes
with filename="blob" instead of filename="". An empty filename
argument gives the File an empty name.
A Blob that is not a FormData entry (a WebSocket message) has no
filename, so it keeps having no name. DOMFormData stores a non-null
filename for every entry, so the two cases can be told apart.

The default entry name "blob" is a static string: no allocation per
append.
append() replaced each lone surrogate in the filename. set() stored the
filename as it came. get() returns the entry filename, so the two gave
different names for the same File. Both now use one helper.

toJSNewlyCreated maps a null filename to no name, the same as toJS.

Tests: a lone surrogate through append() and set(), a parsed part with
filename="", and the Content-Disposition filename that Bun.serve sends
for an entry File.
…wn extension

get_loader used the TSX loader for every Blob that has a name with no
known extension, and ignored the type. A FormData entry made from an
unnamed Blob is now a File named "blob", so an import of that entry
through URL.createObjectURL parsed JSON as TSX. The extension decides
when the name has one that maps to a loader. If not, the type decides.

Also shorten the comments of the FormData entry helpers to one line.
They cover an empty File and an empty Blob with a filename argument,
one File appended under two names, and a File over Bun.file() that is
appended with no filename argument.
Blob::dupe() copied the File brand, name and lastModified, so every
producer built on it handed the source File's identity to a new object:
file.slice(), new Blob([file]), and Response/Request.blob().

The default copy of a File is now a plain view of its bytes. The holders
that give the same File back to JS ask for dupe_with_identity(): a
FormData entry, a Response body (for the Content-Disposition filename
and clone()), an object URL, and a route that reuses a Response.
Body.blob() strips the identity where the Blob leaves the body.

A view of a File has no name, so name_detached stops the fallback to
the store's path (a Bun.file path, an S3 key, an embedded file path).
Structured clone carries the flag in a new version 5 byte.
slice, the Blob constructor, Body.blob(), structured clone, FormData
entries and the Content-Disposition filename that Bun.serve sends.
The stream sources keep the store, not the Blob, so the Blob rebuilt
from an unread stream fell back to the store's path. They now carry
name_detached. Also pins the Content-Disposition filename of an array
body.
The loop reads each object URL through fetch().blob() again, and checks
that the Blob has no name.
@robobun

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. It is stacked on #45373, and it asks one design question (see the Background section).

How I reproduced it. On main 5f5799e (debug and release builds) and on Bun 1.4.2:

const file = new File(["0123456789"], "report.pdf", { type: "application/pdf", lastModified: 1234 });
const chunk = file.slice(0, 5);
console.log(chunk instanceof File, chunk.name, chunk.lastModified);
// main:    true "report.pdf" 1234
// this PR: false undefined <the value of a plain Blob>    (Node v26.3.0: false undefined undefined)

const fd = new FormData();
fd.append("chunk", chunk);
console.log((await new Response(fd).text()).match(/filename="[^"]*"/)[0]);
// main:    filename="report.pdf"
// this PR: filename="blob"    (Node prints the same)

The repro script from the report (6 checks on the chunk, 5 controls) ends with exit code 1 on main ("6 of 6 checks treat the chunk as the whole file") and with exit code 0 on this PR.

test/js/web/fetch/blob.test.ts, test/js/web/html/FormData.test.ts and test/js/web/workers/worker_blob.test.ts fail 25 tests with the source of #45373 and pass with this PR.

@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 5464c61e-de28-4a76-993a-02e129c48ffd
📥 Commits

Reviewing files that changed from the base of the PR and between a407eae and 734ff52.

📒 Files selected for processing: 16
  • src/jsc/bindings/DOMFormData.cpp
  • src/jsc/bindings/blob.cpp
  • src/jsc/bindings/webcore/JSDOMFormData.cpp
  • src/jsc/webcore_types.rs
  • src/runtime/server/FileRoute.rs
  • src/runtime/server/StaticRoute.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/ByteBlobLoader.rs
  • src/runtime/webcore/FileReader.rs
  • src/runtime/webcore/ObjectURLRegistry.rs
  • src/runtime/webcore/ReadableStream.rs
  • test/js/web/fetch/blob.test.ts
  • test/js/web/html/FormData-multipart-serialization.test.ts
  • test/js/web/html/FormData.test.ts
  • test/js/web/workers/worker_blob.test.ts

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

Walkthrough

Blob and File copies now track whether File identity and filename state are retained or detached. The change updates Blob conversions, serialization, streams, and server paths. FormData append and set now apply filename conversion and use "blob" when a Blob has no filename.

Changes

Blob and File semantics

Layer / File(s) Summary
Blob identity and filename state
src/jsc/webcore_types.rs, src/runtime/webcore/Blob.rs, test/js/web/fetch/blob.test.ts
Blob copy operations now preserve or remove File identity and metadata according to the copy type. Filename lookup uses detached-name state, and loader selection checks the extension before MIME type.
Identity across Blob lifecycle
src/jsc/bindings/blob.cpp, src/runtime/server/FileRoute.rs, src/runtime/server/StaticRoute.rs, src/runtime/webcore/{Blob,Body,ByteBlobLoader,FileReader,ObjectURLRegistry,ReadableStream}.rs, test/js/web/fetch/blob.test.ts, test/js/web/workers/worker_blob.test.ts
Blob identity and detached-name state are propagated through cloning, serialization, streams, body conversion, object URLs, and response routes. Tests cover these paths and version-4 serialized records.
FormData entry filenames
src/jsc/bindings/DOMFormData.cpp, src/jsc/bindings/webcore/JSDOMFormData.cpp, test/js/web/html/FormData.test.ts, test/js/web/html/FormData-multipart-serialization.test.ts
FormData append and set sanitize filenames and use "blob" when an unnamed Blob has no explicit filename. Tests cover overrides, default and empty names, multipart serialization, and server responses.

Suggested reviewers: dylan-conway, jarred-sumner

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 734ff

The Blob/File and FormData changes are ready to merge after normal checks. Callers that store serialized Blob records should be aware of the documented older-version compatibility limitation.

Pre-merge checks: Passed 3 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Out of Scope Changes check Warning The PR also changes behavior outside #43691. It changes file.slice(), Response.blob(), structured cloning, object URL resolution, stream reconstruction, route bodies, and several other dupe() ho… Remove these unrelated behavior changes from this PR or move them to separate linked issues and pull requests. Keep only the changes required to implement #43691 and their supporting tests.
Passed Passed checks: 3 passed
Check name Status Explanation
Linked Issues check Passed #43691 requires new Blob(parts) to return a plain Blob when a part is a File, and requires FormData.set(name, blobValue, filename) to use the supplied filename. dupe_with_content_type() now …
Title check Passed The title clearly identifies the main change: copied Files become plain Blobs in the listed operations.
Description check Passed The description explains the problem, implementation, scope, tradeoffs, test coverage, and verification results. It does not use the template headings exactly, but it provides the required information…
Full details: Out of Scope Changes check

Explanation

The PR also changes behavior outside #43691. It changes file.slice(), Response.blob(), structured cloning, object URL resolution, stream reconstruction, route bodies, and several other dupe() holders. It also changes get_loader() precedence from filename-first fallback behavior. These changes are not required to make new Blob([file]) a plain Blob or to make FormData.set() honor its filename.

  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the version-5 structured-clone pair (the trailing name_detached byte is written at Blob.rs:773 and read only for version >= 5 at Blob.rs:3935, with versions 1-4 still breaking out before it) and the ByteBlobLoader/FileReader/ReadableStream blob-to-stream-to-blob round trip, where the flag is set from is_file_or_view() at construction and copied back at every rebuild site. The Content-Disposition change for new Response(file.stream()) over a Bun.file()-backed File and the absolute-path multipart filename for a bare Bun.file() entry were looked at and are respectively intended by this PR's contract and pre-existing on the base branch.

Extended reasoning...

The PR changes the default semantics of Blob::dupe() for File sources across 32 Rust call sites, adds a name_detached field with a serialization version bump, and reworks FormData entry naming in C++ and Rust; it touches no auth, crypto or injection surface. The inline findings already call for a human look, and the PR itself asks a maintainer whether the changed dupe() default is wanted, so this note only records what else was examined.

Comment thread src/jsc/webcore_types.rs
Comment on lines 472 to +476
let name = self.name.get();
if !name.is_empty() {
return Some(name.to_utf8());
}
self.store_path().map(bun_core::Utf8Bytes::Borrowed)
self.store_name().map(bun_core::Utf8Bytes::Borrowed)

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.

🟡 (optional) A server that returns a FormData entry appended with an explicit empty filename now sends the underlying disk file's basename in Content-Disposition, where the base branch sent the File's own name. Blob__setAsFile at src/runtime/webcore/Blob.rs:4008 now overwrites name whenever the entry filename is not Dead, so fd.append(k, new File([Bun.file(p)], "public.csv"), "") followed by fd.get(k) yields a File whose name is "". get_file_name (src/jsc/webcore_types.rs:472-476) treats "" as no name and falls back to store_name, which is still the path because the identity copy is not name_detached. …

Why this was flagged

…Fix: an entry whose filename is "" must not fall back to the store path; either mark the store-path fallback off when a File has an explicit (even empty) name, or make get_file_name return None for a File with an empty name at every site (RequestContext.rs:3872, multipart entryFileName).

Trigger: fd.append("f", new File([Bun.file("/srv/private/report-q3.csv")], "public.csv"), ""), then new Response(fd.get("f")) served by Bun.serve. On base, Blob__setAsFile only set the name when path_str was non-empty and no name existed, so fd.get kept "public.csv" and the response carried filename="public.csv". After the diff, src/runtime/webcore/Blob.rs:4008 sets name to "" because the sanitized entry filename is emptyString() (src/jsc/bindings/DOMFormData.cpp:103), not Dead. fd.get returns dupe_with_identity() (Blob.rs:4016) so name_detached stays false, and src/jsc/webcore_types.rs:472-476 falls through store_name() to the Bun.file path; src/runtime/server/RequestContext.rs:3872-3888 writes content-disposition: filename="report-q3.csv".

Verification: nit — trigger: fd.append(k, new File([Bun.file(p)], "x"), "") (explicit empty filename on a File that wraps a disk-backed Bun.file) followed by new Response(fd.get(k)) served by Bun.serve, with a content type whose category autosets the filename (application/*, octet-stream — NOT text/csv, see below).

Mechanism verified:

  • src/jsc/bindings/webcore/JSDOMFormData.cpp:291-292 entryFileName: a non-undefined filename arg wins → "" (non-null empty WTF::String).

Comment on lines +1962 to 1969
let from_extension = self.get_file_name().and_then(|filename| {
bun_resolver::fs::Path::init(&filename).loader(&jsc_vm.transpiler.options.loaders)
});
if let Some(loader) = from_extension {
return Some(loader);
}
if let Some(mime_type) = self.get_mime_type_or_content_type() {
return Some(bun_ast::Loader::from_mime_type(mime_type));

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.

🟡 (optional) Users who import or run a Worker from an object URL of a named File with an unknown-extension name and a text/* type get a string module instead of executing their code. In get_loader (src/runtime/webcore/Blob.rs:1962-1969) a name whose extension maps to no loader now falls through to the type, and Loader::from_mime_type turns any Category::Text type (text/plain, text/typescript, text/x-...) into Loader::Text. On the base branch a named Blob always got Loader::Tsx there. Fix: when the name has no known extension, let the type pick the loader only when it maps to a code or data loader, and keep the permissive Tsx fallback for Category::Text types so named Files behave as before while fd.get() entries named "blob" with application/json still load as JSON.

Why this was flagged

A user creates new File([code], "worker", { type: "text/plain" }) (or type "text/typescript") and passes URL.createObjectURL(file) to new Worker() or import(); the blob: specifier reaches get_loader through src/runtime/jsc_hooks.rs:3970. src/runtime/webcore/Blob.rs:1962 computes from_extension, which is None for "worker", so line 1968 takes get_mime_type_or_content_type and line 1969 calls Loader::from_mime_type. src/bundler/options.rs:402-403 maps mime_type.category == Category::Text to Loader::Text. The module is then loaded as text: import() resolves to a string and a Worker runs no code. The base branch's get_loader returned Some(Tsx) via unwrap_or whenever get_file_name was Some, so the same File executed as TSX. The new test at test/js/web/fetch/blob.test.ts only covers application/json, which lands on the Json loader, so the Category::Text regression is unpinned.

Verification: Triggered when a blob: URL of a named File whose name has no known extension and whose type is a non-JS text/* is imported or used as a Worker script. Base: any named Blob got Tsx. Head src/runtime/webcore/Blob.rs:1962-1970: when from_extension is None it falls to Loader::from_mime_type, and src/bundler/options.rs:402-403 turns Category::Text into Loader::Text, so the File loads as a text module instead of executing.

Comment on lines 3102 to +3105
),
charset: Cell::new(blob.charset.get()),
is_jsdom_file: Cell::new(blob.is_jsdom_file.get()),
name_detached: Cell::new(blob.name_detached.get()),

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.

🟣 pre-existing, not blocking: pre-existing: a user who passes a Blob or File inside an array body, new Response([file]), finds their original object emptied afterwards. The single-element array fast path at src/runtime/webcore/Blob.rs:3098 calls blob.take_store() on the caller's own JS Blob when MOVE is true, which nulls that object's store while its size stays. Fix: the array body path must copy the store ref (dupe_with_identity()-style +1) instead of moving it out of a JS-owned Blob, so file.text() still returns the bytes after the Response is built, while the body still keeps the File's name as the new /array-body test pins.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: const f = new File(["abc"], "a.txt"); const r = new Response([f]); await f.text(). Body.rs:1056 sends an array body to Blob::get::<true, false> (from_js_move). In Blob.rs:3035-3043 a one-element array sets top_value to the user's File, and at Blob.rs:3084-3098 the MOVE arm does store: JsCell::new(blob.take_store()), where take_store (src/jsc/webcore_types.rs:264) is self.store.replace(None) on the JS wrapper's Blob. After the call the user's File has store == None and size == 3; shared_view() (webcore_types.rs:321-324) returns b"", so f.text() yields "" and f.size still reports 3. This diff does not introduce the move (it only adds the name_detached copy at Blob.rs:3105), so the consequence is the same on the base branch; the PR text calls this path 'moves the File into the body' and the new test at test/js/web/fetch/blob.test.ts:1153 only uses a temporary makeFile(), so nothing exercises the source after the move. No safeguard prevents it: the non-array path (Body.rs:1012) copies with dupe_with_identity(), only the array literal moves.

Verification: pre-existing. Trigger: any user passes their own Blob/File as a single-element array body and then reads the original object afterwards. At src/runtime/webcore/Blob.rs:3078-3098 the DOMWrapper arm builds the body Blob with store: JsCell::new(blob.take_store()); take_store is self.store.replace(None) on the caller's object, so file.text() resolves to "".

Comment on lines 4006 to 4011
pub(crate) extern "C" fn Blob__setAsFile(this: &mut Blob, path_str: &BunString) {
this.is_jsdom_file.set(true);
if !path_str.is_empty() && this.get_file_name().is_none() {
if path_str.tag() != bun_core::Tag::Dead {
this.name.set(path_str.clone());
}
}

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.

🟣 pre-existing, not blocking: pre-existing: WebSocket binaryType = "blob" message data and any other non-FormData Blob handed to toJS(Blob&) is reported as instanceof File even though it has no name. Blob__setAsFile at src/runtime/webcore/Blob.rs:4008 sets is_jsdom_file to true unconditionally, and src/jsc/bindings/blob.cpp:20 now calls it for every Blob, passing a Dead string for the not-an-entry case. Fix: brand the copy as a File only when the entry filename is not Dead (or skip Blob__setAsFile in blob.cpp when entryName.isNull()), while keeping FormData entries, including those with an empty filename, as Files.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A client sets ws.binaryType = "blob" and the server sends a binary frame. src/jsc/bindings/webcore/WebSocket.cpp:1291 builds a WebCore::Blob with a null fileName and dispatches a MessageEvent; src/jsc/bindings/webcore/JSMessageEventCustom.cpp:64 converts it with toJS<IDLInterface<Blob>>, which lands in toJS(JSC::JSGlobalObject*, JSDOMGlobalObject*, WebCore::Blob&) at src/jsc/bindings/blob.cpp:15. The new code builds BunString { BunStringTag::Dead } via fileNameOrDead and calls Blob__setAsFile(dupe, &filename) at blob.cpp:20. Blob__setAsFile at src/runtime/webcore/Blob.rs:4008 runs this.is_jsdom_file.set(true) before the Dead check, so event.data instanceof File is true and event.data.name is undefined. The base branch already branded the shared impl the same way, so this is pre-existing, but the PR's own comment at blob.cpp:9 says a null fileName means "not a FormData entry", and the new FormData test at test/js/web/html/FormData.test.ts ("a WebSocket Blob message has no name") only asserts name and never checks instanceof File, so the mismatch is not caught.

Verification: pre-existing. Trigger: a WebSocket client with binaryType = "blob" receives a binary frame. toJS(Blob&) at src/jsc/bindings/blob.cpp:15 calls Blob__setAsFile(dupe, &filename) for every Blob, with a Dead string for the null-name case. src/runtime/webcore/Blob.rs:4006-4011 sets is_jsdom_file unconditionally, so event.data instanceof File === true. No test in test/js/web/websocket pins either behavior.

if path_str.tag() != bun_core::Tag::Dead {
this.name.set(path_str.clone());
}
}

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.

🟣 pre-existing, not blocking: Callers of fd.get() on an entry appended as a plain Blob receive a File whose lastModified is 0 (1970) rather than the time of the append, which browsers and Node return. Blob__setAsFile at src/runtime/webcore/Blob.rs:4007-4011 sets is_jsdom_file and name but never sets last_modified, while the lastModified getter at Blob.rs:1997-1998 reports the raw field once is_jsdom_file is true. Fix: when a Blob that was not a File is turned into an entry File, set last_modified to the current time (the create-an-entry step this PR implements), for both append and set entries.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: fd.append("f", new Blob(["x"])) then fd.get("f").lastModified, or multipart parsing via WebCore__DOMFormData__appendBlob (bindings.cpp:6306) followed by get. toJS(Blob&) at src/jsc/bindings/blob.cpp:19-20 dupes and calls Blob__setAsFile; at Blob.rs:4007 is_jsdom_file becomes true but last_modified stays the copied 0.0 from copy_fields (src/jsc/webcore_types.rs:397-401). The getter at Blob.rs:1997 returns 0 for any is_jsdom_file Blob, so the File reports epoch 0, and serialization at Blob.rs:759 persists it. The base branch had the same gap, but this PR rewrites the entry-creation path under the spec comment at JSDOMFormData.cpp:288 and adds tests pinning name, without the sibling lastModified rule from the same spec step. Population: every FormData consumer reading lastModified from a parsed or appended entry. Remedy: set last_modified to now in Blob__setAsFile when the source was not already a File.

Verification: pre-existing — triggered whenever a plain Blob (not a File) is appended to FormData and then read back via fd.get(). Blob__setAsFile (src/runtime/webcore/Blob.rs:4006-4011) sets is_jsdom_file and name but never touches last_modified, so the entry File reports lastModified === 0. No test in test/js/web/html/FormData.test.ts asserts lastModified for a plain-Blob entry.

@robobun

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:14 PM PT - Oct 15th, 2026

✅ @robobun, your commit 734ff524ee86d8b284227d587cedbd5e00e7f86b passed in Build #126162! 🎉


🧪   To try this PR locally:

bunx bun-pr 45401

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

bun-45401 --bun

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

new Blob([file]) returns a File, and FormData.set(name, value, filename) then ignores the filename

2 participants