From 73cc514eef78e443ae88519eb11d54e0643ab11c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:40:16 +0000 Subject: [PATCH] FormData: read a Bun.file() part whose stat size is 0 A procfs file is a regular file that reports st_size 0 and has content. The multipart serializer called resolve_size() before the read. That stored the stat size 0 as the size of the blob, so the read got max_size = Some(0) and the part was empty. The serializer no longer resolves the size. A whole-file part reaches NodeFS::read_file with the "size unknown" sentinel, as a fetch body does. read_file is unchanged: a part is still bounded by its scratch buffer when the stat size is wrong, and a device or FIFO is read as before. The FormDataEntry doc comment named the removed call and a type that does not exist. --- src/runtime/webcore/Blob.rs | 8 +--- test/js/web/html/FormData.test.ts | 66 ++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 54b8c275cdf7..091c7c75f791 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3471,10 +3471,7 @@ use self::write_file::{WriteFilePromise, WriteFileWaitFromLockedValueTask}; use bun_bundler::options_impl::LoaderExt as _; use bun_jsc::JsClass as _; -/// Local mirror of `jsc.DOMFormData.FormDataEntry` (`union(enum) { string, file }`). -/// `bun_jsc::dom_form_data::FormDataEntry` carries `&Blob` (immutable) but -/// `FormDataContext::on_entry` needs `&mut Blob` to call `resolve_size()`, so -/// we drive the C++ `DOMFormData__forEach` directly with this mutable variant. +/// One `DOMFormData` entry, as the `DOMFormData__forEach` callback receives it. pub(crate) enum FormDataEntry<'a> { String(EncodedSlice<'a>), File { @@ -3662,9 +3659,6 @@ impl FormDataContext<'_> { joiner.push_static(b"\r\n\r\n"); if blob.store.get().is_some() { - if blob.size.get() == MAX_SIZE { - blob.resolve_size(); - } let store = blob .store .get() diff --git a/test/js/web/html/FormData.test.ts b/test/js/web/html/FormData.test.ts index 311852c31eaa..fb936eb56752 100644 --- a/test/js/web/html/FormData.test.ts +++ b/test/js/web/html/FormData.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isLinux, isWindows, tempDir } from "harness"; +import { readFileSync } from "node:fs"; import { join } from "path"; describe("FormData", () => { @@ -1004,3 +1005,66 @@ describe("USVString conversion of lone surrogates", () => { expect(formData.get("\uFFFD")).toBeNull(); }); }); + +describe("FormData with Bun.file() parts", () => { + async function roundTrip(formData: FormData): Promise { + const res = new Response(formData); + const contentType = res.headers.get("content-type")!; + const wire = await res.bytes(); + return new Response(wire, { headers: { "content-type": contentType } }).formData(); + } + + // procfs files are regular files whose st_size is 0 but that have content. + it.skipIf(!isLinux)("serializes a procfs Bun.file() part with its content", async () => { + const path = "/proc/version"; + const expected = readFileSync(path, "utf8"); + expect(expected.length).toBeGreaterThan(0); + + const formData = new FormData(); + formData.append("f", Bun.file(path), "version.txt"); + formData.append("mem", new Blob(["in-memory"]), "mem.txt"); + + const back = await roundTrip(formData); + expect(await (back.get("f") as File).text()).toBe(expected); + expect(await (back.get("mem") as File).text()).toBe("in-memory"); + }); + + it("serializes a sliced Bun.file() part with only the slice", async () => { + using dir = tempDir("formdata-slice", { "a.txt": "0123456789" }); + const file = Bun.file(join(String(dir), "a.txt")); + + const formData = new FormData(); + formData.append("mid", file.slice(2, 5), "mid.txt"); + formData.append("tail", file.slice(7), "tail.txt"); + formData.append("all", file, "all.txt"); + + const back = await roundTrip(formData); + expect(await (back.get("mid") as File).text()).toBe("234"); + expect(await (back.get("tail") as File).text()).toBe("789"); + expect(await (back.get("all") as File).text()).toBe("0123456789"); + }); + + // /dev/zero has no EOF, so "read to EOF" applies to regular files only. The + // part is read on the JS thread: a read with no bound never returns, so the + // serialize runs in a child that this process can outlive. + it.skipIf(isWindows)("serializes a bounded part for a device with no EOF", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const formData = new FormData(); + formData.append("f", Bun.file("/dev/zero"), "zero.bin"); + console.log((await new Response(formData).bytes()).length);`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const serialized = Number(stdout); + expect(serialized).toBeGreaterThan(0); + expect(serialized).toBeLessThanOrEqual(1024 * 1024); + expect(exitCode).toBe(0); + }); +});