fix(editing): enforce maxlength for inserted text

This commit is contained in:
ldm0
2026-09-16 22:02:04 +08:00
parent bd9608c644
commit 8a5f23df19
3 changed files with 176 additions and 43 deletions
@@ -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
@@ -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<Self> {
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::<EditingCommand>() 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::<EditingCommand>().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::<EditingCommand>() {
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<String> {
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<DomHandle> {
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,
@@ -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,
'<b>A</b><br>B<script>C</script>&amp;'
);
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/");