mirror of
https://github.com/lexmount/moli.git
synced 2026-10-08 08:01:01 +00:00
4566 lines
146 KiB
Rust
4566 lines
146 KiB
Rust
//! Shared page and protocol value types for Moli.
|
|
//!
|
|
//! This crate holds renderer-neutral data structures such as navigation
|
|
//! responses, script execution reports, subresource records, and frame
|
|
//! snapshots that are shared by the facade, renderer, and CDP crates.
|
|
|
|
mod document_activity;
|
|
mod inspector_identity;
|
|
mod inspector_state;
|
|
mod layout;
|
|
mod navigation_history;
|
|
mod navigator_overrides;
|
|
mod renderer_transport_memory;
|
|
mod session_history;
|
|
|
|
pub use session_history::{SessionHistoryCommit, SessionHistorySeed, SessionHistoryUpdate};
|
|
|
|
use std::{
|
|
borrow::Cow,
|
|
collections::{BTreeMap, BTreeSet},
|
|
io::{self, Write},
|
|
sync::Arc,
|
|
};
|
|
|
|
use moli_disk_pool::{DiskData, DiskPool};
|
|
use moli_parkable_image::ParkableImage;
|
|
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use url::Url;
|
|
|
|
use http_auth::parser::ChallengeParser;
|
|
use moli_cookie_jar::{StoredCookieQueryReport, StoredCookieSetReport};
|
|
use moli_dom::NodeId;
|
|
use moli_fetch::{
|
|
NegotiatedHttpVersion, NetworkRequestExtraInfo, NetworkResponseExtraInfo, RedirectInfo,
|
|
RequestAuth, RequestAuthScheme, RequestAuthTarget, Response, ResponseBody, ResponseHead,
|
|
};
|
|
use moli_web_mime::is_json_module_mime;
|
|
|
|
const SUBRESOURCE_RESPONSE_BODY_MEMORY_LIMIT: usize = 1024 * 1024;
|
|
|
|
pub use document_activity::DocumentActivity;
|
|
pub use inspector_identity::{
|
|
DevToolsSessionKey, FrontendCommandId, RendererAgentAttachmentId, RendererCallId,
|
|
RendererCallIdOutOfRange, RendererDevToolsAgentToken, RendererDevToolsCommandId,
|
|
RendererInspectorResponseDelivery,
|
|
};
|
|
pub use layout::LayoutPolicy;
|
|
pub use navigator_overrides::{
|
|
GeolocationPositionOverride, NavigatorEmulationSessions, NavigatorOverrides,
|
|
NavigatorQueryOverrides,
|
|
};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DocumentNodeAttributeSnapshot {
|
|
pub local_name: String,
|
|
pub value: String,
|
|
}
|
|
|
|
/// Snapshot-local DOM storage id used inside one captured document payload.
|
|
pub type DocumentSnapshotNodeId = NodeId;
|
|
|
|
/// Stable renderer identity for a node that exists only in the inspector
|
|
/// projection and therefore has no independent DOM storage handle.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum DocumentNodeInspectorIdentity {
|
|
MarkerPseudoElement,
|
|
UserAgentShadowTreeNode {
|
|
tree_kind: u16,
|
|
ordinal: u16,
|
|
state: u64,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum DocumentNodeAssociatedSnapshot {
|
|
TemplateContent(DocumentNodeSnapshot),
|
|
ContentDocument(DocumentNodeSnapshot),
|
|
}
|
|
|
|
impl DocumentNodeAssociatedSnapshot {
|
|
pub fn node(&self) -> &DocumentNodeSnapshot {
|
|
match self {
|
|
Self::TemplateContent(node) | Self::ContentDocument(node) => node,
|
|
}
|
|
}
|
|
|
|
pub fn node_mut(&mut self) -> &mut DocumentNodeSnapshot {
|
|
match self {
|
|
Self::TemplateContent(node) | Self::ContentDocument(node) => node,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DocumentNodeSnapshot {
|
|
pub node_id: DocumentSnapshotNodeId,
|
|
pub parent_id: Option<DocumentSnapshotNodeId>,
|
|
pub inspector_identity: Option<DocumentNodeInspectorIdentity>,
|
|
pub inspector_parent_identity: Option<DocumentNodeInspectorIdentity>,
|
|
pub frontend_node_id: Option<u32>,
|
|
pub parent_frontend_node_id: Option<u32>,
|
|
pub backend_node_id: Option<u32>,
|
|
pub frame_id: Option<String>,
|
|
pub node_type: u8,
|
|
pub node_name: String,
|
|
pub local_name: String,
|
|
pub node_value: String,
|
|
pub child_count: usize,
|
|
pub document_url: String,
|
|
pub base_url: String,
|
|
pub namespace_uri: Option<String>,
|
|
pub attributes: Vec<DocumentNodeAttributeSnapshot>,
|
|
pub document_type_name: Option<String>,
|
|
pub public_id: Option<String>,
|
|
pub system_id: Option<String>,
|
|
pub is_element: bool,
|
|
pub has_geometry: bool,
|
|
pub shadow_root_type: Option<String>,
|
|
pub shadow_roots: Vec<DocumentNodeSnapshot>,
|
|
/// CDP pseudo-element type for an inspector-only node such as `marker`.
|
|
/// The originating element remains the snapshot identity owner.
|
|
pub pseudo_type: Option<String>,
|
|
pub pseudo_elements: Vec<DocumentNodeSnapshot>,
|
|
/// A non-child node associated with this DOM node. An HTML template owns a
|
|
/// shallow template-content fragment; an iframe/frame owner can expose its
|
|
/// current content document. Those host categories are mutually exclusive.
|
|
pub associated: Option<Box<DocumentNodeAssociatedSnapshot>>,
|
|
pub children: Vec<DocumentNodeSnapshot>,
|
|
}
|
|
|
|
impl DocumentNodeSnapshot {
|
|
pub fn associated_node(&self) -> Option<&Self> {
|
|
self.associated
|
|
.as_deref()
|
|
.map(|associated| associated.node())
|
|
}
|
|
|
|
pub fn associated_node_mut(&mut self) -> Option<&mut Self> {
|
|
self.associated
|
|
.as_deref_mut()
|
|
.map(|associated| associated.node_mut())
|
|
}
|
|
|
|
pub fn template_content(&self) -> Option<&Self> {
|
|
match self.associated.as_deref() {
|
|
Some(DocumentNodeAssociatedSnapshot::TemplateContent(content)) => Some(content),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn content_document(&self) -> Option<&Self> {
|
|
match self.associated.as_deref() {
|
|
Some(DocumentNodeAssociatedSnapshot::ContentDocument(document)) => Some(document),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub const RENDERER_BACKEND_NODE_ID_START: u32 = 2_000_000_000;
|
|
// Chromium's V8 inspector caps protocol value construction at
|
|
// kMaxProtocolDepth = 1000. Blink's DOM deep serialization keeps DOM node
|
|
// expansion as a separate maxNodeDepth concern. Keep the same split here:
|
|
// protocol/BiDi depth parameters retain their protocol meaning, while these
|
|
// implementation caps stop Rust output builders from recursing without bound.
|
|
pub const MAX_INSPECTOR_PROTOCOL_VALUE_DEPTH: usize = 1000;
|
|
pub const MAX_DOM_OUTPUT_TREE_DEPTH: usize = MAX_INSPECTOR_PROTOCOL_VALUE_DEPTH;
|
|
pub const MAX_JSON_OUTPUT_TREE_DEPTH: usize = MAX_INSPECTOR_PROTOCOL_VALUE_DEPTH;
|
|
|
|
pub const fn is_renderer_backend_node_id(id: u32) -> bool {
|
|
id >= RENDERER_BACKEND_NODE_ID_START
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DocumentNodeObjectSnapshot {
|
|
pub frame_id: Option<String>,
|
|
pub owner_node_id: Option<DocumentSnapshotNodeId>,
|
|
pub node_path: Option<Vec<usize>>,
|
|
pub snapshot: DocumentNodeSnapshot,
|
|
}
|
|
|
|
pub use inspector_state::{
|
|
RendererDomDebuggerDomBreakpointType, RendererDomDebuggerEventListenerBreakpoint,
|
|
RendererDomDebuggerXhrBreakpoint, RendererInspectorProtocolConfiguration,
|
|
RendererInspectorProtocolConfigurationCommand, RendererInspectorSessionRestoreSnapshot,
|
|
V8InspectorSessionAttach, V8InspectorSessionState,
|
|
renderer_inspector_protocol_configuration_command_from_message,
|
|
renderer_inspector_protocol_configuration_command_from_method,
|
|
};
|
|
pub use navigation_history::{
|
|
NavigationActivationSeed, NavigationHistoryEntryId, NavigationHistoryEntrySeed,
|
|
NavigationHistoryMutation, NavigationHistorySerializedEntry, NavigationTraversalSeedCandidate,
|
|
SessionHistoryUpdateKind, apply_child_browsing_context_javascript_url_navigation_to_entry_seed,
|
|
apply_child_browsing_context_navigation_to_entry_seed,
|
|
child_browsing_context_single_entry_seed, cross_document_navigation_seed,
|
|
initial_navigation_history_seed, reload_navigation_seed,
|
|
replace_child_browsing_context_navigation_in_entry_seed, traversal_navigation_seed_candidate,
|
|
};
|
|
|
|
/// A rectangle relative to a DOM node that should be exposed by a
|
|
/// scroll-into-view operation.
|
|
///
|
|
/// CDP's `DOM.scrollIntoViewIfNeeded` accepts this optional payload. Keeping
|
|
/// it in the shared page types lets the protocol pass the request to the
|
|
/// renderer without erasing it into JSON or reinterpreting it in the browser
|
|
/// process.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct DomScrollIntoViewRect {
|
|
x: f64,
|
|
y: f64,
|
|
width: f64,
|
|
height: f64,
|
|
}
|
|
|
|
impl DomScrollIntoViewRect {
|
|
/// Creates a relative scroll rectangle when every component is a finite
|
|
/// protocol number.
|
|
///
|
|
/// Keeping the fields private makes this invariant shared by every command
|
|
/// frontend and prevents non-finite coordinates from reaching renderer
|
|
/// geometry.
|
|
pub fn try_new(x: f64, y: f64, width: f64, height: f64) -> Option<Self> {
|
|
[x, y, width, height]
|
|
.into_iter()
|
|
.all(f64::is_finite)
|
|
.then_some(Self {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
})
|
|
}
|
|
|
|
pub fn x(self) -> f64 {
|
|
self.x
|
|
}
|
|
|
|
pub fn y(self) -> f64 {
|
|
self.y
|
|
}
|
|
|
|
pub fn width(self) -> f64 {
|
|
self.width
|
|
}
|
|
|
|
pub fn height(self) -> f64 {
|
|
self.height
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct NavigationRedirect {
|
|
pub source: moli_fetch::RedirectSource,
|
|
pub from_url: Url,
|
|
pub to_url: Url,
|
|
pub status: u16,
|
|
pub headers: Vec<(String, Vec<u8>)>,
|
|
pub network_extra_info_available: bool,
|
|
pub request_extra_info: Option<NetworkRequestExtraInfo>,
|
|
pub response_extra_info: Option<NetworkResponseExtraInfo>,
|
|
pub redirect_has_extra_info: bool,
|
|
pub request_cookie_report: Option<StoredCookieQueryReport>,
|
|
pub cookie_set_reports: Vec<StoredCookieSetReport>,
|
|
pub from_cache: bool,
|
|
pub negotiated_http_version: Option<NegotiatedHttpVersion>,
|
|
}
|
|
|
|
impl From<RedirectInfo> for NavigationRedirect {
|
|
fn from(value: RedirectInfo) -> Self {
|
|
Self {
|
|
source: value.source,
|
|
from_url: value.from_url,
|
|
to_url: value.to_url,
|
|
status: value.status,
|
|
headers: value.headers,
|
|
network_extra_info_available: value.network_extra_info_available,
|
|
request_extra_info: value.request_extra_info,
|
|
response_extra_info: value.response_extra_info,
|
|
redirect_has_extra_info: value.redirect_has_extra_info,
|
|
request_cookie_report: value.request_cookie_report,
|
|
cookie_set_reports: value.cookie_set_reports,
|
|
from_cache: value.from_cache,
|
|
negotiated_http_version: value.negotiated_http_version,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<NavigationRedirect> for RedirectInfo {
|
|
fn from(value: NavigationRedirect) -> Self {
|
|
Self {
|
|
source: value.source,
|
|
from_url: value.from_url,
|
|
to_url: value.to_url,
|
|
status: value.status,
|
|
headers: value.headers,
|
|
network_extra_info_available: value.network_extra_info_available,
|
|
request_extra_info: value.request_extra_info,
|
|
response_extra_info: value.response_extra_info,
|
|
redirect_has_extra_info: value.redirect_has_extra_info,
|
|
request_cookie_report: value.request_cookie_report,
|
|
cookie_set_reports: value.cookie_set_reports,
|
|
from_cache: value.from_cache,
|
|
negotiated_http_version: value.negotiated_http_version,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct NavigationResponse {
|
|
pub final_url: Url,
|
|
pub status: u16,
|
|
pub headers: Vec<(String, Vec<u8>)>,
|
|
body: ResponseBody,
|
|
pub request_cookie_report: Option<StoredCookieQueryReport>,
|
|
pub cookie_set_reports: Vec<StoredCookieSetReport>,
|
|
pub redirected: bool,
|
|
pub redirect_chain: Vec<NavigationRedirect>,
|
|
pub from_cache: bool,
|
|
pub negotiated_http_version: Option<NegotiatedHttpVersion>,
|
|
network_request_headers: Option<Vec<(String, String)>>,
|
|
}
|
|
|
|
impl Clone for NavigationResponse {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
final_url: self.final_url.clone(),
|
|
status: self.status,
|
|
headers: self.headers.clone(),
|
|
body: self
|
|
.body
|
|
.clone_materialized()
|
|
.expect("NavigationResponse body should remain materialized"),
|
|
request_cookie_report: self.request_cookie_report.clone(),
|
|
cookie_set_reports: self.cookie_set_reports.clone(),
|
|
redirected: self.redirected,
|
|
redirect_chain: self.redirect_chain.clone(),
|
|
from_cache: self.from_cache,
|
|
negotiated_http_version: self.negotiated_http_version,
|
|
network_request_headers: self.network_request_headers.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl NavigationResponse {
|
|
pub fn body_text(&self) -> &str {
|
|
self.body
|
|
.as_materialized_text()
|
|
.expect("NavigationResponse body should remain materialized text")
|
|
}
|
|
|
|
pub fn body_bytes(&self) -> &[u8] {
|
|
self.body
|
|
.as_materialized_bytes()
|
|
.expect("NavigationResponse body should remain materialized")
|
|
}
|
|
|
|
/// Clones the complete materialized byte payload for compatibility callers
|
|
/// whose public contract requires an owned full-body buffer.
|
|
pub fn clone_body_bytes(&self) -> Vec<u8> {
|
|
self.body_bytes().to_vec()
|
|
}
|
|
|
|
/// Moves the complete materialized payload out of this response.
|
|
///
|
|
/// The response head remains usable and the response body becomes empty.
|
|
/// This is intended for handing large payloads to an owning backing store
|
|
/// without briefly retaining both the response buffer and a clone.
|
|
pub fn take_body_bytes(&mut self) -> Vec<u8> {
|
|
std::mem::replace(
|
|
&mut self.body,
|
|
ResponseBody::materialized_text(String::new(), Vec::new()),
|
|
)
|
|
.try_into_materialized_bytes()
|
|
.expect("NavigationResponse body should remain materialized")
|
|
}
|
|
|
|
pub fn materialized_body(&self) -> ResponseBody {
|
|
self.body
|
|
.clone_materialized()
|
|
.expect("NavigationResponse body should remain materialized")
|
|
}
|
|
|
|
pub fn head(&self) -> ResponseHead {
|
|
ResponseHead {
|
|
final_url: self.final_url.clone(),
|
|
status: self.status,
|
|
headers: self.headers.clone(),
|
|
request_cookie_report: self.request_cookie_report.clone(),
|
|
cookie_set_reports: self.cookie_set_reports.clone(),
|
|
redirected: self.redirected,
|
|
redirect_chain: self
|
|
.redirect_chain
|
|
.clone()
|
|
.into_iter()
|
|
.map(Into::into)
|
|
.collect(),
|
|
from_cache: self.from_cache,
|
|
negotiated_http_version: self.negotiated_http_version,
|
|
}
|
|
}
|
|
|
|
pub fn from_head_and_body(head: ResponseHead, body: String, body_bytes: Vec<u8>) -> Self {
|
|
Self::from_head_and_materialized_body(
|
|
head,
|
|
ResponseBody::materialized_text(body, body_bytes),
|
|
)
|
|
}
|
|
|
|
/// Headers configured on the HTTP transfer that produced this response.
|
|
/// `None` distinguishes local, cached, and synthetic responses from a
|
|
/// response observed at the network transport boundary.
|
|
pub fn with_network_request_headers(
|
|
mut self,
|
|
network_request_headers: Option<Vec<(String, String)>>,
|
|
) -> Self {
|
|
self.network_request_headers = network_request_headers;
|
|
self
|
|
}
|
|
|
|
pub fn network_request_headers(&self) -> Option<&[(String, String)]> {
|
|
self.network_request_headers.as_deref()
|
|
}
|
|
|
|
pub fn from_head_and_materialized_body(head: ResponseHead, body: ResponseBody) -> Self {
|
|
let body = body
|
|
.try_into_materialized_text_body()
|
|
.expect("NavigationResponse body should remain materialized text");
|
|
Self {
|
|
final_url: head.final_url,
|
|
status: head.status,
|
|
headers: head.headers,
|
|
body,
|
|
request_cookie_report: head.request_cookie_report,
|
|
cookie_set_reports: head.cookie_set_reports,
|
|
redirected: head.redirected,
|
|
redirect_chain: head.redirect_chain.into_iter().map(Into::into).collect(),
|
|
from_cache: head.from_cache,
|
|
negotiated_http_version: head.negotiated_http_version,
|
|
network_request_headers: None,
|
|
}
|
|
}
|
|
|
|
pub fn from_head_and_text_body(head: ResponseHead, body: String) -> Self {
|
|
Self::from_head_and_body(head, body, Vec::new())
|
|
}
|
|
|
|
pub fn from_text_body(
|
|
final_url: Url,
|
|
status: u16,
|
|
headers: Vec<(String, Vec<u8>)>,
|
|
body: String,
|
|
) -> Self {
|
|
Self::from_head_and_text_body(
|
|
ResponseHead {
|
|
final_url,
|
|
status,
|
|
headers,
|
|
request_cookie_report: None,
|
|
cookie_set_reports: Vec::new(),
|
|
redirected: false,
|
|
redirect_chain: Vec::new(),
|
|
from_cache: false,
|
|
negotiated_http_version: None,
|
|
},
|
|
body,
|
|
)
|
|
}
|
|
|
|
pub fn with_status_headers_from(
|
|
source: &Self,
|
|
status: u16,
|
|
headers: Vec<(String, Vec<u8>)>,
|
|
) -> Self {
|
|
let mut head = source.head();
|
|
head.status = status;
|
|
head.headers = headers;
|
|
Self::from_head_and_materialized_body(head, source.materialized_body())
|
|
.with_network_request_headers(source.network_request_headers.clone())
|
|
}
|
|
|
|
pub fn into_parts(self) -> (ResponseHead, String, Vec<u8>) {
|
|
let head = ResponseHead {
|
|
final_url: self.final_url,
|
|
status: self.status,
|
|
headers: self.headers,
|
|
request_cookie_report: self.request_cookie_report,
|
|
cookie_set_reports: self.cookie_set_reports,
|
|
redirected: self.redirected,
|
|
redirect_chain: self.redirect_chain.into_iter().map(Into::into).collect(),
|
|
from_cache: self.from_cache,
|
|
negotiated_http_version: self.negotiated_http_version,
|
|
};
|
|
let (body, body_bytes) = self
|
|
.body
|
|
.try_into_lossy_materialized_text()
|
|
.expect("NavigationResponse body should remain materialized text");
|
|
(head, body, body_bytes)
|
|
}
|
|
|
|
/// Consumes a materialized navigation response when the next renderer step
|
|
/// only accepts text and intentionally discards the exact byte payload.
|
|
pub fn into_text_parts(self) -> (ResponseHead, String) {
|
|
let (head, body, _) = self.into_parts();
|
|
(head, body)
|
|
}
|
|
|
|
/// Consumes a materialized response and transfers its exact byte payload
|
|
/// without retaining the text view or copying UTF-8 text storage.
|
|
pub fn into_byte_parts(self) -> (ResponseHead, Vec<u8>) {
|
|
let (head, body) = self.into_body();
|
|
let bytes = body
|
|
.try_into_materialized_bytes()
|
|
.expect("NavigationResponse body should remain materialized");
|
|
(head, bytes)
|
|
}
|
|
|
|
pub fn into_body(self) -> (ResponseHead, ResponseBody) {
|
|
let head = ResponseHead {
|
|
final_url: self.final_url,
|
|
status: self.status,
|
|
headers: self.headers,
|
|
request_cookie_report: self.request_cookie_report,
|
|
cookie_set_reports: self.cookie_set_reports,
|
|
redirected: self.redirected,
|
|
redirect_chain: self.redirect_chain.into_iter().map(Into::into).collect(),
|
|
from_cache: self.from_cache,
|
|
negotiated_http_version: self.negotiated_http_version,
|
|
};
|
|
(head, self.body)
|
|
}
|
|
}
|
|
|
|
impl From<Response> for NavigationResponse {
|
|
fn from(value: Response) -> Self {
|
|
let network_request_headers = value
|
|
.network_request_extra_info()
|
|
.map(|extra_info| extra_info.headers.clone());
|
|
let (head, body) = value.into_body();
|
|
Self::from_head_and_materialized_body(head, body)
|
|
.with_network_request_headers(network_request_headers)
|
|
}
|
|
}
|
|
|
|
impl From<NavigationResponse> for Response {
|
|
fn from(value: NavigationResponse) -> Self {
|
|
let network_request_headers = value.network_request_headers.clone();
|
|
let request_cookie_report = value.request_cookie_report.clone().unwrap_or_default();
|
|
let (head, body) = value.into_body();
|
|
Self::from_head_and_materialized_body(head, body)
|
|
.expect("NavigationResponse body should remain materialized at Response boundary")
|
|
.with_network_request_extra_info(network_request_headers.map(|headers| {
|
|
NetworkRequestExtraInfo {
|
|
headers,
|
|
cookie_report: request_cookie_report,
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub enum ScriptGlobalsSnapshotState {
|
|
#[default]
|
|
Uncaptured,
|
|
Fresh,
|
|
Dirty,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ScriptExecutionReport {
|
|
pub runs: Vec<ScriptRun>,
|
|
globals: Arc<BTreeMap<String, JsValueSnapshot>>,
|
|
globals_snapshot_state: ScriptGlobalsSnapshotState,
|
|
observable_output_items: Vec<ScriptObservableOutputItem>,
|
|
console_messages: Vec<String>,
|
|
lifecycle_errors: Vec<String>,
|
|
inspector_issues: Vec<InspectorIssueSnapshot>,
|
|
subresource_network_records: Vec<SubresourceNetworkRecord>,
|
|
staged_subresource_lifecycle: Option<Box<StagedSubresourceReportState>>,
|
|
websocket_network_events: Vec<WebSocketNetworkEvent>,
|
|
websocket_lifecycle_events: Vec<WebSocketLifecycleEvent>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
struct StagedSubresourceReportState {
|
|
requests: BTreeMap<u64, SubresourceRequestStarted>,
|
|
responses: BTreeMap<u64, SubresourceResponseStarted>,
|
|
bodies: BTreeMap<u64, SubresourceBodyFinished>,
|
|
completed_handles: BTreeSet<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ScriptNetworkOutput {
|
|
items: Vec<ScriptNetworkOutputItem>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ScriptNetworkOutputItem {
|
|
SubresourceNetworkRecord(Box<SubresourceNetworkRecord>),
|
|
SubresourceRequestStarted(Box<SubresourceRequestStarted>),
|
|
SubresourceResponseStarted(Box<SubresourceResponseStarted>),
|
|
SubresourceDataReceived(SubresourceDataReceived),
|
|
SubresourceEventSourceMessageReceived(Box<SubresourceEventSourceMessageReceived>),
|
|
SubresourceBodyFinished(Box<SubresourceBodyFinished>),
|
|
WebSocketNetworkEvent(WebSocketNetworkEvent),
|
|
WebSocketLifecycleEvent(WebSocketLifecycleEvent),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ScriptObservableOutput {
|
|
items: Vec<ScriptObservableOutputItem>,
|
|
console_messages: Vec<String>,
|
|
lifecycle_errors: Vec<String>,
|
|
inspector_issues: Vec<InspectorIssueSnapshot>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ScriptObservableOutputItem {
|
|
ConsoleMessage(String),
|
|
LifecycleError(String),
|
|
InspectorIssue(Box<InspectorIssueSnapshot>),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum InspectorIssueSnapshot {
|
|
QuirksMode(QuirksModeIssueSnapshot),
|
|
ContentSecurityPolicy(ContentSecurityPolicyIssueSnapshot),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct QuirksModeIssueSnapshot {
|
|
is_limited_quirks_mode: bool,
|
|
document_node_id: u32,
|
|
url: String,
|
|
}
|
|
|
|
impl QuirksModeIssueSnapshot {
|
|
pub fn new(is_limited_quirks_mode: bool, document_node_id: u32, url: String) -> Self {
|
|
Self {
|
|
is_limited_quirks_mode,
|
|
document_node_id,
|
|
url,
|
|
}
|
|
}
|
|
|
|
pub fn is_limited_quirks_mode(&self) -> bool {
|
|
self.is_limited_quirks_mode
|
|
}
|
|
|
|
pub fn document_node_id(&self) -> u32 {
|
|
self.document_node_id
|
|
}
|
|
|
|
pub fn url(&self) -> &str {
|
|
&self.url
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ContentSecurityPolicyViolationType {
|
|
Eval,
|
|
WasmEval,
|
|
Inline,
|
|
TrustedTypesPolicy,
|
|
TrustedTypesSink,
|
|
Url,
|
|
SubresourceIntegrity,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct InspectorSourceCodeLocationSnapshot {
|
|
url: String,
|
|
line_number: u32,
|
|
column_number: u32,
|
|
}
|
|
|
|
impl InspectorSourceCodeLocationSnapshot {
|
|
pub fn new(url: String, line_number: u32, column_number: u32) -> Self {
|
|
Self {
|
|
url,
|
|
line_number,
|
|
column_number,
|
|
}
|
|
}
|
|
|
|
pub fn url(&self) -> &str {
|
|
&self.url
|
|
}
|
|
|
|
pub fn line_number(&self) -> u32 {
|
|
self.line_number
|
|
}
|
|
|
|
pub fn column_number(&self) -> u32 {
|
|
self.column_number
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ContentSecurityPolicyIssueSnapshot {
|
|
is_report_only: bool,
|
|
violated_directive: String,
|
|
violation_type: ContentSecurityPolicyViolationType,
|
|
blocked_url: Option<String>,
|
|
source_code_location: Option<InspectorSourceCodeLocationSnapshot>,
|
|
violating_node_id: Option<u32>,
|
|
}
|
|
|
|
impl ContentSecurityPolicyIssueSnapshot {
|
|
pub fn new(
|
|
is_report_only: bool,
|
|
violated_directive: String,
|
|
violation_type: ContentSecurityPolicyViolationType,
|
|
) -> Self {
|
|
Self {
|
|
is_report_only,
|
|
violated_directive,
|
|
violation_type,
|
|
blocked_url: None,
|
|
source_code_location: None,
|
|
violating_node_id: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_blocked_url(mut self, blocked_url: Option<String>) -> Self {
|
|
self.blocked_url = blocked_url;
|
|
self
|
|
}
|
|
|
|
pub fn with_source_code_location(
|
|
mut self,
|
|
source_code_location: Option<InspectorSourceCodeLocationSnapshot>,
|
|
) -> Self {
|
|
self.source_code_location = source_code_location;
|
|
self
|
|
}
|
|
|
|
pub fn with_violating_node_id(mut self, violating_node_id: Option<u32>) -> Self {
|
|
self.violating_node_id = violating_node_id;
|
|
self
|
|
}
|
|
|
|
pub fn is_report_only(&self) -> bool {
|
|
self.is_report_only
|
|
}
|
|
|
|
pub fn violated_directive(&self) -> &str {
|
|
&self.violated_directive
|
|
}
|
|
|
|
pub fn violation_type(&self) -> ContentSecurityPolicyViolationType {
|
|
self.violation_type
|
|
}
|
|
|
|
pub fn blocked_url(&self) -> Option<&str> {
|
|
self.blocked_url.as_deref()
|
|
}
|
|
|
|
pub fn source_code_location(&self) -> Option<&InspectorSourceCodeLocationSnapshot> {
|
|
self.source_code_location.as_ref()
|
|
}
|
|
|
|
pub fn violating_node_id(&self) -> Option<u32> {
|
|
self.violating_node_id
|
|
}
|
|
}
|
|
|
|
impl ScriptNetworkOutput {
|
|
pub fn is_empty(&self) -> bool {
|
|
self.items.is_empty()
|
|
}
|
|
|
|
pub fn from_items(items: impl IntoIterator<Item = ScriptNetworkOutputItem>) -> Self {
|
|
let mut output = Self::default();
|
|
for item in items {
|
|
output.push_item(item);
|
|
}
|
|
output
|
|
}
|
|
|
|
pub fn into_items(self) -> impl Iterator<Item = ScriptNetworkOutputItem> {
|
|
self.items.into_iter()
|
|
}
|
|
|
|
pub fn push_item(&mut self, item: ScriptNetworkOutputItem) {
|
|
self.items.push(item);
|
|
}
|
|
}
|
|
|
|
impl ScriptObservableOutput {
|
|
pub fn is_empty(&self) -> bool {
|
|
self.items.is_empty()
|
|
}
|
|
|
|
pub fn from_items(items: impl IntoIterator<Item = ScriptObservableOutputItem>) -> Self {
|
|
let mut output = Self::default();
|
|
for item in items {
|
|
output.push_item(item);
|
|
}
|
|
output
|
|
}
|
|
|
|
pub fn into_items(self) -> impl Iterator<Item = ScriptObservableOutputItem> {
|
|
self.items.into_iter()
|
|
}
|
|
|
|
pub fn push_item(&mut self, item: ScriptObservableOutputItem) {
|
|
self.items.push(item.clone());
|
|
match item {
|
|
ScriptObservableOutputItem::ConsoleMessage(message) => {
|
|
self.console_messages.push(message);
|
|
}
|
|
ScriptObservableOutputItem::LifecycleError(error) => {
|
|
self.lifecycle_errors.push(error);
|
|
}
|
|
ScriptObservableOutputItem::InspectorIssue(issue) => {
|
|
self.inspector_issues.push(*issue);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ScriptExecutionReport {
|
|
pub fn runs(&self) -> &[ScriptRun] {
|
|
&self.runs
|
|
}
|
|
|
|
pub fn globals(&self) -> &BTreeMap<String, JsValueSnapshot> {
|
|
&self.globals
|
|
}
|
|
|
|
pub fn global(&self, name: &str) -> Option<&JsValueSnapshot> {
|
|
self.globals.get(name)
|
|
}
|
|
|
|
/// Whether [`Self::globals`] reflects the current JavaScript realm.
|
|
///
|
|
/// [`ScriptGlobalsSnapshotState::Uncaptured`] means the realm was created
|
|
/// without the opt-in diagnostics needed to distinguish browser-provided
|
|
/// globals from page-created globals. Protocol turns deliberately avoid
|
|
/// refreshing an enabled snapshot. While this is
|
|
/// [`ScriptGlobalsSnapshotState::Dirty`], `globals()` and `global()` expose
|
|
/// the last complete snapshot for backward compatibility; callers that
|
|
/// require current values must first request an asynchronous full
|
|
/// page-state refresh.
|
|
pub fn globals_snapshot_state(&self) -> ScriptGlobalsSnapshotState {
|
|
self.globals_snapshot_state
|
|
}
|
|
|
|
pub fn globals_are_fresh(&self) -> bool {
|
|
self.globals_snapshot_state == ScriptGlobalsSnapshotState::Fresh
|
|
}
|
|
|
|
pub fn fresh_globals(&self) -> Option<&BTreeMap<String, JsValueSnapshot>> {
|
|
self.globals_are_fresh().then_some(self.globals())
|
|
}
|
|
|
|
pub fn mark_globals_snapshot_dirty(&mut self) -> bool {
|
|
match self.globals_snapshot_state {
|
|
ScriptGlobalsSnapshotState::Fresh => {
|
|
self.globals_snapshot_state = ScriptGlobalsSnapshotState::Dirty;
|
|
true
|
|
}
|
|
ScriptGlobalsSnapshotState::Uncaptured | ScriptGlobalsSnapshotState::Dirty => false,
|
|
}
|
|
}
|
|
|
|
pub fn replace_globals_snapshot(&mut self, globals: BTreeMap<String, JsValueSnapshot>) -> bool {
|
|
if self.globals_snapshot_state == ScriptGlobalsSnapshotState::Fresh
|
|
&& self.globals.as_ref() == &globals
|
|
{
|
|
return false;
|
|
}
|
|
self.globals = Arc::new(globals);
|
|
self.globals_snapshot_state = ScriptGlobalsSnapshotState::Fresh;
|
|
true
|
|
}
|
|
|
|
pub fn console_messages(&self) -> &[String] {
|
|
&self.console_messages
|
|
}
|
|
|
|
pub fn lifecycle_errors(&self) -> &[String] {
|
|
&self.lifecycle_errors
|
|
}
|
|
|
|
pub fn inspector_issues(&self) -> &[InspectorIssueSnapshot] {
|
|
&self.inspector_issues
|
|
}
|
|
|
|
pub fn subresource_network_records(&self) -> &[SubresourceNetworkRecord] {
|
|
&self.subresource_network_records
|
|
}
|
|
|
|
pub fn websocket_network_events(&self) -> &[WebSocketNetworkEvent] {
|
|
&self.websocket_network_events
|
|
}
|
|
|
|
pub fn websocket_lifecycle_events(&self) -> &[WebSocketLifecycleEvent] {
|
|
&self.websocket_lifecycle_events
|
|
}
|
|
|
|
pub fn observable_output_items(&self) -> &[ScriptObservableOutputItem] {
|
|
&self.observable_output_items
|
|
}
|
|
|
|
pub fn extend_observable_output(&mut self, output: ScriptObservableOutput) {
|
|
for item in output.into_items() {
|
|
self.push_observable_output_item(item);
|
|
}
|
|
}
|
|
|
|
pub fn extend_network_output(&mut self, output: ScriptNetworkOutput) {
|
|
for item in output.into_items() {
|
|
self.push_network_output_item(item);
|
|
}
|
|
}
|
|
|
|
fn push_observable_output_item(&mut self, item: ScriptObservableOutputItem) {
|
|
self.observable_output_items.push(item.clone());
|
|
match item {
|
|
ScriptObservableOutputItem::ConsoleMessage(message) => {
|
|
self.console_messages.push(message);
|
|
}
|
|
ScriptObservableOutputItem::LifecycleError(error) => {
|
|
self.lifecycle_errors.push(error);
|
|
}
|
|
ScriptObservableOutputItem::InspectorIssue(issue) => {
|
|
self.inspector_issues.push(*issue);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn push_network_output_item(&mut self, item: ScriptNetworkOutputItem) {
|
|
match item {
|
|
ScriptNetworkOutputItem::SubresourceNetworkRecord(record) => {
|
|
if let Some(handle) = record.request_handle() {
|
|
let handle = handle.get();
|
|
let state = self.staged_subresource_report_state_mut();
|
|
state.requests.remove(&handle);
|
|
state.responses.remove(&handle);
|
|
state.bodies.remove(&handle);
|
|
if !state.completed_handles.insert(handle) {
|
|
return;
|
|
}
|
|
}
|
|
self.subresource_network_records.push(*record);
|
|
}
|
|
ScriptNetworkOutputItem::SubresourceRequestStarted(request) => {
|
|
let handle = request.handle().get();
|
|
let state = self.staged_subresource_report_state_mut();
|
|
if state.completed_handles.contains(&handle) {
|
|
return;
|
|
}
|
|
state.requests.insert(handle, *request);
|
|
self.try_materialize_staged_subresource_record(handle);
|
|
}
|
|
ScriptNetworkOutputItem::SubresourceResponseStarted(response) => {
|
|
let handle = response.handle().get();
|
|
let state = self.staged_subresource_report_state_mut();
|
|
if state.completed_handles.contains(&handle) {
|
|
return;
|
|
}
|
|
state.responses.insert(handle, *response);
|
|
self.try_materialize_staged_subresource_record(handle);
|
|
}
|
|
ScriptNetworkOutputItem::SubresourceBodyFinished(body) => {
|
|
let handle = body.handle().get();
|
|
let state = self.staged_subresource_report_state_mut();
|
|
if state.completed_handles.contains(&handle) {
|
|
return;
|
|
}
|
|
state.bodies.insert(handle, *body);
|
|
self.try_materialize_staged_subresource_record(handle);
|
|
}
|
|
ScriptNetworkOutputItem::SubresourceDataReceived(_)
|
|
| ScriptNetworkOutputItem::SubresourceEventSourceMessageReceived(_) => {}
|
|
ScriptNetworkOutputItem::WebSocketNetworkEvent(event) => {
|
|
self.websocket_network_events.push(event);
|
|
}
|
|
ScriptNetworkOutputItem::WebSocketLifecycleEvent(event) => {
|
|
self.websocket_lifecycle_events.push(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn staged_subresource_report_state_mut(&mut self) -> &mut StagedSubresourceReportState {
|
|
self.staged_subresource_lifecycle
|
|
.get_or_insert_with(|| Box::new(StagedSubresourceReportState::default()))
|
|
}
|
|
|
|
fn try_materialize_staged_subresource_record(&mut self, handle: u64) {
|
|
let record = {
|
|
let Some(state) = self.staged_subresource_lifecycle.as_ref() else {
|
|
return;
|
|
};
|
|
let Some(request) = state.requests.get(&handle) else {
|
|
return;
|
|
};
|
|
let Some(body) = state.bodies.get(&handle) else {
|
|
return;
|
|
};
|
|
let response = state.responses.get(&handle);
|
|
let Some(record) =
|
|
SubresourceNetworkRecord::from_staged_lifecycle(request, response, body)
|
|
else {
|
|
return;
|
|
};
|
|
record
|
|
};
|
|
|
|
let state = self
|
|
.staged_subresource_lifecycle
|
|
.as_mut()
|
|
.expect("staged subresource state should remain allocated");
|
|
state.requests.remove(&handle);
|
|
state.responses.remove(&handle);
|
|
state.bodies.remove(&handle);
|
|
if state.completed_handles.insert(handle) {
|
|
self.subresource_network_records.push(record);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct BidiPreloadChannelHandoff {
|
|
pub handoff_id: String,
|
|
pub token: String,
|
|
pub channel: String,
|
|
pub ownership: Option<String>,
|
|
pub serialization_options: Option<Value>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DocumentStartScript {
|
|
pub registry_key: Option<String>,
|
|
/// DevTools session whose Page agent owns a named-world script.
|
|
///
|
|
/// Blink keeps `InspectorPageAgent` state per DevTools session, so equal
|
|
/// `worldName` values from two sessions must materialize different worlds.
|
|
/// `None` is reserved for non-CDP registrations such as configured and
|
|
/// WebDriver BiDi preloads.
|
|
pub devtools_session: Option<DevToolsSessionKey>,
|
|
pub source: String,
|
|
pub world_name: Option<String>,
|
|
pub has_bidi_channel_argument: bool,
|
|
pub bidi_channel_handoffs: Vec<BidiPreloadChannelHandoff>,
|
|
}
|
|
|
|
impl DocumentStartScript {
|
|
pub fn with_registry_key(&self, registry_key: impl Into<String>) -> Self {
|
|
let mut script = self.clone();
|
|
script.registry_key = Some(registry_key.into());
|
|
script
|
|
}
|
|
|
|
pub fn with_devtools_session(&self, devtools_session: DevToolsSessionKey) -> Self {
|
|
let mut script = self.clone();
|
|
script.devtools_session = Some(devtools_session);
|
|
script
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SubresourceNetworkRecord {
|
|
inner: Arc<SubresourceNetworkRecordInner>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SubresourceNetworkRecordInner {
|
|
frame_id: Option<String>,
|
|
document_url: Url,
|
|
url: Url,
|
|
request_handle: Option<SubresourceNetworkRequestHandle>,
|
|
websocket_socket_id: Option<u64>,
|
|
method: String,
|
|
request_headers: moli_fetch::RequestHeaders,
|
|
request_body: Option<String>,
|
|
request_body_bytes: Option<Vec<u8>>,
|
|
resource_type: SubresourceResourceType,
|
|
request_initiator_type: SubresourceRequestInitiatorType,
|
|
request_cookie_report: Option<StoredCookieQueryReport>,
|
|
outcome: SubresourceNetworkOutcome,
|
|
cookie_set_reports: Vec<StoredCookieSetReport>,
|
|
from_cache: bool,
|
|
network_request_headers: Option<Vec<(String, String)>>,
|
|
negotiated_http_version: Option<NegotiatedHttpVersion>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub struct SubresourceNetworkRequestHandle(u64);
|
|
|
|
impl SubresourceNetworkRequestHandle {
|
|
pub fn new(value: u64) -> Self {
|
|
Self(value)
|
|
}
|
|
|
|
pub fn get(self) -> u64 {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SubresourceRequestStarted {
|
|
handle: SubresourceNetworkRequestHandle,
|
|
frame_id: Option<String>,
|
|
document_url: Url,
|
|
url: Url,
|
|
method: String,
|
|
request_headers: moli_fetch::RequestHeaders,
|
|
request_body: Option<String>,
|
|
request_body_bytes: Option<Vec<u8>>,
|
|
keepalive: bool,
|
|
resource_type: SubresourceResourceType,
|
|
request_initiator_type: SubresourceRequestInitiatorType,
|
|
request_cookie_report: Option<StoredCookieQueryReport>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SubresourceResponseStarted {
|
|
handle: SubresourceNetworkRequestHandle,
|
|
redirect_chain: Vec<NavigationRedirect>,
|
|
final_url: Url,
|
|
status: u16,
|
|
status_text: Option<String>,
|
|
response_headers: Vec<(String, Vec<u8>)>,
|
|
cookie_set_reports: Vec<StoredCookieSetReport>,
|
|
from_cache: bool,
|
|
network_request_headers: Option<Vec<(String, String)>>,
|
|
negotiated_http_version: Option<NegotiatedHttpVersion>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SubresourceDataReceived {
|
|
handle: SubresourceNetworkRequestHandle,
|
|
data_length: usize,
|
|
encoded_data_length: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SubresourceEventSourceMessageReceived {
|
|
handle: SubresourceNetworkRequestHandle,
|
|
event_name: String,
|
|
event_id: String,
|
|
data: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SubresourceBodyFinished {
|
|
handle: SubresourceNetworkRequestHandle,
|
|
result: SubresourceBodyFinishedResult,
|
|
data_was_streamed: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum SubresourceBodyFinishedResult {
|
|
Ready(SubresourceResponseBody),
|
|
Failed(String),
|
|
FailedWithPartialBody {
|
|
error_text: String,
|
|
partial_body: SubresourceResponseBody,
|
|
},
|
|
}
|
|
|
|
impl PartialEq for SubresourceNetworkRecord {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
let left = self.inner.as_ref();
|
|
let right = other.inner.as_ref();
|
|
Arc::ptr_eq(&self.inner, &other.inner)
|
|
|| (left.frame_id == right.frame_id
|
|
&& left.document_url == right.document_url
|
|
&& left.url == right.url
|
|
&& left.websocket_socket_id == right.websocket_socket_id
|
|
&& left.method == right.method
|
|
&& left.request_headers == right.request_headers
|
|
&& left.request_body == right.request_body
|
|
&& left.request_body_bytes == right.request_body_bytes
|
|
&& left.resource_type == right.resource_type
|
|
&& left.request_initiator_type == right.request_initiator_type
|
|
&& left.request_cookie_report == right.request_cookie_report
|
|
&& left.outcome == right.outcome
|
|
&& left.cookie_set_reports == right.cookie_set_reports
|
|
&& left.from_cache == right.from_cache
|
|
&& left.network_request_headers == right.network_request_headers
|
|
&& left.negotiated_http_version == right.negotiated_http_version)
|
|
}
|
|
}
|
|
|
|
impl Eq for SubresourceNetworkRecord {}
|
|
|
|
impl SubresourceRequestStarted {
|
|
pub fn new(
|
|
handle: SubresourceNetworkRequestHandle,
|
|
frame_id: Option<String>,
|
|
document_url: Url,
|
|
url: Url,
|
|
method: String,
|
|
request_headers: moli_fetch::RequestHeaders,
|
|
request_body: Option<String>,
|
|
resource_type: SubresourceResourceType,
|
|
request_initiator_type: SubresourceRequestInitiatorType,
|
|
request_cookie_report: Option<StoredCookieQueryReport>,
|
|
) -> Self {
|
|
let request_body_bytes = request_body_text_bytes(&request_body);
|
|
Self {
|
|
handle,
|
|
frame_id,
|
|
document_url,
|
|
url,
|
|
method,
|
|
request_headers,
|
|
request_body,
|
|
request_body_bytes,
|
|
keepalive: false,
|
|
resource_type,
|
|
request_initiator_type,
|
|
request_cookie_report,
|
|
}
|
|
}
|
|
|
|
pub fn handle(&self) -> SubresourceNetworkRequestHandle {
|
|
self.handle
|
|
}
|
|
|
|
pub fn with_request_body_bytes(mut self, request_body_bytes: Option<Vec<u8>>) -> Self {
|
|
self.request_body_bytes =
|
|
request_body_bytes.or_else(|| request_body_text_bytes(&self.request_body));
|
|
self
|
|
}
|
|
|
|
pub fn with_keepalive(mut self, keepalive: bool) -> Self {
|
|
self.keepalive = keepalive;
|
|
self
|
|
}
|
|
|
|
pub fn frame_id(&self) -> Option<&str> {
|
|
self.frame_id.as_deref()
|
|
}
|
|
|
|
pub fn document_url(&self) -> &Url {
|
|
&self.document_url
|
|
}
|
|
|
|
pub fn url(&self) -> &Url {
|
|
&self.url
|
|
}
|
|
|
|
pub fn method(&self) -> &str {
|
|
&self.method
|
|
}
|
|
|
|
pub fn request_headers(&self) -> &moli_fetch::RequestHeaders {
|
|
&self.request_headers
|
|
}
|
|
|
|
pub fn request_body(&self) -> Option<&str> {
|
|
self.request_body.as_deref()
|
|
}
|
|
|
|
pub fn request_body_bytes(&self) -> Option<&[u8]> {
|
|
self.request_body_bytes.as_deref()
|
|
}
|
|
|
|
pub fn keepalive(&self) -> bool {
|
|
self.keepalive
|
|
}
|
|
|
|
pub fn resource_type(&self) -> SubresourceResourceType {
|
|
self.resource_type
|
|
}
|
|
|
|
pub fn request_initiator_type(&self) -> SubresourceRequestInitiatorType {
|
|
self.request_initiator_type
|
|
}
|
|
|
|
pub fn request_cookie_report(&self) -> Option<&StoredCookieQueryReport> {
|
|
self.request_cookie_report.as_ref()
|
|
}
|
|
}
|
|
|
|
impl SubresourceResponseStarted {
|
|
pub fn new(
|
|
handle: SubresourceNetworkRequestHandle,
|
|
redirect_chain: Vec<NavigationRedirect>,
|
|
final_url: Url,
|
|
status: u16,
|
|
response_headers: Vec<(String, Vec<u8>)>,
|
|
cookie_set_reports: Vec<StoredCookieSetReport>,
|
|
) -> Self {
|
|
Self {
|
|
handle,
|
|
redirect_chain,
|
|
final_url,
|
|
status,
|
|
status_text: None,
|
|
response_headers,
|
|
cookie_set_reports,
|
|
from_cache: false,
|
|
network_request_headers: None,
|
|
negotiated_http_version: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_status_text(mut self, status_text: Option<String>) -> Self {
|
|
self.status_text = status_text;
|
|
self
|
|
}
|
|
|
|
pub fn with_from_cache(mut self, from_cache: bool) -> Self {
|
|
self.from_cache = from_cache;
|
|
self
|
|
}
|
|
|
|
pub fn with_network_request_headers(
|
|
mut self,
|
|
network_request_headers: Option<Vec<(String, String)>>,
|
|
) -> Self {
|
|
self.network_request_headers = network_request_headers;
|
|
self
|
|
}
|
|
|
|
pub fn with_negotiated_http_version(
|
|
mut self,
|
|
negotiated_http_version: Option<NegotiatedHttpVersion>,
|
|
) -> Self {
|
|
self.negotiated_http_version = negotiated_http_version;
|
|
self
|
|
}
|
|
|
|
pub fn handle(&self) -> SubresourceNetworkRequestHandle {
|
|
self.handle
|
|
}
|
|
|
|
pub fn redirect_chain(&self) -> &[NavigationRedirect] {
|
|
&self.redirect_chain
|
|
}
|
|
|
|
pub fn final_url(&self) -> &Url {
|
|
&self.final_url
|
|
}
|
|
|
|
pub fn status(&self) -> u16 {
|
|
self.status
|
|
}
|
|
|
|
pub fn status_text(&self) -> Option<&str> {
|
|
self.status_text.as_deref()
|
|
}
|
|
|
|
pub fn response_headers(&self) -> &[(String, Vec<u8>)] {
|
|
&self.response_headers
|
|
}
|
|
|
|
pub fn cookie_set_reports(&self) -> &[StoredCookieSetReport] {
|
|
&self.cookie_set_reports
|
|
}
|
|
|
|
pub fn from_cache(&self) -> bool {
|
|
self.from_cache
|
|
}
|
|
|
|
pub fn network_request_headers(&self) -> Option<&[(String, String)]> {
|
|
self.network_request_headers.as_deref()
|
|
}
|
|
|
|
pub fn negotiated_http_version(&self) -> Option<NegotiatedHttpVersion> {
|
|
self.negotiated_http_version
|
|
}
|
|
}
|
|
|
|
impl SubresourceDataReceived {
|
|
pub fn new(
|
|
handle: SubresourceNetworkRequestHandle,
|
|
data_length: usize,
|
|
encoded_data_length: usize,
|
|
) -> Self {
|
|
Self {
|
|
handle,
|
|
data_length,
|
|
encoded_data_length,
|
|
}
|
|
}
|
|
|
|
pub fn handle(&self) -> SubresourceNetworkRequestHandle {
|
|
self.handle
|
|
}
|
|
|
|
pub fn data_length(&self) -> usize {
|
|
self.data_length
|
|
}
|
|
|
|
pub fn encoded_data_length(&self) -> usize {
|
|
self.encoded_data_length
|
|
}
|
|
}
|
|
|
|
impl SubresourceEventSourceMessageReceived {
|
|
pub fn new(
|
|
handle: SubresourceNetworkRequestHandle,
|
|
event_name: String,
|
|
event_id: String,
|
|
data: String,
|
|
) -> Self {
|
|
Self {
|
|
handle,
|
|
event_name,
|
|
event_id,
|
|
data,
|
|
}
|
|
}
|
|
|
|
pub fn handle(&self) -> SubresourceNetworkRequestHandle {
|
|
self.handle
|
|
}
|
|
|
|
pub fn event_name(&self) -> &str {
|
|
&self.event_name
|
|
}
|
|
|
|
pub fn event_id(&self) -> &str {
|
|
&self.event_id
|
|
}
|
|
|
|
pub fn data(&self) -> &str {
|
|
&self.data
|
|
}
|
|
}
|
|
|
|
impl SubresourceBodyFinished {
|
|
pub fn ready(handle: SubresourceNetworkRequestHandle, body: SubresourceResponseBody) -> Self {
|
|
Self {
|
|
handle,
|
|
result: SubresourceBodyFinishedResult::Ready(body),
|
|
data_was_streamed: false,
|
|
}
|
|
}
|
|
|
|
pub fn ready_after_streaming(
|
|
handle: SubresourceNetworkRequestHandle,
|
|
body: SubresourceResponseBody,
|
|
) -> Self {
|
|
Self {
|
|
handle,
|
|
result: SubresourceBodyFinishedResult::Ready(body),
|
|
data_was_streamed: true,
|
|
}
|
|
}
|
|
|
|
pub fn failed(handle: SubresourceNetworkRequestHandle, error_text: String) -> Self {
|
|
Self {
|
|
handle,
|
|
result: SubresourceBodyFinishedResult::Failed(error_text),
|
|
data_was_streamed: false,
|
|
}
|
|
}
|
|
|
|
pub fn failed_with_partial_body(
|
|
handle: SubresourceNetworkRequestHandle,
|
|
error_text: String,
|
|
partial_body: SubresourceResponseBody,
|
|
) -> Self {
|
|
Self {
|
|
handle,
|
|
result: SubresourceBodyFinishedResult::FailedWithPartialBody {
|
|
error_text,
|
|
partial_body,
|
|
},
|
|
data_was_streamed: false,
|
|
}
|
|
}
|
|
|
|
pub fn handle(&self) -> SubresourceNetworkRequestHandle {
|
|
self.handle
|
|
}
|
|
|
|
pub fn result(&self) -> &SubresourceBodyFinishedResult {
|
|
&self.result
|
|
}
|
|
|
|
pub fn data_was_streamed(&self) -> bool {
|
|
self.data_was_streamed
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SubresourceResponseBody {
|
|
inner: Arc<SubresourceResponseBodyInner>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum SubresourceResponseBodyInner {
|
|
Memory(Vec<u8>),
|
|
Disk {
|
|
storage: PooledSubresourceResponseBody,
|
|
},
|
|
ParkableImage(ParkableImage),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PooledSubresourceResponseBody {
|
|
chunks: Vec<DiskData>,
|
|
trailing_bytes: Box<[u8]>,
|
|
len: usize,
|
|
}
|
|
|
|
impl PooledSubresourceResponseBody {
|
|
fn read_range(&self, offset: usize, max_len: usize) -> io::Result<Vec<u8>> {
|
|
if offset >= self.len || max_len == 0 {
|
|
return Ok(Vec::new());
|
|
}
|
|
let len = max_len.min(self.len - offset);
|
|
let mut bytes = vec![0; len];
|
|
self.read_exact_at(offset, &mut bytes)?;
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn read_exact_at(&self, mut offset: usize, mut buffer: &mut [u8]) -> io::Result<()> {
|
|
for chunk in &self.chunks {
|
|
if buffer.is_empty() {
|
|
break;
|
|
}
|
|
if offset >= chunk.len() {
|
|
offset -= chunk.len();
|
|
continue;
|
|
}
|
|
let len = buffer.len().min(chunk.len() - offset);
|
|
chunk.read_exact_at(offset, &mut buffer[..len])?;
|
|
buffer = &mut buffer[len..];
|
|
offset = 0;
|
|
}
|
|
|
|
if !buffer.is_empty() {
|
|
let trailing = self.trailing_bytes.get(offset..).ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::UnexpectedEof,
|
|
"pooled resource body ended before its recorded length",
|
|
)
|
|
})?;
|
|
let len = buffer.len().min(trailing.len());
|
|
buffer[..len].copy_from_slice(&trailing[..len]);
|
|
buffer = &mut buffer[len..];
|
|
}
|
|
|
|
if buffer.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(io::Error::new(
|
|
io::ErrorKind::UnexpectedEof,
|
|
"pooled resource body ended before its recorded length",
|
|
))
|
|
}
|
|
}
|
|
|
|
fn write_to<W: Write>(&self, writer: &mut W) -> io::Result<()> {
|
|
for chunk in &self.chunks {
|
|
chunk.write_to(writer)?;
|
|
}
|
|
writer.write_all(&self.trailing_bytes)
|
|
}
|
|
}
|
|
|
|
impl PartialEq for SubresourceResponseBody {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
if Arc::ptr_eq(&self.inner, &other.inner) {
|
|
return true;
|
|
}
|
|
match (self.inner.as_ref(), other.inner.as_ref()) {
|
|
(
|
|
SubresourceResponseBodyInner::Memory(left),
|
|
SubresourceResponseBodyInner::Memory(right),
|
|
) => left == right,
|
|
(
|
|
SubresourceResponseBodyInner::ParkableImage(left),
|
|
SubresourceResponseBodyInner::ParkableImage(right),
|
|
) => left.shares_storage_with(right),
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Eq for SubresourceResponseBody {}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SubresourceResponseBodyWriter {
|
|
memory_limit: usize,
|
|
len: usize,
|
|
memory: Vec<u8>,
|
|
disk_pool: Option<DiskPool>,
|
|
disk_chunks: Vec<DiskData>,
|
|
disk_write_failed: bool,
|
|
}
|
|
|
|
impl Default for SubresourceResponseBodyWriter {
|
|
fn default() -> Self {
|
|
Self::new(SUBRESOURCE_RESPONSE_BODY_MEMORY_LIMIT)
|
|
}
|
|
}
|
|
|
|
impl SubresourceResponseBodyWriter {
|
|
pub fn new(memory_limit: usize) -> Self {
|
|
Self::with_memory_limit_and_disk_pool(memory_limit, None)
|
|
}
|
|
|
|
/// Creates a writer using the default in-memory threshold and a shared
|
|
/// browser resource disk pool when one is available.
|
|
pub fn with_disk_pool(disk_pool: Option<DiskPool>) -> Self {
|
|
Self::with_memory_limit_and_disk_pool(SUBRESOURCE_RESPONSE_BODY_MEMORY_LIMIT, disk_pool)
|
|
}
|
|
|
|
pub fn with_memory_limit_and_disk_pool(
|
|
memory_limit: usize,
|
|
disk_pool: Option<DiskPool>,
|
|
) -> Self {
|
|
Self {
|
|
memory_limit,
|
|
len: 0,
|
|
memory: Vec::new(),
|
|
disk_pool,
|
|
disk_chunks: Vec::new(),
|
|
disk_write_failed: false,
|
|
}
|
|
}
|
|
|
|
pub fn append(&mut self, mut bytes: &[u8]) {
|
|
if bytes.is_empty() {
|
|
return;
|
|
}
|
|
self.len = self.len.saturating_add(bytes.len());
|
|
if self.disk_write_failed || self.disk_pool.is_none() {
|
|
self.memory.extend_from_slice(bytes);
|
|
return;
|
|
}
|
|
|
|
if self.disk_chunks.is_empty()
|
|
&& self.memory.len().saturating_add(bytes.len()) <= self.memory_limit
|
|
{
|
|
self.memory.extend_from_slice(bytes);
|
|
return;
|
|
}
|
|
|
|
let chunk_size = self.memory_limit.max(1);
|
|
while !bytes.is_empty() {
|
|
if self.memory.len() == chunk_size && !self.flush_memory_to_disk() {
|
|
self.disk_write_failed = true;
|
|
self.memory.extend_from_slice(bytes);
|
|
return;
|
|
}
|
|
let available = chunk_size - self.memory.len();
|
|
let consumed = available.min(bytes.len());
|
|
self.memory.extend_from_slice(&bytes[..consumed]);
|
|
bytes = &bytes[consumed..];
|
|
if self.memory.len() == chunk_size && !self.flush_memory_to_disk() {
|
|
self.disk_write_failed = true;
|
|
self.memory.extend_from_slice(bytes);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn finish(mut self) -> SubresourceResponseBody {
|
|
if !self.disk_chunks.is_empty() {
|
|
if !self.memory.is_empty() && !self.disk_write_failed {
|
|
let _ = self.flush_memory_to_disk();
|
|
}
|
|
let stored_len = self
|
|
.disk_chunks
|
|
.iter()
|
|
.map(DiskData::len)
|
|
.sum::<usize>()
|
|
.saturating_add(self.memory.len());
|
|
debug_assert_eq!(stored_len, self.len);
|
|
return SubresourceResponseBody {
|
|
inner: Arc::new(SubresourceResponseBodyInner::Disk {
|
|
storage: PooledSubresourceResponseBody {
|
|
chunks: std::mem::take(&mut self.disk_chunks),
|
|
trailing_bytes: std::mem::take(&mut self.memory).into_boxed_slice(),
|
|
len: self.len,
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
SubresourceResponseBody::from_bytes(std::mem::take(&mut self.memory))
|
|
}
|
|
|
|
fn flush_memory_to_disk(&mut self) -> bool {
|
|
if self.memory.is_empty() {
|
|
return true;
|
|
}
|
|
let Some(pool) = self.disk_pool.as_ref() else {
|
|
return false;
|
|
};
|
|
let Ok(Some(chunk)) = pool.store(&self.memory) else {
|
|
return false;
|
|
};
|
|
self.disk_chunks.push(chunk);
|
|
self.memory.clear();
|
|
true
|
|
}
|
|
}
|
|
|
|
impl SubresourceResponseBody {
|
|
pub fn from_bytes(bytes: Vec<u8>) -> Self {
|
|
Self {
|
|
inner: Arc::new(SubresourceResponseBodyInner::Memory(bytes)),
|
|
}
|
|
}
|
|
|
|
/// Builds a response body that shares the encoded image backing.
|
|
/// Cloning this body does not clone bytes.
|
|
pub fn from_parkable_image(image: ParkableImage) -> Self {
|
|
Self {
|
|
inner: Arc::new(SubresourceResponseBodyInner::ParkableImage(image)),
|
|
}
|
|
}
|
|
|
|
/// Copies the exact bytes from a materialized fetch response into the
|
|
/// renderer-neutral subresource body carrier.
|
|
pub fn from_fetch_response(response: &Response) -> Self {
|
|
Self::from_bytes(response.body_bytes().to_vec())
|
|
}
|
|
|
|
/// Copies the exact bytes from a materialized navigation response into the
|
|
/// renderer-neutral subresource body carrier.
|
|
pub fn from_navigation_response(response: &NavigationResponse) -> Self {
|
|
Self::from_bytes(response.body_bytes().to_vec())
|
|
}
|
|
|
|
pub fn bytes(&self) -> Cow<'_, [u8]> {
|
|
self.diagnostic_bytes()
|
|
}
|
|
|
|
/// Best-effort byte view for diagnostics and legacy tests. Production
|
|
/// protocol paths should use `try_bytes`.
|
|
pub fn diagnostic_bytes(&self) -> Cow<'_, [u8]> {
|
|
self.try_bytes().unwrap_or_else(|_| Cow::Owned(Vec::new()))
|
|
}
|
|
|
|
pub fn try_bytes(&self) -> io::Result<Cow<'_, [u8]>> {
|
|
match self.inner.as_ref() {
|
|
SubresourceResponseBodyInner::Memory(bytes) => Ok(Cow::Borrowed(bytes)),
|
|
SubresourceResponseBodyInner::Disk { .. } => self.materialize_bytes().map(Cow::Owned),
|
|
SubresourceResponseBodyInner::ParkableImage(image) => image
|
|
.snapshot()
|
|
.map(|snapshot| Cow::Owned(snapshot.to_vec())),
|
|
}
|
|
}
|
|
|
|
/// Explicit byte-level equality for callers that intentionally accept
|
|
/// source I/O while comparing bodies.
|
|
pub fn try_byte_eq(&self, other: &Self) -> io::Result<bool> {
|
|
if self.len() != other.len() {
|
|
return Ok(false);
|
|
}
|
|
let left = self.try_bytes()?;
|
|
let right = other.try_bytes()?;
|
|
Ok(left == right)
|
|
}
|
|
|
|
/// Best-effort byte-level equality for diagnostics and legacy tests.
|
|
pub fn diagnostic_byte_eq(&self, other: &Self) -> bool {
|
|
self.try_byte_eq(other).unwrap_or(false)
|
|
}
|
|
|
|
pub fn clone_body_bytes(&self) -> Vec<u8> {
|
|
self.diagnostic_clone_body_bytes()
|
|
}
|
|
|
|
/// Best-effort owned byte clone for diagnostics and legacy tests.
|
|
/// Production protocol paths should use `materialize_bytes`.
|
|
pub fn diagnostic_clone_body_bytes(&self) -> Vec<u8> {
|
|
self.materialize_bytes().unwrap_or_default()
|
|
}
|
|
|
|
pub fn clone_body_bytes_from(&self, offset: usize) -> Vec<u8> {
|
|
self.diagnostic_clone_body_bytes_from(offset)
|
|
}
|
|
|
|
/// Best-effort owned byte clone from an offset for diagnostics and legacy
|
|
/// tests. Production protocol paths should use `materialize_bytes_from`.
|
|
pub fn diagnostic_clone_body_bytes_from(&self, offset: usize) -> Vec<u8> {
|
|
self.materialize_bytes_from(offset).unwrap_or_default()
|
|
}
|
|
|
|
pub fn materialize_bytes(&self) -> io::Result<Vec<u8>> {
|
|
self.materialize_bytes_from(0)
|
|
}
|
|
|
|
pub fn materialize_bytes_from(&self, offset: usize) -> io::Result<Vec<u8>> {
|
|
match self.inner.as_ref() {
|
|
SubresourceResponseBodyInner::Memory(bytes) => {
|
|
Ok(bytes.get(offset..).map(<[u8]>::to_vec).unwrap_or_default())
|
|
}
|
|
SubresourceResponseBodyInner::Disk { storage, .. } => {
|
|
storage.read_range(offset, storage.len.saturating_sub(offset))
|
|
}
|
|
SubresourceResponseBodyInner::ParkableImage(image) => {
|
|
image.read_range(offset, image.len().saturating_sub(offset))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn read_chunk(&self, offset: usize, max_len: usize) -> io::Result<Vec<u8>> {
|
|
if max_len == 0 {
|
|
return Ok(Vec::new());
|
|
}
|
|
match self.inner.as_ref() {
|
|
SubresourceResponseBodyInner::Memory(bytes) => {
|
|
let Some(remaining) = bytes.get(offset..) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let len = remaining.len().min(max_len);
|
|
Ok(remaining[..len].to_vec())
|
|
}
|
|
SubresourceResponseBodyInner::Disk { storage, .. } => {
|
|
storage.read_range(offset, max_len)
|
|
}
|
|
SubresourceResponseBodyInner::ParkableImage(image) => image.read_range(offset, max_len),
|
|
}
|
|
}
|
|
|
|
pub fn write_bytes_to<W: Write>(&self, writer: &mut W) -> io::Result<()> {
|
|
match self.inner.as_ref() {
|
|
SubresourceResponseBodyInner::Memory(bytes) => writer.write_all(bytes),
|
|
SubresourceResponseBodyInner::Disk { storage, .. } => storage.write_to(writer),
|
|
SubresourceResponseBodyInner::ParkableImage(image) => {
|
|
writer.write_all(&image.snapshot()?)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
match self.inner.as_ref() {
|
|
SubresourceResponseBodyInner::Memory(bytes) => bytes.len(),
|
|
SubresourceResponseBodyInner::Disk { storage, .. } => storage.len,
|
|
SubresourceResponseBodyInner::ParkableImage(image) => image.len(),
|
|
}
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.len() == 0
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SubresourceResponseWaitCriteria {
|
|
pub url_contains: Option<String>,
|
|
pub url_regex: Option<Regex>,
|
|
pub body_contains: Option<String>,
|
|
pub body_regex: Option<Regex>,
|
|
pub json_path_equals: Option<SubresourceJsonPathEquals>,
|
|
pub json_path_regex: Option<SubresourceJsonPathRegex>,
|
|
}
|
|
|
|
impl SubresourceResponseWaitCriteria {
|
|
pub fn is_empty(&self) -> bool {
|
|
self.url_contains.is_none() && self.url_regex.is_none() && !self.requires_response_body()
|
|
}
|
|
|
|
/// Compatibility matcher for diagnostic surfaces that prefer best-effort
|
|
/// matching over surfacing a response-body source read error.
|
|
pub fn matches(&self, record: &SubresourceNetworkRecord) -> bool {
|
|
self.diagnostic_matches(record)
|
|
}
|
|
|
|
/// Best-effort matcher for diagnostics such as trace summaries. Unreadable
|
|
/// response bodies are treated as no match so diagnostics can still render.
|
|
pub fn diagnostic_matches(&self, record: &SubresourceNetworkRecord) -> bool {
|
|
self.try_matches(record).unwrap_or(false)
|
|
}
|
|
|
|
/// Fallible matcher for production wait paths. A body-backed criterion
|
|
/// should not silently treat an unreadable response body as empty text.
|
|
pub fn try_matches(&self, record: &SubresourceNetworkRecord) -> io::Result<bool> {
|
|
if let Some(needle) = self.url_contains.as_deref()
|
|
&& !record.url().as_str().contains(needle)
|
|
&& !matches!(
|
|
record.outcome(),
|
|
SubresourceNetworkOutcome::Success { final_url, .. }
|
|
if final_url.as_str().contains(needle)
|
|
)
|
|
{
|
|
return Ok(false);
|
|
}
|
|
|
|
if let Some(regex) = self.url_regex.as_ref()
|
|
&& !regex.is_match(record.url().as_str())
|
|
&& !matches!(
|
|
record.outcome(),
|
|
SubresourceNetworkOutcome::Success { final_url, .. }
|
|
if regex.is_match(final_url.as_str())
|
|
)
|
|
{
|
|
return Ok(false);
|
|
}
|
|
|
|
let SubresourceNetworkOutcome::Success {
|
|
response_headers,
|
|
response_body,
|
|
..
|
|
} = record.outcome()
|
|
else {
|
|
return Ok(!self.requires_response_body());
|
|
};
|
|
|
|
let response_body_bytes = if self.requires_response_body() {
|
|
Some(response_body.try_bytes()?)
|
|
} else {
|
|
None
|
|
};
|
|
let response_body_text = response_body_bytes.as_deref().map(String::from_utf8_lossy);
|
|
|
|
if let Some(needle) = self.body_contains.as_deref()
|
|
&& !response_body_text
|
|
.as_deref()
|
|
.unwrap_or_default()
|
|
.contains(needle)
|
|
{
|
|
return Ok(false);
|
|
}
|
|
|
|
if let Some(regex) = self.body_regex.as_ref()
|
|
&& !regex.is_match(response_body_text.as_deref().unwrap_or_default())
|
|
{
|
|
return Ok(false);
|
|
}
|
|
|
|
if let Some(expectation) = self.json_path_equals.as_ref()
|
|
&& !json_path_equals(
|
|
response_headers,
|
|
response_body_text.as_deref().unwrap_or_default(),
|
|
expectation,
|
|
)
|
|
{
|
|
return Ok(false);
|
|
}
|
|
|
|
if let Some(expectation) = self.json_path_regex.as_ref()
|
|
&& !json_path_matches_regex(
|
|
response_headers,
|
|
response_body_text.as_deref().unwrap_or_default(),
|
|
expectation,
|
|
)
|
|
{
|
|
return Ok(false);
|
|
}
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
fn requires_response_body(&self) -> bool {
|
|
self.body_contains.is_some()
|
|
|| self.body_regex.is_some()
|
|
|| self.json_path_equals.is_some()
|
|
|| self.json_path_regex.is_some()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SubresourceJsonPathEquals {
|
|
pub path: Vec<String>,
|
|
pub expected: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SubresourceJsonPathRegex {
|
|
pub path: Vec<String>,
|
|
pub regex: Regex,
|
|
}
|
|
|
|
fn json_path_equals(
|
|
headers: &[(String, Vec<u8>)],
|
|
body: &str,
|
|
expectation: &SubresourceJsonPathEquals,
|
|
) -> bool {
|
|
json_path_satisfies(headers, body, &expectation.path, |value| {
|
|
json_value_equals_string(value, &expectation.expected)
|
|
})
|
|
}
|
|
|
|
fn json_path_matches_regex(
|
|
headers: &[(String, Vec<u8>)],
|
|
body: &str,
|
|
expectation: &SubresourceJsonPathRegex,
|
|
) -> bool {
|
|
json_path_satisfies(headers, body, &expectation.path, |value| {
|
|
json_value_matches_regex(value, &expectation.regex)
|
|
})
|
|
}
|
|
|
|
fn json_path_satisfies(
|
|
headers: &[(String, Vec<u8>)],
|
|
body: &str,
|
|
path: &[String],
|
|
predicate: impl FnOnce(&Value) -> bool,
|
|
) -> bool {
|
|
let Some(content_type) = header_value(headers, "content-type") else {
|
|
return false;
|
|
};
|
|
if !is_json_module_mime(&content_type) {
|
|
return false;
|
|
}
|
|
|
|
let Ok(parsed) = serde_json::from_str::<Value>(body) else {
|
|
return false;
|
|
};
|
|
let Some(value) = json_path_value(&parsed, path) else {
|
|
return false;
|
|
};
|
|
predicate(value)
|
|
}
|
|
|
|
fn json_path_value<'a>(mut value: &'a Value, path: &[String]) -> Option<&'a Value> {
|
|
for segment in path {
|
|
value = match value {
|
|
Value::Object(object) => object.get(segment)?,
|
|
Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
|
|
_ => return None,
|
|
};
|
|
}
|
|
Some(value)
|
|
}
|
|
|
|
fn json_value_equals_string(value: &Value, expected: &str) -> bool {
|
|
match value {
|
|
Value::String(actual) => actual == expected,
|
|
Value::Bool(actual) => actual.to_string() == expected,
|
|
Value::Number(actual) => actual.to_string() == expected,
|
|
Value::Null => expected == "null",
|
|
Value::Array(_) | Value::Object(_) => *value == expected,
|
|
}
|
|
}
|
|
|
|
fn json_value_matches_regex(value: &Value, regex: &Regex) -> bool {
|
|
match value {
|
|
Value::String(actual) => regex.is_match(actual),
|
|
Value::Bool(actual) => regex.is_match(if *actual { "true" } else { "false" }),
|
|
Value::Number(actual) => regex.is_match(&actual.to_string()),
|
|
Value::Null => regex.is_match("null"),
|
|
Value::Array(_) | Value::Object(_) => false,
|
|
}
|
|
}
|
|
|
|
fn header_value<'a>(
|
|
headers: &'a [(String, Vec<u8>)],
|
|
name: &str,
|
|
) -> Option<std::borrow::Cow<'a, str>> {
|
|
headers
|
|
.iter()
|
|
.find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
|
|
.map(|(_, value)| moli_fetch::decode_header_value(value))
|
|
}
|
|
|
|
impl SubresourceNetworkRecord {
|
|
fn from_staged_lifecycle(
|
|
request: &SubresourceRequestStarted,
|
|
response: Option<&SubresourceResponseStarted>,
|
|
body: &SubresourceBodyFinished,
|
|
) -> Option<Self> {
|
|
if request.handle() != body.handle()
|
|
|| response.is_some_and(|response| response.handle() != request.handle())
|
|
{
|
|
return None;
|
|
}
|
|
|
|
match body.result() {
|
|
SubresourceBodyFinishedResult::Ready(response_body) => {
|
|
let response = response?;
|
|
let mut record = Self::success_with_body(
|
|
request.frame_id.clone(),
|
|
request.document_url.clone(),
|
|
request.url.clone(),
|
|
request.method.clone(),
|
|
request.request_headers.clone(),
|
|
request.request_body.clone(),
|
|
request.resource_type,
|
|
request.request_cookie_report.clone(),
|
|
response.redirect_chain.clone(),
|
|
response.final_url.clone(),
|
|
response.status,
|
|
response.response_headers.clone(),
|
|
response_body.clone(),
|
|
response.cookie_set_reports.clone(),
|
|
)
|
|
.with_request_handle(request.handle())
|
|
.with_request_body_bytes(request.request_body_bytes.clone())
|
|
.with_request_initiator_type(request.request_initiator_type)
|
|
.with_from_cache(response.from_cache)
|
|
.with_network_request_headers(response.network_request_headers.clone())
|
|
.with_negotiated_http_version(response.negotiated_http_version);
|
|
if let Some(status_text) = response.status_text.clone() {
|
|
record = record.with_response_status_text(status_text);
|
|
}
|
|
Some(record)
|
|
}
|
|
SubresourceBodyFinishedResult::Failed(error_text)
|
|
| SubresourceBodyFinishedResult::FailedWithPartialBody { error_text, .. } => {
|
|
Some(Self {
|
|
inner: Arc::new(SubresourceNetworkRecordInner {
|
|
frame_id: request.frame_id.clone(),
|
|
document_url: request.document_url.clone(),
|
|
url: request.url.clone(),
|
|
request_handle: Some(request.handle()),
|
|
websocket_socket_id: None,
|
|
method: request.method.clone(),
|
|
request_headers: request.request_headers.clone(),
|
|
request_body: request.request_body.clone(),
|
|
request_body_bytes: request.request_body_bytes.clone(),
|
|
resource_type: request.resource_type,
|
|
request_initiator_type: request.request_initiator_type,
|
|
request_cookie_report: request.request_cookie_report.clone(),
|
|
outcome: SubresourceNetworkOutcome::Failure {
|
|
error_text: error_text.clone(),
|
|
},
|
|
cookie_set_reports: response
|
|
.map(|response| response.cookie_set_reports.clone())
|
|
.unwrap_or_default(),
|
|
from_cache: response.is_some_and(|response| response.from_cache),
|
|
network_request_headers: response
|
|
.and_then(|response| response.network_request_headers.clone()),
|
|
negotiated_http_version: response
|
|
.and_then(|response| response.negotiated_http_version),
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn success(
|
|
frame_id: Option<String>,
|
|
document_url: Url,
|
|
url: Url,
|
|
method: String,
|
|
request_headers: moli_fetch::RequestHeaders,
|
|
request_body: Option<String>,
|
|
resource_type: SubresourceResourceType,
|
|
request_cookie_report: Option<StoredCookieQueryReport>,
|
|
redirect_chain: Vec<NavigationRedirect>,
|
|
final_url: Url,
|
|
status: u16,
|
|
response_headers: Vec<(String, Vec<u8>)>,
|
|
response_body: String,
|
|
cookie_set_reports: Vec<StoredCookieSetReport>,
|
|
) -> Self {
|
|
Self::success_with_body(
|
|
frame_id,
|
|
document_url,
|
|
url,
|
|
method,
|
|
request_headers,
|
|
request_body,
|
|
resource_type,
|
|
request_cookie_report,
|
|
redirect_chain,
|
|
final_url,
|
|
status,
|
|
response_headers,
|
|
SubresourceResponseBody::from_bytes(response_body.into_bytes()),
|
|
cookie_set_reports,
|
|
)
|
|
}
|
|
|
|
pub fn success_with_body(
|
|
frame_id: Option<String>,
|
|
document_url: Url,
|
|
url: Url,
|
|
method: String,
|
|
request_headers: moli_fetch::RequestHeaders,
|
|
request_body: Option<String>,
|
|
resource_type: SubresourceResourceType,
|
|
request_cookie_report: Option<StoredCookieQueryReport>,
|
|
redirect_chain: Vec<NavigationRedirect>,
|
|
final_url: Url,
|
|
status: u16,
|
|
response_headers: Vec<(String, Vec<u8>)>,
|
|
response_body: SubresourceResponseBody,
|
|
cookie_set_reports: Vec<StoredCookieSetReport>,
|
|
) -> Self {
|
|
let request_body_bytes = request_body_text_bytes(&request_body);
|
|
Self {
|
|
inner: Arc::new(SubresourceNetworkRecordInner {
|
|
frame_id,
|
|
document_url,
|
|
url,
|
|
request_handle: None,
|
|
websocket_socket_id: None,
|
|
method,
|
|
request_headers,
|
|
request_body,
|
|
request_body_bytes,
|
|
resource_type,
|
|
request_initiator_type: SubresourceRequestInitiatorType::Script,
|
|
request_cookie_report,
|
|
outcome: SubresourceNetworkOutcome::Success {
|
|
redirect_chain,
|
|
final_url,
|
|
status,
|
|
status_text: None,
|
|
response_headers,
|
|
response_body,
|
|
},
|
|
cookie_set_reports,
|
|
from_cache: false,
|
|
network_request_headers: None,
|
|
negotiated_http_version: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn failure(
|
|
frame_id: Option<String>,
|
|
document_url: Url,
|
|
url: Url,
|
|
method: String,
|
|
request_headers: moli_fetch::RequestHeaders,
|
|
request_body: Option<String>,
|
|
resource_type: SubresourceResourceType,
|
|
error_text: String,
|
|
) -> Self {
|
|
let request_body_bytes = request_body_text_bytes(&request_body);
|
|
Self {
|
|
inner: Arc::new(SubresourceNetworkRecordInner {
|
|
frame_id,
|
|
document_url,
|
|
url,
|
|
request_handle: None,
|
|
websocket_socket_id: None,
|
|
method,
|
|
request_headers,
|
|
request_body,
|
|
request_body_bytes,
|
|
resource_type,
|
|
request_initiator_type: SubresourceRequestInitiatorType::Script,
|
|
request_cookie_report: None,
|
|
outcome: SubresourceNetworkOutcome::Failure { error_text },
|
|
cookie_set_reports: Vec::new(),
|
|
from_cache: false,
|
|
network_request_headers: None,
|
|
negotiated_http_version: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn with_websocket_socket_id(mut self, socket_id: u64) -> Self {
|
|
Arc::make_mut(&mut self.inner).websocket_socket_id = Some(socket_id);
|
|
self
|
|
}
|
|
|
|
pub fn with_request_handle(mut self, handle: SubresourceNetworkRequestHandle) -> Self {
|
|
Arc::make_mut(&mut self.inner).request_handle = Some(handle);
|
|
self
|
|
}
|
|
|
|
pub fn with_request_body_bytes(mut self, request_body_bytes: Option<Vec<u8>>) -> Self {
|
|
let inner = Arc::make_mut(&mut self.inner);
|
|
inner.request_body_bytes =
|
|
request_body_bytes.or_else(|| request_body_text_bytes(&inner.request_body));
|
|
self
|
|
}
|
|
|
|
pub fn with_request_initiator_type(
|
|
mut self,
|
|
request_initiator_type: SubresourceRequestInitiatorType,
|
|
) -> Self {
|
|
Arc::make_mut(&mut self.inner).request_initiator_type = request_initiator_type;
|
|
self
|
|
}
|
|
|
|
pub fn with_response_status_text(mut self, status_text: impl Into<String>) -> Self {
|
|
if let SubresourceNetworkOutcome::Success {
|
|
status_text: current,
|
|
..
|
|
} = &mut Arc::make_mut(&mut self.inner).outcome
|
|
{
|
|
*current = Some(status_text.into());
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn with_from_cache(mut self, from_cache: bool) -> Self {
|
|
Arc::make_mut(&mut self.inner).from_cache = from_cache;
|
|
self
|
|
}
|
|
|
|
pub fn with_network_request_headers(
|
|
mut self,
|
|
network_request_headers: Option<Vec<(String, String)>>,
|
|
) -> Self {
|
|
Arc::make_mut(&mut self.inner).network_request_headers = network_request_headers;
|
|
self
|
|
}
|
|
|
|
pub fn with_negotiated_http_version(
|
|
mut self,
|
|
negotiated_http_version: Option<NegotiatedHttpVersion>,
|
|
) -> Self {
|
|
Arc::make_mut(&mut self.inner).negotiated_http_version = negotiated_http_version;
|
|
self
|
|
}
|
|
|
|
pub fn request_handle(&self) -> Option<SubresourceNetworkRequestHandle> {
|
|
self.inner.request_handle
|
|
}
|
|
|
|
pub fn websocket_socket_id(&self) -> Option<u64> {
|
|
self.inner.websocket_socket_id
|
|
}
|
|
|
|
pub fn url(&self) -> &Url {
|
|
&self.inner.url
|
|
}
|
|
|
|
pub fn frame_id(&self) -> Option<&str> {
|
|
self.inner.frame_id.as_deref()
|
|
}
|
|
|
|
pub fn document_url(&self) -> &Url {
|
|
&self.inner.document_url
|
|
}
|
|
|
|
pub fn method(&self) -> &str {
|
|
&self.inner.method
|
|
}
|
|
|
|
pub fn request_headers(&self) -> &moli_fetch::RequestHeaders {
|
|
&self.inner.request_headers
|
|
}
|
|
|
|
pub fn request_body(&self) -> Option<&str> {
|
|
self.inner.request_body.as_deref()
|
|
}
|
|
|
|
pub fn request_body_bytes(&self) -> Option<&[u8]> {
|
|
self.inner.request_body_bytes.as_deref()
|
|
}
|
|
|
|
pub fn resource_type(&self) -> SubresourceResourceType {
|
|
self.inner.resource_type
|
|
}
|
|
|
|
pub fn request_initiator_type(&self) -> SubresourceRequestInitiatorType {
|
|
self.inner.request_initiator_type
|
|
}
|
|
|
|
pub fn request_cookie_report(&self) -> Option<&StoredCookieQueryReport> {
|
|
self.inner.request_cookie_report.as_ref()
|
|
}
|
|
|
|
pub fn outcome(&self) -> &SubresourceNetworkOutcome {
|
|
&self.inner.outcome
|
|
}
|
|
|
|
pub fn try_response_body_byte_eq(&self, other: &Self) -> io::Result<bool> {
|
|
match (&self.inner.outcome, &other.inner.outcome) {
|
|
(
|
|
SubresourceNetworkOutcome::Success {
|
|
response_body: left,
|
|
..
|
|
},
|
|
SubresourceNetworkOutcome::Success {
|
|
response_body: right,
|
|
..
|
|
},
|
|
) => left.try_byte_eq(right),
|
|
_ => Ok(false),
|
|
}
|
|
}
|
|
|
|
pub fn diagnostic_response_body_byte_eq(&self, other: &Self) -> bool {
|
|
self.try_response_body_byte_eq(other).unwrap_or(false)
|
|
}
|
|
|
|
pub fn cookie_set_reports(&self) -> &[StoredCookieSetReport] {
|
|
&self.inner.cookie_set_reports
|
|
}
|
|
|
|
pub fn from_cache(&self) -> bool {
|
|
self.inner.from_cache
|
|
}
|
|
|
|
pub fn network_request_headers(&self) -> Option<&[(String, String)]> {
|
|
self.inner.network_request_headers.as_deref()
|
|
}
|
|
|
|
pub fn negotiated_http_version(&self) -> Option<NegotiatedHttpVersion> {
|
|
self.inner.negotiated_http_version
|
|
}
|
|
}
|
|
|
|
fn request_body_text_bytes(request_body: &Option<String>) -> Option<Vec<u8>> {
|
|
request_body.as_ref().map(|body| body.as_bytes().to_vec())
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PendingSubresourceFetchInfo {
|
|
pub internal_id: u64,
|
|
pub network_request_handle: Option<SubresourceNetworkRequestHandle>,
|
|
pub frame_id: Option<String>,
|
|
pub document_url: Url,
|
|
pub url: Url,
|
|
pub websocket_socket_id: Option<u64>,
|
|
pub method: String,
|
|
pub request_headers: moli_fetch::RequestHeaders,
|
|
pub request_body: Option<String>,
|
|
pub request_body_bytes: Option<Vec<u8>>,
|
|
pub resource_type: SubresourceResourceType,
|
|
pub request_cookie_report: Option<StoredCookieQueryReport>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PendingSubresourceResponseInfo {
|
|
pub internal_id: u64,
|
|
pub url: Url,
|
|
pub final_url: Url,
|
|
pub method: String,
|
|
pub request_headers: moli_fetch::RequestHeaders,
|
|
pub request_body: Option<String>,
|
|
pub resource_type: SubresourceResourceType,
|
|
pub request_cookie_report: Option<StoredCookieQueryReport>,
|
|
pub network_request_headers: Option<Vec<(String, String)>>,
|
|
pub response_status: u16,
|
|
pub response_headers: Vec<(String, Vec<u8>)>,
|
|
/// Exact response bytes plus the lossy compatibility text view needed while
|
|
/// a response-stage Fetch pause is held.
|
|
pub response_body: SubresourceResponseBody,
|
|
pub from_cache: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PendingSubresourceAuthInfo {
|
|
pub internal_id: u64,
|
|
pub url: Url,
|
|
pub method: String,
|
|
pub request_headers: moli_fetch::RequestHeaders,
|
|
pub request_body: Option<String>,
|
|
pub resource_type: SubresourceResourceType,
|
|
pub request_cookie_report: Option<StoredCookieQueryReport>,
|
|
pub network_request_headers: Option<Vec<(String, String)>>,
|
|
pub challenge: SubresourceAuthChallenge,
|
|
pub intercept_response: bool,
|
|
pub response_final_url: Url,
|
|
pub response_status: u16,
|
|
pub response_headers: Vec<(String, Vec<u8>)>,
|
|
pub response_body: SubresourceResponseBody,
|
|
pub response_from_cache: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SubresourceAuthChallenge {
|
|
pub source: String,
|
|
pub scheme: String,
|
|
pub realm: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SubresourceAuthTarget {
|
|
Server,
|
|
Proxy,
|
|
ProxyHeader,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SubresourceAuthScheme {
|
|
Basic,
|
|
Digest,
|
|
Negotiate,
|
|
Ntlm,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SubresourceAuthCredentials {
|
|
pub target: SubresourceAuthTarget,
|
|
pub scheme: SubresourceAuthScheme,
|
|
pub username: String,
|
|
pub password: String,
|
|
}
|
|
|
|
pub fn extract_subresource_auth_challenge(
|
|
headers: &[(String, Vec<u8>)],
|
|
) -> Option<SubresourceAuthChallenge> {
|
|
let mut first_challenge = None;
|
|
for (source, value) in headers.iter().filter_map(|(name, value)| {
|
|
if name.eq_ignore_ascii_case("www-authenticate") {
|
|
Some(("Server", moli_fetch::decode_header_value(value)))
|
|
} else if name.eq_ignore_ascii_case("proxy-authenticate") {
|
|
Some(("Proxy", moli_fetch::decode_header_value(value)))
|
|
} else {
|
|
None
|
|
}
|
|
}) {
|
|
for candidate in parse_auth_challenge_candidates(&value) {
|
|
let challenge = SubresourceAuthChallenge {
|
|
source: source.to_owned(),
|
|
scheme: candidate.scheme,
|
|
realm: candidate.realm,
|
|
};
|
|
if first_challenge.is_none() {
|
|
first_challenge = Some(challenge.clone());
|
|
}
|
|
if subresource_auth_scheme_from_name(&challenge.scheme).is_some() {
|
|
return Some(challenge);
|
|
}
|
|
}
|
|
}
|
|
first_challenge
|
|
}
|
|
|
|
pub fn subresource_auth_credentials_for_challenge(
|
|
challenge: &SubresourceAuthChallenge,
|
|
username: &str,
|
|
password: &str,
|
|
) -> Option<SubresourceAuthCredentials> {
|
|
let target = if challenge.source.eq_ignore_ascii_case("proxy") {
|
|
if challenge.scheme.is_empty() || challenge.scheme.eq_ignore_ascii_case("basic") {
|
|
SubresourceAuthTarget::ProxyHeader
|
|
} else {
|
|
SubresourceAuthTarget::Proxy
|
|
}
|
|
} else {
|
|
SubresourceAuthTarget::Server
|
|
};
|
|
let scheme = subresource_auth_scheme_from_name(&challenge.scheme)?;
|
|
Some(SubresourceAuthCredentials {
|
|
target,
|
|
scheme,
|
|
username: username.to_owned(),
|
|
password: password.to_owned(),
|
|
})
|
|
}
|
|
|
|
fn subresource_auth_scheme_from_name(name: &str) -> Option<SubresourceAuthScheme> {
|
|
match name.to_ascii_lowercase().as_str() {
|
|
"" | "basic" => Some(SubresourceAuthScheme::Basic),
|
|
"digest" => Some(SubresourceAuthScheme::Digest),
|
|
"negotiate" => Some(SubresourceAuthScheme::Negotiate),
|
|
"ntlm" => Some(SubresourceAuthScheme::Ntlm),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ParsedAuthChallenge {
|
|
scheme: String,
|
|
realm: String,
|
|
}
|
|
|
|
fn parse_auth_challenge_candidates(value: &str) -> Vec<ParsedAuthChallenge> {
|
|
ChallengeParser::new(value)
|
|
.filter_map(Result::ok)
|
|
.map(|challenge| ParsedAuthChallenge {
|
|
scheme: challenge.scheme.to_ascii_lowercase(),
|
|
realm: challenge
|
|
.params
|
|
.iter()
|
|
.find_map(|(name, value)| {
|
|
name.eq_ignore_ascii_case("realm")
|
|
.then(|| value.to_unescaped())
|
|
})
|
|
.unwrap_or_default(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
impl From<SubresourceAuthCredentials> for RequestAuth {
|
|
fn from(value: SubresourceAuthCredentials) -> Self {
|
|
Self {
|
|
target: match value.target {
|
|
SubresourceAuthTarget::Server => RequestAuthTarget::Server,
|
|
SubresourceAuthTarget::Proxy => RequestAuthTarget::Proxy,
|
|
SubresourceAuthTarget::ProxyHeader => RequestAuthTarget::ProxyHeader,
|
|
},
|
|
scheme: match value.scheme {
|
|
SubresourceAuthScheme::Basic => RequestAuthScheme::Basic,
|
|
SubresourceAuthScheme::Digest => RequestAuthScheme::Digest,
|
|
SubresourceAuthScheme::Negotiate => RequestAuthScheme::Negotiate,
|
|
SubresourceAuthScheme::Ntlm => RequestAuthScheme::Ntlm,
|
|
},
|
|
username: value.username,
|
|
password: value.password,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PendingSubresourceContinueOutcome {
|
|
Started,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PendingSubresourceContinueEvent {
|
|
Completed { internal_id: u64 },
|
|
ResponsePaused(PendingSubresourceResponseInfo),
|
|
AuthRequired(PendingSubresourceAuthInfo),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum SubresourceResourceType {
|
|
Script,
|
|
Stylesheet,
|
|
Image,
|
|
Font,
|
|
Audio,
|
|
Video,
|
|
Media,
|
|
TextTrack,
|
|
Fetch,
|
|
EventSource,
|
|
Xhr,
|
|
Ping,
|
|
CspReport,
|
|
Dictionary,
|
|
Manifest,
|
|
WebSocket,
|
|
}
|
|
|
|
bitflags::bitflags! {
|
|
/// Browser-level opt-ins for resource families that the lightweight
|
|
/// default does not fetch from the network.
|
|
///
|
|
/// This mask deliberately contains only optional render/media resources.
|
|
/// Script, stylesheet, Fetch/XHR, and other behaviorally required requests
|
|
/// are outside this policy and remain enabled.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
|
|
pub struct OptionalResourceFetchMask: u8 {
|
|
const NONE = 0;
|
|
const IMAGE = 1 << 0;
|
|
const FONT = 1 << 1;
|
|
const AUDIO = 1 << 2;
|
|
const VIDEO = 1 << 3;
|
|
const MEDIA = 1 << 4;
|
|
const TEXT_TRACK = 1 << 5;
|
|
const ALL = Self::IMAGE.bits()
|
|
| Self::FONT.bits()
|
|
| Self::AUDIO.bits()
|
|
| Self::VIDEO.bits()
|
|
| Self::MEDIA.bits()
|
|
| Self::TEXT_TRACK.bits();
|
|
}
|
|
}
|
|
|
|
impl OptionalResourceFetchMask {
|
|
pub const fn for_resource_type(resource_type: SubresourceResourceType) -> Option<Self> {
|
|
match resource_type {
|
|
SubresourceResourceType::Image => Some(Self::IMAGE),
|
|
SubresourceResourceType::Font => Some(Self::FONT),
|
|
SubresourceResourceType::Audio => Some(Self::AUDIO),
|
|
SubresourceResourceType::Video => Some(Self::VIDEO),
|
|
SubresourceResourceType::Media => Some(Self::MEDIA),
|
|
SubresourceResourceType::TextTrack => Some(Self::TEXT_TRACK),
|
|
SubresourceResourceType::Script
|
|
| SubresourceResourceType::Stylesheet
|
|
| SubresourceResourceType::Fetch
|
|
| SubresourceResourceType::EventSource
|
|
| SubresourceResourceType::Xhr
|
|
| SubresourceResourceType::Ping
|
|
| SubresourceResourceType::CspReport
|
|
| SubresourceResourceType::Dictionary
|
|
| SubresourceResourceType::Manifest
|
|
| SubresourceResourceType::WebSocket => None,
|
|
}
|
|
}
|
|
|
|
pub const fn allows(self, resource_type: SubresourceResourceType) -> bool {
|
|
match Self::for_resource_type(resource_type) {
|
|
Some(resource) => self.contains(resource),
|
|
None => true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum SubresourceRequestInitiatorType {
|
|
Other,
|
|
Parser,
|
|
Script,
|
|
Css,
|
|
}
|
|
|
|
impl SubresourceRequestInitiatorType {
|
|
pub fn as_cdp_initiator_type(self) -> &'static str {
|
|
match self {
|
|
Self::Parser | Self::Css => "parser",
|
|
Self::Script => "script",
|
|
Self::Other => "other",
|
|
}
|
|
}
|
|
|
|
pub fn as_bidi_request_initiator_type(self) -> Option<&'static str> {
|
|
match self {
|
|
Self::Script => Some("script"),
|
|
Self::Css => Some("css"),
|
|
Self::Other | Self::Parser => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SubresourceResourceType {
|
|
pub fn as_cdp_type(self) -> &'static str {
|
|
match self {
|
|
Self::Script => "Script",
|
|
Self::Stylesheet => "Stylesheet",
|
|
Self::Image => "Image",
|
|
Self::Font => "Font",
|
|
Self::Audio | Self::Video => "Media",
|
|
Self::Media => "Media",
|
|
Self::TextTrack => "TextTrack",
|
|
Self::Fetch => "Fetch",
|
|
Self::EventSource => "EventSource",
|
|
Self::Xhr => "XHR",
|
|
Self::Ping => "Ping",
|
|
Self::CspReport => "CSPViolationReport",
|
|
Self::Dictionary => "Other",
|
|
Self::Manifest => "Manifest",
|
|
Self::WebSocket => "WebSocket",
|
|
}
|
|
}
|
|
|
|
/// Returns the resource type exposed by the CDP Fetch interception domain.
|
|
///
|
|
/// Chromium's loader interception receives Fetch, EventSource, and XHR as
|
|
/// Blink's shared `kXhr` resource type. The Network domain still reports
|
|
/// their higher-level types through `as_cdp_type`.
|
|
pub fn as_cdp_fetch_interception_type(self) -> &'static str {
|
|
match self {
|
|
Self::Fetch | Self::EventSource | Self::Xhr => "XHR",
|
|
_ => self.as_cdp_type(),
|
|
}
|
|
}
|
|
|
|
pub fn has_same_cdp_fetch_interception_type(self, other: Self) -> bool {
|
|
self.as_cdp_fetch_interception_type() == other.as_cdp_fetch_interception_type()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum SubresourceNetworkOutcome {
|
|
Success {
|
|
redirect_chain: Vec<NavigationRedirect>,
|
|
final_url: Url,
|
|
status: u16,
|
|
status_text: Option<String>,
|
|
response_headers: Vec<(String, Vec<u8>)>,
|
|
response_body: SubresourceResponseBody,
|
|
},
|
|
Failure {
|
|
error_text: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct WebSocketNetworkEvent {
|
|
socket_id: u64,
|
|
document_url: Url,
|
|
url: Url,
|
|
direction: WebSocketFrameDirection,
|
|
opcode: WebSocketFrameOpcode,
|
|
payload_length: usize,
|
|
}
|
|
|
|
impl WebSocketNetworkEvent {
|
|
pub fn new(
|
|
socket_id: u64,
|
|
document_url: Url,
|
|
url: Url,
|
|
direction: WebSocketFrameDirection,
|
|
opcode: WebSocketFrameOpcode,
|
|
payload_length: usize,
|
|
) -> Self {
|
|
Self {
|
|
socket_id,
|
|
document_url,
|
|
url,
|
|
direction,
|
|
opcode,
|
|
payload_length,
|
|
}
|
|
}
|
|
|
|
pub fn socket_id(&self) -> u64 {
|
|
self.socket_id
|
|
}
|
|
|
|
pub fn document_url(&self) -> &Url {
|
|
&self.document_url
|
|
}
|
|
|
|
pub fn url(&self) -> &Url {
|
|
&self.url
|
|
}
|
|
|
|
pub fn direction(&self) -> WebSocketFrameDirection {
|
|
self.direction
|
|
}
|
|
|
|
pub fn opcode(&self) -> WebSocketFrameOpcode {
|
|
self.opcode
|
|
}
|
|
|
|
pub fn payload_length(&self) -> usize {
|
|
self.payload_length
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct WebSocketLifecycleEvent {
|
|
socket_id: u64,
|
|
document_url: Url,
|
|
url: Url,
|
|
kind: WebSocketLifecycleKind,
|
|
error_text: Option<String>,
|
|
close_code: Option<u16>,
|
|
close_reason: Option<String>,
|
|
was_clean: Option<bool>,
|
|
}
|
|
|
|
impl WebSocketLifecycleEvent {
|
|
pub fn open(socket_id: u64, document_url: Url, url: Url) -> Self {
|
|
Self {
|
|
socket_id,
|
|
document_url,
|
|
url,
|
|
kind: WebSocketLifecycleKind::Open,
|
|
error_text: None,
|
|
close_code: None,
|
|
close_reason: None,
|
|
was_clean: None,
|
|
}
|
|
}
|
|
|
|
pub fn error(socket_id: u64, document_url: Url, url: Url, error_text: String) -> Self {
|
|
Self {
|
|
socket_id,
|
|
document_url,
|
|
url,
|
|
kind: WebSocketLifecycleKind::Error,
|
|
error_text: Some(error_text),
|
|
close_code: None,
|
|
close_reason: None,
|
|
was_clean: None,
|
|
}
|
|
}
|
|
|
|
pub fn closing(socket_id: u64, document_url: Url, url: Url) -> Self {
|
|
Self {
|
|
socket_id,
|
|
document_url,
|
|
url,
|
|
kind: WebSocketLifecycleKind::Closing,
|
|
error_text: None,
|
|
close_code: None,
|
|
close_reason: None,
|
|
was_clean: None,
|
|
}
|
|
}
|
|
|
|
pub fn close(
|
|
socket_id: u64,
|
|
document_url: Url,
|
|
url: Url,
|
|
code: u16,
|
|
reason: String,
|
|
was_clean: bool,
|
|
) -> Self {
|
|
Self {
|
|
socket_id,
|
|
document_url,
|
|
url,
|
|
kind: WebSocketLifecycleKind::Close,
|
|
error_text: None,
|
|
close_code: Some(code),
|
|
close_reason: Some(reason),
|
|
was_clean: Some(was_clean),
|
|
}
|
|
}
|
|
|
|
pub fn socket_id(&self) -> u64 {
|
|
self.socket_id
|
|
}
|
|
|
|
pub fn document_url(&self) -> &Url {
|
|
&self.document_url
|
|
}
|
|
|
|
pub fn url(&self) -> &Url {
|
|
&self.url
|
|
}
|
|
|
|
pub fn kind(&self) -> WebSocketLifecycleKind {
|
|
self.kind
|
|
}
|
|
|
|
pub fn error_text(&self) -> Option<&str> {
|
|
self.error_text.as_deref()
|
|
}
|
|
|
|
pub fn close_code(&self) -> Option<u16> {
|
|
self.close_code
|
|
}
|
|
|
|
pub fn close_reason(&self) -> Option<&str> {
|
|
self.close_reason.as_deref()
|
|
}
|
|
|
|
pub fn was_clean(&self) -> Option<bool> {
|
|
self.was_clean
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum WebSocketLifecycleKind {
|
|
Open,
|
|
Error,
|
|
Closing,
|
|
Close,
|
|
}
|
|
|
|
impl WebSocketLifecycleKind {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Open => "open",
|
|
Self::Error => "error",
|
|
Self::Closing => "closing",
|
|
Self::Close => "close",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum WebSocketFrameDirection {
|
|
Sent,
|
|
Received,
|
|
}
|
|
|
|
impl WebSocketFrameDirection {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Sent => "sent",
|
|
Self::Received => "received",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum WebSocketFrameOpcode {
|
|
Text,
|
|
Binary,
|
|
}
|
|
|
|
impl WebSocketFrameOpcode {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Text => "text",
|
|
Self::Binary => "binary",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Identifies the renderer realm that invoked one Runtime binding.
|
|
///
|
|
/// The realm generation is the renderer's opaque runtime-observable context
|
|
/// token, not a protocol execution-context id. It is only exact together with
|
|
/// the installed Page generation carried by the protocol prepared batch.
|
|
/// Keeping it on the call prevents a delayed consumer from reinterpreting the
|
|
/// call through whichever realm currently reuses the public context id.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub struct RuntimeBindingCallSourceIdentity {
|
|
local_window_id: u64,
|
|
realm_generation: u64,
|
|
}
|
|
|
|
impl RuntimeBindingCallSourceIdentity {
|
|
pub const fn new(local_window_id: u64, realm_generation: u64) -> Self {
|
|
Self {
|
|
local_window_id,
|
|
realm_generation,
|
|
}
|
|
}
|
|
|
|
pub const fn local_window_id(self) -> u64 {
|
|
self.local_window_id
|
|
}
|
|
|
|
pub const fn realm_generation(self) -> u64 {
|
|
self.realm_generation
|
|
}
|
|
}
|
|
|
|
/// One Runtime binding invocation frozen at the renderer callback boundary.
|
|
///
|
|
/// `execution_context_id` is the public compatibility id emitted to CDP.
|
|
/// `source` is the opaque renderer identity that proves which realm produced
|
|
/// it; consumers must not recompute either value from current Page state.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PendingRuntimeBindingCall {
|
|
pub source: RuntimeBindingCallSourceIdentity,
|
|
pub name: String,
|
|
pub payload: String,
|
|
pub execution_context_id: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RuntimeBindingRegistration {
|
|
/// Renderer DevTools session that owns this binding when it is part of an
|
|
/// aggregate Page snapshot. Session-local Inspector configuration leaves
|
|
/// this as `None` because the containing session is already authoritative.
|
|
pub devtools_session: Option<DevToolsSessionKey>,
|
|
pub name: String,
|
|
pub execution_context_name: Option<String>,
|
|
}
|
|
|
|
impl RuntimeBindingRegistration {
|
|
pub fn with_devtools_session(&self, devtools_session: DevToolsSessionKey) -> Self {
|
|
let mut binding = self.clone();
|
|
binding.devtools_session = Some(devtools_session);
|
|
binding
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum RuntimeContextRestoreEvent {
|
|
Created(RuntimeExecutionContextRestoreEvent),
|
|
Destroyed(RuntimeExecutionContextRestoreEvent),
|
|
Cleared(RuntimeExecutionContextsClearedRestoreEvent),
|
|
}
|
|
|
|
impl RuntimeContextRestoreEvent {
|
|
pub fn from_v8_inspector_message(message: Value) -> Option<Self> {
|
|
match message["method"].as_str()? {
|
|
"Runtime.executionContextCreated" => Some(Self::Created(
|
|
RuntimeExecutionContextRestoreEvent::created_from_v8_inspector_params(
|
|
message["params"].clone(),
|
|
),
|
|
)),
|
|
"Runtime.executionContextDestroyed" => Some(Self::Destroyed(
|
|
RuntimeExecutionContextRestoreEvent::destroyed_from_v8_inspector_params(
|
|
message["params"].clone(),
|
|
),
|
|
)),
|
|
"Runtime.executionContextsCleared" => Some(Self::Cleared(
|
|
RuntimeExecutionContextsClearedRestoreEvent {},
|
|
)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn execution_context_created_id(&self) -> Option<i64> {
|
|
match self {
|
|
Self::Created(event) => event.context_id,
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct RuntimeExecutionContextRestoreEvent {
|
|
pub context_id: Option<i64>,
|
|
pub realm_id: Option<String>,
|
|
pub frame_id: Option<String>,
|
|
pub origin: Option<String>,
|
|
pub name: Option<String>,
|
|
pub is_default: Option<bool>,
|
|
pub context_type: Option<String>,
|
|
pub grant_universal_access: Option<bool>,
|
|
}
|
|
|
|
impl RuntimeExecutionContextRestoreEvent {
|
|
fn created_from_v8_inspector_params(params: Value) -> Self {
|
|
let context = ¶ms["context"];
|
|
let aux_data = &context["auxData"];
|
|
Self {
|
|
context_id: context["id"].as_i64(),
|
|
realm_id: context["uniqueId"].as_str().map(str::to_owned),
|
|
frame_id: aux_data["frameId"].as_str().map(str::to_owned),
|
|
origin: context["origin"].as_str().map(str::to_owned),
|
|
name: context["name"].as_str().map(str::to_owned),
|
|
is_default: aux_data["isDefault"].as_bool(),
|
|
context_type: aux_data["type"].as_str().map(str::to_owned),
|
|
grant_universal_access: aux_data["grantUniversalAccess"].as_bool(),
|
|
}
|
|
}
|
|
|
|
fn destroyed_from_v8_inspector_params(params: Value) -> Self {
|
|
Self {
|
|
context_id: params["executionContextId"].as_i64(),
|
|
realm_id: params["executionContextUniqueId"]
|
|
.as_str()
|
|
.map(str::to_owned),
|
|
frame_id: None,
|
|
origin: None,
|
|
name: None,
|
|
is_default: None,
|
|
context_type: None,
|
|
grant_universal_access: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct RuntimeExecutionContextsClearedRestoreEvent {}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct PermissionOverrideRegistration {
|
|
pub permission: Value,
|
|
pub setting: String,
|
|
pub origin: Option<String>,
|
|
pub embedded_origin: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RuntimeIsolatedWorldDefinition {
|
|
pub name: String,
|
|
pub grant_universal_access: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct EmulatedMediaOverrides {
|
|
pub media: Option<String>,
|
|
pub color_scheme: Option<String>,
|
|
pub reduced_motion: Option<String>,
|
|
pub reduced_transparency: Option<String>,
|
|
pub forced_colors: Option<String>,
|
|
pub contrast: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct EmulatedIdleOverride {
|
|
pub is_user_active: bool,
|
|
pub is_screen_unlocked: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct ViewportSurface {
|
|
pub inner_width: u32,
|
|
pub inner_height: u32,
|
|
pub outer_width: u32,
|
|
pub outer_height: u32,
|
|
pub device_pixel_ratio: f64,
|
|
pub screen_width: u32,
|
|
pub screen_height: u32,
|
|
pub screen_avail_width: u32,
|
|
pub screen_avail_height: u32,
|
|
pub window_x: i32,
|
|
pub window_y: i32,
|
|
pub screen_orientation: ScreenOrientation,
|
|
/// Presentation of the native widget, independent of the CSS layout size.
|
|
pub emulated_view: Option<EmulatedView>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct EmulatedView {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub scale: f64,
|
|
/// The compositor keeps its native DPR even when CSS reports an override.
|
|
pub native_device_pixel_ratio: f64,
|
|
pub viewport: Option<EmulatedViewport>,
|
|
}
|
|
|
|
/// Page-coordinate origin and scale of a forced compositor viewport. Its
|
|
/// requested extent is already accounted for in the native widget dimensions.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct EmulatedViewport {
|
|
pub x: f64,
|
|
pub y: f64,
|
|
pub scale: f64,
|
|
}
|
|
|
|
impl ViewportSurface {
|
|
pub fn visible_size(self) -> (u32, u32) {
|
|
self.emulated_view
|
|
.map_or((self.inner_width, self.inner_height), |view| {
|
|
(view.width, view.height)
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct ScreenOrientation {
|
|
pub kind: &'static str,
|
|
pub angle: u16,
|
|
}
|
|
|
|
impl Default for ScreenOrientation {
|
|
fn default() -> Self {
|
|
let profile = moli_browser_profile::DEFAULT_WINDOW_SURFACE_PROFILE;
|
|
Self {
|
|
kind: profile.orientation_type,
|
|
angle: profile.orientation_angle as u16,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for ViewportSurface {
|
|
fn default() -> Self {
|
|
let profile = moli_browser_profile::DEFAULT_WINDOW_SURFACE_PROFILE;
|
|
Self {
|
|
inner_width: profile.inner_width as u32,
|
|
inner_height: profile.inner_height as u32,
|
|
outer_width: profile.inner_width as u32,
|
|
outer_height: profile.inner_height as u32,
|
|
device_pixel_ratio: profile.device_pixel_ratio,
|
|
screen_width: profile.screen_width as u32,
|
|
screen_height: profile.screen_height as u32,
|
|
screen_avail_width: profile.screen_avail_width as u32,
|
|
screen_avail_height: profile.screen_avail_height as u32,
|
|
window_x: 0,
|
|
window_y: 0,
|
|
screen_orientation: Default::default(),
|
|
emulated_view: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
pub struct ChildFrameTreeSnapshot {
|
|
pub frame_id: String,
|
|
#[serde(default)]
|
|
pub loader_id: String,
|
|
pub name: Option<String>,
|
|
#[serde(default)]
|
|
pub owner_element_id: Option<String>,
|
|
pub url: String,
|
|
#[serde(default)]
|
|
pub storage_key: String,
|
|
#[serde(default)]
|
|
pub security_origin_inherited: bool,
|
|
#[serde(default)]
|
|
pub security_origin_opaque: bool,
|
|
#[serde(default)]
|
|
pub child_frames: Vec<ChildFrameTreeSnapshot>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
|
pub struct ChildFrameAttachmentSnapshot {
|
|
pub frame_id: String,
|
|
#[serde(default)]
|
|
pub parent_frame_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
|
pub struct ChildFrameDocumentOpenedSnapshot {
|
|
pub frame_id: String,
|
|
#[serde(default)]
|
|
pub parent_frame_id: Option<String>,
|
|
#[serde(default)]
|
|
pub loader_id: Option<String>,
|
|
pub name: Option<String>,
|
|
pub url: String,
|
|
#[serde(default)]
|
|
pub security_origin_inherited: bool,
|
|
#[serde(default)]
|
|
pub security_origin_opaque: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
|
pub struct ChildFrameDetachmentSnapshot {
|
|
pub frame_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
|
pub enum ChildFrameTreeEventSnapshot {
|
|
Attached(ChildFrameAttachmentSnapshot),
|
|
Detached(ChildFrameDetachmentSnapshot),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
|
pub struct ChildFrameNavigationSnapshot {
|
|
pub frame_id: String,
|
|
#[serde(default)]
|
|
pub parent_frame_id: Option<String>,
|
|
#[serde(default)]
|
|
pub loader_id: Option<String>,
|
|
pub name: Option<String>,
|
|
pub url: String,
|
|
#[serde(default)]
|
|
pub document_open_replacement: bool,
|
|
#[serde(default)]
|
|
pub security_origin_inherited: bool,
|
|
#[serde(default)]
|
|
pub security_origin_opaque: bool,
|
|
#[serde(default)]
|
|
pub document_network: Option<ChildFrameDocumentNetworkSnapshot>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
|
pub struct ChildFrameDocumentNetworkSnapshot {
|
|
pub request_url: String,
|
|
pub request_method: String,
|
|
#[serde(default)]
|
|
pub request_headers: Vec<(String, String)>,
|
|
pub final_url: String,
|
|
pub status: u16,
|
|
#[serde(default)]
|
|
pub response_headers: Vec<(String, Vec<u8>)>,
|
|
#[serde(default)]
|
|
pub encoded_data_length: usize,
|
|
/// Exact in-process response body source for protocol consumers.
|
|
///
|
|
/// Renderer/protocol transport shares this carrier without copying the
|
|
/// complete payload. Serialized snapshots retain their historical wire
|
|
/// shape and therefore deserialize without a body source.
|
|
#[serde(skip)]
|
|
pub response_body: Option<SubresourceResponseBody>,
|
|
#[serde(default)]
|
|
pub from_cache: bool,
|
|
}
|
|
|
|
/// A completed child main-resource request whose Network facts remain
|
|
/// observable even though its navigation no longer owns the current child
|
|
/// Document.
|
|
///
|
|
/// Keeping this separate from `ChildFrameNavigationSnapshot` prevents a stale
|
|
/// response from synthesizing a navigation commit or lifecycle terminal.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
|
pub struct ChildFrameDocumentNetworkActivitySnapshot {
|
|
pub frame_id: String,
|
|
#[serde(default)]
|
|
pub parent_frame_id: Option<String>,
|
|
pub loader_id: String,
|
|
pub snapshot: ChildFrameDocumentNetworkSnapshot,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ScriptRun {
|
|
node_id: NodeId,
|
|
kind: ScriptKind,
|
|
mode: ScriptMode,
|
|
source_kind: ScriptSourceKind,
|
|
url: Url,
|
|
outcome: ScriptRunOutcome,
|
|
}
|
|
|
|
impl ScriptRun {
|
|
pub fn executed(
|
|
node_id: NodeId,
|
|
kind: ScriptKind,
|
|
mode: ScriptMode,
|
|
source_kind: ScriptSourceKind,
|
|
url: Url,
|
|
) -> Self {
|
|
Self {
|
|
node_id,
|
|
kind,
|
|
mode,
|
|
source_kind,
|
|
url,
|
|
outcome: ScriptRunOutcome::Executed,
|
|
}
|
|
}
|
|
|
|
pub fn skipped(
|
|
node_id: NodeId,
|
|
kind: ScriptKind,
|
|
mode: ScriptMode,
|
|
source_kind: ScriptSourceKind,
|
|
url: Url,
|
|
reason: ScriptSkipReason,
|
|
) -> Self {
|
|
Self {
|
|
node_id,
|
|
kind,
|
|
mode,
|
|
source_kind,
|
|
url,
|
|
outcome: ScriptRunOutcome::Skipped(reason),
|
|
}
|
|
}
|
|
|
|
pub fn failed(
|
|
node_id: NodeId,
|
|
kind: ScriptKind,
|
|
mode: ScriptMode,
|
|
source_kind: ScriptSourceKind,
|
|
url: Url,
|
|
message: String,
|
|
) -> Self {
|
|
Self {
|
|
node_id,
|
|
kind,
|
|
mode,
|
|
source_kind,
|
|
url,
|
|
outcome: ScriptRunOutcome::Failed(message),
|
|
}
|
|
}
|
|
|
|
pub fn node_id(&self) -> NodeId {
|
|
self.node_id
|
|
}
|
|
|
|
pub fn kind(&self) -> ScriptKind {
|
|
self.kind
|
|
}
|
|
|
|
pub fn mode(&self) -> ScriptMode {
|
|
self.mode
|
|
}
|
|
|
|
pub fn source_kind(&self) -> ScriptSourceKind {
|
|
self.source_kind
|
|
}
|
|
|
|
pub fn url(&self) -> &Url {
|
|
&self.url
|
|
}
|
|
|
|
pub fn outcome(&self) -> &ScriptRunOutcome {
|
|
&self.outcome
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum JsValueSnapshot {
|
|
Undefined,
|
|
Null,
|
|
Bool(bool),
|
|
Number(f64),
|
|
String(String),
|
|
Unsupported(String),
|
|
}
|
|
|
|
impl JsValueSnapshot {
|
|
pub fn as_bool(&self) -> Option<bool> {
|
|
match self {
|
|
Self::Bool(value) => Some(*value),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_number(&self) -> Option<f64> {
|
|
match self {
|
|
Self::Number(value) => Some(*value),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_str(&self) -> Option<&str> {
|
|
match self {
|
|
Self::String(value) | Self::Unsupported(value) => Some(value),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum ScriptRunOutcome {
|
|
Executed,
|
|
Skipped(ScriptSkipReason),
|
|
Failed(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ScriptKind {
|
|
Classic,
|
|
Module,
|
|
ImportMap,
|
|
DataBlock,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ScriptMode {
|
|
Normal,
|
|
Defer,
|
|
ModuleDefer,
|
|
InOrder,
|
|
ImportMapInOrder,
|
|
ModuleInOrder,
|
|
Async,
|
|
}
|
|
|
|
impl ScriptMode {
|
|
pub fn is_defer_like(self) -> bool {
|
|
matches!(self, Self::Defer | Self::ModuleDefer)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ScriptSourceKind {
|
|
Inline,
|
|
External,
|
|
}
|
|
|
|
impl ScriptSourceKind {
|
|
pub fn from_script_src(src: &Option<String>) -> Self {
|
|
if src.is_some() {
|
|
Self::External
|
|
} else {
|
|
Self::Inline
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ScriptSkipReason {
|
|
NotInMainDocument,
|
|
AlreadyStarted,
|
|
ScriptExecutionDisabled,
|
|
EmptyInlineScript,
|
|
NoModule,
|
|
ModuleNotSupported,
|
|
ImportMapNotSupported,
|
|
UnsupportedType(String),
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_url(path: &str) -> Url {
|
|
Url::parse(&format!("https://example.test{path}")).expect("test URL should parse")
|
|
}
|
|
|
|
fn pooled_body_writer(memory_limit: usize, pool: &DiskPool) -> SubresourceResponseBodyWriter {
|
|
SubresourceResponseBodyWriter::with_memory_limit_and_disk_pool(
|
|
memory_limit,
|
|
Some(pool.clone()),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn navigation_response_byte_parts_transfer_exact_storage() {
|
|
let template =
|
|
NavigationResponse::from_text_body(test_url("/module"), 200, Vec::new(), String::new());
|
|
for bytes in [b"module source".to_vec(), vec![b'a', 0xff, b'b']] {
|
|
let expected = bytes.clone();
|
|
let storage = bytes.as_ptr();
|
|
let response = NavigationResponse::from_head_and_materialized_body(
|
|
template.head(),
|
|
ResponseBody::lossy_text_from_bytes(bytes),
|
|
);
|
|
|
|
let (head, bytes) = response.into_byte_parts();
|
|
|
|
assert_eq!(head.final_url, template.final_url);
|
|
assert_eq!(bytes, expected);
|
|
assert_eq!(bytes.as_ptr(), storage, "exact bytes must be transferred");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn script_execution_report_exposes_globals_snapshot_freshness() {
|
|
let mut report = ScriptExecutionReport::default();
|
|
assert_eq!(
|
|
report.globals_snapshot_state(),
|
|
ScriptGlobalsSnapshotState::Uncaptured
|
|
);
|
|
assert_eq!(report.fresh_globals(), None);
|
|
assert!(!report.mark_globals_snapshot_dirty());
|
|
|
|
let mut globals = BTreeMap::new();
|
|
globals.insert("answer".to_owned(), JsValueSnapshot::Number(42.0));
|
|
assert!(report.replace_globals_snapshot(globals));
|
|
assert_eq!(
|
|
report
|
|
.fresh_globals()
|
|
.and_then(|values| values.get("answer")),
|
|
Some(&JsValueSnapshot::Number(42.0))
|
|
);
|
|
|
|
let cloned = report.clone();
|
|
assert!(Arc::ptr_eq(&report.globals, &cloned.globals));
|
|
|
|
assert!(report.mark_globals_snapshot_dirty());
|
|
assert!(!report.mark_globals_snapshot_dirty());
|
|
assert_eq!(
|
|
report.globals_snapshot_state(),
|
|
ScriptGlobalsSnapshotState::Dirty
|
|
);
|
|
assert!(report.fresh_globals().is_none());
|
|
assert_eq!(
|
|
report.global("answer"),
|
|
Some(&JsValueSnapshot::Number(42.0)),
|
|
"the compatibility accessor must retain the last complete snapshot"
|
|
);
|
|
|
|
assert!(report.replace_globals_snapshot(BTreeMap::new()));
|
|
assert!(report.globals_are_fresh());
|
|
assert!(report.globals().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_network_record_clones_share_until_modified() {
|
|
let record = SubresourceNetworkRecord::failure(
|
|
Some("FRAME".to_owned()),
|
|
test_url("/"),
|
|
test_url("/asset.js"),
|
|
"GET".to_owned(),
|
|
vec![("accept".to_owned(), "*/*".to_owned())].into(),
|
|
None,
|
|
SubresourceResourceType::Script,
|
|
"network failed".to_owned(),
|
|
);
|
|
let cloned = record.clone();
|
|
|
|
assert!(Arc::ptr_eq(&record.inner, &cloned.inner));
|
|
|
|
let modified = cloned.with_from_cache(true);
|
|
assert!(!Arc::ptr_eq(&record.inner, &modified.inner));
|
|
assert!(!record.from_cache());
|
|
assert!(modified.from_cache());
|
|
}
|
|
|
|
#[test]
|
|
fn scroll_into_view_rect_only_accepts_finite_protocol_numbers() {
|
|
assert_eq!(
|
|
DomScrollIntoViewRect::try_new(1.0, 2.0, -3.0, 4.0),
|
|
Some(DomScrollIntoViewRect {
|
|
x: 1.0,
|
|
y: 2.0,
|
|
width: -3.0,
|
|
height: 4.0,
|
|
})
|
|
);
|
|
|
|
for components in [
|
|
[f64::NAN, 0.0, 0.0, 0.0],
|
|
[0.0, f64::INFINITY, 0.0, 0.0],
|
|
[0.0, 0.0, f64::NEG_INFINITY, 0.0],
|
|
[0.0, 0.0, 0.0, f64::NAN],
|
|
] {
|
|
assert!(
|
|
DomScrollIntoViewRect::try_new(
|
|
components[0],
|
|
components[1],
|
|
components[2],
|
|
components[3],
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn optional_resource_fetch_mask_assigns_one_stable_bit_per_resource_family() {
|
|
let resources = [
|
|
(
|
|
OptionalResourceFetchMask::IMAGE,
|
|
1 << 0,
|
|
SubresourceResourceType::Image,
|
|
),
|
|
(
|
|
OptionalResourceFetchMask::FONT,
|
|
1 << 1,
|
|
SubresourceResourceType::Font,
|
|
),
|
|
(
|
|
OptionalResourceFetchMask::AUDIO,
|
|
1 << 2,
|
|
SubresourceResourceType::Audio,
|
|
),
|
|
(
|
|
OptionalResourceFetchMask::VIDEO,
|
|
1 << 3,
|
|
SubresourceResourceType::Video,
|
|
),
|
|
(
|
|
OptionalResourceFetchMask::MEDIA,
|
|
1 << 4,
|
|
SubresourceResourceType::Media,
|
|
),
|
|
(
|
|
OptionalResourceFetchMask::TEXT_TRACK,
|
|
1 << 5,
|
|
SubresourceResourceType::TextTrack,
|
|
),
|
|
];
|
|
|
|
assert_eq!(
|
|
OptionalResourceFetchMask::default(),
|
|
OptionalResourceFetchMask::NONE
|
|
);
|
|
assert!(OptionalResourceFetchMask::NONE.is_empty());
|
|
assert_eq!(OptionalResourceFetchMask::ALL.bits(), 0b00_111111);
|
|
|
|
for (index, (mask, expected_bit, resource_type)) in resources.iter().enumerate() {
|
|
assert_eq!(mask.bits(), *expected_bit);
|
|
assert_eq!(
|
|
OptionalResourceFetchMask::for_resource_type(*resource_type),
|
|
Some(*mask)
|
|
);
|
|
for (other_index, (other, _, _)) in resources.iter().enumerate() {
|
|
assert_eq!(
|
|
mask.contains(*other),
|
|
index == other_index,
|
|
"{resource_type:?} unexpectedly shared a bit"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cdp_fetch_interception_groups_fetch_event_source_and_xhr() {
|
|
let fetch_like = [
|
|
SubresourceResourceType::Fetch,
|
|
SubresourceResourceType::EventSource,
|
|
SubresourceResourceType::Xhr,
|
|
];
|
|
|
|
for resource_type in fetch_like {
|
|
assert_eq!(resource_type.as_cdp_fetch_interception_type(), "XHR");
|
|
for other in fetch_like {
|
|
assert!(resource_type.has_same_cdp_fetch_interception_type(other));
|
|
}
|
|
assert!(
|
|
!resource_type
|
|
.has_same_cdp_fetch_interception_type(SubresourceResourceType::Script)
|
|
);
|
|
}
|
|
|
|
assert_eq!(SubresourceResourceType::Fetch.as_cdp_type(), "Fetch");
|
|
assert_eq!(
|
|
SubresourceResourceType::EventSource.as_cdp_type(),
|
|
"EventSource"
|
|
);
|
|
assert_eq!(SubresourceResourceType::Xhr.as_cdp_type(), "XHR");
|
|
}
|
|
|
|
#[test]
|
|
fn optional_resource_fetch_mask_round_trips_valid_bits_and_rejects_unknown_bits() {
|
|
for bits in 0..=OptionalResourceFetchMask::ALL.bits() {
|
|
assert_eq!(
|
|
OptionalResourceFetchMask::from_bits(bits).map(|mask| mask.bits()),
|
|
Some(bits)
|
|
);
|
|
}
|
|
for bits in [
|
|
1 << 6,
|
|
1 << 7,
|
|
OptionalResourceFetchMask::ALL.bits() | (1 << 6),
|
|
u8::MAX,
|
|
] {
|
|
assert_eq!(OptionalResourceFetchMask::from_bits(bits), None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn optional_resource_fetch_mask_allows_only_enabled_optional_families() {
|
|
let optional = [
|
|
(
|
|
SubresourceResourceType::Image,
|
|
OptionalResourceFetchMask::IMAGE,
|
|
),
|
|
(
|
|
SubresourceResourceType::Font,
|
|
OptionalResourceFetchMask::FONT,
|
|
),
|
|
(
|
|
SubresourceResourceType::Audio,
|
|
OptionalResourceFetchMask::AUDIO,
|
|
),
|
|
(
|
|
SubresourceResourceType::Video,
|
|
OptionalResourceFetchMask::VIDEO,
|
|
),
|
|
(
|
|
SubresourceResourceType::Media,
|
|
OptionalResourceFetchMask::MEDIA,
|
|
),
|
|
(
|
|
SubresourceResourceType::TextTrack,
|
|
OptionalResourceFetchMask::TEXT_TRACK,
|
|
),
|
|
];
|
|
let required = [
|
|
SubresourceResourceType::Script,
|
|
SubresourceResourceType::Stylesheet,
|
|
SubresourceResourceType::Fetch,
|
|
SubresourceResourceType::EventSource,
|
|
SubresourceResourceType::Xhr,
|
|
SubresourceResourceType::Ping,
|
|
SubresourceResourceType::CspReport,
|
|
SubresourceResourceType::Dictionary,
|
|
SubresourceResourceType::WebSocket,
|
|
];
|
|
|
|
for resource_type in required {
|
|
assert!(OptionalResourceFetchMask::NONE.allows(resource_type));
|
|
}
|
|
for (resource_type, bit) in optional {
|
|
assert!(!OptionalResourceFetchMask::NONE.allows(resource_type));
|
|
assert!(bit.allows(resource_type));
|
|
assert!(OptionalResourceFetchMask::ALL.allows(resource_type));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn optional_resource_fetch_mask_combines_and_removes_independent_bits() {
|
|
let mut mask = OptionalResourceFetchMask::IMAGE | OptionalResourceFetchMask::AUDIO;
|
|
mask |= OptionalResourceFetchMask::TEXT_TRACK;
|
|
assert_eq!(
|
|
mask.bits(),
|
|
OptionalResourceFetchMask::IMAGE.bits()
|
|
| OptionalResourceFetchMask::AUDIO.bits()
|
|
| OptionalResourceFetchMask::TEXT_TRACK.bits()
|
|
);
|
|
|
|
mask.set(OptionalResourceFetchMask::AUDIO, false);
|
|
mask.set(OptionalResourceFetchMask::FONT, true);
|
|
assert!(mask.contains(OptionalResourceFetchMask::IMAGE));
|
|
assert!(mask.contains(OptionalResourceFetchMask::FONT));
|
|
assert!(!mask.contains(OptionalResourceFetchMask::AUDIO));
|
|
assert!(mask.contains(OptionalResourceFetchMask::TEXT_TRACK));
|
|
assert_eq!(
|
|
mask & OptionalResourceFetchMask::ALL,
|
|
mask,
|
|
"intersection with ALL should preserve every enabled bit"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn script_execution_report_extends_network_output_as_one_batch() {
|
|
let document_url = test_url("/");
|
|
let record = SubresourceNetworkRecord::failure(
|
|
Some("FRAME".to_owned()),
|
|
document_url.clone(),
|
|
test_url("/api"),
|
|
"GET".to_owned(),
|
|
Vec::new().into(),
|
|
None,
|
|
SubresourceResourceType::Fetch,
|
|
"network failed".to_owned(),
|
|
);
|
|
let websocket_event = WebSocketNetworkEvent::new(
|
|
7,
|
|
document_url.clone(),
|
|
test_url("/socket"),
|
|
WebSocketFrameDirection::Received,
|
|
WebSocketFrameOpcode::Text,
|
|
5,
|
|
);
|
|
let lifecycle_event =
|
|
WebSocketLifecycleEvent::open(7, document_url.clone(), test_url("/socket"));
|
|
let output = ScriptNetworkOutput::from_items([
|
|
ScriptNetworkOutputItem::SubresourceNetworkRecord(Box::new(record.clone())),
|
|
ScriptNetworkOutputItem::WebSocketNetworkEvent(websocket_event.clone()),
|
|
ScriptNetworkOutputItem::WebSocketLifecycleEvent(lifecycle_event.clone()),
|
|
]);
|
|
assert!(!output.is_empty());
|
|
|
|
let mut report = ScriptExecutionReport::default();
|
|
report.extend_network_output(output);
|
|
|
|
assert_eq!(
|
|
report.subresource_network_records(),
|
|
std::slice::from_ref(&record)
|
|
);
|
|
assert_eq!(
|
|
report.websocket_network_events(),
|
|
std::slice::from_ref(&websocket_event)
|
|
);
|
|
assert_eq!(
|
|
report.websocket_lifecycle_events(),
|
|
std::slice::from_ref(&lifecycle_event)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn staged_subresource_network_output_materializes_settled_report_record() {
|
|
let handle = SubresourceNetworkRequestHandle::new(42);
|
|
let document_url = test_url("/");
|
|
let request = SubresourceRequestStarted::new(
|
|
handle,
|
|
Some("FRAME".to_owned()),
|
|
document_url,
|
|
test_url("/image.png"),
|
|
"GET".to_owned(),
|
|
Vec::new().into(),
|
|
None,
|
|
SubresourceResourceType::Image,
|
|
SubresourceRequestInitiatorType::Parser,
|
|
None,
|
|
);
|
|
let response = SubresourceResponseStarted::new(
|
|
handle,
|
|
Vec::new(),
|
|
test_url("/image.png"),
|
|
200,
|
|
vec![("content-type".to_owned(), b"image/png".to_vec())],
|
|
Vec::new(),
|
|
);
|
|
let body = SubresourceBodyFinished::ready(
|
|
handle,
|
|
SubresourceResponseBody::from_bytes(vec![1, 2, 3]),
|
|
);
|
|
let mut report = ScriptExecutionReport::default();
|
|
report.extend_network_output(ScriptNetworkOutput::from_items([
|
|
ScriptNetworkOutputItem::SubresourceRequestStarted(Box::new(request.clone())),
|
|
]));
|
|
report.extend_network_output(ScriptNetworkOutput::from_items([
|
|
ScriptNetworkOutputItem::SubresourceResponseStarted(Box::new(response.clone())),
|
|
]));
|
|
assert!(report.subresource_network_records().is_empty());
|
|
report.extend_network_output(ScriptNetworkOutput::from_items([
|
|
ScriptNetworkOutputItem::SubresourceBodyFinished(Box::new(body.clone())),
|
|
]));
|
|
|
|
let [record] = report.subresource_network_records() else {
|
|
panic!("settled staged lifecycle should materialize one report record")
|
|
};
|
|
assert_eq!(record.request_handle(), Some(handle));
|
|
assert_eq!(
|
|
record.request_initiator_type(),
|
|
SubresourceRequestInitiatorType::Parser
|
|
);
|
|
let SubresourceNetworkOutcome::Success {
|
|
status,
|
|
response_body,
|
|
..
|
|
} = record.outcome()
|
|
else {
|
|
panic!("settled staged response should remain successful")
|
|
};
|
|
assert_eq!(*status, 200);
|
|
assert_eq!(response_body.try_bytes().unwrap().as_ref(), &[1, 2, 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn script_network_output_items_preserve_explicit_append_order() {
|
|
let document_url = test_url("/");
|
|
let record = SubresourceNetworkRecord::failure(
|
|
Some("FRAME".to_owned()),
|
|
document_url.clone(),
|
|
test_url("/api"),
|
|
"GET".to_owned(),
|
|
Vec::new().into(),
|
|
None,
|
|
SubresourceResourceType::Fetch,
|
|
"network failed".to_owned(),
|
|
);
|
|
let websocket_event = WebSocketNetworkEvent::new(
|
|
7,
|
|
document_url.clone(),
|
|
test_url("/socket"),
|
|
WebSocketFrameDirection::Received,
|
|
WebSocketFrameOpcode::Text,
|
|
5,
|
|
);
|
|
let lifecycle_event =
|
|
WebSocketLifecycleEvent::open(7, document_url.clone(), test_url("/socket"));
|
|
let output = ScriptNetworkOutput::from_items([
|
|
ScriptNetworkOutputItem::WebSocketLifecycleEvent(lifecycle_event.clone()),
|
|
ScriptNetworkOutputItem::SubresourceNetworkRecord(Box::new(record.clone())),
|
|
ScriptNetworkOutputItem::WebSocketNetworkEvent(websocket_event.clone()),
|
|
]);
|
|
|
|
let items: Vec<_> = output.into_items().collect();
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![
|
|
ScriptNetworkOutputItem::WebSocketLifecycleEvent(lifecycle_event),
|
|
ScriptNetworkOutputItem::SubresourceNetworkRecord(Box::new(record)),
|
|
ScriptNetworkOutputItem::WebSocketNetworkEvent(websocket_event),
|
|
],
|
|
"script network output iteration should preserve explicit producer append order"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn script_observable_output_items_preserve_batch_append_order() {
|
|
let output = ScriptObservableOutput::from_items([
|
|
ScriptObservableOutputItem::LifecycleError("warn-a".to_owned()),
|
|
ScriptObservableOutputItem::ConsoleMessage("console-a".to_owned()),
|
|
ScriptObservableOutputItem::LifecycleError("warn-b".to_owned()),
|
|
]);
|
|
|
|
let mut report = ScriptExecutionReport::default();
|
|
report.extend_observable_output(output);
|
|
|
|
assert_eq!(report.console_messages(), &["console-a".to_owned()]);
|
|
assert_eq!(
|
|
report.lifecycle_errors(),
|
|
&["warn-a".to_owned(), "warn-b".to_owned()]
|
|
);
|
|
assert_eq!(
|
|
report.observable_output_items(),
|
|
&[
|
|
ScriptObservableOutputItem::LifecycleError("warn-a".to_owned()),
|
|
ScriptObservableOutputItem::ConsoleMessage("console-a".to_owned()),
|
|
ScriptObservableOutputItem::LifecycleError("warn-b".to_owned()),
|
|
],
|
|
"report should keep observable producer item order for later core/CDP queues"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_response_body_stores_only_exact_response_bytes() {
|
|
let response = Response::from_head_and_body(
|
|
ResponseHead {
|
|
final_url: test_url("/utf8.txt"),
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
request_cookie_report: None,
|
|
cookie_set_reports: Vec::new(),
|
|
redirected: false,
|
|
redirect_chain: Vec::new(),
|
|
from_cache: false,
|
|
negotiated_http_version: None,
|
|
},
|
|
"hello".to_owned(),
|
|
b"hello".to_vec(),
|
|
);
|
|
let body = SubresourceResponseBody::from_fetch_response(&response);
|
|
let SubresourceResponseBodyInner::Memory(bytes) = body.inner.as_ref() else {
|
|
panic!("fetch response should use in-memory byte storage");
|
|
};
|
|
|
|
assert_eq!(bytes, b"hello");
|
|
assert_eq!(body.try_bytes().unwrap().as_ref(), b"hello");
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_response_body_preserves_non_utf8_response_bytes() {
|
|
let response = Response::from_head_and_body(
|
|
ResponseHead {
|
|
final_url: test_url("/legacy.txt"),
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
request_cookie_report: None,
|
|
cookie_set_reports: Vec::new(),
|
|
redirected: false,
|
|
redirect_chain: Vec::new(),
|
|
from_cache: false,
|
|
negotiated_http_version: None,
|
|
},
|
|
"é".to_owned(),
|
|
vec![0xe9],
|
|
);
|
|
let body = SubresourceResponseBody::from_fetch_response(&response);
|
|
|
|
assert_eq!(body.try_bytes().unwrap().as_ref(), &[0xe9]);
|
|
assert_eq!(String::from_utf8_lossy(&body.try_bytes().unwrap()), "�");
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_response_body_writer_keeps_small_body_in_memory() {
|
|
let pool = DiskPool::new(None).unwrap();
|
|
let mut writer = pooled_body_writer(16, &pool);
|
|
writer.append(b"hello");
|
|
writer.append(b" world");
|
|
let body = writer.finish();
|
|
|
|
assert_eq!(body.len(), 11);
|
|
assert_eq!(body.try_bytes().unwrap().as_ref(), b"hello world");
|
|
assert_eq!(body.clone_body_bytes(), b"hello world");
|
|
assert_eq!(pool.diagnostics().disk_footprint_bytes, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn navigation_response_body_can_move_into_an_external_backing_without_cloning() {
|
|
let mut response = NavigationResponse::from_head_and_body(
|
|
ResponseHead {
|
|
final_url: test_url("/image.png"),
|
|
status: 200,
|
|
headers: vec![("Content-Type".to_owned(), b"image/png".to_vec())],
|
|
request_cookie_report: None,
|
|
cookie_set_reports: Vec::new(),
|
|
redirected: false,
|
|
redirect_chain: Vec::new(),
|
|
from_cache: false,
|
|
negotiated_http_version: None,
|
|
},
|
|
String::new(),
|
|
b"encoded-image".to_vec(),
|
|
);
|
|
|
|
assert_eq!(response.take_body_bytes(), b"encoded-image");
|
|
assert!(response.body_bytes().is_empty());
|
|
assert_eq!(response.status, 200);
|
|
assert_eq!(response.final_url, test_url("/image.png"));
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_response_body_writer_pools_large_body_and_materializes_on_demand() {
|
|
let pool = DiskPool::new(None).unwrap();
|
|
let mut writer = pooled_body_writer(4, &pool);
|
|
writer.append(b"hello");
|
|
writer.append(b" world");
|
|
let body = writer.finish();
|
|
|
|
assert_eq!(body.len(), 11);
|
|
assert_eq!(pool.diagnostics().disk_footprint_bytes, 11);
|
|
assert_eq!(body.try_bytes().unwrap().as_ref(), b"hello world");
|
|
|
|
let mut copied = Vec::new();
|
|
body.write_bytes_to(&mut copied)
|
|
.expect("pooled body should stream-copy into writer");
|
|
assert_eq!(copied, b"hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn pooled_response_body_releases_the_empty_staging_allocation() {
|
|
let memory_limit = 1024 * 1024;
|
|
let bytes = vec![7; memory_limit + 1];
|
|
let pool = DiskPool::new(None).unwrap();
|
|
let mut writer = pooled_body_writer(memory_limit, &pool);
|
|
writer.append(&bytes);
|
|
let body = writer.finish();
|
|
|
|
assert_eq!(body.len(), bytes.len());
|
|
assert_eq!(body.materialize_bytes().unwrap(), bytes);
|
|
assert!(
|
|
body.renderer_transport_retained_memory_bytes() < memory_limit,
|
|
"a disk-backed body must not retain its full staging allocation"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_response_body_reads_memory_and_pooled_chunks_by_offset() {
|
|
let memory = SubresourceResponseBody::from_bytes(b"hello world".to_vec());
|
|
assert_eq!(memory.read_chunk(6, 3).unwrap(), b"wor");
|
|
assert_eq!(memory.materialize_bytes_from(6).unwrap(), b"world");
|
|
assert_eq!(memory.diagnostic_clone_body_bytes_from(6), b"world");
|
|
|
|
let pool = DiskPool::new(None).unwrap();
|
|
let mut writer = pooled_body_writer(4, &pool);
|
|
writer.append(b"hello");
|
|
writer.append(b" world");
|
|
let pooled = writer.finish();
|
|
assert_eq!(pooled.read_chunk(3, 5).unwrap(), b"lo wo");
|
|
assert_eq!(pooled.read_chunk(6, 3).unwrap(), b"wor");
|
|
assert_eq!(pooled.materialize_bytes_from(6).unwrap(), b"world");
|
|
assert_eq!(pooled.diagnostic_clone_body_bytes_from(6), b"world");
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_response_bodies_share_pool_and_release_extents_on_last_drop() {
|
|
let pool = DiskPool::new(None).unwrap();
|
|
let mut first_writer = pooled_body_writer(4, &pool);
|
|
first_writer.append(b"abcdefgh");
|
|
let first = first_writer.finish();
|
|
let mut second_writer = pooled_body_writer(4, &pool);
|
|
second_writer.append(b"ijklmnop");
|
|
let second = second_writer.finish();
|
|
|
|
assert_eq!(pool.diagnostics().disk_footprint_bytes, 16);
|
|
assert_eq!(pool.diagnostics().free_bytes, 0);
|
|
let first_clone = first.clone();
|
|
drop(first);
|
|
assert_eq!(
|
|
pool.diagnostics().free_bytes,
|
|
0,
|
|
"the body Arc should own its pooled extents"
|
|
);
|
|
drop(first_clone);
|
|
assert_eq!(pool.diagnostics().free_bytes, 8);
|
|
drop(second);
|
|
assert_eq!(pool.diagnostics().free_bytes, 16);
|
|
assert_eq!(pool.diagnostics().free_chunk_count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn parkable_image_body_shares_one_extent_and_unparks_on_demand() {
|
|
let pool = DiskPool::new(None).unwrap();
|
|
let manager = moli_parkable_image::ParkableImageManager::new(
|
|
Some(pool.clone()),
|
|
moli_parkable_image::ParkableImagePolicy {
|
|
min_size_to_park: 1,
|
|
parking_delay: std::time::Duration::ZERO,
|
|
reader_release_delay: std::time::Duration::ZERO,
|
|
},
|
|
);
|
|
let encoded = manager.from_frozen_bytes(b"encoded image".to_vec());
|
|
let body = SubresourceResponseBody::from_parkable_image(encoded.clone());
|
|
|
|
manager.force_park_images();
|
|
assert_eq!(manager.diagnostics().parked_count, 1);
|
|
assert_eq!(pool.diagnostics().disk_footprint_bytes, 13);
|
|
assert_eq!(body.read_chunk(8, 5).unwrap(), b"image");
|
|
assert_eq!(body.materialize_bytes().unwrap(), b"encoded image");
|
|
assert_eq!(pool.diagnostics().disk_footprint_bytes, 13);
|
|
|
|
drop(encoded);
|
|
assert_eq!(pool.diagnostics().free_bytes, 0);
|
|
drop(body);
|
|
assert_eq!(pool.diagnostics().free_bytes, 13);
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_response_body_keeps_order_when_pool_reaches_capacity() {
|
|
let pool = DiskPool::new(Some(4)).unwrap();
|
|
let mut writer = pooled_body_writer(4, &pool);
|
|
writer.append(b"hello world");
|
|
let body = writer.finish();
|
|
|
|
assert_eq!(pool.diagnostics().disk_footprint_bytes, 4);
|
|
assert_eq!(body.materialize_bytes().unwrap(), b"hello world");
|
|
assert_eq!(body.read_chunk(3, 6).unwrap(), b"lo wor");
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_response_body_fallible_materialize_reports_pool_corruption() {
|
|
let pool = DiskPool::new(None).unwrap();
|
|
let mut writer = pooled_body_writer(2, &pool);
|
|
writer.append(b"hello");
|
|
let body = writer.finish();
|
|
pool.truncate_file_for_test(0).unwrap();
|
|
|
|
assert!(body.materialize_bytes().is_err());
|
|
assert!(body.materialize_bytes_from(2).is_err());
|
|
assert!(body.try_bytes().is_err());
|
|
assert_eq!(body.diagnostic_clone_body_bytes(), Vec::<u8>::new());
|
|
assert_eq!(body.diagnostic_bytes().as_ref(), &[] as &[u8]);
|
|
|
|
let record = SubresourceNetworkRecord::success_with_body(
|
|
None,
|
|
Url::parse("https://example.test/page").unwrap(),
|
|
Url::parse("https://example.test/api").unwrap(),
|
|
"GET".to_owned(),
|
|
Vec::new().into(),
|
|
None,
|
|
SubresourceResourceType::Fetch,
|
|
None,
|
|
Vec::new(),
|
|
Url::parse("https://example.test/api").unwrap(),
|
|
200,
|
|
vec![("content-type".to_owned(), b"text/plain".to_vec())],
|
|
body,
|
|
Vec::new(),
|
|
);
|
|
let criteria = SubresourceResponseWaitCriteria {
|
|
body_regex: Some(Regex::new("missing").unwrap()),
|
|
..SubresourceResponseWaitCriteria::default()
|
|
};
|
|
assert!(
|
|
criteria.try_matches(&record).is_err(),
|
|
"production wait criteria should surface body source read errors"
|
|
);
|
|
assert!(
|
|
!criteria.diagnostic_matches(&record),
|
|
"diagnostic compatibility matcher should degrade unreadable body to no match"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn url_wait_criteria_matches_original_and_final_urls_as_regex() {
|
|
let record = SubresourceNetworkRecord::success(
|
|
None,
|
|
Url::parse("https://example.test/page").unwrap(),
|
|
Url::parse("https://api.example.test/v1/orders/42").unwrap(),
|
|
"GET".to_owned(),
|
|
Vec::new().into(),
|
|
None,
|
|
SubresourceResourceType::Fetch,
|
|
None,
|
|
Vec::new(),
|
|
Url::parse("https://api.example.test/v2/orders/42").unwrap(),
|
|
200,
|
|
Vec::new(),
|
|
String::new(),
|
|
Vec::new(),
|
|
);
|
|
|
|
for regex in [r"/v1/orders/\d+$", r"/v2/orders/\d+$"] {
|
|
let criteria = SubresourceResponseWaitCriteria {
|
|
url_regex: Some(Regex::new(regex).unwrap()),
|
|
..SubresourceResponseWaitCriteria::default()
|
|
};
|
|
assert!(criteria.try_matches(&record).is_ok_and(|matches| matches));
|
|
}
|
|
|
|
let non_matching = SubresourceResponseWaitCriteria {
|
|
url_regex: Some(Regex::new(r"/v3/orders/\d+$").unwrap()),
|
|
..SubresourceResponseWaitCriteria::default()
|
|
};
|
|
assert!(
|
|
non_matching
|
|
.try_matches(&record)
|
|
.is_ok_and(|matches| !matches)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn body_wait_criteria_supports_literal_and_regex_matching() {
|
|
let record = SubresourceNetworkRecord::success_with_body(
|
|
None,
|
|
Url::parse("https://example.test/page").unwrap(),
|
|
Url::parse("https://example.test/api").unwrap(),
|
|
"GET".to_owned(),
|
|
Vec::new().into(),
|
|
None,
|
|
SubresourceResourceType::Fetch,
|
|
None,
|
|
Vec::new(),
|
|
Url::parse("https://example.test/api").unwrap(),
|
|
200,
|
|
vec![("content-type".to_owned(), b"text/plain".to_vec())],
|
|
SubresourceResponseBody::from_bytes(b"order #42 ready".to_vec()),
|
|
Vec::new(),
|
|
);
|
|
|
|
let literal = SubresourceResponseWaitCriteria {
|
|
body_contains: Some("order #42 ready".to_owned()),
|
|
..SubresourceResponseWaitCriteria::default()
|
|
};
|
|
assert!(literal.try_matches(&record).is_ok_and(|matches| matches));
|
|
|
|
let regex_syntax_is_literal = SubresourceResponseWaitCriteria {
|
|
body_contains: Some(r"order #\d+ ready".to_owned()),
|
|
..SubresourceResponseWaitCriteria::default()
|
|
};
|
|
assert!(
|
|
regex_syntax_is_literal
|
|
.try_matches(&record)
|
|
.is_ok_and(|matches| !matches)
|
|
);
|
|
|
|
let criteria = SubresourceResponseWaitCriteria {
|
|
body_regex: Some(Regex::new(r"order #\d+ ready").unwrap()),
|
|
..SubresourceResponseWaitCriteria::default()
|
|
};
|
|
assert!(criteria.try_matches(&record).is_ok_and(|matches| matches));
|
|
|
|
let non_matching = SubresourceResponseWaitCriteria {
|
|
body_regex: Some(Regex::new(r"^order #\d{3} ready$").unwrap()),
|
|
..SubresourceResponseWaitCriteria::default()
|
|
};
|
|
assert!(
|
|
non_matching
|
|
.try_matches(&record)
|
|
.is_ok_and(|matches| !matches)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn json_path_wait_criteria_supports_equals_and_regex_with_shared_mime_parser() {
|
|
let expectation = SubresourceJsonPathEquals {
|
|
path: vec!["ok".to_owned()],
|
|
expected: "true".to_owned(),
|
|
};
|
|
assert!(json_path_equals(
|
|
&[(
|
|
"content-type".to_owned(),
|
|
b"application/manifest+json;charset=utf-8".to_vec(),
|
|
)],
|
|
r#"{"ok":true}"#,
|
|
&expectation,
|
|
));
|
|
assert!(json_path_equals(
|
|
&[(
|
|
"content-type".to_owned(),
|
|
b"application/json; charset = utf-8".to_vec(),
|
|
)],
|
|
r#"{"ok":true}"#,
|
|
&expectation,
|
|
));
|
|
assert!(!json_path_equals(
|
|
&[("content-type".to_owned(), b"text/json".to_vec())],
|
|
r#"{"ok":true}"#,
|
|
&expectation,
|
|
));
|
|
|
|
let regex_expectation = SubresourceJsonPathRegex {
|
|
path: vec!["data".to_owned(), "url".to_owned()],
|
|
regex: Regex::new(r"^/item/\d+$").unwrap(),
|
|
};
|
|
assert!(json_path_matches_regex(
|
|
&[(
|
|
"content-type".to_owned(),
|
|
b"application/json; charset=utf-8".to_vec(),
|
|
)],
|
|
r#"{"data":{"url":"/item/42"}}"#,
|
|
®ex_expectation,
|
|
));
|
|
assert!(!json_path_matches_regex(
|
|
&[("content-type".to_owned(), b"application/json".to_vec())],
|
|
r#"{"data":{"url":"/orders/42"}}"#,
|
|
®ex_expectation,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_response_body_partial_eq_does_not_treat_unreadable_sources_as_empty() {
|
|
let pool = DiskPool::new(None).unwrap();
|
|
let mut writer_a = pooled_body_writer(2, &pool);
|
|
writer_a.append(b"first");
|
|
let body_a = writer_a.finish();
|
|
let mut writer_b = pooled_body_writer(2, &pool);
|
|
writer_b.append(b"other");
|
|
let body_b = writer_b.finish();
|
|
pool.truncate_file_for_test(0).unwrap();
|
|
|
|
assert_eq!(body_a, body_a.clone());
|
|
assert_ne!(body_a, body_b);
|
|
assert!(!body_a.diagnostic_byte_eq(&body_b));
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_response_body_partial_eq_keeps_disk_backed_equality_io_free() {
|
|
let pool = DiskPool::new(None).unwrap();
|
|
let mut left_writer = pooled_body_writer(2, &pool);
|
|
left_writer.append(b"same");
|
|
let left = left_writer.finish();
|
|
|
|
let mut right_writer = pooled_body_writer(2, &pool);
|
|
right_writer.append(b"same");
|
|
let right = right_writer.finish();
|
|
|
|
assert_ne!(
|
|
left, right,
|
|
"ordinary equality should not read separate disk-backed sources"
|
|
);
|
|
assert!(
|
|
left.try_byte_eq(&right)
|
|
.expect("explicit byte equality should read pooled bodies"),
|
|
"callers that need content equality must opt in to source I/O"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_network_record_response_body_byte_equality_is_explicit() {
|
|
let pool = DiskPool::new(None).unwrap();
|
|
let mut left_writer = pooled_body_writer(2, &pool);
|
|
left_writer.append(b"same");
|
|
let left_body = left_writer.finish();
|
|
|
|
let mut right_writer = pooled_body_writer(2, &pool);
|
|
right_writer.append(b"same");
|
|
let right_body = right_writer.finish();
|
|
|
|
let left_record = SubresourceNetworkRecord::success_with_body(
|
|
None,
|
|
Url::parse("https://example.test/page").unwrap(),
|
|
Url::parse("https://example.test/api").unwrap(),
|
|
"GET".to_owned(),
|
|
Vec::new().into(),
|
|
None,
|
|
SubresourceResourceType::Fetch,
|
|
None,
|
|
Vec::new(),
|
|
Url::parse("https://example.test/api").unwrap(),
|
|
200,
|
|
Vec::new(),
|
|
left_body,
|
|
Vec::new(),
|
|
);
|
|
let right_record = SubresourceNetworkRecord::success_with_body(
|
|
None,
|
|
Url::parse("https://example.test/page").unwrap(),
|
|
Url::parse("https://example.test/api").unwrap(),
|
|
"GET".to_owned(),
|
|
Vec::new().into(),
|
|
None,
|
|
SubresourceResourceType::Fetch,
|
|
None,
|
|
Vec::new(),
|
|
Url::parse("https://example.test/api").unwrap(),
|
|
200,
|
|
Vec::new(),
|
|
right_body,
|
|
Vec::new(),
|
|
);
|
|
|
|
assert_ne!(
|
|
left_record, right_record,
|
|
"ordinary record equality should not read independent body sources"
|
|
);
|
|
assert!(
|
|
left_record
|
|
.try_response_body_byte_eq(&right_record)
|
|
.expect("explicit record body equality should read pooled bodies"),
|
|
"callers that need record body content equality must opt in"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn subresource_response_body_preserves_invalid_bytes_when_pooled() {
|
|
let pool = DiskPool::new(None).unwrap();
|
|
let mut writer = pooled_body_writer(2, &pool);
|
|
writer.append(b"hi ");
|
|
writer.append(&[0xff, b'!']);
|
|
let body = writer.finish();
|
|
|
|
assert_eq!(
|
|
body.materialize_bytes().unwrap(),
|
|
&[b'h', b'i', b' ', 0xff, b'!']
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_challenge_parser_prefers_supported_scheme_from_later_header() {
|
|
let challenge = extract_subresource_auth_challenge(&[
|
|
(
|
|
"www-authenticate".to_owned(),
|
|
b"Bearer realm=\"token-area\"".to_vec(),
|
|
),
|
|
(
|
|
"www-authenticate".to_owned(),
|
|
b"Basic realm=\"basic-area\"".to_vec(),
|
|
),
|
|
])
|
|
.expect("auth challenge");
|
|
|
|
assert_eq!(challenge.source, "Server");
|
|
assert_eq!(challenge.scheme, "basic");
|
|
assert_eq!(challenge.realm, "basic-area");
|
|
}
|
|
|
|
#[test]
|
|
fn auth_challenge_parser_prefers_supported_scheme_from_combined_header() {
|
|
let challenge = extract_subresource_auth_challenge(&[(
|
|
"www-authenticate".to_owned(),
|
|
b"Bearer realm=\"token-area\", Basic realm=\"basic-area\"".to_vec(),
|
|
)])
|
|
.expect("auth challenge");
|
|
|
|
assert_eq!(challenge.source, "Server");
|
|
assert_eq!(challenge.scheme, "basic");
|
|
assert_eq!(challenge.realm, "basic-area");
|
|
}
|
|
|
|
#[test]
|
|
fn auth_challenge_parser_preserves_quoted_commas_and_escaped_quotes() {
|
|
let challenge = extract_subresource_auth_challenge(&[(
|
|
"www-authenticate".to_owned(),
|
|
br#"Digest realm="area, with \"quote\"", nonce="deadbeef", qop="auth""#.to_vec(),
|
|
)])
|
|
.expect("auth challenge");
|
|
|
|
assert_eq!(challenge.source, "Server");
|
|
assert_eq!(challenge.scheme, "digest");
|
|
assert_eq!(challenge.realm, "area, with \"quote\"");
|
|
}
|
|
|
|
#[test]
|
|
fn auth_challenge_parser_recognizes_proxy_and_falls_back_to_unsupported() {
|
|
let challenge = extract_subresource_auth_challenge(&[(
|
|
"proxy-authenticate".to_owned(),
|
|
b"Bearer realm=\"proxy-token\"".to_vec(),
|
|
)])
|
|
.expect("proxy auth challenge");
|
|
|
|
assert_eq!(challenge.source, "Proxy");
|
|
assert_eq!(challenge.scheme, "bearer");
|
|
assert_eq!(challenge.realm, "proxy-token");
|
|
}
|
|
|
|
#[test]
|
|
fn auth_credentials_map_proxy_basic_to_proxy_header_and_digest_to_proxy_auth() {
|
|
let basic = subresource_auth_credentials_for_challenge(
|
|
&SubresourceAuthChallenge {
|
|
source: "Proxy".to_owned(),
|
|
scheme: "Basic".to_owned(),
|
|
realm: "proxy".to_owned(),
|
|
},
|
|
"user",
|
|
"pass",
|
|
)
|
|
.expect("basic credentials");
|
|
assert_eq!(basic.target, SubresourceAuthTarget::ProxyHeader);
|
|
assert_eq!(basic.scheme, SubresourceAuthScheme::Basic);
|
|
|
|
let digest = subresource_auth_credentials_for_challenge(
|
|
&SubresourceAuthChallenge {
|
|
source: "Proxy".to_owned(),
|
|
scheme: "digest".to_owned(),
|
|
realm: "proxy".to_owned(),
|
|
},
|
|
"user",
|
|
"pass",
|
|
)
|
|
.expect("digest credentials");
|
|
assert_eq!(digest.target, SubresourceAuthTarget::Proxy);
|
|
assert_eq!(digest.scheme, SubresourceAuthScheme::Digest);
|
|
}
|
|
}
|