fetch: implement multipart/form-data parsing for Request::formData() (#42041)

Testing: More test passed in `fetch/api/abort/request.any.js.ini`

fixes: #25106

---------

Signed-off-by: Taym Haddadi <haddadi.taym@gmail.com>
This commit is contained in:
Taym Haddadi
2026-02-04 00:31:41 +01:00
committed by GitHub
parent 42359b26a2
commit b18d119261
10 changed files with 263 additions and 68 deletions

100
Cargo.lock generated
View File

@@ -803,6 +803,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff"
[[package]]
name = "base64"
version = "0.21.7"
@@ -1041,6 +1047,12 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "buf-read-ext"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e2c71c44e5bbc64de4ecfac946e05f9bba5cc296ea7bab4d3eda242a3ffa73c"
[[package]]
name = "build-parallel"
version = "0.1.2"
@@ -3112,6 +3124,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
dependencies = [
"cfg-if",
"libc",
"wasi 0.9.0+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -3120,7 +3143,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
@@ -5375,6 +5398,21 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime-multipart-hyper1"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc819448803ee517eb413276ca59613c2850a95c4fdcd87ec82c5a6ce6cdab1a"
dependencies = [
"buf-read-ext",
"http 1.4.0",
"httparse",
"log",
"mime",
"tempfile",
"textnonce",
]
[[package]]
name = "mime_guess"
version = "2.0.5"
@@ -5408,7 +5446,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
@@ -7168,6 +7206,19 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
dependencies = [
"getrandom 0.1.16",
"libc",
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc",
]
[[package]]
name = "rand"
version = "0.8.5"
@@ -7189,6 +7240,16 @@ dependencies = [
"rand_core 0.9.3",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
dependencies = [
"ppv-lite86",
"rand_core 0.5.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -7209,6 +7270,15 @@ dependencies = [
"rand_core 0.9.3",
]
[[package]]
name = "rand_core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
dependencies = [
"getrandom 0.1.16",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -7227,6 +7297,15 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "range-alloc"
version = "0.1.4"
@@ -7762,6 +7841,7 @@ dependencies = [
"media",
"metrics",
"mime",
"mime-multipart-hyper1",
"mime_guess",
"ml-dsa",
"ml-kem",
@@ -9188,6 +9268,16 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "textnonce"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7743f8d70cd784ed1dc33106a18998d77758d281dc40dc3e6d050cf0f5286683"
dependencies = [
"base64 0.12.3",
"rand 0.7.3",
]
[[package]]
name = "thin-vec"
version = "0.2.14"
@@ -10089,6 +10179,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "wasi"
version = "0.9.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"

View File

@@ -111,6 +111,7 @@ malloc_size_of_derive = "0.1"
markup5ever = "0.38"
memmap2 = "0.9.9"
mime = "0.3.13"
mime-multipart-hyper1 = "0.10.0"
mime_guess = "2.0.5"
ml-dsa = "0.0.4"
ml-kem = { version = "0.2", features = ["deterministic"] }

View File

@@ -98,6 +98,7 @@ markup5ever = { workspace = true }
media = { path = "../media" }
metrics = { path = "../metrics" }
mime = { workspace = true }
mime-multipart-hyper1 = { workspace = true }
mime_guess = { workspace = true }
ml-dsa = { workspace = true }
ml-kem = { workspace = true }

View File

@@ -2,12 +2,15 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
use std::io::Cursor;
use std::rc::Rc;
use std::{ptr, slice, str};
use std::{fs, ptr, slice, str};
use base::generic_channel::GenericSharedMemory;
use constellation_traits::BlobImpl;
use encoding_rs::{Encoding, UTF_8};
use http::HeaderMap;
use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
use ipc_channel::ipc::{self, IpcReceiver, IpcSender};
use ipc_channel::router::ROUTER;
use js::jsapi::{Heap, JS_ClearPendingException, JSObject, Value as JSValue};
@@ -17,6 +20,7 @@ use js::rust::HandleValue;
use js::rust::wrappers::{JS_GetPendingException, JS_ParseJSON};
use js::typedarray::{ArrayBufferU8, Uint8};
use mime::{self, Mime};
use mime_multipart_hyper1::{Node, read_multipart_body};
use net_traits::request::{
BodyChunkRequest, BodyChunkResponse, BodySource as NetBodySource, RequestBody,
};
@@ -27,12 +31,14 @@ use crate::dom::bindings::codegen::Bindings::BlobBinding::Blob_Binding::BlobMeth
use crate::dom::bindings::codegen::Bindings::FormDataBinding::FormDataMethods;
use crate::dom::bindings::codegen::Bindings::XMLHttpRequestBinding::BodyInit;
use crate::dom::bindings::error::{Error, Fallible};
use crate::dom::bindings::inheritance::Castable;
use crate::dom::bindings::refcounted::Trusted;
use crate::dom::bindings::reflector::{DomGlobal, DomObject};
use crate::dom::bindings::root::{Dom, DomRoot, MutNullableDom};
use crate::dom::bindings::str::{DOMString, USVString};
use crate::dom::bindings::trace::RootedTraceableBox;
use crate::dom::blob::{Blob, normalize_type_string};
use crate::dom::file::File;
use crate::dom::formdata::FormData;
use crate::dom::globalscope::GlobalScope;
use crate::dom::html::htmlformelement::{encode_multipart_form_data, generate_boundary};
@@ -904,6 +910,114 @@ fn run_blob_data_algorithm(
Ok(FetchedData::BlobData(blob))
}
fn extract_name_from_content_disposition(headers: &HeaderMap) -> Option<String> {
let cd = headers.get(CONTENT_DISPOSITION)?.to_str().ok()?;
for part in cd.split(';').map(|s| s.trim()) {
if let Some(rest) = part.strip_prefix("name=") {
let v = rest.trim();
let v = v.strip_prefix('"').unwrap_or(v);
let v = v.strip_suffix('"').unwrap_or(v);
return Some(v.to_string());
}
}
None
}
fn extract_filename_from_content_disposition(headers: &HeaderMap) -> Option<String> {
let cd = headers.get(CONTENT_DISPOSITION)?.to_str().ok()?;
if let Some(index) = cd.find("filename=") {
let start = index + "filename=".len();
return Some(
cd.get(start..)
.unwrap_or_default()
.trim_matches('"')
.to_owned(),
);
}
if let Some(index) = cd.find("filename*=UTF-8''") {
let start = index + "filename*=UTF-8''".len();
return Some(
cd.get(start..)
.unwrap_or_default()
.trim_matches('"')
.to_owned(),
);
}
None
}
fn content_type_from_headers(headers: &HeaderMap) -> Result<String, Error> {
match headers.get(CONTENT_TYPE) {
Some(value) => Ok(value
.to_str()
.map_err(|_| Error::Type("Inappropriate MIME-type for Body".to_string()))?
.to_string()),
None => Ok("text/plain".to_string()),
}
}
fn append_form_data_entry_from_part(
root: &GlobalScope,
formdata: &FormData,
headers: &HeaderMap,
body: Vec<u8>,
can_gc: CanGc,
) -> Fallible<()> {
let Some(name) = extract_name_from_content_disposition(headers) else {
return Ok(());
};
// A part whose `Content-Disposition` header contains a `name` parameter whose value is `_charset_` is parsed like any other part. It does not change the encoding.
let filename = extract_filename_from_content_disposition(headers);
if let Some(filename) = filename {
// Each part whose `Content-Disposition` header contains a `filename` parameter must be parsed into an entry whose value is a File object whose contents are the contents of the part.
//
// The name attribute of the File object must have the value of the `filename` parameter of the part.
//
// The type attribute of the File object must have the value of the `Content-Type` header of the part if the part has such header, and `text/plain` (the default defined by [RFC7578] section 4.4) otherwise.
let content_type = content_type_from_headers(headers)?;
let file = File::new(
root,
BlobImpl::new_from_bytes(body, normalize_type_string(&content_type)),
DOMString::from(filename),
None,
can_gc,
);
let blob = file.upcast::<Blob>();
formdata.Append_(USVString(name), blob, None);
} else {
// Each part whose `Content-Disposition` header does not contain a `filename` parameter must be parsed into an entry whose value is the UTF-8 decoded without BOM content of the part. This is done regardless of the presence or the value of a `Content-Type` header and regardless of the presence or the value of a `charset` parameter.
let (value, _) = UTF_8.decode_without_bom_handling(&body);
formdata.Append(USVString(name), USVString(value.to_string()));
}
Ok(())
}
fn append_multipart_nodes(
root: &GlobalScope,
formdata: &FormData,
nodes: Vec<Node>,
can_gc: CanGc,
) -> Fallible<()> {
for node in nodes {
match node {
Node::Part(part) => {
append_form_data_entry_from_part(root, formdata, &part.headers, part.body, can_gc)?;
},
Node::File(file_part) => {
let body = fs::read(&file_part.path)
.map_err(|_| Error::Type("file part could not be read".to_string()))?;
append_form_data_entry_from_part(root, formdata, &file_part.headers, body, can_gc)?;
},
Node::Multipart((_, inner)) => {
append_multipart_nodes(root, formdata, inner, can_gc)?;
},
}
}
Ok(())
}
/// <https://fetch.spec.whatwg.org/#ref-for-concept-body-consume-body%E2%91%A2>
fn run_form_data_algorithm(
root: &GlobalScope,
@@ -911,15 +1025,57 @@ fn run_form_data_algorithm(
mime: &[u8],
can_gc: CanGc,
) -> Fallible<FetchedData> {
// The formData() method steps are to return the result of running consume body
// with this and the following steps given a byte sequence bytes:
let mime_str = str::from_utf8(mime).unwrap_or_default();
let mime: Mime = mime_str
.parse()
.map_err(|_| Error::Type("Inappropriate MIME-type for Body".to_string()))?;
// TODO
// ... Parser for Mime(TopLevel::Multipart, SubLevel::FormData, _)
// ... is not fully determined yet.
// Let mimeType be the result of get the MIME type with this.
//
// If mimeType is non-null, then switch on mimeTypes essence and run the corresponding steps:
if mime.type_() == mime::MULTIPART && mime.subtype() == mime::FORM_DATA {
// "multipart/form-data"
// Parse bytes, using the value of the `boundary` parameter from mimeType,
// per the rules set forth in Returning Values from Forms: multipart/form-data. [RFC7578]
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
mime_str
.parse()
.map_err(|_| Error::Type("Inappropriate MIME-type for Body".to_string()))?,
);
if let Some(boundary) = mime.get_param(mime::BOUNDARY) {
let closing_boundary = format!("--{}--", boundary.as_str()).into_bytes();
let trimmed_bytes = bytes.strip_suffix(b"\r\n").unwrap_or(&bytes);
if trimmed_bytes == closing_boundary {
let formdata = FormData::new(None, root, can_gc);
return Ok(FetchedData::FormData(formdata));
}
}
let mut cursor = Cursor::new(bytes);
// If that fails for some reason, then throw a TypeError.
let nodes = read_multipart_body(&mut cursor, &headers, false)
.map_err(|_| Error::Type("Inappropriate MIME-type for Body".to_string()))?;
// The above is a rough approximation of what is needed for `multipart/form-data`,
// a more detailed parsing specification is to be written. Volunteers welcome.
// Return a new FormData object, appending each entry, resulting from the parsing operation, to its entry list.
let formdata = FormData::new(None, root, can_gc);
append_multipart_nodes(root, &formdata, nodes, can_gc)?;
return Ok(FetchedData::FormData(formdata));
}
if mime.type_() == mime::APPLICATION && mime.subtype() == mime::WWW_FORM_URLENCODED {
// "application/x-www-form-urlencoded"
// Let entries be the result of parsing bytes.
//
// Return a new FormData object whose entry list is entries.
let entries = form_urlencoded::parse(&bytes);
let formdata = FormData::new(None, root, can_gc);
for (k, e) in entries {
@@ -928,6 +1084,7 @@ fn run_form_data_algorithm(
return Ok(FetchedData::FormData(formdata));
}
// Throw a TypeError.
Err(Error::Type("Inappropriate MIME-type for Body".to_string()))
}

View File

@@ -98,6 +98,8 @@ skip = [
"bitflags",
"cookie",
"redox_syscall",
# Duplicated by getrandom 0.1 and getrandom 0.2
"wasi",
# New versions of these dependencies is pulled in by GStreamer / GLib.
"itertools",

View File

@@ -1,10 +1,4 @@
[request.any.html]
[Calling formData() on an aborted request]
expected: FAIL
[Aborting a request after calling formData()]
expected: FAIL
[request.any.serviceworker.html]
expected: ERROR
@@ -13,8 +7,3 @@
expected: ERROR
[request.any.worker.html]
[Calling formData() on an aborted request]
expected: FAIL
[Aborting a request after calling formData()]
expected: FAIL

View File

@@ -1,20 +0,0 @@
[formdata.any.worker.html]
[Consume empty response.formData() as FormData]
expected: FAIL
[Consume empty request.formData() as FormData]
expected: FAIL
[Consume multipart/form-data headers case-insensitively]
expected: FAIL
[formdata.any.html]
[Consume empty response.formData() as FormData]
expected: FAIL
[Consume empty request.formData() as FormData]
expected: FAIL
[Consume multipart/form-data headers case-insensitively]
expected: FAIL

View File

@@ -1,13 +1,3 @@
[request-consume.any.html]
[Consume FormData request's body as FormData]
expected: FAIL
[request-consume.any.worker.html]
[Consume FormData request's body as FormData]
expected: FAIL
[request-consume.any.serviceworker.html]
expected: ERROR

View File

@@ -1,21 +1,3 @@
[response-consume.html]
[Consume response's body: from text with correct multipart type to formData]
expected: FAIL
[Consume response's body: from text with correct multipart type to formData with BOM]
expected: FAIL
[Consume response's body: from blob with correct multipart type to formData]
expected: FAIL
[Consume response's body: from FormData to formData]
expected: FAIL
[Consume response's body: from stream with correct multipart type to formData]
expected: FAIL
[Consume response's body: from multipart form data blob to formData]
expected: FAIL
[Consume response's body: from FormData to blob]
expected: FAIL

View File

@@ -1,3 +0,0 @@
[multipart.window.html]
[Ensure capital letters can be used in the boundary value.]
expected: FAIL