diff --git a/moli-renderer-v8/src/context_bootstrap/form_data_runtime/callbacks.rs b/moli-renderer-v8/src/context_bootstrap/form_data_runtime/callbacks.rs index 631be945ac..cec84ff741 100644 --- a/moli-renderer-v8/src/context_bootstrap/form_data_runtime/callbacks.rs +++ b/moli-renderer-v8/src/context_bootstrap/form_data_runtime/callbacks.rs @@ -10,10 +10,11 @@ use crate::{ native_bridge::{ JsContextHost, element::{form_associated_form_owner, is_valid_submit_button}, - node_runtime_and_handle_from_object, throw_dom_exception, + node_relevant_context, node_runtime_and_handle_from_object_or_detached, + throw_dom_exception, }, util::serialize_v8_iter_array, - webidl, + web_api_interfaces, webidl, }; use moli_webapi_declare::WebApiObject; @@ -115,77 +116,82 @@ pub(super) fn form_data_constructor_callback<'s>( return; } - let mut entries = Vec::new(); let Some(parsed) = webidl::parse_args::>(scope, &args) else { return; }; - if let Some(form) = parsed.form { - let Ok(form) = v8::Local::::try_from(form) else { - throw_type_error(scope, "FormData constructor requires an HTMLFormElement"); - return; - }; - if object_string_property_defined(scope, form, "tagName") - .is_none_or(|tag| !tag.eq_ignore_ascii_case("form")) - { - throw_type_error(scope, "FormData constructor requires an HTMLFormElement"); - return; + let form = match parsed.form { + Some(value) => { + let Ok(form) = v8::Local::::try_from(value) else { + throw_type_error(scope, "FormData constructor requires an HTMLFormElement"); + return; + }; + if !web_api_interfaces::HTMLFormElement::is_instance(scope, form) { + throw_type_error(scope, "FormData constructor requires an HTMLFormElement"); + return; + } + Some(form) } - let Ok((form_runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, form) + None => None, + }; + // WebIDL converts the optional submitter even when the form is omitted. + let submitter = match parsed.submitter { + Some(value) if !value.is_null_or_undefined() => { + let Ok(submitter) = v8::Local::::try_from(value) else { + throw_type_error( + scope, + "FormData constructor submitter must be an HTMLElement or null", + ); + return; + }; + if !web_api_interfaces::HTMLElement::is_instance(scope, submitter) { + throw_type_error( + scope, + "FormData constructor submitter must be an HTMLElement or null", + ); + return; + } + Some(submitter) + } + _ => None, + }; + let mut entries = Vec::new(); + if let Some(form) = form { + let Ok((form_runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, form) else { throw_type_error(scope, "FormData constructor requires an HTMLFormElement"); return; }; - let submitter = match parsed.submitter { - Some(value) if value.is_null_or_undefined() => None, - Some(value) => match v8::Local::::try_from(value) { - Ok(submitter) => { - let Ok((submitter_runtime_ptr, submitter_handle)) = - node_runtime_and_handle_from_object(scope, submitter) - else { - throw_type_error( - scope, - "FormData constructor submitter must be a submit button", - ); - return; - }; - if submitter_runtime_ptr != form_runtime_ptr { - throw_dom_exception( - scope, - "NotFoundError", - 8, - "The specified element is not owned by this form element.", - ); - return; - } - let runtime = unsafe { &*form_runtime_ptr }; - if !is_valid_submit_button(runtime, submitter_handle) { - throw_type_error( - scope, - "FormData constructor submitter must be a submit button", - ); - return; - } - if form_associated_form_owner(runtime, submitter_handle) != Some(form_handle) { - throw_dom_exception( - scope, - "NotFoundError", - 8, - "The specified element is not owned by this form element.", - ); - return; - } - Some(submitter) - } - Err(_) => { - throw_type_error( - scope, - "FormData constructor submitter must be an HTMLElement or null", - ); - return; - } - }, - None => None, - }; + if let Some(submitter) = submitter { + let Ok((submitter_runtime_ptr, submitter_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, submitter) + else { + throw_type_error( + scope, + "FormData constructor submitter must be a submit button", + ); + return; + }; + let runtime = unsafe { &*submitter_runtime_ptr }; + if !is_valid_submit_button(runtime, submitter_handle) { + throw_type_error( + scope, + "FormData constructor submitter must be a submit button", + ); + return; + } + if submitter_runtime_ptr != form_runtime_ptr + || form_associated_form_owner(runtime, submitter_handle) != Some(form_handle) + { + throw_dom_exception( + scope, + "NotFoundError", + 8, + "The specified element is not owned by this form element.", + ); + return; + } + } let Some(next_entries) = construct_form_data_entries_for_form( scope, form_runtime_ptr, @@ -219,6 +225,8 @@ pub(crate) fn construct_form_data_entries_for_form<'s>( return None; } + let context = node_relevant_context(scope, form).unwrap_or_else(|| scope.get_current_context()); + let scope = &mut v8::ContextScope::new(scope, context); let entries = serialize_form_data_controls(scope, runtime_ptr, form_handle, submitter); let entries = dispatch_form_data_event_with_entries(scope, form, &entries).unwrap_or(entries); unsafe { &mut *runtime_ptr }.end_form_data_construction(form_handle); @@ -252,7 +260,7 @@ fn dispatch_form_data_event<'s>( form_data: v8::Local<'s, v8::Object>, ) { let Ok((runtime_ptr, form_handle)) = - crate::native_bridge::node_runtime_and_handle_from_object(scope, form) + node_runtime_and_handle_from_object_or_detached(scope, form) else { return; }; diff --git a/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs b/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs index 1e7194b8c5..64044ca04f 100644 --- a/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs +++ b/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs @@ -7,10 +7,10 @@ use crate::dom::{ }; use crate::native_bridge::{ element::{ - element_attribute_for_object, element_internals_form_value_for_target, + control_has_datalist_ancestor, element_internals_form_value_for_target, form_control_is_effectively_disabled, form_data_control_elements, text_control_value, }, - node_runtime_and_handle_from_object, + node_runtime_and_handle_from_object_or_detached, }; use moli_encoding::is_charset_sentinel_name; use moli_webapi_declare::WebApiObject; @@ -31,6 +31,9 @@ pub(super) fn serialize_form_data_controls<'s>( let mut entries = Vec::new(); let controls = form_data_control_elements(unsafe { &*runtime_ptr }, form_handle); for handle in controls { + if control_has_datalist_ancestor(unsafe { &*runtime_ptr }, handle) { + continue; + } let Some(control) = form_data_control_object(scope, runtime_ptr, handle) else { continue; }; @@ -82,10 +85,6 @@ fn append_form_data_entries_for_control<'s>( if control_is_effectively_disabled(scope, control) { return; } - if control_has_datalist_ancestor(scope, control) { - return; - } - let tag = object_string_property_defined(scope, control, "tagName") .map(|tag| tag.to_ascii_lowercase()) .unwrap_or_default(); @@ -243,19 +242,28 @@ fn append_form_data_entries_for_control<'s>( } } -fn form_control_name( - scope: &mut v8::PinScope<'_, '_>, - control: v8::Local<'_, v8::Object>, +fn form_control_name<'s>( + scope: &mut v8::PinScope<'s, '_>, + control: v8::Local<'s, v8::Object>, ) -> String { - element_attribute_for_object(scope, control, "name").unwrap_or_default() + let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object_or_detached(scope, control) + else { + return String::new(); + }; + unsafe { &*runtime_ptr } + .dom_host() + .node(handle) + .and_then(Node::as_element) + .and_then(|element| element.attribute_ns("", "name")) + .unwrap_or_default() + .to_owned() } -fn image_submitter_coordinates( - scope: &mut v8::PinScope<'_, '_>, - control: v8::Local<'_, v8::Object>, +fn image_submitter_coordinates<'s>( + scope: &mut v8::PinScope<'s, '_>, + control: v8::Local<'s, v8::Object>, ) -> (u32, u32) { - let Ok((runtime_ptr, handle)) = - crate::native_bridge::node_runtime_and_handle_from_object(scope, control) + let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object_or_detached(scope, control) else { return (0, 0); }; @@ -381,9 +389,9 @@ fn push_string_form_data_entry( push_form_data_entry(entries, name, v8::Global::new(scope, value)); } -fn form_control_value( - scope: &mut v8::PinScope<'_, '_>, - control: v8::Local<'_, v8::Object>, +fn form_control_value<'s>( + scope: &mut v8::PinScope<'s, '_>, + control: v8::Local<'s, v8::Object>, default: &str, ) -> String { if let Some(value) = native_form_control_value(scope, control) { @@ -392,11 +400,12 @@ fn form_control_value( object_string_property_defined(scope, control, "value").unwrap_or_else(|| default.to_owned()) } -fn native_form_control_value( - scope: &mut v8::PinScope<'_, '_>, - control: v8::Local<'_, v8::Object>, +fn native_form_control_value<'s>( + scope: &mut v8::PinScope<'s, '_>, + control: v8::Local<'s, v8::Object>, ) -> Option { - let (runtime_ptr, handle) = node_runtime_and_handle_from_object(scope, control).ok()?; + let (runtime_ptr, handle) = + node_runtime_and_handle_from_object_or_detached(scope, control).ok()?; let runtime = unsafe { &*runtime_ptr }; let element = runtime.dom_host().node(handle).and_then(Node::as_element)?; if element.is_html_input() { @@ -427,7 +436,9 @@ fn control_is_effectively_disabled<'s>( scope: &mut v8::PinScope<'s, '_>, control: v8::Local<'s, v8::Object>, ) -> bool { - if let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, control) { + if let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, control) + { return form_control_is_effectively_disabled(unsafe { &*runtime_ptr }, handle); } if object_bool_property(scope, control, "disabled").unwrap_or(false) { @@ -453,7 +464,8 @@ fn native_form_associated_custom_element<'s>( scope: &mut v8::PinScope<'s, '_>, control: v8::Local<'s, v8::Object>, ) -> bool { - let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, control) else { + let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object_or_detached(scope, control) + else { return false; }; is_form_associated_custom_element_handle(unsafe { &*runtime_ptr }, handle) @@ -488,23 +500,6 @@ fn control_is_in_first_legend<'s>( false } -fn control_has_datalist_ancestor<'s>( - scope: &mut v8::PinScope<'s, '_>, - control: v8::Local<'s, v8::Object>, -) -> bool { - let mut current = object_property_as_object(scope, control, "parentElement"); - while let Some(element) = current { - let tag = object_string_property_defined(scope, element, "tagName") - .map(|tag| tag.to_ascii_lowercase()) - .unwrap_or_default(); - if tag == "datalist" { - return true; - } - current = object_property_as_object(scope, element, "parentElement"); - } - false -} - fn option_is_disabled(scope: &mut v8::PinScope<'_, '_>, option: v8::Local<'_, v8::Object>) -> bool { let mut current = Some(option); while let Some(element) = current { diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 403341ece0..29effdfae8 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -105,9 +105,9 @@ use trusted_types::{ }; pub(crate) use forms::{ - autocomplete_field_name, autofill_related_form_control_elements, form_associated_form_owner, - form_control_elements, form_data_control_elements, is_valid_submit_button, - submit_form_with_submit_event, + autocomplete_field_name, autofill_related_form_control_elements, control_has_datalist_ancestor, + form_associated_form_owner, form_control_elements, form_data_control_elements, + is_valid_submit_button, submit_form_with_submit_event, }; pub(in crate::native_bridge) use forms::{form_named_control_matches, form_named_image_matches}; #[cfg(test)] diff --git a/moli-renderer-v8/src/native_bridge/element/forms.rs b/moli-renderer-v8/src/native_bridge/element/forms.rs index 0477a6431e..7ddd9aee44 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms.rs @@ -245,6 +245,7 @@ pub(in crate::native_bridge) use self::text_control::{ text_control_set_selection_range_callback, textarea_value_getter_function, textarea_value_setter_function, }; +pub(crate) use self::validation::control_has_datalist_ancestor; pub(crate) use self::validation::v8_pattern_is_usable; pub(in crate::native_bridge) use self::validation::{ control_check_validity_callback, control_matches_validity_pseudo, diff --git a/moli-renderer-v8/src/native_bridge/element/forms/validation.rs b/moli-renderer-v8/src/native_bridge/element/forms/validation.rs index b2c1c9b9fb..4481ce9c42 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/validation.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/validation.rs @@ -488,7 +488,7 @@ fn required_value_missing_is_suppressed_for_immutable_control( || (element.is_html_input() && element.input_type().supports_readonly())) } -fn control_has_datalist_ancestor(runtime: &JsContextHost, handle: DomHandle) -> bool { +pub(crate) fn control_has_datalist_ancestor(runtime: &JsContextHost, handle: DomHandle) -> bool { let mut current = runtime.dom_host().parent_node(handle); while let Some(parent) = current { let Some(parent_element) = runtime.dom_host().node(parent).and_then(Node::as_element) diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/form_data_brands.js b/moli-renderer-v8/src/script_vm/tests/dom_elements/form_data_brands.js new file mode 100644 index 0000000000..c7fd3d83de --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/form_data_brands.js @@ -0,0 +1,84 @@ +(() => { + const globals = [window, document.getElementById('child').contentWindow]; + const rows = [], errors = []; + for (let realm = 0; realm < globals.length; ++realm) { + const g = globals[realm], other = globals[1 - realm]; + for (const kind of ['live', 'windowless', 'parsed']) { + const label = `${realm}/${kind}`; + try { + const d = kind === 'live' ? g.document : kind === 'windowless' ? + g.document.implementation.createHTMLDocument('') : + new g.DOMParser().parseFromString('', 'text/html'); + const form = d.createElement('form'), input = d.createElement('input'); + input.setAttribute('name', 'input-name'); input.setAttribute('value', 'input-value'); + form.appendChild(input); + const select = d.createElement('select'), option = d.createElement('option'); + select.setAttribute('name', 'select-name'); option.setAttribute('value', 'option-value'); + select.appendChild(option); form.appendChild(select); + const button = d.createElement('button'); + button.setAttribute('type', 'submit'); button.setAttribute('name', 'send'); button.setAttribute('value', 'button'); + form.appendChild(button); + let tagReads = 0; + Object.defineProperty(form, 'tagName', {configurable: true, get() { ++tagReads; throw new Error('author tag getter'); }}); + let valueReads = 0; + Object.defineProperty(input, 'value', {configurable: true, get() { ++valueReads; throw new Error('author value getter'); }}); + let eventCount = 0, eventRealm = true, eventTarget = true; + form.addEventListener('formdata', event => { + ++eventCount; + eventRealm &&= event instanceof g.FormDataEvent && event.formData instanceof g.FormData; + eventTarget &&= event.target === form; + event.formData.append('from-handler', 'listener'); + }); + const data = new other.FormData(form); + const checks = {constructorRealm: Object.getPrototypeOf(data) === other.FormData.prototype, + input: data.get('input-name') === 'input-value', + option: data.get('select-name') === 'option-value', + event: eventCount === 1 && eventRealm && eventTarget, + handlerMutation: data.get('from-handler') === 'listener', + nativeProperties: tagReads === 0 && valueReads === 0, + omitSubmitter: !data.has('send')}; + const withSubmitter = new other.FormData(form, button); + checks.submitter = withSubmitter.get('send') === 'button' && eventCount === 2; + let traps = 0; + const author = new g.Proxy(form, {get() { ++traps; return undefined; }, getPrototypeOf() { ++traps; return null; }}); + const revoked = g.Proxy.revocable(form, {}); revoked.revoke(); + checks.invalidForms = true; + for (const invalid of [null, {}, g.Object.create(form), author, revoked.proxy, + d.createElement('div'), d.createElementNS('http://www.w3.org/2000/svg', 'form')]) { + let error; + try { new other.FormData(invalid); } catch (caught) { error = caught; } + checks.invalidForms &&= error instanceof other.TypeError; + } + checks.invalidSubmitters = true; + for (const invalid of [{}, g.Object.create(button), new g.Proxy(button, {get() { ++traps; }}), + d.createElement('div'), other.document.createElement('div')]) { + let error; + try { new other.FormData(form, invalid); } catch (caught) { error = caught; } + checks.invalidSubmitters &&= error instanceof other.TypeError; + } + const foreign = d.createElement('button'); foreign.type = 'submit'; + const crossDocument = other.document.createElement('button'); crossDocument.type = 'submit'; + checks.foreignSubmitter = true; + for (const submitter of [foreign, crossDocument]) { + let notFound; + try { new other.FormData(form, submitter); } catch (caught) { notFound = caught; } + checks.foreignSubmitter &&= notFound instanceof other.DOMException && notFound.name === 'NotFoundError'; + } + let omittedSubmitterError; + try { new other.FormData(undefined, {}); } + catch (error) { omittedSubmitterError = error; } + checks.omittedFormStillConvertsSubmitter = omittedSubmitterError instanceof other.TypeError; + checks.noPublicReads = traps === 0 && tagReads === 0 && valueReads === 0; + other.document.adoptNode(form); + const adoptedData = new other.FormData(form); + checks.adoptedFormRealm = eventCount === 3 && eventRealm && eventTarget && + Object.getPrototypeOf(adoptedData) === other.FormData.prototype && + adoptedData.get('from-handler') === 'listener'; + rows.push({label, checks}); + } catch (error) { errors.push({label, message: String(error)}); } + } + } + globalThis.__uiEventResults = {rows, errors, + passed: errors.length === 0 && rows.every(row => Object.values(row.checks).every(value => value === true))}; + return __uiEventResults.passed; +})() diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/form_data_brands.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/form_data_brands.rs new file mode 100644 index 0000000000..eb73d5fbbd --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/form_data_brands.rs @@ -0,0 +1,33 @@ +use super::*; + +#[tokio::test] +async fn form_data_constructor_accepts_native_forms_across_document_realms() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = new_storage_page_task_executor_test_vm_with_loader( + "https://form-data-brands.test/", + &loader, + ); + vm.eval( + r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + const frame=document.body.appendChild(document.createElement('iframe')); + frame.id='child'; frame.srcdoc=''; void frame.contentWindow; + 'ready' + "#, + ) + .unwrap(); + assert!( + vm.run_one_child_frame_task_executor_turn( + ChildFrameSemanticTurnKind::RealmMaterialization, + &loader, + ) + .await + .unwrap() + ); + vm.drain_ready_page_task_executor_turns_for_setup(&loader, 128) + .await + .unwrap(); + assert_eq!(vm.eval(include_str!("form_data_brands.js")).unwrap(), "true", "{}", + vm.eval("JSON.stringify({errors:__uiEventResults.errors,failures:__uiEventResults.rows.filter(row=>Object.values(row.checks).some(value=>value!==true))})").unwrap()); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/mod.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/mod.rs index 7000e0114d..9ea50f38cf 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/mod.rs @@ -5,6 +5,7 @@ mod custom_elements; mod detached; mod dom_surface; mod focus; +mod form_data_brands; mod live_document; mod text_controls; mod wrapper_identity;