Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions src/runtime/webcore/Body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bun_core::form_data::Encoding> {
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())
}
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.

fn to_any_blob_allow_promise(&mut self) -> Option<AnyBlob> {
let global = self.global();
let mut stream = self.readable.get()?;
Expand Down Expand Up @@ -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<Option<Box<bun_core::form_data::AsyncFormData>>> {
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,
Expand All @@ -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 {
Expand All @@ -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(
Expand All @@ -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.
Comment thread
robobun marked this conversation as resolved.
Expand Down
124 changes: 123 additions & 1 deletion test/js/web/html/FormData.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 });
});
});
Loading