Merge pull request #1234 from Yi-111-a/fix/plain-text-document-for-non-html

fix(browser): build a plain-text document for non-HTML text responses
This commit is contained in:
SGavrl authored and GitHub committed 2026-10-10 20:02:14 +02:00
commit 17fe750e62
6 files changed
+317 -5

No files matched your search

+245 -1
View File
@@ -279,6 +279,10 @@ pub struct Page {
/// Exposed to JS as `document.characterSet` and used for the URL query
/// encoding override on `<a>`/`<area>` hrefs in legacy-charset documents.
pub encoding: String,
/// MIME type of the current document's main resource, lowercased and
/// without parameters, or empty when no type was supplied.
/// Exposed to JS as `document.contentType`.
pub content_type: String,
/// Monotonic origin for the current document's CSS animation timeline.
/// It is reset once author styles are installed, so stylesheet download
/// latency does not incorrectly advance newly-created animations.
@@ -1113,6 +1117,7 @@ impl Page {
device_scale_factor: 1.0,
default_background_color_override: None,
encoding: "UTF-8".to_string(),
content_type: String::new(),
document_timeline_origin: std::time::Instant::now(),
navigation_timing: NavigationTiming::default(),
navigation_timeout: None,
@@ -1778,6 +1783,7 @@ impl Page {
);
rt.set_url(&self.url_string());
rt.set_encoding(&self.encoding);
rt.set_content_type(&self.content_type);
rt.set_title(&self.title);
rt.set_referrer(&self.referrer);
let (session_history, session_index) = self.predicted_session_history();
@@ -3500,7 +3506,17 @@ impl Page {
obscura_net::decode_response_with_name(&response.body, response.content_type());
self.encoding = encoding_name.to_string();
self.navigation_timing.record("domLoading");
let dom = parse_html(&body_text);
// A non-HTML text response is a plain-text document, not an HTML one
// that happens to hold text: every `<word>` in a .txt or .md body
// would otherwise be parsed as an element and its contents dropped
// from textContent (#1231). Chrome wraps the decoded text in a
// single <pre> instead.
let (dom, document_content_type) = match plain_text_document_type(response.content_type())
{
Some(mime) => (parse_plain_text_document(&body_text), mime),
None => (parse_html(&body_text), base_content_type(response.content_type()).unwrap_or_default()),
};
self.content_type = document_content_type;
self.title = dom
.query_selector("title")
@@ -3760,6 +3776,7 @@ impl Page {
"<html><head></head><body></body></html>",
));
self.title = String::new();
self.content_type.clear();
self.lifecycle = LifecycleState::Loaded;
self.document_timeline_origin = std::time::Instant::now();
}
@@ -5661,6 +5678,179 @@ mod tests {
);
}
// #1231: a text/plain or text/markdown response is a plain-text document.
// Parsing it as HTML turned every <word> in the body into an element and
// dropped its text, and document.contentType reported text/html.
#[tokio::test(flavor = "current_thread")]
async fn plain_text_response_builds_a_pre_document() {
let body = "RFC 9110 <URI-reference>: see <https://example.com/>\n2nd line";
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
std::thread::spawn(move || {
use std::io::{Read as _, Write as _};
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 2048];
let _ = stream.read(&mut request).unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
stream.write_all(response.as_bytes()).unwrap();
});
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
"plain-text".to_string(),
None,
false,
None,
None,
true,
));
let mut page = super::Page::new("plain-text".to_string(), context);
page.navigate(&format!("http://{address}/rfc9110.txt")).await.unwrap();
let observed = page
.js
.as_mut()
.unwrap()
.evaluate(
"(function(){return [document.contentType, \
document.body.firstElementChild.tagName, \
document.body.childNodes.length, \
document.body.textContent];})()",
)
.unwrap();
assert_eq!(
observed,
serde_json::json!(["text/plain", "PRE", 1, body])
);
}
#[test]
fn plain_text_document_preserves_leading_newlines_and_literal_markup() {
for (text, expected) in [
("\nfirst\nlast", "\nfirst\nlast"),
("\r\nfirst\rsecond", "\nfirst\nsecond"),
("\n\n", "\n\n"),
("<&amp;> </pre><script>bad()</script>", "<&amp;> </pre><script>bad()</script>"),
] {
let dom = super::parse_plain_text_document(text);
let pre = dom.query_selector("pre").unwrap().unwrap();
assert_eq!(dom.text_content(pre), expected, "input {text:?}");
assert!(dom.query_selector("script").unwrap().is_none());
}
let empty = super::parse_plain_text_document("");
assert_eq!(empty.text_content(empty.document()), "");
assert!(empty.query_selector("pre").unwrap().is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn plain_text_navigation_resets_content_type_on_about_blank() {
let mut page = super::Page::new(
"plain-text-blank".to_string(),
std::sync::Arc::new(crate::BrowserContext::new("plain-text-blank".to_string())),
);
page.navigate("data:text/plain,first%0Alast").await.unwrap();
assert_eq!(page.js.as_mut().unwrap().evaluate(
"[document.contentType, document.body.textContent]"
).unwrap(), serde_json::json!(["text/plain", "first\nlast"]));
page.navigate("about:blank").await.unwrap();
assert_eq!(page.js.as_mut().unwrap().evaluate(
"[document.contentType, document.body.textContent]"
).unwrap(), serde_json::json!(["text/html", ""]));
}
#[tokio::test(flavor = "current_thread")]
async fn text_markdown_response_keeps_markup_as_text() {
let body = "# Title\n\nSee <Component prop={1} /> and 3 < 4.";
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
std::thread::spawn(move || {
use std::io::{Read as _, Write as _};
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 2048];
let _ = stream.read(&mut request).unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/markdown\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
stream.write_all(response.as_bytes()).unwrap();
});
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
"markdown-text".to_string(),
None,
false,
None,
None,
true,
));
let mut page = super::Page::new("markdown-text".to_string(), context);
page.navigate(&format!("http://{address}/settings-reference")).await.unwrap();
let observed = page
.js
.as_mut()
.unwrap()
.evaluate(
"(function(){return [document.contentType, \
document.body.textContent, \
document.body.querySelectorAll('component').length];})()",
)
.unwrap();
assert_eq!(
observed,
serde_json::json!(["text/markdown", body, 0])
);
}
// The HTML parse path is unchanged, including the URL-derived contentType
// sniffing for documents served without a usable Content-Type.
#[tokio::test(flavor = "current_thread")]
async fn html_response_still_builds_an_html_document() {
let body = "<!doctype html><title>page</title><p>hello <b>world</b></p>";
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
std::thread::spawn(move || {
use std::io::{Read as _, Write as _};
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 2048];
let _ = stream.read(&mut request).unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
stream.write_all(response.as_bytes()).unwrap();
});
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
"html-doc".to_string(),
None,
false,
None,
None,
true,
));
let mut page = super::Page::new("html-doc".to_string(), context);
page.navigate(&format!("http://{address}/index")).await.unwrap();
let observed = page
.js
.as_mut()
.unwrap()
.evaluate(
"(function(){return [document.contentType, \
document.body.firstElementChild.tagName, \
document.body.querySelectorAll('b').length, \
document.body.textContent];})()",
)
.unwrap();
assert_eq!(
observed,
serde_json::json!(["text/html", "P", 1, "hello world"])
);
}
// #971: a tab switch suspends other tabs (drops their runtime). Console
// messages logged before suspension must be preserved, not lost.
#[tokio::test(flavor = "current_thread")]
@@ -10189,6 +10379,60 @@ fn is_text_like_content_type(content_type: Option<&str>) -> bool {
|| ct.ends_with("+xml")
}
/// Lowercased MIME type of a Content-Type header, without parameters.
fn base_content_type(content_type: Option<&str>) -> Option<String> {
let ct = content_type?.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
if ct.is_empty() { None } else { Some(ct) }
}
/// A response whose body is text but not markup gets a plain-text document
/// rather than an HTML parse of its content (`text/plain`, `text/markdown`).
/// Returns the MIME type to report as `document.contentType`.
fn plain_text_document_type(content_type: Option<&str>) -> Option<String> {
let ct = base_content_type(content_type)?;
// XML and HTML families keep their existing parse path, including the
// `.xhtml`/`.xml` URL sniffing that `contentType` still applies.
if ct == "text/html"
|| ct == "application/xhtml+xml"
|| ct == "text/xml"
|| ct == "application/xml"
|| ct.ends_with("+xml")
{
return None;
}
matches!(ct.as_str(), "text/plain" | "text/markdown" | "text/x-markdown")
.then_some(ct)
}
/// Escape text for insertion as HTML character data, then build the document
/// Chrome builds for a plain-text response: `<body>` holding one `<pre>` with
/// the whole text. Serializing through the HTML parser keeps one code path for
/// document construction instead of a second native tree builder.
fn parse_plain_text_document(text: &str) -> DomTree {
if text.is_empty() {
return parse_html("<!DOCTYPE html>");
}
let mut html = String::with_capacity(text.len() + 64);
// The parser discards the synthetic newline, not the document's first one.
html.push_str("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title></title></head><body><pre>\n");
html.push_str(&escape_html_text(text));
html.push_str("</pre></body></html>");
parse_html(&html)
}
fn escape_html_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
_ => out.push(ch),
}
}
out
}
fn response_body_entry_limit() -> usize {
std::env::var("OBSCURA_NETWORK_BODY_BUFFER_ENTRIES")
.ok()
+26 -4
View File
@@ -42,7 +42,7 @@ const __obscuraCore = globalThis.Deno.core;
// internal helpers (var-declared throughout the file)
'__processDynScriptQueue', '_decodeDataScriptUrl', '_markNative', '_fpRand', '_fpNoise',
'_fpCache', '_getFp', '_fp', '_splitAsciiWhitespace',
'_getElementsByClassName', '_docEncoding', '_docIsUtf8',
'_getElementsByClassName', '_docEncoding', '_docIsUtf8', '_docContentType',
'_isSpecialScheme', '_applyDocQueryEncoding', '_anchorBase',
'_elemHrefURL', '_setElemHrefPart', '_pad', '_daysInMonth',
'_isoWeek1Monday', '_inputParseNumber', '_inputFormatNumber',
@@ -2919,6 +2919,18 @@ function _docEncoding() {
return __docEncoding;
}
function _docIsUtf8() { if (__docIsUtf8 === undefined) _docEncoding(); return __docIsUtf8; }
// The main resource's MIME type. Cached per runtime for the same reason as
// the encoding: it is fixed for a document's lifetime and contentType is read
// on every _isXMLDocument() call. Empty when the navigation had no usable
// Content-Type header, so the caller can fall back to URL-derived sniffing.
let __docContentType;
function _docContentType() {
if (__docContentType === undefined) {
const t = _domParse("document_content_type");
__docContentType = (typeof t === 'string') ? t : '';
}
return __docContentType;
}
// WHATWG "special scheme" check (these get the special-query percent-encode set).
function _isSpecialScheme(protocol) {
const s = (protocol || '').replace(/:$/, '').toLowerCase();
@@ -5833,12 +5845,19 @@ class Document extends Node {
get charset() { return this.characterSet; }
get inputEncoding() { return this.characterSet; }
get contentType() {
const get = _documentRealmMember(this, 'contentType');
if (get) return Reflect.apply(get, this, []);
// An explicit type set by DOMParser/createDocument wins.
if (this._contentType) return this._contentType;
// `new Document()` (the WHATWG constructor, no backing node id) creates an
// XML document, so createCDATASection/etc. must not throw. Live documents
// wrapped from the tree carry a real nid and fall through to URL-derived.
if (this._nid === undefined || this._nid === null) return "application/xml";
// The main resource's own MIME type wins over the URL: a text/plain or
// text/markdown response must not report text/html just because its URL
// has no extension.
const main = _docContentType();
if (main) return main;
const url = this.URL || "";
// data: URLs carry their MIME type explicitly.
const dm = /^data:([^,;]+)/i.exec(url);
@@ -13386,10 +13405,13 @@ globalThis.Comment = Comment;
globalThis.CDATASection = CDATASection;
globalThis.ProcessingInstruction = ProcessingInstruction;
// True when the document was loaded from an XML/XHTML source. Obscura has no
// native XML tree, so this is inferred from contentType (derived from the URL).
// native XML tree, so this is inferred from contentType. Only the XML types
// count: a plain-text document (text/plain, text/markdown) is still an HTML
// document for createCDATASection/createProcessingInstruction purposes.
function _isXMLDocument(doc) {
const ct = (doc && doc.contentType) || "text/html";
return ct !== "text/html";
return ct === "application/xhtml+xml" || ct === "text/xml"
|| ct === "application/xml" || ct.endsWith("+xml");
}
// XML Name production, sufficient for createProcessingInstruction targets.
const _piNameStart = "A-Za-z_:\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD";
@@ -17221,7 +17243,7 @@ function _installCaretGeometry() {
// Capture late-defined members too, before page code can replace them.
const _nativeElementClick = Element.prototype.click;
const _documentMembers = Object.freeze(Object.fromEntries([
...['URL', 'defaultView', 'readyState', 'compatMode', 'getElementById', 'querySelector', 'querySelectorAll', 'open', 'write', 'close', 'elementFromPoint', 'queryCommandSupported'].map(name => {
...['URL', 'defaultView', 'readyState', 'compatMode', 'contentType', 'getElementById', 'querySelector', 'querySelectorAll', 'open', 'write', 'close', 'elementFromPoint', 'queryCommandSupported'].map(name => {
const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, name);
return [name, descriptor.value || descriptor.get];
}),
+17
View File
@@ -434,6 +434,23 @@ mod tests {
runtime
}
#[test]
fn borrowed_content_type_getter_reads_the_frame_document() {
let mut parent = ObscuraJsRuntime::new();
parent.set_dom(parse_html("<html><body></body></html>"));
parent.set_url("https://parent.example/page");
parent.set_content_type("text/plain");
parent.run_page_init();
let _frame = FrameRealm::new(&mut parent, 1, 0,
"https://parent.example/frame", "<html><body>Child</body></html>")
.expect("frame realm");
assert_eq!(parent.evaluate(r#"(function(){
const child = globalThis.__obscura_frameObjects[1].document;
const get = Object.getOwnPropertyDescriptor(Document.prototype, 'contentType').get;
return [get.call(child), child.contentType, document.contentType];
})()"#).unwrap(), serde_json::json!(["text/html", "text/html", "text/plain"]));
}
#[test]
fn fetched_frame_contexts_survive_runtime_teardown_under_gc_stress() {
crate::set_v8_flags("--stress-compaction --stress-marking=1");
+8
View File
@@ -159,6 +159,10 @@ pub struct ObscuraState {
/// "UTF-8", "EUC-JP"). Backs `document.characterSet` and the URL query
/// encoding override for `<a>`/`<area>` hrefs in legacy-charset documents.
pub encoding: String,
/// MIME type of the document's main resource, lowercased and without
/// parameters. Backs `document.contentType`; a `text/plain` navigation
/// must not report `text/html` just because the URL has no extension.
pub content_type: String,
pub title: String,
/// URL of the document that initiated this document's navigation. Direct
/// browser/API navigations leave this empty; document-initiated
@@ -426,6 +430,7 @@ impl ObscuraState {
about_base_url: None,
inherited_origin: None,
encoding: "UTF-8".to_string(),
content_type: String::new(),
title: String::new(),
referrer: String::new(),
navigation_timing: NavigationTiming::default(),
@@ -2178,6 +2183,9 @@ fn op_dom_inner(shared: SharedState, cmd: String, arg1: String, arg2: String) ->
}
"document_referrer" => serde_json::to_string(&gs.referrer).unwrap_or("\"\"".into()),
"document_encoding" => serde_json::to_string(&gs.encoding).unwrap_or("\"UTF-8\"".into()),
"document_content_type" => {
serde_json::to_string(&gs.content_type).unwrap_or("\"text/html\"".into())
}
"document_element" => {
for cid in dom.children(dom.document()) {
if let Some(n) = dom.get_node(cid) {
+17
View File
@@ -1260,6 +1260,13 @@ impl ObscuraJsRuntime {
self.state.borrow_mut().encoding = encoding.to_string();
}
/// Set the document's MIME type. Backs `document.contentType`, which
/// reports the type of the main resource rather than one guessed from
/// the URL.
pub fn set_content_type(&self, content_type: &str) {
self.state.borrow_mut().content_type = content_type.to_string();
}
pub fn set_title(&self, title: &str) {
self.state.borrow_mut().title = title.to_string();
}
@@ -4542,6 +4549,16 @@ mod tests {
rt
}
#[test]
fn document_content_type_retains_url_fallback_without_response_metadata() {
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(parse_html("<html><body></body></html>"));
rt.set_url("https://example.com/document.xhtml");
rt.run_page_init();
assert_eq!(rt.evaluate("document.contentType").unwrap(),
serde_json::json!("application/xhtml+xml"));
}
#[test]
fn screen_override_clear_before_page_init_preserves_mutable_hidden_slots() {
let mut rt = ObscuraJsRuntime::new();
+4
View File
@@ -27,6 +27,10 @@ await page.goto('https://example.com', { waitUntil: 'networkidle' });
Default is `domcontentloaded`. Other values: `load`, `networkidle`.
Navigating to `text/plain`, `text/markdown`, or `text/x-markdown` responses
preserves literal markup as text instead of parsing it as HTML.
`document.contentType` reports the response MIME type without parameters.
## Evaluate
```js