mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
## Summary - Fix crash in `FormData.from()` when called with very large ArrayBuffer input - Add length check in C++ `toString` function against both Bun's synthetic limit and WebKit's `String::MaxLength` - For UTF-8 tagged strings, use simdutf to calculate actual UTF-16 length only when byte length exceeds the limit ## Root Cause When `FormData.from()` was called with a very large ArrayBuffer (e.g., `new Uint32Array(913148244)` = ~3.6GB), the code would crash with: ``` ASSERTION FAILED: data.size() <= MaxLength vendor/WebKit/Source/WTF/wtf/text/StringImpl.h(886) ``` The `toString()` function in `helpers.h` was only checking against `Bun__stringSyntheticAllocationLimit` (which defaults to ~4GB), but not against WebKit's `String::MaxLength` (INT32_MAX, ~2GB). When the input exceeded `String::MaxLength`, `createWithoutCopying()` would fail with an assertion. ## Changes 1. **helpers.h**: Added `|| str.len > WTF::String::MaxLength` checks to all three code paths in `toString()`: - UTF-8 tagged pointer path (with simdutf length calculation only when needed) - External pointer path - Non-copying creation path 2. **url.zig**: Reverted the incorrect Zig-side check (UTF-8 byte length != UTF-16 character length) ## Test plan - [x] Added test that verifies FormData.from with oversized input doesn't crash - [x] Verified original crash case now returns empty FormData instead of crashing: ```js const v3 = new Uint32Array(913148244); FormData.from(v3); // No longer crashes ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
786 lines
26 KiB
TypeScript
786 lines
26 KiB
TypeScript
import { describe, expect, it, test } from "bun:test";
|
|
import { join } from "path";
|
|
|
|
describe("FormData", () => {
|
|
it("should be able to append a string", () => {
|
|
const formData = new FormData();
|
|
formData.append("foo", "bar");
|
|
expect(formData.get("foo")).toBe("bar");
|
|
expect(formData.getAll("foo")[0]).toBe("bar");
|
|
});
|
|
|
|
it("should be able to append a Blob", async () => {
|
|
const formData = new FormData();
|
|
formData.append("foo", new Blob(["bar"]), "mynameis.txt");
|
|
expect(await ((await formData.get("foo")) as Blob)!.text()).toBe("bar");
|
|
expect(formData.getAll("foo")[0] instanceof Blob).toBe(true);
|
|
expect(formData.getAll("foo")[0] instanceof File).toBe(true);
|
|
expect((formData.getAll("foo")[0] as File).name).toBe("mynameis.txt");
|
|
});
|
|
|
|
it("should be able to set a Blob", async () => {
|
|
const formData = new FormData();
|
|
formData.set("foo", new Blob(["bar"]));
|
|
expect(await ((await formData.get("foo")) as Blob).text()).toBe("bar");
|
|
expect(formData.getAll("foo")[0] instanceof Blob).toBe(true);
|
|
});
|
|
|
|
it("should be able to set a string", async () => {
|
|
const formData = new FormData();
|
|
formData.set("foo", "bar");
|
|
expect(formData.get("foo")).toBe("bar");
|
|
expect(formData.getAll("foo")[0]).toBe("bar");
|
|
});
|
|
|
|
it("should get filename from file", async () => {
|
|
const blob = new Blob(["bar"]);
|
|
const formData = new FormData();
|
|
formData.append("foo", blob);
|
|
// @ts-expect-error
|
|
expect(formData.get("foo").name).toBeUndefined();
|
|
formData.append("foo2", new File([blob], "foo.txt"));
|
|
// @ts-expect-error
|
|
expect(formData.get("foo2").name).toBe("foo.txt");
|
|
});
|
|
|
|
it("should use the correct filenames", async () => {
|
|
const blob = new Blob(["bar"]) as any;
|
|
const form = new FormData();
|
|
form.append("foo", blob);
|
|
expect(blob.name).toBeUndefined();
|
|
|
|
let b1 = form.get("foo") as any;
|
|
expect(blob.name).toBeUndefined();
|
|
expect(b1.name).toBeUndefined();
|
|
|
|
form.set("foo", b1, "foo.txt");
|
|
expect(blob.name).toBeUndefined();
|
|
expect(b1.name).toBeUndefined();
|
|
|
|
b1 = form.get("foo") as Blob;
|
|
expect(blob.name).toBeUndefined();
|
|
expect(b1.name).toBe("foo.txt");
|
|
});
|
|
|
|
const multipartFormDataFixturesRawBody = [
|
|
{
|
|
name: "simple",
|
|
body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo--\r\n',
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
expected: {
|
|
foo: "bar",
|
|
},
|
|
},
|
|
{
|
|
name: "simple with trailing CRLF",
|
|
body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo--\r\n\r\n',
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
expected: {
|
|
foo: "bar",
|
|
},
|
|
},
|
|
{
|
|
name: "simple with trailing CRLF and extra CRLF",
|
|
body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo--\r\n\r\n\r\n',
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
expected: {
|
|
foo: "bar",
|
|
},
|
|
},
|
|
{
|
|
name: "advanced",
|
|
body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo\r\nContent-Disposition: form-data; name="baz"\r\n\r\nqux\r\n--foo--\r\n',
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
expected: {
|
|
foo: "bar",
|
|
baz: "qux",
|
|
},
|
|
},
|
|
{
|
|
name: "advanced with multiple values",
|
|
body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbaz\r\n--foo--\r\n',
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
expected: {
|
|
foo: ["bar", "baz"],
|
|
},
|
|
},
|
|
{
|
|
name: "advanced with multiple values and trailing CRLF",
|
|
body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbaz\r\n--foo--\r\n\r\n',
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
expected: {
|
|
foo: ["bar", "baz"],
|
|
},
|
|
},
|
|
{
|
|
name: "extremely advanced",
|
|
body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo\r\nContent-Disposition: form-data; name="baz"\r\n\r\nqux\r\n--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbaz\r\n--foo--\r\n',
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
expected: {
|
|
foo: ["bar", "baz"],
|
|
baz: "qux",
|
|
},
|
|
},
|
|
{
|
|
name: "with name and filename",
|
|
body: '--foo\r\nContent-Disposition: form-data; name="foo"; filename="bar"\r\n\r\nbaz\r\n--foo--\r\n',
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
expected: {
|
|
foo: new Blob(["baz"]),
|
|
},
|
|
},
|
|
{
|
|
name: "with name and filename and trailing CRLF",
|
|
body: '--foo\r\nContent-Disposition: form-data; name="foo"; filename="bar"\r\n\r\nbaz\r\n--foo--\r\n\r\n',
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
expected: {
|
|
foo: new Blob(["baz"]),
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const { name, body, headers, expected: expected_ } of multipartFormDataFixturesRawBody) {
|
|
const Class = [Response, Request] as const;
|
|
for (const C of Class) {
|
|
it(`should parse multipart/form-data (${name}) with ${C.name}`, async () => {
|
|
const response =
|
|
C === Response ? new Response(body, { headers }) : new Request({ headers, body, url: "http://hello.com" });
|
|
const formData = await response.formData();
|
|
expect(formData instanceof FormData).toBe(true);
|
|
const entry: { [k: string]: any } = {};
|
|
const expected: { [k: string]: any } = Object.assign({}, expected_);
|
|
|
|
for (const key of formData.keys()) {
|
|
const values = formData.getAll(key);
|
|
if (values.length > 1) {
|
|
entry[key] = values;
|
|
} else {
|
|
entry[key] = values[0];
|
|
if (entry[key] instanceof Blob) {
|
|
expect(expected[key] instanceof Blob).toBe(true);
|
|
|
|
entry[key] = await entry[key].text();
|
|
expected[key] = await expected[key].text();
|
|
} else {
|
|
expect(typeof entry[key]).toBe(typeof expected[key]);
|
|
expect(expected[key] instanceof Blob).toBe(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(entry).toEqual(expected);
|
|
});
|
|
|
|
it(`should roundtrip multipart/form-data (${name}) with ${C.name}`, async () => {
|
|
const response =
|
|
C === Response ? new Response(body, { headers }) : new Request({ headers, body, url: "http://hello.com" });
|
|
const formData = await response.formData();
|
|
expect(formData instanceof FormData).toBe(true);
|
|
|
|
const request = await new Response(formData).formData();
|
|
expect(request instanceof FormData).toBe(true);
|
|
|
|
const aKeys = Array.from(formData.keys());
|
|
const bKeys = Array.from(request.keys());
|
|
expect(aKeys).toEqual(bKeys);
|
|
|
|
for (const key of aKeys) {
|
|
const aValues = formData.getAll(key);
|
|
const bValues = request.getAll(key);
|
|
for (let i = 0; i < aValues.length; i++) {
|
|
const a = aValues[i];
|
|
const b = bValues[i];
|
|
if (a instanceof Blob) {
|
|
expect(b instanceof Blob).toBe(true);
|
|
expect(await a.text()).toBe(await (b as Blob).text());
|
|
} else {
|
|
expect(a).toBe(b);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test that it also works with Blob.
|
|
const c = await new Blob([body], { type: headers["Content-Type"] }).formData();
|
|
expect(c instanceof FormData).toBe(true);
|
|
const cKeys = Array.from(c.keys());
|
|
expect(cKeys).toEqual(bKeys);
|
|
for (const key of cKeys) {
|
|
const cValues = c.getAll(key);
|
|
const bValues = request.getAll(key);
|
|
for (let i = 0; i < cValues.length; i++) {
|
|
const c = cValues[i];
|
|
const b = bValues[i];
|
|
if (c instanceof Blob) {
|
|
expect(b instanceof Blob).toBe(true);
|
|
expect(await c.text()).toBe(await (b as Blob).text());
|
|
} else {
|
|
expect(c).toBe(b);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
it("should throw on missing final boundary", async () => {
|
|
const response = new Response('-foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n', {
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
});
|
|
try {
|
|
await response.formData();
|
|
throw "should have thrown";
|
|
} catch (e: any) {
|
|
expect(typeof e.message).toBe("string");
|
|
}
|
|
});
|
|
|
|
test("FormData.from (URLSearchParams)", () => {
|
|
expect(
|
|
// @ts-expect-error
|
|
FormData.from(
|
|
new URLSearchParams({
|
|
a: "b",
|
|
c: "d",
|
|
}).toString(),
|
|
).toJSON(),
|
|
).toEqual({
|
|
a: "b",
|
|
c: "d",
|
|
});
|
|
});
|
|
|
|
test("FormData.toJSON doesn't crash with numbers", () => {
|
|
const fd = new FormData();
|
|
// @ts-expect-error
|
|
fd.append(1, 1);
|
|
// @ts-expect-error
|
|
expect(fd.toJSON()).toEqual({ "1": "1" });
|
|
});
|
|
|
|
test("FormData.from throws on very large input instead of crashing", () => {
|
|
// This test verifies that FormData.from throws an exception instead of crashing
|
|
// when given input larger than WebKit's String::MaxLength (INT32_MAX ~= 2GB).
|
|
// We use a smaller test case with the synthetic limit to avoid actually allocating 2GB+.
|
|
const { setSyntheticAllocationLimitForTesting } = require("bun:internal-for-testing");
|
|
// Set a small limit so we can test the boundary without allocating gigabytes
|
|
const originalLimit = setSyntheticAllocationLimitForTesting(1024 * 1024); // 1MB limit
|
|
try {
|
|
// Create a buffer larger than the limit
|
|
const largeBuffer = new Uint8Array(2 * 1024 * 1024); // 2MB
|
|
// @ts-expect-error - FormData.from is a Bun extension
|
|
expect(() => FormData.from(largeBuffer)).toThrow("Cannot create a string longer than");
|
|
} finally {
|
|
setSyntheticAllocationLimitForTesting(originalLimit);
|
|
}
|
|
});
|
|
|
|
it("should throw on bad boundary", async () => {
|
|
const response = new Response('foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n', {
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
});
|
|
try {
|
|
await response.formData();
|
|
throw "should have thrown";
|
|
} catch (e: any) {
|
|
expect(typeof e.message).toBe("string");
|
|
}
|
|
});
|
|
|
|
it("should throw on bad header", async () => {
|
|
const response = new Response('foo\r\nContent-Disposition: form-data; name"foo"\r\n\r\nbar\r\n', {
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
});
|
|
try {
|
|
await response.formData();
|
|
throw "should have thrown";
|
|
} catch (e: any) {
|
|
expect(typeof e.message).toBe("string");
|
|
}
|
|
});
|
|
|
|
it("file upload on HTTP server (receive)", async () => {
|
|
using server = Bun.serve({
|
|
port: 0,
|
|
development: false,
|
|
async fetch(req) {
|
|
const formData = await req.formData();
|
|
return new Response(formData.get("foo"));
|
|
},
|
|
});
|
|
|
|
const reqBody = new Request(server.url, {
|
|
body: '--foo\r\nContent-Disposition: form-data; name="foo"; filename="bar"\r\n\r\nbaz\r\n--foo--\r\n\r\n',
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
method: "POST",
|
|
});
|
|
|
|
const res = await fetch(reqBody);
|
|
const body = await res.text();
|
|
expect(body).toBe("baz");
|
|
});
|
|
|
|
it("file send on HTTP server (receive)", async () => {
|
|
using server = Bun.serve({
|
|
port: 0,
|
|
development: false,
|
|
async fetch(req) {
|
|
const formData = await req.formData();
|
|
return new Response(formData);
|
|
},
|
|
});
|
|
|
|
const reqBody = new Request(server.url, {
|
|
body: '--foo\r\nContent-Disposition: form-data; name="foo"; filename="bar"\r\n\r\nbaz\r\n--foo--\r\n\r\n',
|
|
headers: {
|
|
"Content-Type": "multipart/form-data; boundary=foo",
|
|
},
|
|
method: "POST",
|
|
});
|
|
|
|
const res = await fetch(reqBody);
|
|
const body = await res.formData();
|
|
expect(await (body.get("foo") as Blob).text()).toBe("baz");
|
|
});
|
|
type FetchReqArgs = [request: Request, init?: RequestInit];
|
|
type FetchURLArgs = [url: string | URL | Request, init?: RequestInit];
|
|
for (let useRequestConstructor of [true, false]) {
|
|
describe(useRequestConstructor ? "Request constructor" : "fetch()", () => {
|
|
function send(args: FetchReqArgs | FetchURLArgs) {
|
|
if (useRequestConstructor) {
|
|
return fetch(new Request(...(args as FetchReqArgs)));
|
|
} else {
|
|
return fetch(...(args as FetchURLArgs));
|
|
}
|
|
}
|
|
for (let headers of [
|
|
{} as {},
|
|
undefined,
|
|
new Headers(),
|
|
new Headers({ x: "y" }),
|
|
new Headers([["x", "y"]]),
|
|
{ X: "Y" },
|
|
{ headers: { X: "Y" } },
|
|
]) {
|
|
describe("headers: " + Bun.inspect(headers).replaceAll(/([\n ])/gim, ""), () => {
|
|
it("send on HTTP server with FormData & Blob (roundtrip)", async () => {
|
|
let contentType = "";
|
|
using server = Bun.serve({
|
|
port: 0,
|
|
development: false,
|
|
async fetch(req) {
|
|
const formData = await req.formData();
|
|
contentType = req.headers.get("Content-Type")!;
|
|
return new Response(formData);
|
|
},
|
|
});
|
|
|
|
const form = new FormData();
|
|
form.append("foo", new Blob(["baz"], { type: "text/plain" }), "bar");
|
|
form.append("bar", "baz");
|
|
|
|
// @ts-ignore
|
|
const reqBody: FetchURLArgs = [
|
|
server.url,
|
|
{
|
|
body: form,
|
|
headers,
|
|
method: "POST",
|
|
},
|
|
];
|
|
const res = await send(reqBody);
|
|
const body = await res.formData();
|
|
expect(await (body.get("foo") as Blob).text()).toBe("baz");
|
|
expect(body.get("bar")).toBe("baz");
|
|
});
|
|
|
|
it("send on HTTP server with FormData & Bun.file (roundtrip)", async () => {
|
|
let contentType = "";
|
|
using server = Bun.serve({
|
|
port: 0,
|
|
development: false,
|
|
async fetch(req) {
|
|
const formData = await req.formData();
|
|
contentType = req.headers.get("Content-Type")!;
|
|
return new Response(formData);
|
|
},
|
|
});
|
|
|
|
const form = new FormData();
|
|
const file = Bun.file(import.meta.dir + "/form-data-fixture.txt");
|
|
const text = await file.text();
|
|
form.append("foo", file);
|
|
form.append("bar", "baz");
|
|
|
|
const reqBody = [
|
|
server.url,
|
|
{
|
|
body: form,
|
|
|
|
headers,
|
|
method: "POST",
|
|
},
|
|
];
|
|
const res = await send(reqBody as FetchURLArgs);
|
|
const body = await res.formData();
|
|
expect(await (body.get("foo") as Blob).text()).toBe(text);
|
|
expect(contentType).toContain("multipart/form-data");
|
|
expect(body.get("bar")).toBe("baz");
|
|
expect(contentType).toContain("multipart/form-data");
|
|
});
|
|
|
|
it("send on HTTP server with FormData (roundtrip)", async () => {
|
|
let contentType = "";
|
|
using server = Bun.serve({
|
|
port: 0,
|
|
development: false,
|
|
async fetch(req) {
|
|
const formData = await req.formData();
|
|
contentType = req.headers.get("Content-Type")!;
|
|
return new Response(formData);
|
|
},
|
|
});
|
|
|
|
const form = new FormData();
|
|
form.append("foo", "boop");
|
|
form.append("bar", "baz");
|
|
|
|
// @ts-ignore
|
|
const reqBody = [
|
|
server.url,
|
|
{
|
|
body: form,
|
|
|
|
headers,
|
|
method: "POST",
|
|
},
|
|
];
|
|
const res = await send(reqBody as FetchURLArgs);
|
|
const body = await res.formData();
|
|
expect(contentType).toContain("multipart/form-data");
|
|
expect(body.get("foo")).toBe("boop");
|
|
expect(body.get("bar")).toBe("baz");
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
describe("Bun.file support", () => {
|
|
describe("roundtrip", () => {
|
|
const path = import.meta.dir + "/form-data-fixture.txt";
|
|
for (const C of [Request, Response]) {
|
|
it(`with ${C.name}`, async () => {
|
|
await Bun.write(path, "foo!");
|
|
const formData = new FormData();
|
|
formData.append("foo", Bun.file(path));
|
|
const response =
|
|
C === Response ? new Response(formData) : new Request({ body: formData, url: "http://example.com" });
|
|
expect(response.headers.get("content-type")?.startsWith("multipart/form-data;")).toBe(true);
|
|
|
|
const formData2 = await response.formData();
|
|
expect(formData2 instanceof FormData).toBe(true);
|
|
expect(formData2.get("foo") instanceof Blob).toBe(true);
|
|
expect(await (formData2.get("foo") as Blob).text()).toBe("foo!");
|
|
});
|
|
}
|
|
});
|
|
|
|
it("doesnt crash when file is missing", async () => {
|
|
const formData = new FormData();
|
|
formData.append("foo", Bun.file("missing"));
|
|
expect(() => new Response(formData)).toThrow();
|
|
});
|
|
});
|
|
|
|
it("Bun.inspect", () => {
|
|
const formData = new FormData();
|
|
formData.append("foo", "bar");
|
|
formData.append("foo", new Blob(["bar"]));
|
|
formData.append("bar", "baz");
|
|
formData.append("boop", Bun.file("missing"));
|
|
expect(Bun.inspect(formData).length > 0).toBe(true);
|
|
});
|
|
|
|
describe("non-standard extensions", () => {
|
|
it("should support .length", () => {
|
|
const formData = new FormData();
|
|
formData.append("foo", "bar");
|
|
formData.append("foo", new Blob(["bar"]));
|
|
formData.append("bar", "baz");
|
|
// @ts-ignore
|
|
expect(formData.length).toBe(3);
|
|
formData.delete("foo");
|
|
// @ts-ignore
|
|
expect(formData.length).toBe(1);
|
|
formData.append("foo", "bar");
|
|
// @ts-ignore
|
|
expect(formData.length).toBe(2);
|
|
formData.delete("foo");
|
|
formData.delete("foo");
|
|
// @ts-ignore
|
|
expect(formData.length).toBe(1);
|
|
formData.delete("bar");
|
|
// @ts-ignore
|
|
expect(formData.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("URLEncoded", () => {
|
|
test("should parse URL encoded", async () => {
|
|
const response = new Response("foo=bar&baz=qux", {
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
});
|
|
const formData = await response.formData();
|
|
expect(formData instanceof FormData).toBe(true);
|
|
expect(formData.get("foo")).toBe("bar");
|
|
expect(formData.get("baz")).toBe("qux");
|
|
});
|
|
|
|
test("should parse URLSearchParams", async () => {
|
|
const searchParams = new URLSearchParams("foo=bar&baz=qux");
|
|
const response = new Response(searchParams);
|
|
expect(response.headers.get("Content-Type")).toBe("application/x-www-form-urlencoded;charset=UTF-8");
|
|
|
|
expect(searchParams instanceof URLSearchParams).toBe(true);
|
|
expect(searchParams.get("foo")).toBe("bar");
|
|
|
|
const formData = await response.formData();
|
|
expect(formData instanceof FormData).toBe(true);
|
|
expect(formData.get("foo")).toBe("bar");
|
|
expect(formData.get("baz")).toBe("qux");
|
|
});
|
|
|
|
test("should parse URL encoded with charset", async () => {
|
|
const response = new Response("foo=bar&baz=qux", {
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
|
},
|
|
});
|
|
const formData = await response.formData();
|
|
expect(formData instanceof FormData).toBe(true);
|
|
expect(formData.get("foo")).toBe("bar");
|
|
expect(formData.get("baz")).toBe("qux");
|
|
});
|
|
|
|
test("should parse URL encoded with charset and space", async () => {
|
|
const response = new Response("foo=bar&baz=qux+quux", {
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
|
},
|
|
});
|
|
const formData = await response.formData();
|
|
expect(formData instanceof FormData).toBe(true);
|
|
expect(formData.get("foo")).toBe("bar");
|
|
expect(formData.get("baz")).toBe("qux quux");
|
|
});
|
|
|
|
test("should parse URL encoded with charset and plus", async () => {
|
|
const response = new Response("foo=bar&baz=qux+quux", {
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
|
},
|
|
});
|
|
const formData = await response.formData();
|
|
expect(formData instanceof FormData).toBe(true);
|
|
expect(formData.get("foo")).toBe("bar");
|
|
expect(formData.get("baz")).toBe("qux quux");
|
|
});
|
|
|
|
it("should handle multiple values", async () => {
|
|
const response = new Response("foo=bar&foo=baz", {
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
});
|
|
const formData = await response.formData();
|
|
expect(formData instanceof FormData).toBe(true);
|
|
expect(formData.getAll("foo")).toEqual(["bar", "baz"]);
|
|
});
|
|
|
|
it("should handle slices", async () => {
|
|
using server = Bun.serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
const body = await req.formData();
|
|
return new Response(body.get("file"), {
|
|
headers: { "Content-Type": "text/plain" },
|
|
});
|
|
},
|
|
});
|
|
const fileSlice = Bun.file(join(import.meta.dir, "..", "fetch", "fixture.html")).slice(5, 10);
|
|
const form = new FormData();
|
|
form.append("file", fileSlice);
|
|
const result = await fetch(server.url, {
|
|
method: "POST",
|
|
body: form,
|
|
}).then(res => res.blob());
|
|
expect(result.size).toBe(5);
|
|
expect(fileSlice.size).toBe(result.size);
|
|
});
|
|
});
|
|
|
|
// The minimum repro for this was to not call the .name and .type getter on the Blob
|
|
// But the crux of the issue is that we called dupe() on the Blob, without also incrementing the reference count of the name string.
|
|
// https://github.com/oven-sh/bun/issues/14918
|
|
it("should increment reference count of the name string on Blob", async () => {
|
|
const buffer = new File([Buffer.from(Buffer.alloc(48 * 1024, "abcdefh").toString("base64"), "base64")], "ok.jpg");
|
|
function test() {
|
|
let file = new File([buffer], "ok.jpg");
|
|
file.name;
|
|
file.type;
|
|
|
|
let formData = new FormData();
|
|
formData.append("foo", file);
|
|
formData.get("foo");
|
|
formData.get("foo")!.name;
|
|
formData.get("foo")!.type;
|
|
return formData;
|
|
}
|
|
for (let i = 0; i < 100000; i++) {
|
|
test();
|
|
if (i % 5000 === 0) {
|
|
Bun.gc();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// https://github.com/oven-sh/bun/issues/14988
|
|
describe("Content-Type header propagation", () => {
|
|
describe("https://github.com/oven-sh/bun/issues/21011", () => {
|
|
function createRequest() {
|
|
const formData = new FormData();
|
|
formData.append("key", "value");
|
|
|
|
return new Request("https://example.com/api/endpoint", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
}
|
|
|
|
test("without checking body", async () => {
|
|
const request = createRequest();
|
|
expect(request.headers.get("Content-Type")).toStartWith("multipart/form-data");
|
|
});
|
|
|
|
test("check body", async () => {
|
|
const request = createRequest();
|
|
if (!request.body) {
|
|
expect.unreachable();
|
|
}
|
|
expect(request.headers.get("Content-Type")).toStartWith("multipart/form-data");
|
|
});
|
|
});
|
|
|
|
// Shared test server that validates multipart/form-data content-type
|
|
function createTestServer() {
|
|
return Bun.serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
if (!req.headers.get("content-type")?.includes("multipart/form-data")) {
|
|
return new Response("Missing multipart/form-data content-type", { status: 400 });
|
|
}
|
|
const body = await req.formData();
|
|
expect(body.get("foo")!.size).toBe(3);
|
|
return new Response("Success", { status: 200 });
|
|
},
|
|
});
|
|
}
|
|
|
|
// Custom Request subclass for testing inheritance
|
|
class CustomRequest extends Request {
|
|
constructor(input: string | URL | Request, init?: RequestInit) {
|
|
super(input, init);
|
|
}
|
|
}
|
|
|
|
const testCases = [
|
|
{
|
|
name: "new Request({body: FormData}) (subclass) -> fetch(request)",
|
|
async testFn(server: ReturnType<typeof createTestServer>) {
|
|
const fd = new FormData();
|
|
fd.append("foo", new Blob(["bar"]));
|
|
const request = new CustomRequest(server.url.toString(), {
|
|
method: "POST",
|
|
body: fd,
|
|
});
|
|
return fetch(request);
|
|
},
|
|
},
|
|
{
|
|
name: "FormData -> Request (subclass) -> ReadableStream -> fetch(request)",
|
|
async testFn(server: ReturnType<typeof createTestServer>) {
|
|
const fd = new FormData();
|
|
fd.append("foo", new Blob(["bar"]));
|
|
const request = new CustomRequest(server.url.toString(), {
|
|
method: "POST",
|
|
body: fd,
|
|
});
|
|
return fetch(request);
|
|
},
|
|
},
|
|
{
|
|
name: "FormData -> Request (subclass) -> fetch(url, {body: request.blob()})",
|
|
async testFn(server: ReturnType<typeof createTestServer>) {
|
|
const fd = new FormData();
|
|
fd.append("foo", new Blob(["bar"]));
|
|
const request = new CustomRequest(server.url.toString(), {
|
|
method: "POST",
|
|
body: fd,
|
|
});
|
|
return fetch(server.url.toString(), {
|
|
method: "POST",
|
|
body: await request.blob(),
|
|
});
|
|
},
|
|
},
|
|
{
|
|
name: "FormData -> Request -> fetch(request)",
|
|
async testFn(server: ReturnType<typeof createTestServer>) {
|
|
const fd = new FormData();
|
|
fd.append("foo", new Blob(["bar"]));
|
|
const request = new Request(server.url.toString(), {
|
|
method: "POST",
|
|
body: fd,
|
|
});
|
|
return fetch(request);
|
|
},
|
|
},
|
|
];
|
|
|
|
testCases.forEach(({ name, testFn }) => {
|
|
it(name, async () => {
|
|
using server = createTestServer();
|
|
const res = await testFn(server);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
});
|