From e4684e8f98d206850e34ca08b8d2800a368f3633 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 19 Oct 2026 17:40:41 +0800 Subject: [PATCH] fix(dom): validate Range DocumentType contents before mutation --- .../range_algorithms/contents.rs | 46 ++++++++------ .../extracted/ranges_and_selection.rs | 61 +++++++++++++++++++ 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/moli-renderer-v8/src/context_bootstrap/range_algorithms/contents.rs b/moli-renderer-v8/src/context_bootstrap/range_algorithms/contents.rs index 5506f53ecb..f0880f8a86 100644 --- a/moli-renderer-v8/src/context_bootstrap/range_algorithms/contents.rs +++ b/moli-renderer-v8/src/context_bootstrap/range_algorithms/contents.rs @@ -175,6 +175,31 @@ fn process_contents_from_snapshot<'s>( highest_ancestor_under_common_root(scope, snapshot.start_container, common_root); let partial_end = highest_ancestor_under_common_root(scope, snapshot.end_container, common_root); + let mut process_start = child_of_common_root_before_offset( + scope, + snapshot.start_container, + snapshot.start_offset, + common_root, + )?; + if snapshot.start_container != common_root { + process_start = process_start.and_then(|node| next_sibling_handle(scope, node)); + } + let process_end = child_of_common_root_before_offset( + scope, + snapshot.end_container, + snapshot.end_offset, + common_root, + )?; + + let contained_children = match process_start { + Some(start) => collect_following_siblings_until(scope, start, process_end), + None => Vec::new(), + }; + if action.produces_fragment() && contains_document_type(scope, &contained_children) { + throw_contents_hierarchy_request_error(scope); + return None; + } + let mutation_collapse_target = if action.mutates_range_contents() { Some(contents_mutation_collapse_target( scope, @@ -229,22 +254,6 @@ fn process_contents_from_snapshot<'s>( )? }; - let mut process_start = child_of_common_root_before_offset( - scope, - snapshot.start_container, - snapshot.start_offset, - common_root, - )?; - if snapshot.start_container != common_root { - process_start = process_start.and_then(|node| next_sibling_handle(scope, node)); - } - let process_end = child_of_common_root_before_offset( - scope, - snapshot.end_container, - snapshot.end_offset, - common_root, - )?; - if action.mutates_range_contents() { collapse_range_after_contents_mutation( scope, @@ -260,10 +269,7 @@ fn process_contents_from_snapshot<'s>( let _ = append_child_internal_handle(scope, fragment, left_contents); } - if let Some(process_start) = process_start { - let nodes = collect_following_siblings_until(scope, process_start, process_end); - process_nodes(scope, action, nodes, common_root, fragment)?; - } + process_nodes(scope, action, contained_children, common_root, fragment)?; if let (Some(fragment), Some(right_contents)) = (fragment, right_contents) { let _ = append_child_internal_handle(scope, fragment, right_contents); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/ranges_and_selection.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/ranges_and_selection.rs index 6c9e0a0da6..fc6c5dd36a 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/ranges_and_selection.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/ranges_and_selection.rs @@ -1012,3 +1012,64 @@ fn svg_svg_element_deselect_all_clears_the_owner_document_selection() { assert_eq!(result, "ok"); } + +#[test] +fn range_document_type_preflight_preserves_partial_contents_and_live_boundaries() { + let mut vm = new_storage_test_vm("https://range-doctype-preflight.test/"); + assert_eq!( + vm.eval( + r#" + (() => { + let cases = 0; + for (const method of ['extractContents', 'cloneContents']) { + for (const kind of ['document-start', 'partial-comment', 'same-container']) { + const doc = document.implementation.createDocument(null, 'root', + document.implementation.createDocumentType('root', '', '')); + const root = doc.documentElement; + root.appendChild(doc.createTextNode('abcd')); + const text = root.firstChild; + const comment = doc.createComment('before'); + doc.insertBefore(comment, doc.doctype); + const doctype = doc.doctype; + const children = Array.from(doc.childNodes); + const range = doc.createRange(); + if (kind === 'partial-comment') range.setStart(comment, 2); + else range.setStart(doc, 0); + if (kind === 'same-container') range.setEnd(doc, 2); + else range.setEnd(text, 2); + const other = range.cloneRange(); + const boundaries = [range.startContainer, range.startOffset, + range.endContainer, range.endOffset]; + const observer = new MutationObserver(() => {}); + observer.observe(doc, {subtree: true, childList: true, characterData: true}); + let error; + try { range[method](); } catch (caught) { error = caught; } + const label = method + '/' + kind; + const check = (condition, detail) => { + if (!condition) throw new Error(label + ': ' + detail); + }; + check(error && error.name === 'HierarchyRequestError', 'expected hierarchy error'); + check(text.data === 'abcd' && comment.data === 'before', 'partial data changed'); + check(doc.doctype === doctype && doctype.parentNode === doc, 'doctype moved'); + check(root.parentNode === doc && text.parentNode === root, 'tree ownership changed'); + check(children.length === doc.childNodes.length && + children.every((node, index) => doc.childNodes[index] === node), 'children changed'); + for (const candidate of [range, other]) { + check(candidate.startContainer === boundaries[0] && + candidate.startOffset === boundaries[1] && + candidate.endContainer === boundaries[2] && + candidate.endOffset === boundaries[3], 'live boundaries changed'); + } + check(observer.takeRecords().length === 0, 'observable mutation before rejection'); + observer.disconnect(); + ++cases; + } + } + return String(cases); + })() + "# + ) + .unwrap(), + "6" + ); +}