mirror of
https://github.com/lexmount/moli.git
synced 2026-09-30 16:01:42 +00:00
fix(dom): return standard XML parser error documents
Replace partial XML trees with a single parsererror root in the namespace specified by HTML's DOMParser algorithm. Keep diagnostics as text and retain the requested content type and document metadata. XHR parsing continues to return no document on XML errors. Replace Chromium-specific error-wrapper assertions with coverage for empty, malformed and namespace-invalid input, prologues, doctypes, cross-realm documents, serialization and valid author-created parsererror elements. Validation: workspace fmt and strict all-feature Clippy; full nextest 19258 passed, 13 skipped; 22 WPT cases / 344 subtests; 1776 JS assertions.
This commit is contained in:
@@ -2505,7 +2505,6 @@ dom/ranges/tentative/OpaqueRange-range-updates.html
|
||||
dom/ranges/tentative/OpaqueRange-supported-elements.html
|
||||
dom/ranges/tentative/OpaqueRange-unsupported-elements.html
|
||||
dom/ranges/tentative/OpaqueRange-validation.html
|
||||
domparsing/DOMParser-parseFromString-xml.html
|
||||
domparsing/tentative/all-stream-methods-with-trusted-types-no-policy.html
|
||||
domparsing/tentative/positional-methods-with-trusted-types.html
|
||||
domparsing/tentative/positional-methods.html
|
||||
|
||||
@@ -4602,6 +4602,7 @@ domparsing/DOMParser-parseFromString-url.html
|
||||
domparsing/DOMParser-parseFromString-xml-doctype.html
|
||||
domparsing/DOMParser-parseFromString-xml-internal-subset.html
|
||||
domparsing/DOMParser-parseFromString-xml-parsererror.html
|
||||
domparsing/DOMParser-parseFromString-xml.html
|
||||
domparsing/XMLSerializer-serializeToString.html
|
||||
domparsing/createContextualFragment-in-detached-xml-document-crash.html
|
||||
domparsing/createContextualFragment.html
|
||||
|
||||
@@ -6,7 +6,7 @@ use url::Url;
|
||||
|
||||
use crate::{
|
||||
document_runtime::DomHandle,
|
||||
dom::native::{DomHost, NativeDom, NativeNodeId},
|
||||
dom::native::{DomHost, NativeDom},
|
||||
parser::{HtmlParser, XmlParser},
|
||||
webidl,
|
||||
};
|
||||
@@ -27,9 +27,7 @@ use super::{
|
||||
|
||||
pub(crate) const DOM_PARSER_FOREIGN_NODE_SLOT: &str = "__moliDomParserForeignNode";
|
||||
const DOM_PARSER_DOCUMENT_HANDLE_SLOT: &str = "__moliDomParserDocumentHandle";
|
||||
const HTML_NAMESPACE: &str = "http://www.w3.org/1999/xhtml";
|
||||
const PARSER_ERROR_STYLE: &str = "display: block; white-space: pre; border: 2px solid #c77; padding: 0 1em 0 1em; margin: 1em; background-color: #fdd; color: black";
|
||||
const PARSER_ERROR_DETAIL_STYLE: &str = "font-family:monospace;font-size:12px";
|
||||
const XML_PARSER_ERROR_NAMESPACE: &str = "http://www.mozilla.org/newlayout/xml/parsererror.xml";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum XmlParseErrorBehavior {
|
||||
@@ -309,102 +307,21 @@ fn materialize_xml_parser_error_document(parsed: NativeDom) -> NativeDom {
|
||||
.unwrap_or_else(|| "XML document has no document element".to_owned());
|
||||
let mut host = DomHost::from_dom(parsed);
|
||||
let document = host.document_handle();
|
||||
let document_element = host
|
||||
.child_handles(document)
|
||||
.find(|handle| host.node(*handle).is_some_and(|node| node.is_element()));
|
||||
|
||||
let parser_error = create_dom_parser_error_element(&mut host, document, &error_detail);
|
||||
if let Some(document_element) = document_element {
|
||||
let first_child = host
|
||||
.node(document_element)
|
||||
.and_then(|node| node.first_child());
|
||||
let _ = host.insert_before(document_element, parser_error, first_child);
|
||||
return host.snapshot_document();
|
||||
}
|
||||
|
||||
// DOMParser's XML error document contains only the error root, even if
|
||||
// the underlying parser recovered a partial tree, doctype or prologue.
|
||||
for child in host.child_handles(document).collect::<Vec<_>>() {
|
||||
let _ = host.remove_child(document, child);
|
||||
}
|
||||
let html = host.create_parser_element_without_attributes_for_document(
|
||||
document,
|
||||
"html".to_owned(),
|
||||
HTML_NAMESPACE.to_owned(),
|
||||
None,
|
||||
);
|
||||
let body = host.create_parser_element_without_attributes_for_document(
|
||||
document,
|
||||
"body".to_owned(),
|
||||
HTML_NAMESPACE.to_owned(),
|
||||
None,
|
||||
);
|
||||
let _ = host.append_child(document, html);
|
||||
let _ = host.append_child(html, body);
|
||||
let _ = host.append_child(body, parser_error);
|
||||
host.snapshot_document()
|
||||
}
|
||||
|
||||
fn create_dom_parser_error_element(
|
||||
host: &mut DomHost,
|
||||
document: NativeNodeId,
|
||||
error_detail: &str,
|
||||
) -> NativeNodeId {
|
||||
let parser_error = host.create_parser_element_without_attributes_for_document(
|
||||
document,
|
||||
"parsererror".to_owned(),
|
||||
HTML_NAMESPACE.to_owned(),
|
||||
XML_PARSER_ERROR_NAMESPACE.to_owned(),
|
||||
None,
|
||||
);
|
||||
let _ = host.set_attribute(parser_error, "style", PARSER_ERROR_STYLE);
|
||||
|
||||
let heading = create_dom_parser_error_child(host, document, "h3", None);
|
||||
append_dom_parser_error_text(
|
||||
host,
|
||||
document,
|
||||
heading,
|
||||
"This page contains the following errors:",
|
||||
);
|
||||
let detail =
|
||||
create_dom_parser_error_child(host, document, "div", Some(PARSER_ERROR_DETAIL_STYLE));
|
||||
append_dom_parser_error_text(host, document, detail, error_detail);
|
||||
let footer = create_dom_parser_error_child(host, document, "h3", None);
|
||||
append_dom_parser_error_text(
|
||||
host,
|
||||
document,
|
||||
footer,
|
||||
"Below is a rendering of the page up to the first error.",
|
||||
);
|
||||
let _ = host.append_child(parser_error, heading);
|
||||
let detail = host.create_text_node_for_document(document, &error_detail);
|
||||
let _ = host.append_child(parser_error, detail);
|
||||
let _ = host.append_child(parser_error, footer);
|
||||
parser_error
|
||||
}
|
||||
|
||||
fn create_dom_parser_error_child(
|
||||
host: &mut DomHost,
|
||||
document: NativeNodeId,
|
||||
local_name: &str,
|
||||
style: Option<&str>,
|
||||
) -> NativeNodeId {
|
||||
let element = host.create_parser_element_without_attributes_for_document(
|
||||
document,
|
||||
local_name.to_owned(),
|
||||
HTML_NAMESPACE.to_owned(),
|
||||
None,
|
||||
);
|
||||
if let Some(style) = style {
|
||||
let _ = host.set_attribute(element, "style", style);
|
||||
}
|
||||
element
|
||||
}
|
||||
|
||||
fn append_dom_parser_error_text(
|
||||
host: &mut DomHost,
|
||||
document: NativeNodeId,
|
||||
parent: NativeNodeId,
|
||||
text: &str,
|
||||
) {
|
||||
let text = host.create_text_node_for_document(document, text);
|
||||
let _ = host.append_child(parent, text);
|
||||
let _ = host.append_child(document, parser_error);
|
||||
host.snapshot_document()
|
||||
}
|
||||
|
||||
/// Builds a detached HTML document wrapper from raw markup and an explicit document URL.
|
||||
@@ -628,6 +545,38 @@ mod tests {
|
||||
.expect("document element")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_parser_error_document_discards_the_tree_and_preserves_error_text() {
|
||||
let mut parsed = XmlParser.parse(
|
||||
Url::parse("https://example.test/source.xml").unwrap(),
|
||||
"<!DOCTYPE root><!--before--><?before data?><root><child/></root><?after data?>"
|
||||
.to_owned(),
|
||||
);
|
||||
assert!(parsed.parse_errors().is_empty());
|
||||
let detail = "Unexpected <script> & </parsererror> near \"quoted\" text";
|
||||
parsed.push_parse_error(detail.to_owned());
|
||||
let error_document = materialize_xml_parser_error_document(parsed);
|
||||
let root = first_document_element(&error_document);
|
||||
assert_eq!(
|
||||
error_document
|
||||
.child_ids(error_document.document_node_id())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![root]
|
||||
);
|
||||
let element = error_document.node(root).unwrap().as_element().unwrap();
|
||||
assert_eq!(element.local_name(), "parsererror");
|
||||
assert_eq!(element.namespace(), XML_PARSER_ERROR_NAMESPACE);
|
||||
assert_eq!(error_document.text_content(root).as_deref(), Some(detail));
|
||||
let children = error_document.child_ids(root).collect::<Vec<_>>();
|
||||
assert_eq!(children.len(), 1);
|
||||
assert!(error_document.node(children[0]).unwrap().is_text());
|
||||
assert!(
|
||||
error_document
|
||||
.serialize_document()
|
||||
.contains("Unexpected <script> & </parsererror>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_document_snapshot_uses_xml_parser_for_xml_like_urls() {
|
||||
let (xml, xml_kind) = parse_browsing_context_document_snapshot(
|
||||
|
||||
@@ -363,50 +363,26 @@ fn offline_html_documents_parse_and_serialize_noscript_with_scripting_disabled()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domparser_xml_uses_document_interface_and_chromium_error_documents() {
|
||||
let mut vm = new_storage_test_vm("https://domparser-xml-content-type.test/");
|
||||
|
||||
fn domparser_xml_error_documents_preserve_metadata_and_replace_partial_trees() {
|
||||
let mut vm = new_parsed_test_vm(
|
||||
"https://domparser-xml-errors.test/source.html",
|
||||
"<!doctype html><html><head></head><body></body></html>",
|
||||
);
|
||||
let fixture = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/domparser-xml-errors.js"
|
||||
));
|
||||
let result = vm
|
||||
.eval(
|
||||
r#"
|
||||
(() => {
|
||||
const parser = new DOMParser();
|
||||
return JSON.stringify([
|
||||
"text/xml",
|
||||
"application/xml",
|
||||
"application/xhtml+xml",
|
||||
"image/svg+xml"
|
||||
].map(contentType => {
|
||||
const valid = parser.parseFromString("<root/>", contentType);
|
||||
const invalid = parser.parseFromString("<foo>", contentType);
|
||||
const namespaceInvalid = parser.parseFromString(
|
||||
'<span x:test="testing">1</span>',
|
||||
contentType
|
||||
);
|
||||
const invalidError = invalid.getElementsByTagName("parsererror")[0];
|
||||
const namespaceError = namespaceInvalid.getElementsByTagName("parsererror")[0];
|
||||
return [
|
||||
valid.contentType,
|
||||
Object.getPrototypeOf(valid) === Document.prototype,
|
||||
valid instanceof XMLDocument,
|
||||
invalid.contentType,
|
||||
Object.getPrototypeOf(invalid) === Document.prototype,
|
||||
invalid instanceof XMLDocument,
|
||||
invalid.documentElement.localName,
|
||||
invalidError.namespaceURI,
|
||||
namespaceInvalid.documentElement.localName,
|
||||
namespaceError.namespaceURI
|
||||
];
|
||||
}));
|
||||
})()
|
||||
"#,
|
||||
)
|
||||
.expect("DOMParser XML content type probe should evaluate");
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
r#"[["text/xml",true,false,"text/xml",true,false,"foo","http://www.w3.org/1999/xhtml","html","http://www.w3.org/1999/xhtml"],["application/xml",true,false,"application/xml",true,false,"foo","http://www.w3.org/1999/xhtml","html","http://www.w3.org/1999/xhtml"],["application/xhtml+xml",true,false,"application/xhtml+xml",true,false,"foo","http://www.w3.org/1999/xhtml","html","http://www.w3.org/1999/xhtml"],["image/svg+xml",true,false,"image/svg+xml",true,false,"foo","http://www.w3.org/1999/xhtml","html","http://www.w3.org/1999/xhtml"]]"#
|
||||
);
|
||||
.eval(&format!(
|
||||
r#"{fixture}
|
||||
(() => {{
|
||||
const result = domParserXmlErrorsProbe();
|
||||
return JSON.stringify({{count: result.checks.length, failures: result.checks.filter(check => !check.pass)}});
|
||||
}})()
|
||||
"#
|
||||
))
|
||||
.expect("DOMParser XML error document probe should evaluate");
|
||||
assert_eq!(result, r#"{"count":1776,"failures":[]}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -9645,7 +9621,7 @@ fn domparser_xml_preserves_requested_content_type_for_success_and_error_document
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
r#"[["text/xml","text/xml","html"],["application/xml","application/xml","html"],["application/xhtml+xml","application/xhtml+xml","html"],["image/svg+xml","image/svg+xml","html"]]"#
|
||||
r#"[["text/xml","text/xml","parsererror"],["application/xml","application/xml","parsererror"],["application/xhtml+xml","application/xhtml+xml","parsererror"],["image/svg+xml","image/svg+xml","parsererror"]]"#
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2007,7 +2007,7 @@ fn outer_html_uses_xml_serialization_for_elements_in_xml_documents() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_serializer_matches_chromium_for_empty_elements_attrs_and_parser_errors() {
|
||||
fn xml_serializer_handles_empty_elements_attrs_and_xml_error_documents() {
|
||||
let mut vm = new_storage_test_vm("https://xml-serializer-node-kinds.test/");
|
||||
|
||||
let result = vm
|
||||
@@ -2040,17 +2040,18 @@ fn xml_serializer_matches_chromium_for_empty_elements_attrs_and_parser_errors()
|
||||
serializer.serializeToString(xml.documentElement) === '<root/>',
|
||||
serializer.serializeToString(attribute) ===
|
||||
'a<&">	 ',
|
||||
emptyXml.documentElement.localName === 'html',
|
||||
emptyXml.documentElement.namespaceURI === 'http://www.w3.org/1999/xhtml',
|
||||
emptyXml.documentElement.localName === 'parsererror',
|
||||
emptyXml.documentElement.namespaceURI === 'http://www.mozilla.org/newlayout/xml/parsererror.xml',
|
||||
emptyXml.documentElement.getAttribute('xmlns') === null,
|
||||
emptyError.getAttributeNames().join(',') === 'style',
|
||||
emptyError === emptyXml.documentElement,
|
||||
emptySerialized.startsWith(
|
||||
'<html xmlns="http://www.w3.org/1999/xhtml"><body><parsererror style='
|
||||
'<parsererror xmlns="http://www.mozilla.org/newlayout/xml/parsererror.xml"'
|
||||
),
|
||||
!emptySerialized.includes('<parsererror xmlns='),
|
||||
new DOMParser().parseFromString(emptySerialized, 'text/xml').documentElement.textContent ===
|
||||
emptyError.textContent,
|
||||
partialError.getAttribute('xmlns') === null,
|
||||
partialSerialized.startsWith(
|
||||
'<catalog><parsererror xmlns="http://www.w3.org/1999/xhtml" style='
|
||||
'<parsererror xmlns="http://www.mozilla.org/newlayout/xml/parsererror.xml"'
|
||||
)
|
||||
].join('|');
|
||||
})()
|
||||
@@ -2064,35 +2065,6 @@ fn xml_serializer_matches_chromium_for_empty_elements_attrs_and_parser_errors()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dom_parser_xml_errors_preserve_the_partial_document_root() {
|
||||
let mut vm = new_storage_test_vm("https://dom-parser-partial-xml-error.test/");
|
||||
|
||||
let result = vm
|
||||
.eval(
|
||||
r#"
|
||||
(() => {
|
||||
const parsed = new DOMParser().parseFromString(
|
||||
'<catalog><item></catalog>',
|
||||
'application/xml'
|
||||
);
|
||||
const errors = parsed.getElementsByTagName('parsererror');
|
||||
return [
|
||||
parsed.documentElement.localName,
|
||||
errors.length,
|
||||
errors[0].parentNode === parsed.documentElement,
|
||||
errors[0].namespaceURI,
|
||||
errors[0].nextElementSibling.localName,
|
||||
errors[0].querySelectorAll('h3').length
|
||||
].join('|');
|
||||
})()
|
||||
"#,
|
||||
)
|
||||
.expect("DOMParser partial XML error tree should evaluate");
|
||||
|
||||
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/");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
function domParserXmlErrorsProbe() {
|
||||
const checks = [];
|
||||
const check = (label, actual, expected) => checks.push({label, actual, expected, pass: actual === expected});
|
||||
const namespace = 'http://www.mozilla.org/newlayout/xml/parsererror.xml';
|
||||
const types = ['text/xml', 'application/xml', 'application/xhtml+xml', 'image/svg+xml'];
|
||||
const invalid = [
|
||||
['empty', ''],
|
||||
['whitespace', ' \t\r\n'],
|
||||
['prologue-only', '<!--before--><?only data?>'],
|
||||
['unclosed', '<root>'],
|
||||
['mismatched', '<root><child></root>'],
|
||||
['multiple-roots', '<root/><second/>'],
|
||||
['partial-doctype', '<!DOCTYPE root><!--before--><?before data?><root><kept id="original"/><script xmlns="http://www.w3.org/1999/xhtml">globalThis.domParserErrorScriptRan = true;</script><child></root>'],
|
||||
['namespace', '<root undeclared:attr="value"/>'],
|
||||
['encoding-declaration', '<?xml version="1.0" encoding="ISO-8859-1"?><root><child>'],
|
||||
];
|
||||
function metadata(doc, mime, label, realm) {
|
||||
check(label + '/Document', doc instanceof realm.Document, true);
|
||||
check(label + '/not-XMLDocument', doc instanceof realm.XMLDocument, false);
|
||||
check(label + '/prototype', Object.getPrototypeOf(doc) === realm.Document.prototype, true);
|
||||
check(label + '/URL', doc.URL, realm.document.URL);
|
||||
check(label + '/documentURI', doc.documentURI, realm.document.URL);
|
||||
check(label + '/baseURI', doc.baseURI, realm.document.URL);
|
||||
check(label + '/contentType', doc.contentType, mime);
|
||||
for (const property of ['characterSet', 'charset', 'inputEncoding']) check(label + '/' + property, doc[property], 'UTF-8');
|
||||
check(label + '/readyState', doc.readyState, 'complete');
|
||||
check(label + '/defaultView', doc.defaultView, null);
|
||||
check(label + '/location', doc.location, null);
|
||||
check(label + '/hidden', doc.hidden, true);
|
||||
check(label + '/visibilityState', doc.visibilityState, 'hidden');
|
||||
check(label + '/compatMode', doc.compatMode, 'CSS1Compat');
|
||||
}
|
||||
function errorDocument(realm, parser, source, mime, label) {
|
||||
const doc = parser.parseFromString(source, mime);
|
||||
metadata(doc, mime, label, realm);
|
||||
const root = doc.documentElement;
|
||||
check(label + '/namespace', root.namespaceURI, namespace);
|
||||
check(label + '/localName', root.localName, 'parsererror');
|
||||
check(label + '/tagName', root.tagName, 'parsererror');
|
||||
check(label + '/prefix', root.prefix, null);
|
||||
check(label + '/only-child', doc.childNodes.length, 1);
|
||||
check(label + '/first-child', doc.firstChild === root, true);
|
||||
check(label + '/parent', root.parentNode === doc, true);
|
||||
check(label + '/owner', root.ownerDocument === doc, true);
|
||||
check(label + '/doctype-removed', doc.doctype, null);
|
||||
check(label + '/partial-tree-removed', doc.getElementById('original'), null);
|
||||
check(label + '/error-count', doc.getElementsByTagName('parsererror').length, 1);
|
||||
check(label + '/error-by-namespace', doc.getElementsByTagNameNS(namespace, 'parsererror')[0] === root, true);
|
||||
check(label + '/error-description', root.textContent.length > 0, true);
|
||||
check(label + '/body', doc.body, null);
|
||||
check(label + '/head', doc.head, null);
|
||||
check(label + '/script-inert', realm.domParserErrorScriptRan, undefined);
|
||||
const serialized = new realm.XMLSerializer().serializeToString(doc);
|
||||
check(label + '/serialized-root', serialized.startsWith('<parsererror'), true);
|
||||
const roundtrip = parser.parseFromString(serialized, mime);
|
||||
check(label + '/roundtrip-namespace', roundtrip.documentElement.namespaceURI, namespace);
|
||||
check(label + '/roundtrip-text', roundtrip.documentElement.textContent, root.textContent);
|
||||
check(label + '/new-document', roundtrip !== doc, true);
|
||||
}
|
||||
function run(realm, cases, label) {
|
||||
const parser = new realm.DOMParser();
|
||||
for (const mime of types) {
|
||||
for (const [name, source] of cases) {
|
||||
const prefix = label + '/' + mime + '/' + name;
|
||||
try { errorDocument(realm, parser, source, mime, prefix); }
|
||||
catch (error) { check(prefix + '/exception', String(error.stack || error), 'success'); }
|
||||
}
|
||||
for (const authorNamespace of [namespace, 'http://www.w3.org/1999/xhtml']) {
|
||||
const prefix = label + '/' + mime + '/author/' + authorNamespace;
|
||||
try {
|
||||
const doc = parser.parseFromString('<!DOCTYPE parsererror><parsererror xmlns="' + authorNamespace + '" data-author="yes"><part xmlns="urn:kept">kept < & 💡</part></parsererror>', mime);
|
||||
metadata(doc, mime, prefix, realm);
|
||||
check(prefix + '/namespace', doc.documentElement.namespaceURI, authorNamespace);
|
||||
check(prefix + '/marker', doc.documentElement.getAttribute('data-author'), 'yes');
|
||||
check(prefix + '/doctype', doc.doctype?.name, 'parsererror');
|
||||
check(prefix + '/child-namespace', doc.documentElement.firstElementChild.namespaceURI, 'urn:kept');
|
||||
check(prefix + '/text', doc.documentElement.textContent, 'kept < & 💡');
|
||||
} catch (error) { check(prefix + '/exception', String(error.stack || error), 'success'); }
|
||||
}
|
||||
}
|
||||
}
|
||||
run(globalThis, invalid, 'main');
|
||||
let frame;
|
||||
try {
|
||||
frame = document.body.appendChild(document.createElement('iframe'));
|
||||
run(frame.contentWindow, [['mismatched', '<root><child></root>']], 'child');
|
||||
} catch (error) { check('child/exception', String(error.stack || error), 'success'); }
|
||||
finally { frame?.remove(); }
|
||||
return {state: checks.every(item => item.pass) ? 'pass' : 'fail', checks};
|
||||
}
|
||||
Reference in New Issue
Block a user