From 6247d28a6118f1d63891c3a12084c62458475289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 11:46:17 +0200 Subject: [PATCH 1/2] fix(fetch): serialize FormData upload bodies --- changelog.d/9842-formdata-upload.md | 4 + .../src/lower_call/options/fetch.rs | 12 +- .../src/runtime_decls/strings_part2.rs | 12 +- .../perry-stdlib/src/fetch/body_metadata.rs | 231 +++++++++++++++++- crates/perry-stdlib/src/fetch/dispatch.rs | 6 + crates/perry-stdlib/src/fetch/mod.rs | 31 ++- crates/perry-stdlib/src/fetch/request_ctor.rs | 16 +- .../test_issue_9842_form_data_blob_upload.ts | 55 +++++ 8 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 changelog.d/9842-formdata-upload.md create mode 100644 test-files/test_issue_9842_form_data_blob_upload.ts diff --git a/changelog.d/9842-formdata-upload.md b/changelog.d/9842-formdata-upload.md new file mode 100644 index 0000000000..63c3e3402b --- /dev/null +++ b/changelog.d/9842-formdata-upload.md @@ -0,0 +1,4 @@ +### Fixed + +- Preserve `Blob` and `File` entries in `FormData`, and serialize `FormData` + request bodies with multipart bytes and a generated `content-type` header. diff --git a/crates/perry-codegen/src/lower_call/options/fetch.rs b/crates/perry-codegen/src/lower_call/options/fetch.rs index 72eaee489f..63e1f6dd84 100644 --- a/crates/perry-codegen/src/lower_call/options/fetch.rs +++ b/crates/perry-codegen/src/lower_call/options/fetch.rs @@ -664,6 +664,11 @@ pub(in crate::lower_call) fn lower_fetch_native_method( } else { double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }; + let filename = if args.len() >= 3 { + lower_expr(ctx, &args[2])? + } else { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + }; let runtime_fn = if method == "append" { "js_form_data_append" } else { @@ -672,7 +677,12 @@ pub(in crate::lower_call) fn lower_fetch_native_method( ctx.block().call( DOUBLE, runtime_fn, - &[(DOUBLE, &handle), (DOUBLE, &name), (DOUBLE, &value)], + &[ + (DOUBLE, &handle), + (DOUBLE, &name), + (DOUBLE, &value), + (DOUBLE, &filename), + ], ); return Ok(Some(double_literal(f64::from_bits( crate::nanbox::TAG_UNDEFINED, diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 97d14748e1..5017076121 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -1052,8 +1052,16 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { module.declare_function("js_response_bytes", I64, &[DOUBLE]); module.declare_function("js_response_form_data", I64, &[DOUBLE]); module.declare_function("js_form_data_new", DOUBLE, &[]); - module.declare_function("js_form_data_append", DOUBLE, &[DOUBLE, DOUBLE, DOUBLE]); - module.declare_function("js_form_data_set", DOUBLE, &[DOUBLE, DOUBLE, DOUBLE]); + module.declare_function( + "js_form_data_append", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE, DOUBLE], + ); + module.declare_function( + "js_form_data_set", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE, DOUBLE], + ); module.declare_function("js_form_data_delete", DOUBLE, &[DOUBLE, I64]); module.declare_function("js_form_data_get", DOUBLE, &[DOUBLE, I64]); module.declare_function("js_form_data_get_all", DOUBLE, &[DOUBLE, I64]); diff --git a/crates/perry-stdlib/src/fetch/body_metadata.rs b/crates/perry-stdlib/src/fetch/body_metadata.rs index 4074a10e1c..0695ff11ac 100644 --- a/crates/perry-stdlib/src/fetch/body_metadata.rs +++ b/crates/perry-stdlib/src/fetch/body_metadata.rs @@ -157,6 +157,90 @@ fn file_last_modified_now() -> f64 { .unwrap_or(0.0) } +unsafe fn form_data_entry_from_js(value: f64, filename: f64) -> FormDataValue { + let value_id = handle_id(value); + let blob = JSValue::from_bits(value.to_bits()) + .is_pointer() + .then(|| BLOB_REGISTRY.lock().unwrap().get(&value_id).cloned()) + .flatten(); + let Some(mut blob) = blob else { + return FormDataValue::Text(form_data_value_string(value)); + }; + + let filename_override = + (filename.to_bits() != TAG_UNDEFINED).then(|| form_data_value_string(filename)); + if filename_override.is_none() && blob.file_name.is_some() { + return FormDataValue::File(value_id); + } + + blob.file_name = Some( + filename_override + .or(blob.file_name) + .unwrap_or_else(|| "blob".to_string()), + ); + blob.last_modified_ms = Some(file_last_modified_now()); + FormDataValue::File(alloc_blob(blob)) +} + +fn multipart_quoted(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '\r' => escaped.push_str("%0D"), + '\n' => escaped.push_str("%0A"), + '"' => escaped.push_str("%22"), + _ => escaped.push(ch), + } + } + escaped +} + +pub(super) fn serialize_form_data(handle: usize) -> Option<(Vec, String)> { + static NEXT_BOUNDARY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + + let entries = FORM_DATA_REGISTRY.lock().unwrap().get(&handle)?.clone(); + let serial = NEXT_BOUNDARY.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let boundary = format!("----PerryFormDataBoundary{handle:012x}{serial:016x}"); + let mut body = Vec::new(); + + for (name, value) in entries.entries { + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + match value { + FormDataValue::Text(value) => { + body.extend_from_slice( + format!( + "Content-Disposition: form-data; name=\"{}\"\r\n\r\n", + multipart_quoted(&name) + ) + .as_bytes(), + ); + body.extend_from_slice(value.as_bytes()); + } + FormDataValue::File(blob_id) => { + let blob = BLOB_REGISTRY.lock().unwrap().get(&blob_id)?.clone(); + let filename = blob.file_name.as_deref().unwrap_or("blob"); + let content_type = if blob.content_type.is_empty() { + "application/octet-stream" + } else { + &blob.content_type + }; + body.extend_from_slice( + format!( + "Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\nContent-Type: {content_type}\r\n\r\n", + multipart_quoted(&name), + multipart_quoted(filename), + ) + .as_bytes(), + ); + body.extend_from_slice(&blob.body); + } + } + body.extend_from_slice(b"\r\n"); + } + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + Some((body, format!("multipart/form-data; boundary={boundary}"))) +} + fn form_data_from_multipart( body: &[u8], content_type: &str, @@ -509,23 +593,41 @@ pub extern "C" fn js_form_data_new() -> f64 { } #[no_mangle] -pub unsafe extern "C" fn js_form_data_append(handle: f64, name: f64, value: f64) -> f64 { +pub unsafe extern "C" fn js_form_data_append( + handle: f64, + name: f64, + value: f64, + filename: f64, +) -> f64 { let id = handle_id(handle); - let name = form_data_value_string(name); - let value = form_data_value_string(value); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let name = scope.root_nanbox_f64(name); + let value = scope.root_nanbox_f64(value); + let filename = scope.root_nanbox_f64(filename); + let name = form_data_value_string(name.get_nanbox_f64()); + let value = form_data_entry_from_js(value.get_nanbox_f64(), filename.get_nanbox_f64()); if let Some(form) = FORM_DATA_REGISTRY.lock().unwrap().get_mut(&id) { - form.append(name, FormDataValue::Text(value)); + form.append(name, value); } f64::from_bits(TAG_UNDEFINED) } #[no_mangle] -pub unsafe extern "C" fn js_form_data_set(handle: f64, name: f64, value: f64) -> f64 { +pub unsafe extern "C" fn js_form_data_set( + handle: f64, + name: f64, + value: f64, + filename: f64, +) -> f64 { let id = handle_id(handle); - let name = form_data_value_string(name); - let value = form_data_value_string(value); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let name = scope.root_nanbox_f64(name); + let value = scope.root_nanbox_f64(value); + let filename = scope.root_nanbox_f64(filename); + let name = form_data_value_string(name.get_nanbox_f64()); + let value = form_data_entry_from_js(value.get_nanbox_f64(), filename.get_nanbox_f64()); if let Some(form) = FORM_DATA_REGISTRY.lock().unwrap().get_mut(&id) { - form.set(name, FormDataValue::Text(value)); + form.set(name, value); } f64::from_bits(TAG_UNDEFINED) } @@ -695,6 +797,12 @@ pub fn form_data_contains_handle(handle: usize) -> bool { mod tests { use super::*; + unsafe fn string_value(value: &str) -> f64 { + f64::from_bits( + JSValue::string_ptr(js_string_from_bytes(value.as_ptr(), value.len() as u32)).bits(), + ) + } + #[test] fn selects_urlencoded_and_multipart_parsers_from_content_type() { let encoded = form_data_from_body( @@ -724,4 +832,111 @@ mod tests { assert!(form_data_from_body(b"{}", "application/json").is_err()); } + + #[test] + fn appended_blob_becomes_a_file_and_serializes_binary_multipart() { + let blob_id = alloc_blob(BlobData::blob( + vec![0, 0xff, b'\r', b'\n'], + "application/octet-stream".to_string(), + )); + let form = js_form_data_new(); + unsafe { + js_form_data_append( + form, + string_value("bin\r\nname"), + handle_to_f64(blob_id), + string_value("a\"b.bin"), + ); + } + + let form_id = handle_id(form); + let stored_entry = FORM_DATA_REGISTRY + .lock() + .unwrap() + .get(&form_id) + .unwrap() + .entries[0] + .1 + .clone(); + let stored_blob_id = match stored_entry { + FormDataValue::File(id) => id, + FormDataValue::Text(_) => panic!("Blob was stringified"), + }; + let stored_blob = BLOB_REGISTRY + .lock() + .unwrap() + .get(&stored_blob_id) + .unwrap() + .clone(); + assert_eq!(stored_blob.file_name.as_deref(), Some("a\"b.bin")); + + let (body, content_type) = serialize_form_data(form_id).unwrap(); + assert!(content_type.starts_with("multipart/form-data; boundary=")); + let wire = String::from_utf8_lossy(&body); + assert!(wire.contains("name=\"bin%0D%0Aname\"")); + assert!(wire.contains("filename=\"a%22b.bin\"")); + + let parsed = form_data_from_body(&body, &content_type).unwrap(); + let parsed_blob_id = match &parsed.entries[0].1 { + FormDataValue::File(id) => *id, + FormDataValue::Text(_) => panic!("serialized Blob parsed as text"), + }; + let parsed_blob = BLOB_REGISTRY + .lock() + .unwrap() + .get(&parsed_blob_id) + .unwrap() + .clone(); + assert_eq!(parsed_blob.body, [0, 0xff, b'\r', b'\n']); + assert_eq!(parsed_blob.file_name.as_deref(), Some("a%22b.bin")); + assert_eq!(parsed_blob.content_type, "application/octet-stream"); + } + + #[test] + fn request_owns_serialized_form_data_and_default_content_type() { + let form = js_form_data_new(); + unsafe { + js_form_data_append( + form, + string_value("caption"), + string_value("hello"), + f64::from_bits(TAG_UNDEFINED), + ); + } + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let url = scope.root_string_ptr(js_string_from_bytes(b"http://example.test/".as_ptr(), 20)); + let method = scope.root_string_ptr(js_string_from_bytes(b"POST".as_ptr(), 4)); + let request = unsafe { + js_request_new( + url.get_raw_const_ptr(), + method.get_raw_const_ptr(), + handle_id(form) as *const StringHeader, + 0.0, + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + f64::from_bits(TAG_FALSE), + std::ptr::null(), + f64::from_bits(TAG_UNDEFINED), + ) + }; + let request_id = handle_id(request); + let request = REQUEST_REGISTRY + .lock() + .unwrap() + .get(&request_id) + .unwrap() + .clone(); + let content_type = request.headers.get("content-type").unwrap(); + assert!(content_type.starts_with("multipart/form-data; boundary=")); + let parsed = form_data_from_body(request.body.as_deref().unwrap(), &content_type).unwrap(); + assert!(matches!( + &parsed.entries[0], + (name, FormDataValue::Text(value)) if name == "caption" && value == "hello" + )); + } } diff --git a/crates/perry-stdlib/src/fetch/dispatch.rs b/crates/perry-stdlib/src/fetch/dispatch.rs index 5aea55446f..2fc06a1a7c 100644 --- a/crates/perry-stdlib/src/fetch/dispatch.rs +++ b/crates/perry-stdlib/src/fetch/dispatch.rs @@ -686,6 +686,9 @@ pub fn dispatch_form_data_method(form_id: usize, method: &str, args: &[f64]) -> args.get(1) .copied() .unwrap_or(f64::from_bits(TAG_UNDEFINED)), + args.get(2) + .copied() + .unwrap_or(f64::from_bits(TAG_UNDEFINED)), )), "set" => Some(js_form_data_set( form_f64, @@ -695,6 +698,9 @@ pub fn dispatch_form_data_method(form_id: usize, method: &str, args: &[f64]) -> args.get(1) .copied() .unwrap_or(f64::from_bits(TAG_UNDEFINED)), + args.get(2) + .copied() + .unwrap_or(f64::from_bits(TAG_UNDEFINED)), )), "delete" => Some(js_form_data_delete(form_f64, str_arg(0))), "get" => Some(js_form_data_get(form_f64, str_arg(0))), diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index c80dd3cfad..a467c21ffb 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -678,9 +678,17 @@ pub unsafe extern "C" fn js_fetch_post( // so a binary body (Buffer / Uint8Array / typed array / ArrayBuffer) is sent // byte-for-byte instead of being shifted left 12 bytes by the StringHeader // data offset (#5757). `reqwest::Body` accepts `Vec` directly. - let body = fetch_request_body_bytes(body_ptr).unwrap_or_default(); - let content_type = - string_from_header(content_type_ptr).unwrap_or_else(|| "application/json".to_string()); + let form_data_body = body_metadata::serialize_form_data(body_ptr as usize); + let form_data_content_type = form_data_body + .as_ref() + .map(|(_, content_type)| content_type.clone()); + let body = form_data_body + .map(|(body, _)| body) + .or_else(|| fetch_request_body_bytes(body_ptr)) + .unwrap_or_default(); + let content_type = string_from_header(content_type_ptr) + .or(form_data_content_type) + .unwrap_or_else(|| "application/json".to_string()); spawn(async move { let client = fetch_client(); @@ -763,13 +771,20 @@ pub unsafe extern "C" fn js_fetch_with_options( // `Request` object and call `fetch(request, init)`; its handle id lands in // the `url_ptr` slot. Recover url/method/body/headers from the Request // registry so the request is dispatched (`init` members override). - let inputs = match request_handle::resolve_fetch_inputs( + let form_data_body = body_metadata::serialize_form_data(body_ptr as usize); + let form_data_content_type = form_data_body + .as_ref() + .map(|(_, content_type)| content_type.clone()); + let body_bytes = form_data_body + .map(|(body, _)| body) + .or_else(|| fetch_request_body_bytes(body_ptr)); + let mut inputs = match request_handle::resolve_fetch_inputs( string_from_header(url_ptr), string_from_header(method_ptr), // Read the body as raw bytes (binary bodies probe the buffer/typed-array // registry first) so a Buffer/Uint8Array body isn't corrupted by a lossy // StringHeader read (#5757). - fetch_request_body_bytes(body_ptr), + body_bytes, string_from_header(headers_json_ptr), url_ptr as usize, ) { @@ -779,6 +794,12 @@ pub unsafe extern "C" fn js_fetch_with_options( return promise; } }; + if let Some(content_type) = form_data_content_type { + inputs + .custom_headers + .entry("content-type".to_string()) + .or_insert(content_type); + } // Dispatch + abort handling live in `abort_bridge::run_request` (keeps this // file under the line-size lint gate). diff --git a/crates/perry-stdlib/src/fetch/request_ctor.rs b/crates/perry-stdlib/src/fetch/request_ctor.rs index cb3d526c55..a2354caad9 100644 --- a/crates/perry-stdlib/src/fetch/request_ctor.rs +++ b/crates/perry-stdlib/src/fetch/request_ctor.rs @@ -62,7 +62,11 @@ pub unsafe extern "C" fn js_request_new( // in `js_response_body_init_ptr` (the Response twin), which falls through via // `or_else` rather than if/else. let pending_stream_id = take_pending_fetch_body_stream_id(); - let non_stream_body: Option> = + let form_data_body = body_metadata::serialize_form_data(body_ptr as usize); + let form_data_content_type = form_data_body + .as_ref() + .map(|(_, content_type)| content_type.clone()); + let non_stream_body: Option> = form_data_body.map(|(body, _)| body).or_else(|| { if perry_runtime::value::addr_class::is_handle_band(body_ptr as usize) { crate::fetch::blob_bytes_clone(body_ptr as usize) .or_else(|| dispatch::incoming_message_raw_body_bytes(body_ptr as usize)) @@ -72,7 +76,8 @@ pub unsafe extern "C" fn js_request_new( // misread the handle id as a string pointer. .or_else(|| dispatch::incoming_message_raw_body_bytes(body_ptr as usize)) .or_else(|| dispatch::body_bytes_from_header(body_ptr)) - }; + } + }); // GET/HEAD requests may not carry a body (WHATWG fetch). Refs #2643. if (pending_stream_id.is_some() || non_stream_body.is_some()) && (method == "GET" || method == "HEAD") @@ -83,7 +88,7 @@ pub unsafe extern "C" fn js_request_new( .map(crate::streams::drain_readable_into_bytes) .or(non_stream_body); let headers_id_in = handle_id(headers_handle); - let headers = if headers_id_in != 0 { + let mut headers = if headers_id_in != 0 { HEADERS_REGISTRY .lock() .unwrap() @@ -93,6 +98,11 @@ pub unsafe extern "C" fn js_request_new( } else { HeadersStore::default() }; + if let Some(content_type) = form_data_content_type { + if !headers.has("content-type") { + headers.set("content-type", &content_type); + } + } // `signal` is a heap value the registry keeps (and the GC scanner in // `super::gc` roots), and defaulting it ALLOCATES an `AbortController` — // so resolve it, and build the whole record, before taking the registry diff --git a/test-files/test_issue_9842_form_data_blob_upload.ts b/test-files/test_issue_9842_form_data_blob_upload.ts new file mode 100644 index 0000000000..e80c71b2a6 --- /dev/null +++ b/test-files/test_issue_9842_form_data_blob_upload.ts @@ -0,0 +1,55 @@ +// Regression for #9842: FormData.append/set must preserve Blob and File +// values, and Request must serialize FormData as a non-empty multipart body. + +const form = new FormData(); +const original = new File( + [new Uint8Array([0, 255, 13, 10, 65])], + "original.bin", + { type: "application/octet-stream", lastModified: 1234 }, +); +form.append("caption", "Perry upload"); +form.append("original", original); +form.append("renamed", original, "renamed.bin"); +form.set("blob", new Blob(["blob payload"], { type: "text/plain" })); + +const originalEntry = form.get("original") as File; +const renamedEntry = form.get("renamed") as File; +const blobEntry = form.get("blob") as File; +console.log( + `entries=${originalEntry instanceof File}/${originalEntry.name}/${originalEntry.lastModified};` + + `${renamedEntry instanceof File}/${renamedEntry.name};` + + `${blobEntry instanceof File}/${blobEntry.name}/${blobEntry.type}`, +); + +const request = new Request("https://example.test/upload", { + method: "POST", + body: form, +}); +const contentType = request.headers.get("content-type") || ""; +console.log(`multipart=${contentType.startsWith("multipart/form-data; boundary=")}`); + +const bytes = new Uint8Array(await request.arrayBuffer()); +console.log(`body=${bytes.byteLength > 5}/${bytes.includes(255)}`); + +const parsedRequest = new Request("https://example.test/upload", { + method: "POST", + body: form, +}); +const parsed = await parsedRequest.formData(); +const parsedOriginal = parsed.get("original") as File; +const parsedRenamed = parsed.get("renamed") as File; +const parsedBlob = parsed.get("blob") as File; +console.log( + `parsed=${parsed.get("caption")};${parsedOriginal.name}/${parsedOriginal.type}/${parsedOriginal.size};` + + `${parsedRenamed.name};${parsedBlob.name}/${await parsedBlob.text()}`, +); +console.log( + `binary=${[...new Uint8Array(await parsedOriginal.arrayBuffer())].join(",")}`, +); + +const explicit = new Request("https://example.test/upload", { + method: "POST", + headers: { "content-type": "application/custom" }, + body: form, +}); +console.log(`explicit=${explicit.headers.get("content-type")}`); From 9a7fc55963ad58739b085404dfc218cc96e72647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 11:51:23 +0200 Subject: [PATCH 2/2] docs(changelog): key FormData fix to PR 9868 --- changelog.d/{9842-formdata-upload.md => 9868-formdata-upload.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{9842-formdata-upload.md => 9868-formdata-upload.md} (100%) diff --git a/changelog.d/9842-formdata-upload.md b/changelog.d/9868-formdata-upload.md similarity index 100% rename from changelog.d/9842-formdata-upload.md rename to changelog.d/9868-formdata-upload.md