diff --git a/builtins/web/form-data/form-data-parser.cpp b/builtins/web/form-data/form-data-parser.cpp index e11fbfe2..afd089df 100644 --- a/builtins/web/form-data/form-data-parser.cpp +++ b/builtins/web/form-data/form-data-parser.cpp @@ -93,9 +93,12 @@ JSObject *MultipartParser::parse(JSContext *cx, std::string_view body) { return nullptr; } + // Per the Fetch spec, a part's value is the "UTF-8 decode without BOM" of its + // content, which preserves a leading BOM (U+FEFF) rather than stripping it. + // That corresponds to a decoder with BOM handling disabled. auto deleter2 = [&](auto *dec) { jsencoding::decoder_free(dec); }; std::unique_ptr decoder( - jsencoding::encoding_new_decoder_with_bom_removal(encoding), deleter2); + jsencoding::encoding_new_decoder_without_bom_handling(encoding), deleter2); if (!decoder) { JS_ReportOutOfMemory(cx); diff --git a/tests/integration/formdata/formdata.js b/tests/integration/formdata/formdata.js new file mode 100644 index 00000000..1d4f58ad --- /dev/null +++ b/tests/integration/formdata/formdata.js @@ -0,0 +1,38 @@ +import { serveTest } from "../test-server.js"; +import { strictEqual } from "../../assert.js"; + +function multipart(parts, boundary = "X") { + let body = ""; + for (const p of parts) { + body += `--${boundary}\r\n`; + body += `Content-Disposition: form-data; name="${p.name}"\r\n\r\n`; + body += `${p.value}\r\n`; + } + body += `--${boundary}--\r\n`; + return new Response(new TextEncoder().encode(body), { + headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, + }); +} + +export const handler = serveTest(async (t) => { + await t.test("formData-preserves-leading-bom", async () => { + // Per the Fetch spec a part's value is "UTF-8 decode without BOM", which + // preserves a leading U+FEFF rather than stripping it. + const fd = await multipart([{ name: "field", value: "hello" }]).formData(); + const value = fd.get("field"); + strictEqual(value.length, 6, "leading BOM must be preserved"); + strictEqual(value.charCodeAt(0), 0xfeff, "first code unit is U+FEFF"); + strictEqual(value, "hello", "value keeps the BOM"); + }); + + await t.test("formData-non-bom-content-unchanged", async () => { + const fd = await multipart([{ name: "a", value: "plain" }]).formData(); + strictEqual(fd.get("a"), "plain", "ordinary content decodes unchanged"); + }); + + await t.test("formData-bom-in-the-middle-unchanged", async () => { + // A BOM that is not at the very start is always preserved. + const fd = await multipart([{ name: "m", value: "abcd" }]).formData(); + strictEqual(fd.get("m"), "abcd", "interior BOM is preserved"); + }); +}); diff --git a/tests/integration/handlers.js b/tests/integration/handlers.js index fc07035a..f678fb11 100644 --- a/tests/integration/handlers.js +++ b/tests/integration/handlers.js @@ -5,3 +5,4 @@ export { handler as crypto } from './crypto/crypto.js'; export { handler as timers } from './timers/timers.js'; export { handler as fetch } from './fetch/fetch.js'; export { handler as event } from './event/event.js'; +export { handler as formdata } from './formdata/formdata.js'; diff --git a/tests/tests.cmake b/tests/tests.cmake index 7b7840f3..fd74f387 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -67,6 +67,7 @@ integration_tests( crypto event fetch + formdata performance timers )