diff --git a/moli-parser/src/xml.rs b/moli-parser/src/xml.rs index be3592356c..976670ed19 100644 --- a/moli-parser/src/xml.rs +++ b/moli-parser/src/xml.rs @@ -37,6 +37,13 @@ impl XmlPrepassError { } } + fn before_document_element(detail: impl Into) -> Self { + Self { + detail: detail.into(), + discard_partial_tree: true, + } + } + fn namespace(detail: impl Into) -> Self { Self { detail: detail.into(), @@ -159,10 +166,12 @@ fn xml_element_stack_error(xml: &str) -> Option { let mut pending_declared_prefixes = Vec::new(); let mut open_elements = Vec::new(); let mut active_prefixes = HashMap::from([("xml".to_owned(), 1usize)]); + let mut saw_document_element = false; for token in XmlTokenizer::from(xml) { match token { Ok(XmlTokenizerToken::ElementStart { prefix, local, .. }) => { + saw_document_element = true; let prefix = prefix.as_str(); if !prefix.is_empty() { pending_used_prefixes.push(prefix.to_owned()); @@ -243,7 +252,17 @@ fn xml_element_stack_error(xml: &str) -> Option { } }, Ok(_) => {} - Err(error) => return Some(XmlPrepassError::tree(error.to_string())), + Err(error) => { + let detail = error.to_string(); + return Some(if saw_document_element { + XmlPrepassError::tree(detail) + } else { + // xml5ever can recover a malformed prologue or doctype and + // then expose nodes parsed after the error. DOMParser error + // documents must not retain that recovered document tree. + XmlPrepassError::before_document_element(detail) + }); + } } } @@ -801,6 +820,39 @@ mod tests { ); } + #[test] + fn xml_parser_discards_recovered_tree_after_doctype_error() { + let url = Url::parse("https://example.test/doctype.xml").unwrap(); + let invalid = XmlParser.parse( + url.clone(), + concat!( + "", + "
" + ) + .to_owned(), + ); + assert!(!invalid.parse_errors().is_empty()); + assert!( + invalid + .child_ids(invalid.document_node_id()) + .all(|child| !invalid.node(child).is_some_and(Node::is_element)) + ); + + for system_id in ["", "x"] { + let valid = XmlParser.parse( + url.clone(), + format!( + "" + ), + ); + assert!( + valid.parse_errors().is_empty(), + "system ID `{system_id}` should be accepted: {:?}", + valid.parse_errors() + ); + } + } + #[test] fn xml_parser_preserves_namespace_declarations_as_dom_attributes() { let dom = XmlParser.parse( diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs index e1f779be21..df91d264a1 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs @@ -1764,6 +1764,35 @@ fn dom_parser_xml_errors_preserve_the_partial_document_root() { assert_eq!(result, "catalog|1|true|http://www.w3.org/1999/xhtml|item|2"); } +#[test] +fn dom_parser_rejects_public_doctype_without_system_literal() { + let mut vm = new_storage_test_vm("https://dom-parser-doctype.test/"); + + let result = vm + .eval( + r#" +(() => { + const parser = new DOMParser(); + const prefix = '
'; + const invalid = parser.parseFromString(prefix + suffix, 'application/xhtml+xml'); + const emptySystemId = parser.parseFromString(prefix + ' ""' + suffix, 'application/xhtml+xml'); + const quotedSystemId = parser.parseFromString(prefix + ' "x"' + suffix, 'application/xhtml+xml'); + + return [ + invalid.getElementById('test') === null, + invalid.getElementsByTagName('parsererror').length === 1, + emptySystemId.getElementById('test') !== null, + quotedSystemId.getElementById('test') !== null + ].join('|'); +})() +"#, + ) + .expect("DOMParser doctype system ID validation should evaluate"); + + assert_eq!(result, "true|true|true|true"); +} + #[test] fn xhtml_element_interface_survives_move_through_xml_document() { let mut vm = new_storage_test_vm("https://xhtml-xml-document-move.test/");