diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 558c917fa360..dfa66e03c31e 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -361,6 +361,16 @@ impl PendingValue { false } + /// The form-data encoding of the Blob behind this body's stream, which is the type [`Self::to_any_blob`] lifts back out. + fn form_data_encoding_behind_stream(&self) -> Option { + let stream = ReadableStream::from_js_direct(self.readable.get()?.value)?; + let webcore::readable_stream::Source::Blob(loader) = stream.ptr else { + return None; + }; + // SAFETY: ptr came from ReadableStreamTag__tagged; valid while stream alive. Read-only. + bun_core::form_data::Encoding::get(unsafe { &*loader }.content_type.as_slice()) + } + fn to_any_blob_allow_promise(&mut self) -> Option { let global = self.global(); let mut stream = self.readable.get()?; @@ -2054,6 +2064,25 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { result } + /// The encoding `formData()` parses with. Without a `Content-Type` header the Blob behind an unread `.body` stream decides, as it does after a lift. + fn form_data_encoding(&self) -> JsResult>> { + if let Some(encoder) = self.get_form_data_encoding()? { + return Ok(Some(encoder)); + } + let Value::Locked(locked) = self.get_body_value() else { + return Ok(None); + }; + if let Some(headers) = BodyMixin::get_fetch_headers(self) { + let headers = bun_opaque::opaque_deref_mut(headers.as_ptr()); + if headers.fast_get(HTTPHeaderName::ContentType).is_some() { + return Ok(None); + } + } + Ok(locked + .form_data_encoding_behind_stream() + .map(bun_core::form_data::AsyncFormData::init)) + } + fn get_form_data( &self, global_object: &JSGlobalObject, @@ -2073,8 +2102,6 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { if let Some(rejected) = handle_body_stream_unusable(&readable, global_object) { return Ok(rejected); } - let value = self.get_body_value(); - value.to_blob_if_possible(); } let value = self.get_body_value(); if let Value::Locked(locked) = value { @@ -2083,12 +2110,11 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { { return Ok(handle_body_already_used(global_object)); } - let value = self.get_body_value(); - value.to_blob_if_possible(); } } - let Some(encoder) = self.get_form_data_encoding()? else { + // Decided before the lift below, which spends an unread `.body` stream. Fetch reads the body first, Bun leaves a body that is not form data as it is. + let Some(encoder) = self.form_data_encoding()? else { // TODO: catch specific errors from getFormDataEncoding return Ok(global_object .err( @@ -2101,6 +2127,9 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { }; let value = self.get_body_value(); + if matches!(value, Value::Locked(_)) { + value.to_blob_if_possible(); + } if let Value::Locked(_locked) = value { let owned_readable = self.get_body_readable_stream(); // reshaped for borrowck — re-borrow after self method call. diff --git a/test/js/web/html/FormData.test.ts b/test/js/web/html/FormData.test.ts index b95654151eae..1c6ec34bd9f3 100644 --- a/test/js/web/html/FormData.test.ts +++ b/test/js/web/html/FormData.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; import { join } from "path"; describe("FormData", () => { @@ -1004,3 +1004,125 @@ describe("USVString conversion of lone surrogates", () => { expect(formData.get("\uFFFD")).toBeNull(); }); }); + +// Fetch's "consume body" reads the body before formData() looks at the type, +// so undici and browsers use the body up. Bun rejects first and has read +// nothing: the body stays as it was. A stream that was taken off `.body` +// before is still the body, unlocked, with every byte. +describe("formData() on a body that is not form data leaves the body as it was", () => { + const notFormData = { "content-type": "application/json" }; + const sources: [string, () => Bun.BodyInit, string][] = [ + ["string", () => "payload", "payload"], + ["Blob", () => new Blob(["payload"]), "payload"], + ["Uint8Array", () => new TextEncoder().encode("payload"), "payload"], + ["URLSearchParams", () => new URLSearchParams("a=1"), "a=1"], + [ + "ReadableStream", + () => + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("payload")); + controller.close(); + }, + }), + "payload", + ], + ["Blob.stream()", () => new Blob(["payload"]).stream(), "payload"], + ['""', () => "", ""], + ]; + const owners: [string, (body: Bun.BodyInit, headers?: Bun.HeadersInit) => Request | Response][] = [ + [ + "Request", + (body, headers) => new Request("http://a/", { method: "POST", body, headers, duplex: "half" } as RequestInit), + ], + ["Response", (body, headers) => new Response(body, { headers } as ResponseInit)], + ]; + const cases = sources.flatMap(([name, init, content]) => [ + [name, "", init, content, false] as const, + [name, " after .body", init, content, true] as const, + ]); + + describe.each(owners)("%s", (ownerName, make) => { + test.each(cases)("%s%s", async (name, when, init, content, touch) => { + const afterFailure = async () => { + const owner = make(init(), notFormData); + const stream = touch ? owner.body! : null; + expect(await owner.formData().then(String, e => e.code)).toBe("ERR_FORMDATA_PARSE_ERROR"); + return { owner, stream }; + }; + const { owner, stream } = await afterFailure(); + expect({ + bodyUsed: owner.bodyUsed, + sameStream: stream === null || owner.body === stream, + locked: owner.body!.locked, + again: await owner.formData().then(String, e => e.code), + clone: await owner.clone().text(), + }).toEqual({ + bodyUsed: false, + sameStream: true, + locked: false, + again: "ERR_FORMDATA_PARSE_ERROR", + clone: content, + }); + expect({ + text: await (await afterFailure()).owner.text(), + stream: await new Response((await afterFailure()).owner.body).text(), + teed: await new Response((await afterFailure()).owner.body!.tee()[0]).text(), + }).toEqual({ text: content, stream: content, teed: content }); + }); + + // With no Content-Type header the body's own type decides, also while the + // body is behind a stream that was read off `.body`. + test.each([ + ["", false], + [" after .body", true], + ] as const)("a body that is form data still parses%s", async (when, touch) => { + const form = new FormData(); + form.append("a", "1"); + const parsed = async (body: Bun.BodyInit, headers?: Bun.HeadersInit) => { + const owner = make(body, headers); + if (touch) owner.body; + return { entries: Object.fromEntries(await owner.formData()), bodyUsed: owner.bodyUsed }; + }; + expect({ + multipart: await parsed(form), + urlencoded: await parsed(new URLSearchParams("a=1")), + // An empty Content-Type header is no Content-Type. + emptyHeader: await parsed(new URLSearchParams("a=1"), { "content-type": "" }), + }).toEqual({ + multipart: { entries: { a: "1" }, bodyUsed: true }, + urlencoded: { entries: { a: "1" }, bodyUsed: true }, + emptyHeader: { entries: { a: "1" }, bodyUsed: true }, + }); + }); + }); + + // The same for a body over a file on disk, and for Bun.write() as the reader. + test.each([ + ["", false], + [" after .body", true], + ] as const)("a Bun.file() body%s", async (when, touch) => { + const text = "every byte of a small file on disk\n"; + using dir = tempDir("body-formdata-file", { "a.txt": text }); + const afterFailure = async () => { + const response = new Response(Bun.file(join(String(dir), "a.txt"))); + const stream = touch ? response.body! : null; + expect(await response.formData().then(String, e => e.code)).toBe("ERR_FORMDATA_PARSE_ERROR"); + return { response, stream }; + }; + const { response, stream } = await afterFailure(); + expect({ + bodyUsed: response.bodyUsed, + sameStream: stream === null || response.body === stream, + locked: response.body!.locked, + }).toEqual({ bodyUsed: false, sameStream: true, locked: false }); + const written = join(String(dir), "out.txt"); + await Bun.write(written, (await afterFailure()).response); + expect({ + text: await (await afterFailure()).response.text(), + stream: await new Response((await afterFailure()).response.body).text(), + teed: await new Response((await afterFailure()).response.body!.tee()[0]).text(), + written: await Bun.file(written).text(), + }).toEqual({ text, stream: text, teed: text, written: text }); + }); +});