From 8a5f23df19eccd5eb9c6f0b1c2ada90f3c1d2c5f Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 15 Jul 2026 02:40:58 +0800 Subject: [PATCH] fix(editing): enforce maxlength for inserted text --- .../wpt-cross-current/passed-cases.txt | 1 + .../src/native_bridge/document/lifecycle.rs | 148 +++++++++++++----- .../tests/dom_elements/text_controls.rs | 70 +++++++++ 3 files changed, 176 insertions(+), 43 deletions(-) diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index c073c90e46..3f9c637cb2 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -5771,6 +5771,7 @@ html/semantics/forms/constraints/form-validation-validity-valueMissing-weekmonth html/semantics/forms/constraints/form-validation-validity-valueMissing.html html/semantics/forms/constraints/form-validation-willValidate-datalist.html html/semantics/forms/constraints/form-validation-willValidate.html +html/semantics/forms/constraints/input-maxlength-emoji.html html/semantics/forms/constraints/input-number-validity-dynamic-value-no-change.html html/semantics/forms/constraints/input-pattern-dynamic-value.html html/semantics/forms/constraints/inputwillvalidate.html diff --git a/moli-renderer-v8/src/native_bridge/document/lifecycle.rs b/moli-renderer-v8/src/native_bridge/document/lifecycle.rs index 70755cc2cb..0823b0d02b 100644 --- a/moli-renderer-v8/src/native_bridge/document/lifecycle.rs +++ b/moli-renderer-v8/src/native_bridge/document/lifecycle.rs @@ -8,7 +8,8 @@ use super::{ is_html_document, throw_dom_exception, }; use crate::native_bridge::element::{ - contenteditable_editing_host, dispatch_text_control_event, is_text_control, + contenteditable_editing_host, dispatch_text_control_event, + form_control_is_effectively_disabled, is_text_control, queue_text_control_document_selection_change_event, replace_contenteditable_selection, replace_text_control_selection, text_control_value, }; @@ -16,6 +17,8 @@ use crate::{ context_bootstrap::WINDOW_EVENT_HANDLER_PROPERTIES, custom_elements, document_runtime::DomHandle, + dom::native::{NativeDom, NodeData}, + parser::HtmlParser, util::{ call_object_method, node_wrapper_from_handle, utf16_next_scalar_boundary, utf16_previous_scalar_boundary, utf16_replace_units_range_lossy, utf16_units, v8str, @@ -361,38 +364,17 @@ fn normalized_editing_command<'s>( .unwrap_or_default() } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, strum::EnumString, strum::IntoStaticStr)] +#[strum(serialize_all = "lowercase")] enum EditingCommand { Copy, Delete, ForwardDelete, + InsertHtml, InsertText, SelectAll, } -impl EditingCommand { - fn parse(command: &str) -> Option { - match command { - "copy" => Some(Self::Copy), - "delete" => Some(Self::Delete), - "forwarddelete" => Some(Self::ForwardDelete), - "inserttext" => Some(Self::InsertText), - "selectall" => Some(Self::SelectAll), - _ => None, - } - } - - fn name(self) -> &'static str { - match self { - Self::Copy => "copy", - Self::Delete => "delete", - Self::ForwardDelete => "forwarddelete", - Self::InsertText => "inserttext", - Self::SelectAll => "selectall", - } - } -} - fn editing_command_document<'s>( scope: &mut v8::PinScope<'s, '_>, args: &v8::FunctionCallbackArguments<'s>, @@ -438,7 +420,7 @@ pub(in crate::native_bridge) fn node_document_exec_command_callback<'s>( ) else { return; }; - let Some(command) = EditingCommand::parse(&command) else { + let Ok(command) = command.parse::() else { rv.set(v8::Boolean::new(scope, false).into()); return; }; @@ -455,21 +437,25 @@ pub(in crate::native_bridge) fn node_document_exec_command_callback<'s>( return; } EditingCommand::InsertText => { - let replacement = if args.length() > 2 { - let Some(value) = args.get(2).to_string(scope) else { - return; - }; - value.to_rust_string_lossy(scope) - } else { - String::new() + let Some(value) = editing_command_value(scope, &args) else { + return; }; - let inserted = exec_command_insert_text(scope, runtime_ptr, &replacement); + let inserted = exec_command_insert_text(scope, runtime_ptr, &value); + rv.set(v8::Boolean::new(scope, inserted).into()); + return; + } + EditingCommand::InsertHtml => { + let Some(value) = editing_command_value(scope, &args) else { + return; + }; + let inserted = exec_command_insert_html(scope, runtime_ptr, &value); rv.set(v8::Boolean::new(scope, inserted).into()); return; } EditingCommand::Delete | EditingCommand::ForwardDelete => {} } - let removed = exec_command_delete_selection(scope, runtime_ptr, args.this(), command.name()); + let command_name: &'static str = command.into(); + let removed = exec_command_delete_selection(scope, runtime_ptr, args.this(), command_name); rv.set(v8::Boolean::new(scope, removed).into()); } @@ -504,7 +490,7 @@ pub(in crate::native_bridge) fn node_document_query_command_supported_callback<' ) else { return; }; - rv.set(v8::Boolean::new(scope, EditingCommand::parse(&command).is_some()).into()); + rv.set(v8::Boolean::new(scope, command.parse::().is_ok()).into()); } pub(in crate::native_bridge) fn node_document_query_command_enabled_callback<'s>( @@ -522,21 +508,20 @@ pub(in crate::native_bridge) fn node_document_query_command_enabled_callback<'s> return; }; let runtime = unsafe { &*runtime_ptr }; - let enabled = match EditingCommand::parse(&command) { - Some( - EditingCommand::Delete | EditingCommand::ForwardDelete | EditingCommand::InsertText, - ) => { + let enabled = match command.parse::() { + Ok(EditingCommand::Delete | EditingCommand::ForwardDelete | EditingCommand::InsertText) => { runtime.document_design_mode_enabled(document_handle) || runtime.active_element_handle().is_some_and(|active| { is_text_control(runtime, active) || contenteditable_editing_host(runtime, active).is_some() }) } - Some(EditingCommand::SelectAll) => { + Ok(EditingCommand::InsertHtml) => exec_command_insert_html_target(runtime).is_some(), + Ok(EditingCommand::SelectAll) => { exec_command_select_all_target(runtime, document_handle).is_some() } - Some(EditingCommand::Copy) => current_protocol_user_gesture_activation(scope), - None => false, + Ok(EditingCommand::Copy) => current_protocol_user_gesture_activation(scope), + Err(_) => false, }; rv.set(v8::Boolean::new(scope, enabled).into()); } @@ -670,6 +655,83 @@ fn current_protocol_user_gesture_activation(scope: &mut v8::PinScope<'_, '_>) -> .is_some_and(|host_ptr| unsafe { (&*host_ptr).protocol_user_gesture_activation() }) } +fn editing_command_value<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, +) -> Option { + if args.length() < 3 { + return Some(String::new()); + } + args.get(2) + .to_string(scope) + .map(|value| value.to_rust_string_lossy(scope)) +} + +fn exec_command_insert_html( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + value: &str, +) -> bool { + let runtime = unsafe { &*runtime_ptr }; + let Some(target) = exec_command_insert_html_target(runtime) else { + return false; + }; + let insertion_text = input_text_from_html_fragment(runtime, value); + replace_text_control_selection(scope, runtime_ptr, target, &insertion_text) +} + +fn exec_command_insert_html_target(runtime: &JsContextHost) -> Option { + let handle = runtime.active_element_handle()?; + let element = runtime.dom_host().node(handle)?.as_element()?; + let accepts_plain_text = element.is_html_textarea() + || (element.is_html_input() && element.input_type().supports_text_length_validation()); + if !accepts_plain_text + || element.has_attribute("readonly") + || form_control_is_effectively_disabled(runtime, handle) + { + return None; + } + Some(handle) +} + +fn input_text_from_html_fragment(runtime: &JsContextHost, value: &str) -> String { + let parsed = HtmlParser.parse_fragment_without_declarative_shadow_roots_with_scripting( + runtime.host_document().url().clone(), + "http://www.w3.org/1999/xhtml", + "body", + value.to_owned(), + true, + ); + let root = parsed + .body_node_id() + .unwrap_or_else(|| parsed.document_node_id()); + let mut text = String::new(); + for child in parsed.child_ids(root) { + append_input_fragment_text(&parsed, child, &mut text); + } + text +} + +fn append_input_fragment_text(dom: &NativeDom, handle: DomHandle, text: &mut String) { + let Some(node) = dom.node(handle) else { + return; + }; + if node.is_html_element_named("br") { + text.push('\n'); + return; + } + match node.data() { + NodeData::Text(value) => text.push_str(value.data()), + NodeData::CDataSection(value) => text.push_str(value.data()), + NodeData::Document(_) | NodeData::Element(_) | NodeData::DocumentFragment(_) => { + for child in dom.child_ids(handle) { + append_input_fragment_text(dom, child, text); + } + } + NodeData::DocumentType(_) | NodeData::Comment(_) | NodeData::ProcessingInstruction(_) => {} + } +} + fn exec_command_delete_selection<'s>( scope: &mut v8::PinScope<'s, '_>, runtime_ptr: *mut JsContextHost, diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs index 6c26b8d260..17013423ae 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs @@ -1228,6 +1228,76 @@ fn exec_command_text_control_deletion_preserves_surrogate_pairs() { ); } +#[test] +fn exec_command_insert_html_enforces_maxlength_in_utf16_units() { + let mut vm = new_storage_test_vm("https://exec-command-insert-html-maxlength.test/"); + + let result = vm + .eval( + r#" +(() => { + const root = document.documentElement || document.appendChild(document.createElement('html')); + const body = document.body || root.appendChild(document.createElement('body')); + const input = document.createElement('input'); + input.setAttribute('maxlength', ' +10tail'); + body.append(input); + const events = []; + input.addEventListener('beforeinput', () => events.push('beforeinput')); + input.addEventListener('input', () => events.push('input')); + + const supported = document.queryCommandSupported('InsertHTML'); + const enabledBeforeFocus = document.queryCommandEnabled('InsertHTML'); + input.focus(); + const enabled = document.queryCommandEnabled('InsertHTML'); + const returned = document.execCommand('InsertHTML', false, '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ'); + + const markupInput = document.createElement('input'); + body.append(markupInput); + markupInput.focus(); + const markupReturned = document.execCommand( + 'InsertHTML', + false, + 'A
B&' + ); + + const rangeInput = document.createElement('input'); + rangeInput.value = 'A๐Ÿ˜€B'; + body.append(rangeInput); + rangeInput.setRangeText('x', 1, 3, 'end'); + + input.readOnly = true; + input.focus(); + const enabledReadonly = document.queryCommandEnabled('InsertHTML'); + const readonlyReturned = document.execCommand('InsertHTML', false, 'x'); + + return JSON.stringify({ + supported, + enabledBeforeFocus, + enabled, + returned, + value: input.value, + valueLength: input.value.length, + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd, + events, + markupReturned, + markupValue: markupInput.value, + rangeValue: rangeInput.value, + rangeSelection: [rangeInput.selectionStart, rangeInput.selectionEnd], + enabledReadonly, + readonlyReturned + }); +})() +"#, + ) + .expect("execCommand InsertHTML maxlength probe should evaluate"); + + assert_eq!( + result, + r#"{"supported":true,"enabledBeforeFocus":false,"enabled":true,"returned":true,"value":"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€","valueLength":9,"selectionStart":9,"selectionEnd":9,"events":["beforeinput","input"],"markupReturned":true,"markupValue":"A BC&","rangeValue":"AxB","rangeSelection":[2,2],"enabledReadonly":false,"readonlyReturned":false}"# + ); +} + #[test] fn input_file_value_setter_rejects_non_empty_values() { let mut vm = new_storage_test_vm("https://forms-input-file-value.test/");