fix(cdp): expose main-resource response body and real mime on navigation

Navigating straight to an image, PDF, or other resource could not read the
main response body over CDP, and Page.frameNavigated always reported
text/html.

Two problems: the main resource's body was stored under its internal
request id, but the client sees it as the navigation loaderId (Chrome's
requestId === loaderId convention), so Network.getResponseBody(loaderId)
missed. And binary bodies were stored as UTF-8-lossy text, corrupting the
bytes. Alias the stored body to the loaderId, store non-text-like content
types (images, PDF, octet-stream) base64, and report the response's real
Content-Type in frameNavigated. HTML pages are unchanged.

Fixes #340.
This commit is contained in:
SGavrl committed 2026-07-01 06:55:37 +00:00
1 parent d5d9b7788f
commit 630f9894f6
2 files changed
+69 -2

No files matched your search

+45 -1
View File
@@ -984,6 +984,10 @@ impl Page {
PageError::NetworkError(e.to_string())
})?;
// Store binary main resources (images, PDFs, octet-stream) base64 so
// Network.getResponseBody returns intact bytes. A UTF-8-lossy text store
// corrupts them (issue #340). Text-like types stay as text.
let main_is_binary = !is_text_like_content_type(response.content_type());
self.record_network_event_with_body(
url.as_str(),
"GET",
@@ -991,7 +995,7 @@ impl Page {
response.status,
&response.headers,
&response.body,
false,
main_is_binary,
);
if !response.redirected_from.is_empty() {
@@ -1469,6 +1473,23 @@ impl Page {
})
}
/// Make the body stored under `from_id` also retrievable under `to_id`.
/// The main navigation resource is stored under its internal request id, but
/// the CDP layer reports it to clients with the navigation's loaderId as the
/// requestId (Chrome's `requestId === loaderId` convention). Without this
/// alias, `Network.getResponseBody(loaderId)` misses and a client navigating
/// straight to an image or other resource cannot read the main-response body
/// (issue #340).
pub fn alias_response_body(&mut self, from_id: &str, to_id: &str) {
if from_id == to_id || self.response_bodies.contains_key(to_id) {
return;
}
if let Some(body) = self.response_bodies.get(from_id).cloned() {
self.response_bodies.insert(to_id.to_string(), body);
self.response_body_order.push_back(to_id.to_string());
}
}
pub fn clear_response_bodies(&mut self) {
self.response_bodies.clear();
self.response_body_order.clear();
@@ -1678,6 +1699,29 @@ impl From<ObscuraNetError> for PageError {
}
}
/// Whether a Content-Type is text-like and can be stored/returned as a UTF-8
/// string. Everything else (images, PDF, fonts, octet-stream) is binary and must
/// be base64-encoded so Network.getResponseBody returns intact bytes.
fn is_text_like_content_type(content_type: Option<&str>) -> bool {
let ct = match content_type {
Some(c) => c.split(';').next().unwrap_or(c).trim().to_ascii_lowercase(),
// No Content-Type: assume text (matches the HTML-parse default).
None => return true,
};
if ct.is_empty() {
return true;
}
ct.starts_with("text/")
|| ct == "application/json"
|| ct == "application/xml"
|| ct == "application/xhtml+xml"
|| ct == "application/javascript"
|| ct == "application/ecmascript"
|| ct == "image/svg+xml"
|| ct.ends_with("+json")
|| ct.ends_with("+xml")
}
fn response_body_entry_limit() -> usize {
std::env::var("OBSCURA_NETWORK_BODY_BUFFER_ENTRIES")
.ok()