fix(html): preserve document scripting mode

This commit is contained in:
ldm0
2026-09-16 22:05:22 +08:00
parent c85c1d99f4
commit 5acd07f9d4
13 changed files with 312 additions and 37 deletions
+18
View File
@@ -51,6 +51,10 @@ pub struct Document {
ready_state: DocumentReadyState,
quirks_mode: QuirksMode,
kind: DocumentKind,
// The document's HTML scripting flag. Browsing-context policy can still
// override execution, but detached/template documents must retain `false`
// for fragment parsing and serialization even without a window.
scripting_enabled: bool,
css_target: Option<NativeNodeId>,
default_language: Option<Box<str>>,
source_last_modified_ms: Option<f64>,
@@ -63,6 +67,10 @@ impl Document {
}
pub fn new_html(url: Url) -> Self {
Self::new_html_with_scripting(url, true)
}
pub fn new_html_with_scripting(url: Url, scripting_enabled: bool) -> Self {
Self {
base_url_state: DocumentBaseUrlState::new(&url),
url,
@@ -70,6 +78,7 @@ impl Document {
ready_state: DocumentReadyState::Complete,
quirks_mode: QuirksMode::NoQuirks,
kind: DocumentKind::Html,
scripting_enabled,
css_target: None,
default_language: None,
source_last_modified_ms: None,
@@ -84,6 +93,7 @@ impl Document {
ready_state: DocumentReadyState::Complete,
quirks_mode: QuirksMode::NoQuirks,
kind: DocumentKind::Xml,
scripting_enabled: true,
css_target: None,
default_language: None,
source_last_modified_ms: None,
@@ -126,6 +136,10 @@ impl Document {
self.kind == DocumentKind::Html
}
pub fn scripting_enabled(&self) -> bool {
self.scripting_enabled
}
pub fn fallback_base_url(&self) -> &Url {
self.base_url_state.fallback_base_url()
}
@@ -159,6 +173,10 @@ impl Document {
self.content_type = content_type.into().into_boxed_str();
}
pub fn set_scripting_enabled(&mut self, scripting_enabled: bool) {
self.scripting_enabled = scripting_enabled;
}
pub fn set_css_target(&mut self, target: Option<NativeNodeId>) -> bool {
if self.css_target == target {
return false;
+40 -1
View File
@@ -22,8 +22,19 @@ impl DomHost {
}
pub fn create_detached_html_document_with_url(&mut self, url: Url) -> DomHandle {
self.create_detached_html_document_with_url_and_scripting(url, true)
}
pub fn create_detached_html_document_with_url_and_scripting(
&mut self,
url: Url,
scripting_enabled: bool,
) -> DomHandle {
self.dom.create_node(
NodeData::Document(Box::new(Document::new_html(url))),
NodeData::Document(Box::new(Document::new_html_with_scripting(
url,
scripting_enabled,
))),
None,
false,
false,
@@ -973,6 +984,15 @@ impl DomHost {
.map(Document::quirks_mode)
}
pub fn document_scripting_enabled_for_handle(
&self,
document_handle: DomHandle,
) -> Option<bool> {
self.node(document_handle)
.and_then(Node::as_document)
.map(Document::scripting_enabled)
}
pub fn document_base_url(&self) -> Option<Url> {
self.document_base_url_for_handle(self.document_handle())
}
@@ -1134,6 +1154,25 @@ impl DomHost {
true
}
pub fn set_document_scripting_enabled_for_handle(
&mut self,
document_handle: DomHandle,
scripting_enabled: bool,
) -> bool {
let Some(document) = self
.node_mut(document_handle)
.and_then(|node| node.data_mut().as_document_mut())
else {
return false;
};
if document.scripting_enabled() == scripting_enabled {
return false;
}
document.set_scripting_enabled(scripting_enabled);
self.record_mutation(MutationScope::LocalState);
true
}
pub fn set_document_quirks_mode_for_handle(
&mut self,
document_handle: DomHandle,
@@ -458,6 +458,23 @@ pub(super) fn serialize_html(
.then_some(html)
}
pub(super) fn serialize_html_with_stored_scripting_state(
dom: &NativeDom,
node_id: NativeNodeId,
target: HtmlSerializationTarget,
) -> Option<String> {
let scripting_enabled_for_node =
|node| dom.node_document_scripting_enabled(node).unwrap_or(false);
let mut html = String::new();
serialize_html_into_sink(
dom,
node_id,
HtmlSerializationOptions::new(target, &scripting_enabled_for_node),
&mut html,
)
.then_some(html)
}
pub(in crate::native) fn serialize_html_with_shadow_root_provider<F>(
dom: &NativeDom,
node_id: NativeNodeId,
@@ -483,7 +500,8 @@ pub(super) fn serialize_html_with_limit(
node_id: NativeNodeId,
max_bytes: usize,
) -> Result<Option<String>, HtmlSerializationLimitExceeded> {
let scripting_enabled_for_node = |_: NativeNodeId| true;
let scripting_enabled_for_node =
|node| dom.node_document_scripting_enabled(node).unwrap_or(false);
let mut out = BoundedHtmlSerialization::new(max_bytes);
if !serialize_html_into_sink(
dom,
+19 -4
View File
@@ -8,7 +8,9 @@ pub(super) use engine::{
HtmlSerializationTarget, HtmlSerializedShadowRoot, escape_html_attribute_into_string,
serialize_html_with_shadow_root_provider,
};
use engine::{serialize_html, serialize_html_with_limit};
use engine::{
serialize_html, serialize_html_with_limit, serialize_html_with_stored_scripting_state,
};
/// A bounded serialization stopped before appending bytes past its limit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -30,7 +32,12 @@ impl std::error::Error for HtmlSerializationLimitExceeded {}
impl NativeDom {
pub fn serialize_document(&self) -> String {
self.serialize_document_with_scripting_enabled(true)
serialize_html_with_stored_scripting_state(
self,
self.document_node_id,
HtmlSerializationTarget::ChildrenOnly,
)
.unwrap_or_default()
}
pub fn serialize_document_with_scripting_enabled(&self, scripting_enabled: bool) -> String {
@@ -44,7 +51,11 @@ impl NativeDom {
}
pub fn outer_html(&self, node_id: NativeNodeId) -> Option<String> {
self.outer_html_with_scripting_enabled(node_id, true)
serialize_html_with_stored_scripting_state(
self,
node_id,
HtmlSerializationTarget::IncludeNode,
)
}
pub fn outer_html_with_scripting_enabled(
@@ -74,7 +85,11 @@ impl NativeDom {
}
pub fn inner_html(&self, node_id: NativeNodeId) -> Option<String> {
self.inner_html_with_scripting_enabled(node_id, true)
serialize_html_with_stored_scripting_state(
self,
node_id,
HtmlSerializationTarget::ChildrenOnly,
)
}
pub fn inner_html_with_scripting_enabled(
+15 -2
View File
@@ -159,13 +159,20 @@ impl NativeDom {
}
pub fn new_html(final_url: url::Url) -> Self {
Self::new_html_with_scripting(final_url, true)
}
pub fn new_html_with_scripting(final_url: url::Url, scripting_enabled: bool) -> Self {
let document_node_id = NativeNodeId::new(0);
let document_node = Node::new(
document_node_id,
None,
None,
NodeFlags::new(true),
NodeData::Document(Box::new(Document::new_html(final_url))),
NodeData::Document(Box::new(Document::new_html_with_scripting(
final_url,
scripting_enabled,
))),
);
Self {
nodes: NativeNodeStorage::from_node(document_node),
@@ -507,7 +514,7 @@ impl NativeDom {
.map(|document| document.url().clone())
.unwrap_or_else(|| url::Url::parse("about:blank").expect("about:blank is valid"));
let owner_document = self.create_node(
NodeData::Document(Box::new(Document::new_html(url))),
NodeData::Document(Box::new(Document::new_html_with_scripting(url, false))),
None,
false,
false,
@@ -2584,6 +2591,12 @@ mod tests {
.and_then(Node::owner_document)
.expect("template content owner document");
assert_ne!(content_owner, dom.document_node_id());
assert_eq!(
dom.node(content_owner)
.and_then(Node::as_document)
.map(Document::scripting_enabled),
Some(false)
);
let child = dom.create_element("span");
assert_eq!(
+11
View File
@@ -285,6 +285,17 @@ impl NativeDom {
.map(|document| document.is_html_document())
}
pub fn node_document_scripting_enabled(&self, node_id: NativeNodeId) -> Option<bool> {
let node = self.node(node_id)?;
if let Some(document) = node.as_document() {
return Some(document.scripting_enabled());
}
let owner_document = node.owner_document()?;
self.node(owner_document)
.and_then(Node::as_document)
.map(|document| document.scripting_enabled())
}
fn collect_matching_elements(
&self,
root: NativeNodeId,
+40 -7
View File
@@ -417,7 +417,14 @@ impl HtmlParser {
}
pub fn parse_dom_host(&self, final_url: Url, html: String) -> DomHost {
let stream = self.start_document(final_url);
let target =
ParserStreamHtmlTreeSinkTarget::new_with_declarative_shadow_roots_and_scripting(
final_url,
true,
self.scripting_enabled,
);
let stream =
HtmlTreeSinkStream::from_target_with_scripting(target, self.scripting_enabled);
for chunk in html_chunks(&html) {
stream.feed(chunk);
}
@@ -430,8 +437,13 @@ impl HtmlParser {
html: String,
) -> NativeDom {
let target =
ParserStreamHtmlTreeSinkTarget::new_with_declarative_shadow_roots(final_url, false);
let stream = HtmlTreeSinkStream::from_target_with_scripting(target, self.scripting_enabled);
ParserStreamHtmlTreeSinkTarget::new_with_declarative_shadow_roots_and_scripting(
final_url,
false,
self.scripting_enabled,
);
let stream =
HtmlTreeSinkStream::from_target_with_scripting(target, self.scripting_enabled);
for chunk in html_chunks(&html) {
stream.feed(chunk);
}
@@ -476,10 +488,12 @@ impl HtmlParser {
allow_declarative_shadow_roots: bool,
scripting_enabled: bool,
) -> NativeDom {
let target = ParserStreamHtmlTreeSinkTarget::new_with_declarative_shadow_roots(
final_url,
allow_declarative_shadow_roots,
);
let target =
ParserStreamHtmlTreeSinkTarget::new_with_declarative_shadow_roots_and_scripting(
final_url,
allow_declarative_shadow_roots,
scripting_enabled,
);
let context = QualName::new(
None,
Namespace::from(context_namespace),
@@ -2087,6 +2101,25 @@ mod tests {
);
}
#[test]
fn standalone_html_parser_preserves_the_requested_scripting_mode() {
let document = HtmlParser::SCRIPTING_DISABLED.parse_without_declarative_shadow_roots(
Url::parse("https://example.test/").expect("test url"),
"<body><noscript>&amp;&nbsp;&lt;&gt;</noscript></body>".to_owned(),
);
assert_eq!(
document
.document()
.map(|document| document.scripting_enabled()),
Some(false)
);
let noscript = first_element_by_ns(&document, HTML_NS, "noscript");
assert_eq!(
document.inner_html(noscript).as_deref(),
Some("&amp;&nbsp;&lt;&gt;")
);
}
#[test]
fn mathml_annotation_xml_text_html_is_html_integration_point() {
let document = parse_test_document(concat!(
+62 -8
View File
@@ -2032,15 +2032,15 @@ impl ParserStreamHtmlTreeSinkTarget {
Some(move || sink.finish())
}
fn new(final_url: Url) -> Self {
Self::new_with_declarative_shadow_roots(final_url, true)
}
pub(super) fn new_with_declarative_shadow_roots(
pub(super) fn new_with_declarative_shadow_roots_and_scripting(
final_url: Url,
allow_declarative_shadow_roots: bool,
scripting_enabled: bool,
) -> Self {
let dom_host = DomHost::from_dom(NativeDom::new(final_url.clone()));
let dom_host = DomHost::from_dom(NativeDom::new_html_with_scripting(
final_url.clone(),
scripting_enabled,
));
let document_handle = dom_host.document_handle();
Self {
owned_dom_host: Some(dom_host),
@@ -3557,8 +3557,13 @@ impl ParserStreamHtmlTreeSinkTarget {
pub(super) fn new_parser_stream_html_tree_sink_target(
final_url: Url,
scripting_enabled: bool,
) -> ParserStreamHtmlTreeSinkTarget {
ParserStreamHtmlTreeSinkTarget::new(final_url)
ParserStreamHtmlTreeSinkTarget::new_with_declarative_shadow_roots_and_scripting(
final_url,
true,
scripting_enabled,
)
}
pub(super) fn new_parser_stream_html_tree_sink_stream(
@@ -3566,7 +3571,7 @@ pub(super) fn new_parser_stream_html_tree_sink_stream(
scripting_enabled: bool,
) -> HtmlTreeSinkStream {
HtmlTreeSinkStream::from_target_with_scripting(
new_parser_stream_html_tree_sink_target(final_url),
new_parser_stream_html_tree_sink_target(final_url, scripting_enabled),
scripting_enabled,
)
}
@@ -4314,6 +4319,55 @@ fn parser_stream_live_fragment_root_writes_fragment() {
);
}
#[test]
fn live_fragment_parser_preserves_noscript_when_scripting_is_disabled() {
let url = Url::parse("https://example.test/").expect("test url");
let mut dom_host = DomHost::from_dom(NativeDom::new_html_with_scripting(url.clone(), false));
let document = dom_host.document_handle();
let html = dom_host.create_element("html");
let body = dom_host.create_element("body");
assert!(dom_host.append_child(document, html));
assert!(dom_host.append_child(html, body));
let existing_noscript = dom_host.create_element("noscript");
let existing_text = dom_host.create_text_node("<em>existing&</em>");
assert!(dom_host.append_child(existing_noscript, existing_text));
assert!(dom_host.append_child(body, existing_noscript));
let fragment = dom_host.create_document_fragment_for_document(document);
let ptr = &mut dom_host as *mut DomHost;
let mut effects = DomMutationEffects::default();
{
let mut collector = TestMutationEffectCollector {
host: ptr,
effects: &mut effects,
panic_on_mutation: false,
};
crate::HtmlParser::with_scripting_enabled(false).parse_fragment_into_live_dom(
url,
fragment,
document,
body,
"http://www.w3.org/1999/xhtml",
"body",
"<noscript>&lt;em&gt;fallback&amp;&lt;/em&gt;</noscript><span>tail</span>",
&mut collector,
false,
);
}
assert_eq!(
dom_host
.elements_by_tag_name(fragment, "noscript", false)
.len(),
1,
"a scripting-disabled live fragment must preserve its noscript root"
);
assert_eq!(
dom_host.elements_by_tag_name(fragment, "span", false).len(),
1
);
}
#[test]
fn live_fragment_parser_uses_template_contents_owner_document() {
let url = Url::parse("https://example.test/").expect("test url");
@@ -585,8 +585,13 @@ impl DocumentRuntime {
self.dom_host.create_detached_xml_document()
}
pub(crate) fn create_detached_html_document_with_url(&mut self, url: url::Url) -> DomHandle {
self.dom_host.create_detached_html_document_with_url(url)
pub(crate) fn create_detached_html_document_with_url_and_scripting(
&mut self,
url: url::Url,
scripting_enabled: bool,
) -> DomHandle {
self.dom_host
.create_detached_html_document_with_url_and_scripting(url, scripting_enabled)
}
pub(crate) fn create_detached_xml_document_with_url(&mut self, url: url::Url) -> DomHandle {
@@ -772,6 +772,10 @@ impl JsContextHost {
}
self.child_browsing_context_host_for_document_handle(document_handle)
.map(|handle| self.child_browsing_context_scripting_enabled(handle))
.or_else(|| {
self.dom_host()
.document_scripting_enabled_for_handle(document_handle)
})
// DOMParser and document.implementation documents have no browsing
// context, so scripting is disabled for their fragment parsers too.
.unwrap_or(false)
@@ -60,14 +60,19 @@ fn create_native_detached_document_handle_with_url(
scope: &mut v8::PinScope<'_, '_>,
kind: &str,
url: Url,
scripting_enabled: bool,
) -> Option<DomHandle> {
let runtime_ptr = context_host_ptr_from_global_bridge(scope)?;
let runtime = unsafe { &mut *runtime_ptr };
Some(if kind == "html" {
runtime.create_detached_html_document_with_url(url)
let handle = if kind == "html" {
runtime.create_detached_html_document_with_url_and_scripting(url, scripting_enabled)
} else {
runtime.create_detached_xml_document_with_url(url)
})
};
let _ = runtime
.dom_host_mut()
.set_document_scripting_enabled_for_handle(handle, scripting_enabled);
Some(handle)
}
fn detached_document_url(parsed: &DomHost) -> Url {
@@ -94,6 +99,7 @@ fn new_detached_document_shell<'s>(
scope: &mut v8::PinScope<'s, '_>,
kind: &str,
url: Url,
scripting_enabled: bool,
) -> Option<v8::Local<'s, v8::Object>> {
let to_string_tag = if kind == "html" {
Some("HTMLDocument")
@@ -116,7 +122,9 @@ fn new_detached_document_shell<'s>(
);
set_detached_document_url_state(scope, state, &url)?;
define_detached_state(scope, document, state);
if let Some(handle) = create_native_detached_document_handle_with_url(scope, kind, url) {
if let Some(handle) =
create_native_detached_document_handle_with_url(scope, kind, url, scripting_enabled)
{
define_detached_native_handle(scope, document, handle);
}
install_detached_document_instance_properties(scope, document, kind);
@@ -201,12 +209,13 @@ pub(crate) fn build_detached_document_object_from_dom_host_with_content_type<'s>
) -> Option<v8::Local<'s, v8::Object>> {
let url = detached_document_url(&parsed);
let quirks_mode = parsed.dom().document()?.quirks_mode();
let scripting_enabled = parsed.dom().document()?.scripting_enabled();
let compat_mode = if quirks_mode == selectors::matching::QuirksMode::Quirks {
"BackCompat"
} else {
"CSS1Compat"
};
let document = new_detached_document_shell(scope, kind, url)?;
let document = new_detached_document_shell(scope, kind, url, scripting_enabled)?;
if let Some(state) = detached_state_object(scope, document) {
let _ = state.set(
scope,
@@ -328,6 +337,7 @@ pub(in crate::native_bridge::document) fn build_detached_html_document_object<'s
scope,
"html",
Url::parse("about:blank").expect("static about:blank parses"),
false,
)?;
populate_native_html_document_shell(scope, document, title)?;
Some(document)
@@ -347,6 +357,7 @@ pub(in crate::native_bridge::document) fn build_detached_document_object<'s>(
scope,
kind,
Url::parse("about:blank").expect("static about:blank parses"),
false,
)?;
if kind == "xml"
&& let Some(namespace_uri) = namespace_uri.as_deref()
@@ -805,13 +805,15 @@ fn exec_command_insert_html_target(runtime: &JsContextHost) -> Option<DomHandle>
}
fn input_text_from_html_fragment(runtime: &JsContextHost, value: &str) -> String {
let parsed = HtmlParser::with_scripting_enabled(true)
.parse_fragment_without_declarative_shadow_roots(
runtime.host_document().url().clone(),
"http://www.w3.org/1999/xhtml",
"body",
value.to_owned(),
);
let document_handle = runtime.dom_host().document_handle();
let parsed =
HtmlParser::with_scripting_enabled(runtime.document_scripting_enabled(document_handle))
.parse_fragment_without_declarative_shadow_roots(
runtime.host_document().url().clone(),
"http://www.w3.org/1999/xhtml",
"body",
value.to_owned(),
);
let root = parsed
.body_node_id()
.unwrap_or_else(|| parsed.document_node_id());
@@ -298,6 +298,58 @@ fn domparser_html_preserves_quirks_mode_and_parses_with_scripting_disabled() {
);
}
#[test]
fn offline_html_documents_parse_and_serialize_noscript_with_scripting_disabled() {
let mut vm = new_storage_test_vm("https://offline-noscript.test/");
let result = vm
.eval(
r#"
(() => {
const encoded = '&amp;&nbsp;&lt;&gt;';
const decoded = '&\u00a0<>';
const markup = value => `<noscript>${value}</noscript>`;
const matches = (node, serialization) =>
node.textContent === decoded && node.innerHTML === serialization;
const live = document.createElement('div');
live.innerHTML = markup(decoded);
const parsed = new DOMParser().parseFromString(
`<body>${markup(encoded)}</body>`,
'text/html'
);
const template = document.createElement('template');
template.innerHTML = markup(encoded);
const created = document.implementation.createHTMLDocument('');
created.body.innerHTML = `<pre>${markup(encoded)}</pre>`;
const contextualDocument = document.implementation.createHTMLDocument('');
const range = contextualDocument.createRange();
range.selectNode(contextualDocument.body);
const contextual = range.createContextualFragment(markup(encoded));
const written = document.implementation.createHTMLDocument('');
written.write(`<div>${markup(decoded)}</div>`);
return [
matches(live.firstChild, decoded),
matches(parsed.body.firstChild, encoded),
matches(template.content.firstChild, encoded),
matches(created.body.firstChild.firstChild, encoded),
matches(contextual.firstChild, encoded),
matches(written.body.firstChild.firstChild, encoded)
].join('|');
})()
"#,
)
.expect("offline noscript parsing and serialization probe should evaluate");
assert_eq!(result, "true|true|true|true|true|true");
}
#[test]
fn domparser_xml_preserves_requested_content_type_for_success_and_error_documents() {
let mut vm = new_storage_test_vm("https://domparser-xml-content-type.test/");