From 4e64dbddd82a367d14f01bb6c3336a95d559ed56 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 31 Aug 2026 14:09:39 +0800 Subject: [PATCH] fix(file-api): enforce WebIDL receiver contracts --- .../fixtures/runtime/secondary_webapis.html | 4 +- moli-renderer-v8/src/blob.rs | 44 ++++ .../assets/constructor_templates.rs | 11 +- .../src/context_bootstrap/file_api.rs | 2 +- .../src/context_bootstrap/file_api/file.rs | 15 ++ .../context_bootstrap/file_api/file_list.rs | 61 ++---- .../context_bootstrap/file_api/file_reader.rs | 9 +- .../file_api/file_reader/events.rs | 8 + .../file_api/file_reader/read.rs | 8 + .../file_api/file_reader/state.rs | 199 +++++++++++++++++- .../src/context_bootstrap/specs/registry.rs | 2 +- .../src/context_bootstrap/specs/types.rs | 1 - .../misc/extracted/blob_file_reader.rs | 93 +++++++- .../src/script_vm/tests/dom_xhr/dom.rs | 18 +- .../src/worker/thread/tests/network.rs | 26 ++- .../wpt/ported/fileapi/file-basic.html | 11 +- .../form-submission-file-multipart-basic.html | 16 +- .../worker/resources/worker-fileapi-worker.js | 22 +- .../ported/worker/worker-fileapi-basic.html | 9 +- 19 files changed, 450 insertions(+), 109 deletions(-) diff --git a/moli-core/tests/fixtures/runtime/secondary_webapis.html b/moli-core/tests/fixtures/runtime/secondary_webapis.html index 64107346b8..76db4ad78d 100644 --- a/moli-core/tests/fixtures/runtime/secondary_webapis.html +++ b/moli-core/tests/fixtures/runtime/secondary_webapis.html @@ -35,7 +35,9 @@ document.body.setAttribute("data-file-type", file.type); document.body.setAttribute("data-file-last-modified", String(file.lastModified)); document.body.setAttribute("data-file-size", String(file.size)); -const list = new FileList([file]); +const transfer = new DataTransfer(); +transfer.items.add(file); +const list = transfer.files; document.body.setAttribute("data-file-list-tag", Object.prototype.toString.call(list)); document.body.setAttribute("data-file-list-length", String(list.length)); document.body.setAttribute("data-file-list-item-same", String(list.item(0) === file)); diff --git a/moli-renderer-v8/src/blob.rs b/moli-renderer-v8/src/blob.rs index b785068fc1..820a3bd0dc 100644 --- a/moli-renderer-v8/src/blob.rs +++ b/moli-renderer-v8/src/blob.rs @@ -110,6 +110,10 @@ fn blob_size_attribute_getter_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !is_blob_object(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } let size = blob_bytes_from_object(scope, args.this()) .map(|bytes| bytes.len() as f64) .unwrap_or(0.0); @@ -121,6 +125,10 @@ fn blob_type_attribute_getter_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !is_blob_object(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } let mime_type = blob_mime_type_from_object(scope, args.this()).unwrap_or_default(); if let Some(value) = v8_string(scope, &mime_type) { rv.set(value.into()); @@ -134,6 +142,10 @@ pub(super) fn blob_text_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !is_blob_object(scope, args.this()) { + set_rejected_type_error_promise(scope, &mut rv, "Illegal invocation"); + return; + } let text = blob_bytes_from_object(scope, args.this()) .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) .unwrap_or_default(); @@ -149,6 +161,10 @@ pub(super) fn blob_array_buffer_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !is_blob_object(scope, args.this()) { + set_rejected_type_error_promise(scope, &mut rv, "Illegal invocation"); + return; + } let bytes = blob_bytes_from_object(scope, args.this()).unwrap_or_default(); let value = array_buffer_from_bytes(scope, bytes) .map(|buffer| buffer.into()) @@ -161,6 +177,10 @@ pub(super) fn blob_bytes_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !is_blob_object(scope, args.this()) { + set_rejected_type_error_promise(scope, &mut rv, "Illegal invocation"); + return; + } let bytes = blob_bytes_from_object(scope, args.this()).unwrap_or_default(); let value = new_uint8_array_from_bytes(scope, bytes) .map(|array| array.into()) @@ -173,6 +193,10 @@ pub(super) fn blob_stream_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !is_blob_object(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } let Some(bytes) = blob_bytes_from_object(scope, args.this()) else { rv.set(v8::undefined(scope).into()); return; @@ -196,6 +220,10 @@ pub(super) fn blob_slice_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !is_blob_object(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } let bytes = blob_bytes_from_object(scope, args.this()).unwrap_or_default(); let size = bytes.len(); let start = clamped_long_long_arg(scope, &args, 0).unwrap_or(0); @@ -940,3 +968,19 @@ fn set_resolved_promise( let _ = resolver.resolve(scope, value); rv.set(promise.into()); } + +fn set_rejected_type_error_promise( + scope: &mut v8::PinScope<'_, '_>, + rv: &mut v8::ReturnValue<'_, v8::Value>, + message: &str, +) { + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set(v8::undefined(scope).into()); + return; + }; + let promise = resolver.get_promise(scope); + let message = v8_string(scope, message).unwrap_or_else(|| v8::String::empty(scope)); + let reason = v8::Exception::type_error(scope, message); + let _ = resolver.reject(scope, reason); + rv.set(promise.into()); +} diff --git a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs index 0a3d48585a..437a73d61c 100644 --- a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs +++ b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs @@ -16,8 +16,7 @@ use super::super::{ exposed_interfaces::{TemplateBuildProfile, install_interface_template_metadata}, file_api::{ data_transfer_constructor_callback, file_constructor_callback, - file_list_constructor_callback, file_reader_constructor_callback, - file_reader_sync_constructor_callback, + file_reader_constructor_callback, file_reader_sync_constructor_callback, }, form_data_runtime::build_form_data_constructor_template, geometry_runtime::{ @@ -463,14 +462,6 @@ pub(in crate::context_bootstrap) fn build_constructor_template_for_profile<'s>( .length(2) .build(scope) } - ConstructorKind::FileList => { - v8::FunctionTemplate::builder(moli_webapi_declare::web_api_constructor!( - web_api_interfaces::FileList, - file_list_constructor_callback - )) - .length(0) - .build(scope) - } ConstructorKind::FileReader => { v8::FunctionTemplate::builder(moli_webapi_declare::web_api_constructor!( web_api_interfaces::FileReader, diff --git a/moli-renderer-v8/src/context_bootstrap/file_api.rs b/moli-renderer-v8/src/context_bootstrap/file_api.rs index 1bb0442f34..219e224325 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api.rs @@ -37,11 +37,11 @@ pub(crate) use file_entry_file::{ FileEntryFileCallbackTask, FileEntryFileCallbackTaskEffect, file_system_file_entry_file_callback, }; +pub(super) use file_list::file_list_item_callback; pub(crate) use file_list::{ build_file_list_object, file_list_files_from_object, is_file_list_object, sync_file_list_contents, }; -pub(super) use file_list::{file_list_constructor_callback, file_list_item_callback}; pub(crate) use file_reader::flush_one_pending_file_reader; pub(super) use file_reader::{ file_reader_abort_callback, file_reader_add_event_listener_callback, diff --git a/moli-renderer-v8/src/context_bootstrap/file_api/file.rs b/moli-renderer-v8/src/context_bootstrap/file_api/file.rs index b483ee62f2..d4f7cb1289 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api/file.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api/file.rs @@ -86,6 +86,10 @@ fn file_name_attribute_getter_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !file_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } let name = file_name_from_object(scope, args.this()).unwrap_or_default(); if let Some(value) = v8_string(scope, &name) { rv.set(value.into()); @@ -106,12 +110,23 @@ fn file_last_modified_attribute_getter_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !file_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } let last_modified = get_private_value(scope, args.this(), FILE_LAST_MODIFIED_SLOT) .and_then(|value| value.number_value(scope)) .unwrap_or_else(unix_epoch_millis); rv.set(v8::Number::new(scope, last_modified).into()); } +fn file_receiver_branded<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + get_private_value(scope, receiver, FILE_NAME_SLOT).is_some() +} + fn private_string_value<'s>( scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/context_bootstrap/file_api/file_list.rs b/moli-renderer-v8/src/context_bootstrap/file_api/file_list.rs index 8d65a4f001..4a0025819b 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api/file_list.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api/file_list.rs @@ -6,10 +6,14 @@ use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; const FILE_LIST_LENGTH_SLOT: &str = "__lmFileListLength"; const FILE_LIST_FILES_SLOT: &str = "__lmFileListFiles"; +const FILE_LIST_BRAND_SLOT: &str = "__lmFileListBrand"; #[derive(WebApiObject)] #[webapi(interface = web_api_interfaces::FileList, require_prototype)] struct FileListObjectDeclaration { + #[webapi(slot = FILE_LIST_BRAND_SLOT, init = true)] + brand: (), + #[webapi(slot = FILE_LIST_LENGTH_SLOT)] length: f64, } @@ -42,22 +46,6 @@ pub(super) fn install_file_list_template_bindings<'s>( ); } -pub(in crate::context_bootstrap) fn file_list_constructor_callback<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'_, v8::Value>, -) { - if !args.is_construct_call() { - throw_type_error( - scope, - "Failed to construct 'FileList': Please use the 'new' operator.", - ); - return; - } - initialize_file_list_object(scope, args.this(), args.get(0)); - rv.set(args.this().into()); -} - pub(crate) fn build_file_list_object<'s>( scope: &mut v8::PinScope<'s, '_>, files: &[v8::Local<'s, v8::Object>], @@ -153,6 +141,10 @@ pub(in crate::context_bootstrap) fn file_list_item_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !file_list_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; @@ -172,6 +164,10 @@ fn file_list_length_getter_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !file_list_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } let length = file_list_length_from_object(scope, args.this()) .filter(|value| value.is_finite() && *value >= 0.0) .unwrap_or(0.0); @@ -186,33 +182,10 @@ fn file_list_length_from_object<'s>( .and_then(|value| value.number_value(scope)) } -fn initialize_file_list_object<'s>( +fn file_list_receiver_branded<'s>( scope: &mut v8::PinScope<'s, '_>, - object: v8::Local<'s, v8::Object>, - files_value: v8::Local<'s, v8::Value>, -) { - let files = if files_value.is_null_or_undefined() { - None - } else { - files_value.to_object(scope) - }; - let length = files - .and_then(|value| value.get(scope, v8str(scope, "length").into())) - .and_then(|value| value.number_value(scope)) - .filter(|value| value.is_finite() && *value >= 0.0) - .map(|value| value as u32) - .unwrap_or(0); - let mut contents = Vec::new(); - for index in 0..length { - let file = files.and_then(|files| files.get_index(scope, index)); - contents.push(file.unwrap_or_else(|| v8::undefined(scope).into())); - if let Some(file) = file { - let _ = object.set_index(scope, index, file); - } - } - let contents = v8::Array::new_with_elements(scope, &contents); - set_private_value(scope, object, FILE_LIST_FILES_SLOT, contents.into()); - FileListObjectDeclaration::new(length as f64) - .initialize(scope, object) - .expect("FileList declaration should initialize object"); + receiver: v8::Local<'s, v8::Object>, +) -> bool { + get_private_value(scope, receiver, FILE_LIST_BRAND_SLOT) + .is_some_and(|value| value.boolean_value(scope)) } diff --git a/moli-renderer-v8/src/context_bootstrap/file_api/file_reader.rs b/moli-renderer-v8/src/context_bootstrap/file_api/file_reader.rs index dbd17ec143..0f92e52eec 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api/file_reader.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api/file_reader.rs @@ -21,10 +21,11 @@ pub(in crate::context_bootstrap::file_api) use state::install_file_reader_templa pub(in crate::context_bootstrap::file_api::file_reader) use events::dispatch_file_reader_event; pub(in crate::context_bootstrap::file_api::file_reader) use state::{ file_reader_pending_result, file_reader_pending_total, file_reader_read_id, - file_reader_ready_state, file_reader_scheduled, file_reader_task_phase, - initialize_file_reader_object, set_file_reader_error, set_file_reader_pending_result, - set_file_reader_pending_total, set_file_reader_read_id, set_file_reader_ready_state, - set_file_reader_result, set_file_reader_scheduled, set_file_reader_task_phase, + file_reader_ready_state, file_reader_receiver_branded, file_reader_scheduled, + file_reader_task_phase, initialize_file_reader_object, set_file_reader_error, + set_file_reader_pending_result, set_file_reader_pending_total, set_file_reader_read_id, + set_file_reader_ready_state, set_file_reader_result, set_file_reader_scheduled, + set_file_reader_task_phase, }; pub(in crate::context_bootstrap::file_api::file_reader) use task::file_reader_flush_callback; pub(crate) use task::flush_one_pending_file_reader; diff --git a/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/events.rs b/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/events.rs index 2a9f73501e..0f5b07cef5 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/events.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/events.rs @@ -24,6 +24,10 @@ pub(in crate::context_bootstrap) fn file_reader_add_event_listener_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !file_reader_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } simple_object_event_target_add_listener(scope, &args, FILE_READER_LISTENERS_SLOT); rv.set_undefined(); } @@ -33,6 +37,10 @@ pub(in crate::context_bootstrap) fn file_reader_remove_event_listener_callback<' args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !file_reader_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } simple_object_event_target_remove_listener(scope, &args, FILE_READER_LISTENERS_SLOT); rv.set_undefined(); } diff --git a/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/read.rs b/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/read.rs index 329ecc1ddc..b03a10aeda 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/read.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/read.rs @@ -126,6 +126,10 @@ pub(in crate::context_bootstrap) fn file_reader_abort_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !file_reader_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } if file_reader_ready_state(scope, args.this()) != 1.0 { rv.set_undefined(); return; @@ -177,6 +181,10 @@ fn ensure_file_reader_can_start_read<'s>( reader: v8::Local<'s, v8::Object>, rv: &mut v8::ReturnValue<'_, v8::Value>, ) -> bool { + if !file_reader_receiver_branded(scope, reader) { + throw_type_error(scope, "Illegal invocation"); + return false; + } if file_reader_ready_state(scope, reader) != 1.0 { return true; } diff --git a/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/state.rs b/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/state.rs index 6cd24963ab..6b3b9c2448 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/state.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api/file_reader/state.rs @@ -1,18 +1,31 @@ use super::*; -use crate::util::{get_private_value, set_private_value}; +use crate::util::{callback_data_item, get_private_value, set_private_value}; use crate::web_api_interfaces; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; const FILE_READER_READY_STATE_SLOT: &str = "__lmFileReaderReadyState"; const FILE_READER_RESULT_SLOT: &str = "__lmFileReaderResult"; const FILE_READER_ERROR_SLOT: &str = "__lmFileReaderError"; +const FILE_READER_BRAND_SLOT: &str = "__lmFileReaderBrand"; +const FILE_READER_ONLOADSTART_SLOT: &str = "__lmFileReaderOnloadstart"; +const FILE_READER_ONPROGRESS_SLOT: &str = "__lmFileReaderOnprogress"; +const FILE_READER_ONLOAD_SLOT: &str = "__lmFileReaderOnload"; +const FILE_READER_ONABORT_SLOT: &str = "__lmFileReaderOnabort"; +const FILE_READER_ONERROR_SLOT: &str = "__lmFileReaderOnerror"; +const FILE_READER_ONLOADEND_SLOT: &str = "__lmFileReaderOnloadend"; #[derive(Default, WebApiObject)] #[webapi(interface = web_api_interfaces::FileReader)] struct FileReaderObjectDeclaration { + #[webapi(slot = FILE_READER_BRAND_SLOT, init = true)] + brand: (), + #[webapi(slot = SIMPLE_EVENT_TARGET_SLOT, value = FILE_READER_LISTENERS_SLOT)] event_target_slot: (), + #[webapi(slot = SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT, init = true)] + ordered_handlers: (), + #[webapi(slot = FILE_READER_READY_STATE_SLOT, init = 0)] ready_state: (), @@ -22,23 +35,23 @@ struct FileReaderObjectDeclaration { #[webapi(slot = FILE_READER_ERROR_SLOT, init = "null")] error: (), - #[webapi(data_property = "onloadstart", init = "null")] + #[webapi(slot = FILE_READER_ONLOADSTART_SLOT, init = "null")] onloadstart: (), - #[webapi(data_property = "onprogress", init = "null")] + #[webapi(slot = FILE_READER_ONPROGRESS_SLOT, init = "null")] onprogress: (), - #[webapi(data_property = "onload", init = "null")] + #[webapi(slot = FILE_READER_ONLOAD_SLOT, init = "null")] onload: (), - #[webapi(data_property = "onloadend", init = "null")] - onloadend: (), + #[webapi(slot = FILE_READER_ONABORT_SLOT, init = "null")] + onabort: (), - #[webapi(data_property = "onerror", init = "null")] + #[webapi(slot = FILE_READER_ONERROR_SLOT, init = "null")] onerror: (), - #[webapi(data_property = "onabort", init = "null")] - onabort: (), + #[webapi(slot = FILE_READER_ONLOADEND_SLOT, init = "null")] + onloadend: (), #[webapi(slot = FILE_READER_LISTENERS_SLOT, init = "null_object")] listeners: (), @@ -74,8 +87,95 @@ struct FileReaderPrototypeAccessorsDeclaration { #[webapi(accessor_property, getter = file_reader_error_getter_callback, enumerable)] error: (), + + #[webapi( + accessor_property, + getter = file_reader_event_handler_getter_callback, + setter = file_reader_event_handler_setter_callback, + data = crate::util::callback_data_index_value(scope, 0), + enumerable + )] + onloadstart: (), + + #[webapi( + accessor_property, + getter = file_reader_event_handler_getter_callback, + setter = file_reader_event_handler_setter_callback, + data = crate::util::callback_data_index_value(scope, 1), + enumerable + )] + onprogress: (), + + #[webapi( + accessor_property, + getter = file_reader_event_handler_getter_callback, + setter = file_reader_event_handler_setter_callback, + data = crate::util::callback_data_index_value(scope, 2), + enumerable + )] + onload: (), + + #[webapi( + accessor_property, + getter = file_reader_event_handler_getter_callback, + setter = file_reader_event_handler_setter_callback, + data = crate::util::callback_data_index_value(scope, 3), + enumerable + )] + onabort: (), + + #[webapi( + accessor_property, + getter = file_reader_event_handler_getter_callback, + setter = file_reader_event_handler_setter_callback, + data = crate::util::callback_data_index_value(scope, 4), + enumerable + )] + onerror: (), + + #[webapi( + accessor_property, + getter = file_reader_event_handler_getter_callback, + setter = file_reader_event_handler_setter_callback, + data = crate::util::callback_data_index_value(scope, 5), + enumerable + )] + onloadend: (), } +#[derive(Clone, Copy)] +struct FileReaderEventHandler { + event_type: &'static str, + slot_name: &'static str, +} + +const FILE_READER_EVENT_HANDLERS: &[FileReaderEventHandler] = &[ + FileReaderEventHandler { + event_type: "loadstart", + slot_name: FILE_READER_ONLOADSTART_SLOT, + }, + FileReaderEventHandler { + event_type: "progress", + slot_name: FILE_READER_ONPROGRESS_SLOT, + }, + FileReaderEventHandler { + event_type: "load", + slot_name: FILE_READER_ONLOAD_SLOT, + }, + FileReaderEventHandler { + event_type: "abort", + slot_name: FILE_READER_ONABORT_SLOT, + }, + FileReaderEventHandler { + event_type: "error", + slot_name: FILE_READER_ONERROR_SLOT, + }, + FileReaderEventHandler { + event_type: "loadend", + slot_name: FILE_READER_ONLOADEND_SLOT, + }, +]; + pub(in crate::context_bootstrap::file_api) fn install_file_reader_template_bindings<'s>( scope: &mut v8::PinScope<'s, '_, ()>, template: v8::Local<'s, v8::FunctionTemplate>, @@ -95,6 +195,10 @@ fn file_reader_ready_state_getter_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !file_reader_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } let ready_state = file_reader_ready_state(scope, args.this()); rv.set(v8::Number::new(scope, ready_state).into()); } @@ -104,6 +208,10 @@ fn file_reader_result_getter_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !file_reader_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } let result = args.this(); let result = file_reader_slot_value(scope, result, FILE_READER_RESULT_SLOT) .unwrap_or_else(|| v8::null(scope).into()); @@ -115,12 +223,77 @@ fn file_reader_error_getter_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !file_reader_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } let error = args.this(); let error = file_reader_slot_value(scope, error, FILE_READER_ERROR_SLOT) .unwrap_or_else(|| v8::null(scope).into()); rv.set(error); } +fn file_reader_event_handler_getter_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + if !file_reader_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } + let Some(handler) = callback_data_item( + scope, + &args, + FILE_READER_EVENT_HANDLERS, + "FileReader event handlers", + ) else { + rv.set_null(); + return; + }; + let value = get_private_value(scope, args.this(), handler.slot_name) + .unwrap_or_else(|| v8::null(scope).into()); + if value.is_null_or_undefined() { + rv.set_null(); + } else { + rv.set(value); + } +} + +fn file_reader_event_handler_setter_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + if !file_reader_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } + let Some(handler) = callback_data_item( + scope, + &args, + FILE_READER_EVENT_HANDLERS, + "FileReader event handlers", + ) else { + return; + }; + let value = args.get(0); + let stored = if value.is_function() { + value + } else { + v8::null(scope).into() + }; + set_private_value(scope, args.this(), handler.slot_name, stored); + simple_object_event_set_ordered_handler( + scope, + args.this(), + FILE_READER_LISTENERS_SLOT, + handler.event_type, + handler.slot_name, + stored.is_function(), + ); +} + pub(in crate::context_bootstrap::file_api::file_reader) fn initialize_file_reader_object<'s>( scope: &mut v8::PinScope<'s, '_>, reader: v8::Local<'s, v8::Object>, @@ -130,6 +303,14 @@ pub(in crate::context_bootstrap::file_api::file_reader) fn initialize_file_reade .expect("FileReader declaration should initialize"); } +pub(in crate::context_bootstrap::file_api::file_reader) fn file_reader_receiver_branded<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + get_private_value(scope, receiver, FILE_READER_BRAND_SLOT) + .is_some_and(|value| value.boolean_value(scope)) +} + pub(in crate::context_bootstrap::file_api::file_reader) fn file_reader_ready_state<'s>( scope: &mut v8::PinScope<'s, '_>, reader: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index a05127aaef..e8defb6d03 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -961,7 +961,7 @@ const CONSTRUCTOR_SPECS_AFTER_STREAMS: &[ConstructorSpec] = &[ }, ConstructorSpec { interface: web_api_interfaces::FileList::DESCRIPTOR, - kind: ConstructorKind::FileList, + kind: ConstructorKind::Illegal, }, ConstructorSpec { interface: web_api_interfaces::FileSystem::DESCRIPTOR, diff --git a/moli-renderer-v8/src/context_bootstrap/specs/types.rs b/moli-renderer-v8/src/context_bootstrap/specs/types.rs index 3678a981ad..9e4a74ac2c 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/types.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/types.rs @@ -71,7 +71,6 @@ pub(in crate::context_bootstrap) enum ConstructorKind { WebGlDebugRendererInfo, WebGlLoseContext, File, - FileList, FileReader, FileReaderSync, XmlSerializer, diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/blob_file_reader.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/blob_file_reader.rs index b25b0c4d9c..663a1bf533 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/blob_file_reader.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/blob_file_reader.rs @@ -62,6 +62,9 @@ fn file_reader_backing_state_is_not_own_property_surface() { ].join(":"); }; const reader = new FileReader(); + const eventHandlerNames = [ + "onloadstart", "onprogress", "onload", "onabort", "onerror", "onloadend" + ]; const leaked = () => Object.getOwnPropertyNames(reader) .filter(name => name.startsWith("__lmFileReader") || name.startsWith("__moliFileReader")) @@ -74,6 +77,19 @@ fn file_reader_backing_state_is_not_own_property_surface() { __lmFileReaderResult: "spoofed", __lmFileReaderError: "spoofed" }; + const throwsTypeError = callback => { + try { + callback(); + return false; + } catch (error) { + return error instanceof TypeError; + } + }; + const handler = () => {}; + reader.onload = handler; + const handlerRoundTrips = reader.onload === handler; + reader.onload = {}; + const nonCallableHandlerBecomesNull = reader.onload === null; const before = leaked(); reader.addEventListener("load", () => {}); reader.readAsText(new Blob(["abc"])); @@ -82,7 +98,8 @@ fn file_reader_backing_state_is_not_own_property_surface() { descriptors: [ descriptorReport("readyState"), descriptorReport("result"), - descriptorReport("error") + descriptorReport("error"), + ...eventHandlerNames.map(descriptorReport) ], constructorConstants: ["EMPTY", "LOADING", "DONE"].map(name => constantDescriptor(FileReader, name) @@ -93,12 +110,18 @@ fn file_reader_backing_state_is_not_own_property_surface() { readerOwnConstants: ["EMPTY", "LOADING", "DONE"].filter(name => Object.prototype.hasOwnProperty.call(reader, name) ), + readerOwnHandlers: eventHandlerNames.filter(name => + Object.prototype.hasOwnProperty.call(reader, name) + ), before, during, resultIsNull: reader.result === null, - fakeReadyState: readyStateDescriptor.get.call(fake), - fakeResult: String(resultDescriptor.get.call(fake)), - fakeError: String(errorDescriptor.get.call(fake)) + handlerRoundTrips, + nonCallableHandlerBecomesNull, + fakeReadyStateThrows: throwsTypeError(() => readyStateDescriptor.get.call(fake)), + fakeResultThrows: throwsTypeError(() => resultDescriptor.get.call(fake)), + fakeErrorThrows: throwsTypeError(() => errorDescriptor.get.call(fake)), + fakeAbortThrows: throwsTypeError(() => FileReader.prototype.abort.call(fake)) }); })() "#, @@ -107,7 +130,7 @@ fn file_reader_backing_state_is_not_own_property_surface() { assert_eq!( result, - r#"{"descriptors":["readyState:function:get readyState:0:undefined:true:true","result:function:get result:0:undefined:true:true","error:function:get error:0:undefined:true:true"],"constructorConstants":["EMPTY:0:true:false:false","LOADING:1:true:false:false","DONE:2:true:false:false"],"prototypeConstants":["EMPTY:0:true:false:false","LOADING:1:true:false:false","DONE:2:true:false:false"],"readerOwnConstants":[],"before":[],"during":[],"resultIsNull":true,"fakeReadyState":0,"fakeResult":"null","fakeError":"null"}"# + r#"{"descriptors":["readyState:function:get readyState:0:undefined:true:true","result:function:get result:0:undefined:true:true","error:function:get error:0:undefined:true:true","onloadstart:function:get onloadstart:0:function:true:true","onprogress:function:get onprogress:0:function:true:true","onload:function:get onload:0:function:true:true","onabort:function:get onabort:0:function:true:true","onerror:function:get onerror:0:function:true:true","onloadend:function:get onloadend:0:function:true:true"],"constructorConstants":["EMPTY:0:true:false:false","LOADING:1:true:false:false","DONE:2:true:false:false"],"prototypeConstants":["EMPTY:0:true:false:false","LOADING:1:true:false:false","DONE:2:true:false:false"],"readerOwnConstants":[],"readerOwnHandlers":[],"before":[],"during":[],"resultIsNull":true,"handlerRoundTrips":true,"nonCallableHandlerBecomesNull":true,"fakeReadyStateThrows":true,"fakeResultThrows":true,"fakeErrorThrows":true,"fakeAbortThrows":true}"# ); } #[test] @@ -1096,3 +1119,63 @@ fn removing_child_frame_revokes_only_its_blob_object_urls() { "removing a child frame must revoke object URLs created by its realm" ); } + +#[test] +fn blob_members_enforce_webidl_receiver_contracts() { + let mut vm = new_storage_test_vm("https://blob-receiver-brand.test/"); + + let result = vm + .eval( + r#" +(() => { + const throwsTypeError = callback => { + try { + callback(); + return false; + } catch (error) { + return error instanceof TypeError; + } + }; + const rejectionName = callback => { + try { + return Promise.resolve(callback()).then( + () => "resolved", + error => error && error.name, + ); + } catch (error) { + return Promise.resolve("threw:" + (error && error.name)); + } + }; + const sizeGetter = Object.getOwnPropertyDescriptor(Blob.prototype, "size").get; + const typeGetter = Object.getOwnPropertyDescriptor(Blob.prototype, "type").get; + globalThis.__blobReceiverProbe = { + syncThrows: [ + throwsTypeError(() => sizeGetter.call({})), + throwsTypeError(() => typeGetter.call({})), + throwsTypeError(() => Blob.prototype.slice.call(null)), + throwsTypeError(() => Blob.prototype.stream.call(null)), + ], + }; + Promise.all([ + rejectionName(() => Blob.prototype.text.call(null)), + rejectionName(() => Blob.prototype.arrayBuffer.call(null)), + rejectionName(() => Blob.prototype.bytes.call(null)), + ]).then(names => { + __blobReceiverProbe.promiseRejections = names; + }); + return "scheduled"; +})() +"#, + ) + .expect("Blob receiver contract probe should evaluate"); + + assert_eq!(result, "scheduled"); + vm.eval("0") + .expect("Blob receiver rejection microtasks should drain"); + + assert_eq!( + vm.eval("JSON.stringify(globalThis.__blobReceiverProbe)") + .expect("Blob receiver contract result should evaluate"), + r#"{"syncThrows":[true,true,true,true],"promiseRejections":["TypeError","TypeError","TypeError"]}"# + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs index 64dd2de36a..5f46ee33ff 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs @@ -4583,6 +4583,14 @@ fn file_constructor_preserves_declared_metadata_slots() { __lmFileLastModified: 99 }; const fakeFileList = { __lmFileListLength: 99 }; + const throwsTypeError = callback => { + try { + callback(); + return false; + } catch (error) { + return error instanceof TypeError; + } + }; return JSON.stringify({ fileDescriptors: [ descriptorReport(File.prototype, "name"), @@ -4604,9 +4612,11 @@ fn file_constructor_preserves_declared_metadata_slots() { tag: Object.prototype.toString.call(explicitFile), ctor: explicitFile instanceof File, weirdLastModifiedFinite: Number.isFinite(weirdFile.lastModified), - fakeName: nameDescriptor.get.call(fakeFile), - fakeLastModifiedFinite: Number.isFinite(lastModifiedDescriptor.get.call(fakeFile)), - fakeLength: lengthDescriptor.get.call(fakeFileList) + constructFileListThrows: throwsTypeError(() => new FileList()), + fakeNameThrows: throwsTypeError(() => nameDescriptor.get.call(fakeFile)), + fakeLastModifiedThrows: throwsTypeError(() => lastModifiedDescriptor.get.call(fakeFile)), + fakeLengthThrows: throwsTypeError(() => lengthDescriptor.get.call(fakeFileList)), + fakeItemThrows: throwsTypeError(() => FileList.prototype.item.call(fakeFileList, 0)) }); })() "#, @@ -4615,7 +4625,7 @@ fn file_constructor_preserves_declared_metadata_slots() { assert_eq!( result, - r#"{"fileDescriptors":["name:function:get name:0:undefined:true:true","lastModified:function:get lastModified:0:undefined:true:true"],"fileListDescriptors":["length:function:get length:0:undefined:true:true"],"fileInternalNames":[],"fileListInternalNames":[],"defaultName":"default.txt","defaultLastModifiedFinite":true,"explicitName":"note.txt","explicitLastModified":7,"explicitType":"text/plain","roundTripName":"note.txt","roundTripLastModified":7,"roundTripType":"text/plain","tag":"[object File]","ctor":true,"weirdLastModifiedFinite":true,"fakeName":"","fakeLastModifiedFinite":true,"fakeLength":0}"# + r#"{"fileDescriptors":["name:function:get name:0:undefined:true:true","lastModified:function:get lastModified:0:undefined:true:true"],"fileListDescriptors":["length:function:get length:0:undefined:true:true"],"fileInternalNames":[],"fileListInternalNames":[],"defaultName":"default.txt","defaultLastModifiedFinite":true,"explicitName":"note.txt","explicitLastModified":7,"explicitType":"text/plain","roundTripName":"note.txt","roundTripLastModified":7,"roundTripType":"text/plain","tag":"[object File]","ctor":true,"weirdLastModifiedFinite":true,"constructFileListThrows":true,"fakeNameThrows":true,"fakeLastModifiedThrows":true,"fakeLengthThrows":true,"fakeItemThrows":true}"# ); } #[test] diff --git a/moli-renderer-v8/src/worker/thread/tests/network.rs b/moli-renderer-v8/src/worker/thread/tests/network.rs index 5d34fab008..076e1794bb 100644 --- a/moli-renderer-v8/src/worker/thread/tests/network.rs +++ b/moli-renderer-v8/src/worker/thread/tests/network.rs @@ -2603,19 +2603,23 @@ async fn worker_filelist_interface_object_is_available() { ensure_v8(); let mut handle = spawn_worker( r#" - const file = new File(["hello"], "note.txt", { type: "text/plain" }); - const list = new FileList([file]); + let constructError = null; + try { + new FileList(); + } catch (error) { + constructError = error && error.name; + } postMessage({ ctorOwn: Object.prototype.hasOwnProperty.call(self, "FileList"), ctorType: typeof FileList, - ctorName: list.constructor && list.constructor.name, - tag: Object.prototype.toString.call(list), - instanceofFileList: list instanceof FileList, - length: list.length, - firstName: list.item(0) && list.item(0).name, - indexName: list[0] && list[0].name, - iterType: typeof list[Symbol.iterator], - iterName: Array.from(list).map(file => file.name).join(","), + ctorName: FileList.name, + constructError, + itemType: typeof FileList.prototype.item, + lengthGetterType: typeof Object.getOwnPropertyDescriptor( + FileList.prototype, + "length", + ).get, + iterType: typeof FileList.prototype[Symbol.iterator], }); close(); "# @@ -2629,7 +2633,7 @@ async fn worker_filelist_interface_object_is_available() { .expect("channel closed"); assert_eq!( expect_post_json(msg), - r#"{"ctorOwn":true,"ctorType":"function","ctorName":"FileList","tag":"[object FileList]","instanceofFileList":true,"length":1,"firstName":"note.txt","indexName":"note.txt","iterType":"function","iterName":"note.txt"}"# + r#"{"ctorOwn":true,"ctorType":"function","ctorName":"FileList","constructError":"TypeError","itemType":"function","lengthGetterType":"function","iterType":"function"}"# ); } diff --git a/moli-wpt-compat/fixtures/wpt/ported/fileapi/file-basic.html b/moli-wpt-compat/fixtures/wpt/ported/fileapi/file-basic.html index 101c8cafbf..c84af9640f 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/fileapi/file-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/fileapi/file-basic.html @@ -14,6 +14,14 @@ function assert_throws_name(expectedName, callback, description) { throw new Error(description || "expected exception"); } +function file_list(files) { + const transfer = new DataTransfer(); + for (const file of files) { + transfer.items.add(file); + } + return transfer.files; +} + promise_test(async function () { const file = new File(["hello ", new Uint8Array([0xe2, 0x99, 0xa5])], "note.txt", { type: "TEXT/PLAIN", @@ -33,7 +41,8 @@ test(function () { const file = new File(["hello"], "note.txt"); const second = new File(["second"], "second.txt"); const third = new File(["third"], "third.txt"); - const list = new FileList([file, second, third]); + assert_throws_name("TypeError", () => new FileList(), "FileList is not constructible"); + const list = file_list([file, second, third]); assert_true(list instanceof FileList, "FileList instance"); assert_equals(list.length, 3, "FileList length"); diff --git a/moli-wpt-compat/fixtures/wpt/ported/forms/form-submission-file-multipart-basic.html b/moli-wpt-compat/fixtures/wpt/ported/forms/form-submission-file-multipart-basic.html index 6c12a0ddb5..d3abdf8437 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/forms/form-submission-file-multipart-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/forms/form-submission-file-multipart-basic.html @@ -54,6 +54,14 @@ function append_target_frame(name) { return frame; } +function file_list(files) { + const transfer = new DataTransfer(); + for (const file of files) { + transfer.items.add(file); + } + return transfer.files; +} + test(function () { const text = document.createElement("input"); assert_equals(text.files, null, "files should not apply to non-file inputs"); @@ -66,7 +74,7 @@ test(function () { assert_equals(input.files, empty, "files getter should return the same object until replaced"); const file = new File(["file-body"], "note.txt", { type: "text/plain" }); - const files = new FileList([file]); + const files = file_list([file]); input.files = files; assert_equals(input.files, files, "files setter should install the selected FileList"); }, "HTMLInputElement.files exposes a replaceable FileList for file inputs"); @@ -80,7 +88,7 @@ test(function () { upload.type = "file"; upload.name = "upload"; upload.multiple = true; - upload.files = new FileList([ + upload.files = file_list([ new File(["alpha-body"], "alpha.txt", { type: "text/plain" }), new File(["beta-body"], "beta.bin"), ]); @@ -115,7 +123,7 @@ promise_test(async function () { const upload = document.createElement("input"); upload.type = "file"; upload.name = "upload"; - upload.files = new FileList([ + upload.files = file_list([ new File(["file-body"], 'report"name.txt', { type: "text/custom" }), ]); const emptyInput = document.createElement("input"); @@ -180,7 +188,7 @@ promise_test(async function () { externalUpload.type = "file"; externalUpload.name = "upload"; externalUpload.multiple = true; - externalUpload.files = new FileList([ + externalUpload.files = file_list([ new File(["external-alpha-body"], "external-alpha.txt", { type: "text/external-alpha" }), new File(["external-beta-body"], "external-beta.bin"), ]); diff --git a/moli-wpt-compat/fixtures/wpt/ported/worker/resources/worker-fileapi-worker.js b/moli-wpt-compat/fixtures/wpt/ported/worker/resources/worker-fileapi-worker.js index 3701fff145..af71081300 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/worker/resources/worker-fileapi-worker.js +++ b/moli-wpt-compat/fixtures/wpt/ported/worker/resources/worker-fileapi-worker.js @@ -2,17 +2,23 @@ self.onmessage = async function (event) { const mode = event.data && event.data.mode; if (mode === "filelist") { - const file = new File(["hello"], "note.txt", { type: "text/plain" }); - const list = new FileList([file]); + let constructError = null; + try { + new FileList(); + } catch (error) { + constructError = error && error.name; + } postMessage({ ctorOwn: Object.prototype.hasOwnProperty.call(self, "FileList"), ctorType: typeof FileList, - ctorName: list.constructor && list.constructor.name, - tag: Object.prototype.toString.call(list), - instanceofFileList: list instanceof FileList, - length: list.length, - firstName: list.item(0) && list.item(0).name, - indexName: list[0] && list[0].name, + ctorName: FileList.name, + constructError: constructError, + itemType: typeof FileList.prototype.item, + lengthGetterType: typeof Object.getOwnPropertyDescriptor( + FileList.prototype, + "length", + ).get, + iterType: typeof FileList.prototype[Symbol.iterator], }); close(); return; diff --git a/moli-wpt-compat/fixtures/wpt/ported/worker/worker-fileapi-basic.html b/moli-wpt-compat/fixtures/wpt/ported/worker/worker-fileapi-basic.html index 5c64b13568..8292e26672 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/worker/worker-fileapi-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/worker/worker-fileapi-basic.html @@ -42,11 +42,10 @@ promise_test(async function () { assert_equals(message.data.ctorOwn, true, "worker should expose FileList"); assert_equals(message.data.ctorType, "function", "FileList constructor type"); assert_equals(message.data.ctorName, "FileList", "FileList constructor name"); - assert_equals(message.data.tag, "[object FileList]", "FileList object brand"); - assert_equals(message.data.instanceofFileList, true, "FileList instanceof"); - assert_equals(message.data.length, 1, "FileList length"); - assert_equals(message.data.firstName, "note.txt", "FileList.item(0)"); - assert_equals(message.data.indexName, "note.txt", "FileList index getter"); + assert_equals(message.data.constructError, "TypeError", "FileList is not constructible"); + assert_equals(message.data.itemType, "function", "FileList.item is exposed"); + assert_equals(message.data.lengthGetterType, "function", "FileList.length is exposed"); + assert_equals(message.data.iterType, "function", "FileList iterator is exposed"); } finally { worker.terminate(); }