fix(dom): validate Range DocumentType contents before mutation

This commit is contained in:
ldm0
2026-10-20 05:22:03 +08:00
committed by Donough Liu
parent 8433ac01e2
commit e4684e8f98
2 changed files with 87 additions and 20 deletions
@@ -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);
@@ -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"
);
}