diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3ba2526..a812a25e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Bounded RFC 6455 frame transport on the established WebDriver BiDi stream: client text frames require a caller-supplied fresh mask key and are masked on the wire, server frames are required to be unmasked, reserved bits/opcodes and nonminimal lengths fail closed, and each frame is limited by payload and monotonic-I/O ceilings; this remains frame transport only and does not assemble BiDi messages or grant browser/Agent authority. +- Bounded WebDriver BiDi `browsingContext.locateNodes` exchange over the established peer-verified WebSocket stream: the exact consumed command is written as one masked text frame, one end-to-end deadline and a 64-frame Ping/Pong budget bound the exchange, each Pong requires a fresh caller-supplied masking key, and only one final bounded text response may pass raw-document admission, exact command correlation, and node-result admission; fragmentation, binary/continuation/close shapes, exhausted entropy, and over-budget control traffic fail closed without granting browser, origin, policy, typed-input, or Agent authority. +- Bounded RFC 6455 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority. - Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. - Explicit no-DNS WebDriver BiDi loopback connection targets that derive exact IPv4/IPv6 loopback `SocketAddr` metadata from a session-correlated endpoint, reject `localhost` as requiring separately trusted name resolution, preserve the TLS requirement and exact session id, perform no socket I/O, and grant no Agent authority. @@ -53,6 +56,19 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Made WebDriver BiDi response-fragment admission reject an already oversized + buffer without subtraction underflow or a production panic. + +- Restored exact locateNodes-stack coverage without introducing an uncovered + test-only pattern-guard branch by exercising the deadline callback + without weakening its no-late-entropy assertion and removing an unreachable + private legacy handshake `Debug` implementation superseded by the redacting + wrapper. +- Removed a redundant 20-microsecond loopback deadline assertion whose error + variant depended on host scheduling; deterministic unit-clock tests retain + the shrinking end-to-end budget contract. +- Kept the no-late-entropy deadline gate as one result chain so exact coverage + does not require recreating an expired-clock race through a real socket. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/Cargo.lock b/Cargo.lock index e2ada3c4e..90b2ed7c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,8 +285,10 @@ dependencies = [ name = "originweave-network" version = "0.1.0" dependencies = [ + "base64", "originweave-core", "originweave-destination", + "sha1", ] [[package]] @@ -448,6 +450,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/crates/originweave-network/Cargo.toml b/crates/originweave-network/Cargo.toml index 3d800d8de..68ac4fc8c 100644 --- a/crates/originweave-network/Cargo.toml +++ b/crates/originweave-network/Cargo.toml @@ -11,8 +11,10 @@ homepage.workspace = true publish = false [dependencies] +base64 = "0.22.1" originweave-core = { path = "../originweave-core" } originweave-destination = { path = "../originweave-destination" } +sha1 = "0.10.6" [lints] workspace = true diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index fc72c341d..59902999d 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -6,15 +6,33 @@ //! It also bridges a session-correlated WebDriver BiDi loopback target from //! `originweave-core` into one bounded exact TCP connection, binds an RFC 6455 //! opening request to that verified plain stream, and can write that exact request -//! under one bounded deadline without claiming a completed WebSocket handshake or -//! granting browser, WebSocket, TLS, policy, or Agent authority. +//! under one bounded deadline, validate its bounded RFC 6455 opening response, +//! carry one bounded frame at a time, and bind one exact `locateNodes` command to +//! its bounded correlated response without granting browser, WebSocket, TLS, +//! policy, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] mod connection; mod webdriver_bidi_connection; +mod webdriver_bidi_locate_nodes_exchange; +#[cfg(test)] +mod webdriver_bidi_locate_nodes_exchange_transport_failure_tests; +#[cfg(test)] +mod webdriver_bidi_locate_nodes_exchange_unit_coverage_tests; +mod webdriver_bidi_websocket_control; +#[cfg(test)] +#[allow(clippy::expect_used)] +mod webdriver_bidi_websocket_coverage_tests; +#[cfg(test)] +#[allow(clippy::expect_used)] +mod webdriver_bidi_websocket_debug_tests; +#[path = "webdriver_bidi_websocket_validated.rs"] mod webdriver_bidi_websocket_handshake; +#[path = "webdriver_bidi_websocket_raw_redacted.rs"] +mod webdriver_bidi_websocket_handshake_raw; +mod webdriver_bidi_websocket_mask_key; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, @@ -24,8 +42,20 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; +pub use webdriver_bidi_locate_nodes_exchange::{ + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, +}; pub use webdriver_bidi_websocket_handshake::{ + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningRequestSent, +}; +pub use webdriver_bidi_websocket_handshake_raw::{ + MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, + MAX_WEBSOCKET_OPENING_RESPONSE_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, + WebDriverBiDiWebSocketOpeningWriteError, }; +pub use webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey; diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs new file mode 100644 index 000000000..95c5f9a78 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -0,0 +1,649 @@ +use std::{ + error::Error, + fmt, + time::{Duration, Instant}, +}; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextOriginEpochDispatchTarget, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, + ObservedNodeHandle, ValidatedBrowserProtocolUse, ValidatedWebDriverBiDiLocateNodesResult, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, +}; + +use crate::{ + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, +}; + +/// Maximum number of valid RFC 6455 Ping/Pong control frames one `locateNodes` exchange will process. +/// +/// RFC 6455 permits control frames to be interleaved with data frames; this OriginWeave-owned +/// resource budget prevents a peer from turning that permission into an unbounded control-frame loop +/// before the correlated BiDi response arrives. The end-to-end exchange deadline remains an +/// independent wall-clock bound. +pub const MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE: usize = 64; + +/// Maximum number of data fragments accepted for one `locateNodes` response message. +/// +/// RFC 6455 permits a text message to be split into an arbitrary number of continuation frames, +/// including empty fragments. This OriginWeave product-safety budget prevents a peer from turning +/// bounded response bytes into unbounded frame-processing work before the end-to-end deadline. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE: usize = 256; + +/// Fail-closed failures while exchanging one bounded WebDriver BiDi `locateNodes` command. +/// +/// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded +/// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the +/// admitted document must correlate to the exact consumed command before result nodes are returned. +/// Protocol-shape, resource-budget, exhausted-deadline, missing caller entropy, and adjacent client +/// masking-key reuse refusals have no nested source because none masks an underlying I/O or parser +/// failure. +#[derive(Debug)] +pub enum WebDriverBiDiLocateNodesExchangeError { + /// Bounded WebSocket frame write or read failed. + Frame(WebDriverBiDiWebSocketFrameError), + /// The single end-to-end exchange deadline was exhausted before the next operation could proceed. + ExchangeDeadlineExceeded { + /// Original caller-supplied deadline budget for the complete exchange. + exchange_timeout: Duration, + }, + /// The peer exceeded the local resource budget for interleaved Ping/Pong frames. + ControlFrameLimitExceeded { + /// Maximum number of control frames admitted for one exchange. + maximum_control_frames: usize, + }, + /// The peer exceeded the local resource budget for response-message data fragments. + ResponseFragmentLimitExceeded { + /// Maximum number of response-message data fragments admitted for one exchange. + maximum_fragments: usize, + }, + /// A server Ping required a fresh client masking key, but the caller supplied none. + PongMaskingKeyUnavailable, + /// A caller supplied the same Pong masking key as the immediately preceding client frame. + PongMaskingKeyReused, + /// The returned frame could not continue the one admissible text response message. + UnexpectedResponseFrame { + /// Whether the returned frame carried the RFC 6455 FIN bit. + fin: bool, + /// Exact returned RFC 6455 opcode. + opcode: u8, + }, + /// The exact response-message payload failed bounded raw-document admission. + ResponseDocument(WebDriverBiDiResponseDocumentAdmissionError), + /// The admitted response document failed parsing, exact correlation, or node admission. + LocateNodesResponse(WebDriverBiDiLocateNodesResponseDocumentError), +} + +impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Frame(error) => write!( + formatter, + "WebDriver BiDi locateNodes WebSocket frame exchange failed: {error}" + ), + Self::ExchangeDeadlineExceeded { exchange_timeout } => write!( + formatter, + "WebDriver BiDi locateNodes exchange exhausted its {exchange_timeout:?} end-to-end deadline before the next operation" + ), + Self::ControlFrameLimitExceeded { + maximum_control_frames, + } => write!( + formatter, + "WebDriver BiDi locateNodes exchange exceeded the maximum {maximum_control_frames} interleaved control frames" + ), + Self::ResponseFragmentLimitExceeded { maximum_fragments } => write!( + formatter, + "WebDriver BiDi locateNodes exchange exceeded the maximum {maximum_fragments} response-message data fragments" + ), + Self::PongMaskingKeyUnavailable => formatter.write_str( + "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key", + ), + Self::PongMaskingKeyReused => formatter.write_str( + "WebDriver BiDi locateNodes exchange refused a Pong masking key matching the immediately preceding client frame", + ), + Self::UnexpectedResponseFrame { fin, opcode } => write!( + formatter, + "WebDriver BiDi locateNodes exchange requires control handling or one bounded text response message; received fin={fin}, opcode=0x{opcode:02x}" + ), + Self::ResponseDocument(error) => write!( + formatter, + "WebDriver BiDi locateNodes response message failed raw-document admission: {error}" + ), + Self::LocateNodesResponse(error) => write!( + formatter, + "WebDriver BiDi locateNodes response document failed exact wire admission: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesExchangeError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Frame(error) => Some(error), + Self::ResponseDocument(error) => Some(error), + Self::LocateNodesResponse(error) => Some(error), + Self::ExchangeDeadlineExceeded { .. } + | Self::ControlFrameLimitExceeded { .. } + | Self::ResponseFragmentLimitExceeded { .. } + | Self::PongMaskingKeyUnavailable + | Self::PongMaskingKeyReused + | Self::UnexpectedResponseFrame { .. } => None, + } + } +} + +fn remaining_exchange_budget( + exchange_timeout: Duration, + elapsed: Duration, +) -> Result { + match exchange_timeout.checked_sub(elapsed) { + Some(remaining) if !remaining.is_zero() => Ok(remaining), + Some(_) | None => Err( + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { exchange_timeout }, + ), + } +} + +fn remaining_frame_operation_budget( + exchange_timeout: Duration, + elapsed: Duration, +) -> Result { + remaining_exchange_budget(exchange_timeout, elapsed) + .map(|remaining| remaining.min(MAX_WEBSOCKET_FRAME_TIMEOUT)) +} + +fn next_pong_masking_key( + next_key: &mut dyn FnMut() -> Option, +) -> Result { + next_key().ok_or(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) +} + +fn next_pong_masking_key_before_deadline( + next_key: &mut dyn FnMut() -> Option, + exchange_timeout: Duration, + elapsed: Duration, +) -> Result { + remaining_frame_operation_budget(exchange_timeout, elapsed) + .and_then(|_| next_pong_masking_key(next_key)) +} + +fn map_established_frame_result( + result: Result, +) -> Result { + result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) +} + +fn read_frame_with_exchange_budget( + exchange_timeout: Duration, + elapsed: Duration, + read_frame: impl FnOnce(Duration) -> Result, +) -> Result { + let remaining_timeout = remaining_frame_operation_budget(exchange_timeout, elapsed)?; + read_frame(remaining_timeout).map_err(WebDriverBiDiLocateNodesExchangeError::Frame) +} + +fn admit_response_fragment( + response_fragment_count: &mut usize, +) -> Result<(), WebDriverBiDiLocateNodesExchangeError> { + if *response_fragment_count == MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE { + return Err( + WebDriverBiDiLocateNodesExchangeError::ResponseFragmentLimitExceeded { + maximum_fragments: MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, + }, + ); + } + *response_fragment_count += 1; + Ok(()) +} + +fn append_response_fragment( + response_message: &mut Vec, + payload: &[u8], +) -> Result<(), WebDriverBiDiLocateNodesExchangeError> { + if response_message.len().saturating_add(payload.len()) + > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + { + return Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge, + )); + } + response_message.extend_from_slice(payload); + Ok(()) +} + +fn admit_response_payload( + command: WebDriverBiDiLocateNodesCommand, + payload: &[u8], +) -> Result { + let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(payload) + .map_err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument)?; + command + .admit_response_document_nodes(document) + .map_err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse) +} + +impl WebDriverBiDiWebSocketEstablished { + /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. + /// + /// The command is serialized by the reviewed core boundary and written as one masked client + /// text frame using `command_masking_key`. Valid server Ping frames are answered with a masked + /// Pong carrying the exact Ping application data, while unsolicited valid Pong frames are + /// consumed without changing BiDi state. Each Ping obtains a caller-supplied client mask from + /// `next_pong_key` only after a positive remaining-budget check; exhausting that caller-owned + /// entropy source or repeating the immediately preceding successful client-frame key fails + /// closed before another client frame is emitted. A later random collision after a different + /// client key remains admissible; the caller is responsible for deriving every key independently + /// from a strong unpredictable entropy source. Callback time is charged by a second deadline + /// check before the Pong write, and the adjacent-key guard does not claim to prove cryptographic + /// unpredictability. + /// + /// RFC 6455 text-message fragmentation is reassembled only for one response message at a time. + /// A non-final text frame starts that message, continuation frames extend it in order, and a final + /// continuation completes it. Ping/Pong control frames remain admissible between fragments. The + /// total assembled response is capped by [`MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES`] before + /// allocation can grow beyond the existing pre-parser budget, and at most + /// [`MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE`] accepted data fragments may compose the + /// message, so empty continuation frames cannot create unbounded processing work. Orphan + /// continuations, a second data message before completion, binary/Close/reserved shapes, and + /// malformed frame sequences fail closed and consume the transport state. + /// + /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, + /// response-fragment read, and response read. Elapsed time is subtracted before every operation, + /// including the initial command write, and the budget is never reset. Each individual frame + /// operation is additionally capped at the established frame timeout ceiling, so a longer + /// end-to-end exchange budget remains valid without widening the per-operation I/O bound. The + /// underlying frame boundary independently caps each frame at its existing size ceiling. In + /// addition, at most [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid Ping/Pong frames are + /// processed before the exchange fails closed, so RFC 6455 control-frame interleaving cannot + /// create an unbounded iteration budget even when the wall-clock deadline has not yet expired. + /// Any failure consumes this transport state and yields no reusable WebSocket stream, preventing + /// a partially written/read protocol state from becoming later authority. + /// + /// The final complete text message passes the existing bounded UTF-8/document admission, + /// complete WebDriver BiDi response parser, exact command-id correlation, and wire-derived node + /// admission. Success returns the same exact peer-verified WebSocket stream plus untrusted + /// normalized node evidence. It does not authenticate Chromium/ChromeDriver process provenance, + /// prove current OriginWeave session/context/origin/document authority, authorize policy or typed + /// input, mint node handles, execute a browser action, or prove a post-condition. + pub fn exchange_locate_nodes( + self, + command: WebDriverBiDiLocateNodesCommand, + command_masking_key: WebDriverBiDiWebSocketMaskKey, + next_pong_key: &mut dyn FnMut() -> Option, + exchange_timeout: Duration, + ) -> Result< + (Self, ValidatedWebDriverBiDiLocateNodesResult), + WebDriverBiDiLocateNodesExchangeError, + > { + let started_at = Instant::now(); + let write_timeout = + remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; + let mut established = map_established_frame_result(self.write_text_frame( + command.as_json(), + command_masking_key, + write_timeout, + ))?; + let mut control_frame_count = 0_usize; + let mut previous_client_masking_key = command_masking_key; + let mut response_fragment_count = 0_usize; + let mut response_message = Vec::new(); + let mut assembling_text_response = false; + + loop { + let (next_established, frame) = read_frame_with_exchange_budget( + exchange_timeout, + started_at.elapsed(), + |remaining_timeout| established.read_frame(remaining_timeout), + )?; + established = next_established; + let opcode = frame.opcode(); + + if matches!(opcode, 0x9 | 0xa) { + if control_frame_count == MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE { + return Err( + WebDriverBiDiLocateNodesExchangeError::ControlFrameLimitExceeded { + maximum_control_frames: MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + }, + ); + } + control_frame_count += 1; + } + + match opcode { + 0x9 => { + let masking_key = next_pong_masking_key_before_deadline( + next_pong_key, + exchange_timeout, + started_at.elapsed(), + )?; + if previous_client_masking_key == masking_key { + return Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyReused); + } + let remaining_timeout = + remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; + established = map_established_frame_result(established.write_pong_frame( + frame.payload(), + masking_key, + remaining_timeout, + ))?; + previous_client_masking_key = masking_key; + } + 0xa => {} + 0x1 if !assembling_text_response && frame.fin() => { + let result = admit_response_payload(command, frame.payload())?; + return Ok((established, result)); + } + 0x1 if !assembling_text_response => { + response_fragment_count = 1; + append_response_fragment(&mut response_message, frame.payload())?; + assembling_text_response = true; + } + 0x0 if assembling_text_response => { + admit_response_fragment(&mut response_fragment_count)?; + append_response_fragment(&mut response_message, frame.payload())?; + if frame.fin() { + let result = admit_response_payload(command, &response_message)?; + return Ok((established, result)); + } + } + _ => { + return Err( + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: frame.fin(), + opcode, + }, + ); + } + } + } + } + + /// Exchange `locateNodes` and bind the exact wire-derived nodes to current browser authority. + /// + /// This is the live transport composition boundary for semantic node observation. The bounded + /// command is exchanged on the already peer-verified WebSocket using [`Self::exchange_locate_nodes`]. + /// Only after exact wire parsing and command correlation succeed does the method revalidate the + /// reviewed WebDriver BiDi `SemanticObservation` proof and exact current + /// session/context/origin/document epoch carried together in `authority` through + /// [`ValidatedWebDriverBiDiLocateNodesResult::bind_current_nodes`]. No raw node identifier can be + /// substituted between the wire response and authority binding. + /// + /// A binding failure consumes this transport result and returns no reusable stream or node + /// handle, so a navigation or authority change observed after command construction cannot be + /// converted into stale node authority. Success returns only current [`ObservedNodeHandle`] + /// values together with the same established peer-verified stream. It still does not authorize + /// typed input, execute an action, or prove a post-condition. + pub fn exchange_locate_nodes_and_bind_current_nodes( + self, + command: WebDriverBiDiLocateNodesCommand, + command_masking_key: WebDriverBiDiWebSocketMaskKey, + next_pong_key: &mut dyn FnMut() -> Option, + exchange_timeout: Duration, + authority: ( + ValidatedBrowserProtocolUse, + BrowserContextOriginEpochDispatchTarget<'_>, + ), + authority_registry: &mut BrowserAuthorityRegistry, + ) -> Result<(Self, Vec), WebDriverBiDiLocateNodesExchangeError> { + let (validated, target) = authority; + let (established, result) = self.exchange_locate_nodes( + command, + command_masking_key, + next_pong_key, + exchange_timeout, + )?; + let handles = match result.bind_current_nodes(validated, authority_registry, target) { + Ok(handles) => handles, + Err(error) => { + return Err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), + )); + } + }; + Ok((established, handles)) + } +} + +#[cfg(test)] +mod tests { + use std::{error::Error as _, time::Duration}; + + use originweave_core::{ + MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, + }; + + use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; + + use super::{ + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + append_response_fragment, next_pong_masking_key, next_pong_masking_key_before_deadline, + read_frame_with_exchange_budget, remaining_exchange_budget, + remaining_frame_operation_budget, + }; + + #[test] + fn exchange_budget_consumes_elapsed_time_instead_of_resetting() { + let total = Duration::from_millis(500); + assert_eq!( + format!( + "{:?}", + remaining_exchange_budget(total, Duration::from_millis(175)) + ), + "Ok(325ms)" + ); + assert_eq!( + format!("{:?}", remaining_exchange_budget(total, total)), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); + assert_eq!( + format!( + "{:?}", + remaining_exchange_budget(total, Duration::from_millis(501)) + ), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); + } + + #[test] + fn exchange_budget_caps_each_frame_operation_without_resetting_total_time() { + assert_eq!( + remaining_frame_operation_budget(Duration::from_secs(6), Duration::ZERO).ok(), + Some(MAX_WEBSOCKET_FRAME_TIMEOUT) + ); + assert_eq!( + remaining_frame_operation_budget(Duration::from_secs(6), Duration::from_secs(2)).ok(), + Some(Duration::from_secs(4)) + ); + assert!( + remaining_frame_operation_budget(Duration::from_secs(6), Duration::from_secs(6)) + .is_err() + ); + } + + #[test] + fn expired_exchange_budget_refuses_frame_read_before_io() { + use std::cell::Cell; + + let exchange_timeout = Duration::from_millis(500); + let read_count = Cell::new(0_usize); + let read_frame = |remaining_timeout| { + read_count.set(read_count.get() + 1); + Ok::(remaining_timeout) + }; + let available = read_frame_with_exchange_budget( + exchange_timeout, + Duration::from_millis(100), + read_frame, + ); + assert_eq!(available.ok(), Some(Duration::from_millis(400))); + assert_eq!(read_count.get(), 1); + + let expired = + read_frame_with_exchange_budget(exchange_timeout, exchange_timeout, read_frame); + assert_eq!( + format!("{expired:?}"), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); + assert_eq!(read_count.get(), 1); + + let frame_error = + read_frame_with_exchange_budget(exchange_timeout, Duration::from_millis(100), |_| { + Err::( + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + ) + }); + assert!(frame_error.is_err()); + } + + #[test] + fn response_fragment_buffer_never_exceeds_document_budget() { + let mut response = vec![0_u8; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - 1]; + assert!(append_response_fragment(&mut response, b"x").is_ok()); + assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); + + assert_eq!( + format!("{:?}", append_response_fragment(&mut response, b"y")), + "Err(ResponseDocument(DocumentTooLarge))" + ); + assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); + } + + #[test] + fn response_fragment_rejects_an_already_oversized_buffer_without_panicking() { + let mut response = vec![0_u8; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1]; + assert!(append_response_fragment(&mut response, &[]).is_err()); + } + + #[test] + fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { + let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let mut available = || Some(expected); + assert_eq!(next_pong_masking_key(&mut available).ok(), Some(expected)); + + let mut unavailable = || None; + assert_eq!( + format!("{:?}", next_pong_masking_key(&mut unavailable)), + "Err(PongMaskingKeyUnavailable)" + ); + } + + #[test] + fn pong_entropy_is_not_drawn_after_exchange_deadline() { + use std::cell::Cell; + + let expected = crate::WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); + let draw_count = Cell::new(0_usize); + let mut next = || { + draw_count.set(draw_count.get() + 1); + Some(expected) + }; + assert_eq!(next(), Some(expected)); + draw_count.set(0); + let deadline_result = next_pong_masking_key_before_deadline( + &mut next, + Duration::from_millis(500), + Duration::from_millis(500), + ); + assert_eq!( + format!("{deadline_result:?}"), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); + assert_eq!(draw_count.get(), 0); + + let mut available = || Some(expected); + assert_eq!( + next_pong_masking_key_before_deadline( + &mut available, + Duration::from_millis(500), + Duration::from_millis(100), + ) + .ok(), + Some(expected) + ); + } + + #[test] + fn exchange_errors_preserve_typed_sources_and_protocol_shape() { + let frame = WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + ); + assert!(frame.source().is_some()); + assert!( + frame + .to_string() + .contains("WebSocket frame exchange failed") + ); + + let deadline = WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout: Duration::from_millis(500), + }; + assert!(deadline.source().is_none()); + assert!(deadline.to_string().contains("end-to-end deadline")); + + let control_limit = WebDriverBiDiLocateNodesExchangeError::ControlFrameLimitExceeded { + maximum_control_frames: MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + }; + assert!(control_limit.source().is_none()); + assert!( + control_limit + .to_string() + .contains("maximum 64 interleaved control frames") + ); + + let fragment_limit = WebDriverBiDiLocateNodesExchangeError::ResponseFragmentLimitExceeded { + maximum_fragments: MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, + }; + assert!(fragment_limit.source().is_none()); + assert!( + fragment_limit + .to_string() + .contains("maximum 256 response-message data fragments") + ); + + let missing_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable; + assert!(missing_mask.source().is_none()); + assert!( + missing_mask + .to_string() + .contains("fresh caller-supplied Pong masking key") + ); + + let reused_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyReused; + assert!(reused_mask.source().is_none()); + assert!( + reused_mask + .to_string() + .contains("Pong masking key matching the immediately preceding client frame") + ); + + let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: false, + opcode: 0x2, + }; + assert!(shape.source().is_none()); + assert!(shape.to_string().contains("fin=false, opcode=0x02")); + + let document = WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8, + ); + assert!(document.source().is_some()); + assert!(document.to_string().contains("raw-document admission")); + + let response = WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ); + assert!(response.source().is_some()); + assert!(response.to_string().contains("exact wire admission")); + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs new file mode 100644 index 000000000..57041b676 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -0,0 +1,367 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{Shutdown, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; + +use crate::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnection, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn connect(endpoint: &str) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client command was not one final masked text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => u64::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + u64::from(u16::from_be_bytes(extended)) + } + _ => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + u64::from_be_bytes(extended) + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut remaining = usize::try_from(payload_length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client command length cannot fit this test process", + ) + })?; + let mut buffer = [0_u8; 512]; + while remaining != 0 { + let chunk = remaining.min(buffer.len()); + stream.read_exact(&mut buffer[..chunk])?; + remaining -= chunk; + } + Ok(()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn locate_nodes_exchange_preserves_initial_command_write_failure() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + let count = stream.read(&mut byte)?; + if count != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "closed client write half still emitted a locateNodes command frame", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let shutdown_stream = established.try_clone_stream_for_test()?; + shutdown_stream.shutdown(Shutdown::Write)?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("initial write failure test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("locateNodes exchange unexpectedly survived a closed client write half") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { .. } + ) + ), + "{error:?}" + ); + assert!(error.source().is_some()); + Ok(()) +} + +#[test] +fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + stream.write_all(&[0x89, 0])?; + thread::sleep(Duration::from_millis(250)); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let shutdown_stream = established.try_clone_stream_for_test()?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + let shutdown = shutdown_stream.shutdown(Shutdown::Write); + assert!(shutdown.is_ok(), "{shutdown:?}"); + Some(pong_key) + }, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("Pong write failure test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("locateNodes exchange unexpectedly survived a closed client write half") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { .. } + ) + ), + "{error:?}" + ); + assert!(error.source().is_some()); + Ok(()) +} + +#[test] +fn zero_exchange_timeout_fails_at_exchange_boundary_before_frame_write() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + let count = stream.read(&mut byte)?; + if count != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "zero-budget locateNodes exchange wrote a client frame", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::ZERO, + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("zero-budget locateNodes exchange unexpectedly succeeded") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + } if exchange_timeout.is_zero() + ), + "{error:?}" + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("zero-budget exchange test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + Ok(()) +} + +#[test] +fn zero_exchange_timeout_binding_wrapper_executes_in_unit_crate_before_frame_write() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + let count = stream.read(&mut byte)?; + if count != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "zero-budget binding wrapper wrote a locateNodes client frame", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + + let exchanged = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::ZERO, + (semantic_observation_proof()?, target), + &mut registry, + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("zero-budget locateNodes binding wrapper unexpectedly succeeded") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + } if exchange_timeout.is_zero() + ), + "{error:?}" + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("zero-budget binding-wrapper test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + Ok(()) +} diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs new file mode 100644 index 000000000..bda5c2b98 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs @@ -0,0 +1,259 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; + +use crate::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one masked final client text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture rejects 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn write_opening_response(stream: &mut TcpStream) -> io::Result<()> { + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + ) +} + +fn write_short_server_frame( + stream: &mut TcpStream, + first_byte: u8, + payload: &[u8], +) -> io::Result<()> { + let payload_length = u8::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "unit coverage server frame exceeds short-frame limit", + ) + })?; + if payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unit coverage server frame exceeds short-frame limit", + )); + } + stream.write_all(&[first_byte, payload_length])?; + stream.write_all(payload) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +fn establish_client( + local_addr: std::net::SocketAddr, +) -> Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + Ok(written.read_opening_response(Duration::from_millis(500))?) +} + +#[test] +fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + read_client_text_frame(&mut stream)?; + write_short_server_frame(&mut stream, 0x81, RESPONSE_DOCUMENT.as_bytes()) + }); + + let established = establish_client(local_addr)?; + + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + + let (_established, handles) = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + (semantic_observation_proof()?, target), + &mut registry, + )?; + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].origin(), &origin); + + server + .join() + .map_err(|_| io::Error::other("unit coverage test server panicked"))??; + Ok(()) +} + +#[test] +fn fragmented_response_executes_nonfinal_text_arm_in_library_unit_crate() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + read_client_text_frame(&mut stream)?; + let response = RESPONSE_DOCUMENT.as_bytes(); + write_short_server_frame(&mut stream, 0x01, &response[..1])?; + write_short_server_frame(&mut stream, 0x80, &response[1..]) + }); + + let established = establish_client(local_addr)?; + let (_established, result) = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x21, 0x22, 0x23, 0x24]), + &mut || None, + Duration::from_millis(500), + )?; + assert_eq!(result.nodes().len(), 1); + + server + .join() + .map_err(|_| io::Error::other("fragmented unit coverage test server panicked"))??; + Ok(()) +} + +#[test] +fn second_text_frame_during_fragmentation_executes_guard_denial_in_library_unit_crate() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + read_client_text_frame(&mut stream)?; + write_short_server_frame(&mut stream, 0x01, b"{")?; + write_short_server_frame(&mut stream, 0x81, b"x") + }); + + let established = establish_client(local_addr)?; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x31, 0x32, 0x33, 0x34]), + &mut || None, + Duration::from_millis(500), + ); + assert_eq!( + format!("{exchange:?}"), + "Err(UnexpectedResponseFrame { fin: true, opcode: 1 })" + ); + + server + .join() + .map_err(|_| io::Error::other("second-text unit coverage test server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs new file mode 100644 index 000000000..d9f7b42af --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -0,0 +1,284 @@ +use std::{ + io::{self, Write}, + net::TcpStream, + thread, + time::{Duration, Instant}, +}; + +use crate::{ + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, + webdriver_bidi_websocket_handshake_raw::WebDriverBiDiWebSocketEstablished, +}; + +const MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES: usize = 125; + +fn validate_pong_parameters( + payload_bytes: usize, + frame_timeout: Duration, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { + if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { + return Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }); + } + if payload_bytes > MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES { + return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes, + maximum_bytes: MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES, + }); + } + Ok(()) +} + +fn serialize_pong_frame(payload: &[u8], masking_key: WebDriverBiDiWebSocketMaskKey) -> Vec { + let mut frame = Vec::with_capacity(payload.len() + 6); + frame.push(0x8a); + frame.push(0x80 | payload.len() as u8); + frame.extend_from_slice(masking_key.as_bytes()); + frame.extend( + payload.iter().enumerate().map(|(index, byte)| { + byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()] + }), + ); + frame +} + +trait PongFrameWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl PongFrameWriter for TcpStream { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + TcpStream::set_write_timeout(self, timeout) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_pong_frame_with_clock( + writer: &mut dyn PongFrameWriter, + frame: &[u8], + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { + let deadline = now() + frame_timeout; + let mut bytes_written = 0; + while bytes_written < frame.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source: io::Error::new( + io::ErrorKind::TimedOut, + "Pong frame write deadline elapsed", + ), + }); + } + writer + .set_write_timeout(Some(remaining)) + .map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written, + source, + } + })?; + match writer.write_frame_bytes(&frame[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }); + } + Ok(written) => bytes_written += written, + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + continue; + } + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written, + source, + }); + } + } + } + writer + .set_write_timeout(None) + .map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; + Ok(()) +} + +impl WebDriverBiDiWebSocketEstablished { + /// Write one final masked RFC 6455 Pong control frame on this verified stream. + /// + /// The payload is limited to the RFC 6455 control-frame maximum of 125 bytes. A caller that is + /// responding to Ping must pass the exact received Ping application data and a fresh, + /// unpredictable masking key dedicated to this client frame. The operation consumes established + /// state and returns it only after the complete frame is written within one monotonic bounded + /// deadline and the operation-local socket timeout is cleared. Failure yields no reusable stream. + /// This protocol response does not create browser, page, policy, origin, or Agent authority. + pub fn write_pong_frame( + mut self, + payload: &[u8], + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + validate_pong_parameters(payload.len(), frame_timeout).and_then(|()| { + let frame = serialize_pong_frame(payload, masking_key); + let mut now = Instant::now; + write_pong_frame_with_clock(&mut self.stream, &frame, frame_timeout, &mut now) + .map(|()| self) + }) + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + + use super::*; + + #[derive(Debug)] + enum WriteAction { + Count(usize), + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeWriter { + timeout_error: Option, + cleanup_error: Option, + actions: VecDeque, + } + + impl FakeWriter { + fn new(actions: impl IntoIterator) -> Self { + Self { + timeout_error: None, + cleanup_error: None, + actions: actions.into_iter().collect(), + } + } + } + + impl PongFrameWriter for FakeWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + let error = if timeout.is_some() { + self.timeout_error + } else { + self.cleanup_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + match self + .actions + .pop_front() + .unwrap_or(WriteAction::Count(bytes.len())) + { + WriteAction::Count(count) => Ok(count.min(bytes.len())), + WriteAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + fn write_with_fake( + writer: &mut FakeWriter, + now_values: impl IntoIterator, + ) -> Result<(), WebDriverBiDiWebSocketFrameError> { + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + write_pong_frame_with_clock(writer, b"abcdef", Duration::from_secs(1), &mut now) + } + + #[test] + fn pong_parameter_validation_is_fail_closed() { + assert!(validate_pong_parameters(0, Duration::from_millis(1)).is_ok()); + + let zero_timeout = validate_pong_parameters(0, Duration::ZERO); + assert!(format!("{zero_timeout:?}").starts_with("Err(InvalidFrameTimeout")); + + let excessive_timeout = + validate_pong_parameters(0, MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1)); + assert!(format!("{excessive_timeout:?}").starts_with("Err(InvalidFrameTimeout")); + + let excessive_payload = validate_pong_parameters(126, Duration::from_millis(1)); + assert!(format!("{excessive_payload:?}").starts_with("Err(FrameTooLarge")); + } + + #[test] + fn pong_serializer_emits_final_masked_control_frame() { + let key = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let frame = serialize_pong_frame(b"abc", key); + assert_eq!(&frame[..6], &[0x8a, 0x83, 1, 2, 3, 4]); + assert_eq!(&frame[6..], &[b'a' ^ 1, b'b' ^ 2, b'c' ^ 3]); + } + + #[test] + fn pong_writer_handles_partial_interrupted_and_would_block_progress() { + let start = Instant::now(); + let mut partial = FakeWriter::new([WriteAction::Count(2), WriteAction::Count(4)]); + assert!(write_with_fake(&mut partial, [start, start, start]).is_ok()); + + let mut interrupted = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(6), + ]); + assert!(write_with_fake(&mut interrupted, [start, start, start]).is_ok()); + + let mut would_block = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::WouldBlock), + WriteAction::Count(6), + ]); + assert!(write_with_fake(&mut would_block, [start, start, start, start]).is_ok()); + } + + #[test] + fn pong_writer_preserves_typed_write_failures() { + let start = Instant::now(); + let later = start + Duration::from_secs(1); + + let mut deadline = FakeWriter::new([]); + let deadline_error = write_with_fake(&mut deadline, [start, later]); + assert!(format!("{deadline_error:?}").starts_with("Err(FrameWriteTimedOut")); + + let mut configure = FakeWriter::new([]); + configure.timeout_error = Some(io::ErrorKind::PermissionDenied); + let configure_error = write_with_fake(&mut configure, [start, start]); + assert!( + format!("{configure_error:?}").starts_with("Err(FrameWriteModeConfigurationFailed") + ); + + let mut zero = FakeWriter::new([WriteAction::Count(0)]); + let zero_error = write_with_fake(&mut zero, [start, start]); + assert!(format!("{zero_error:?}").starts_with("Err(FrameWriteZero")); + + let mut timed_out = FakeWriter::new([WriteAction::Error(io::ErrorKind::TimedOut)]); + let timed_out_error = write_with_fake(&mut timed_out, [start, start, later]); + assert!(format!("{timed_out_error:?}").starts_with("Err(FrameWriteTimedOut")); + + let mut failed = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let failed_error = write_with_fake(&mut failed, [start, start]); + assert!(format!("{failed_error:?}").starts_with("Err(FrameWriteFailed")); + + let mut cleanup = FakeWriter::new([WriteAction::Count(6)]); + cleanup.cleanup_error = Some(io::ErrorKind::PermissionDenied); + let cleanup_error = write_with_fake(&mut cleanup, [start, start]); + assert!(format!("{cleanup_error:?}").starts_with("Err(FrameWriteCleanupFailed")); + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs new file mode 100644 index 000000000..b7508f0c6 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs @@ -0,0 +1,235 @@ +use std::{ + io::{self, Read, Write}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; + +use crate::webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketHandshakePlan; +use crate::{ + MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningRequestSent, + WebDriverBiDiWebSocketOpeningWriteError, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn client_key() -> WebDriverBiDiWebSocketClientKey { + WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ==") + .expect("test client key must be valid") +} + +fn loopback_plan(scheme: &str) -> (WebDriverBiDiTcpConnectionPlan, TcpListener) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let endpoint = + WebDriverBiDiWebSocketEndpoint::new(&format!("{scheme}://{address}/session/{SESSION_ID}")) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id(SESSION_ID) + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid"); + (plan, listener) +} + +fn join_server(server: thread::JoinHandle>) { + server + .join() + .expect("test loopback server must not panic") + .expect("test loopback server must complete"); +} + +fn opening_sent() -> ( + WebDriverBiDiWebSocketOpeningRequestSent, + thread::JoinHandle>, +) { + let (plan, listener) = loopback_plan("ws"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request)?; + Ok(()) + }); + let connection = plan.connect().expect("test connection must succeed"); + let sent = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_secs(1)) + .expect("test opening request must be written"); + (sent, server) +} + +fn established() -> ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +) { + let (plan, listener) = loopback_plan("ws"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + Ok(()) + }); + let connection = plan.connect().expect("test connection must succeed"); + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_secs(1)) + .expect("test opening request must be written") + .read_opening_response(Duration::from_secs(1)) + .expect("test opening response must be valid"); + (established, server) +} + +fn read_server_frame( + frame: &[u8], +) -> Result { + let (plan, listener) = loopback_plan("ws"); + let frame = frame.to_vec(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut opening = Vec::new(); + let mut byte = [0_u8; 1]; + while !opening.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte)?; + opening.push(byte[0]); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.write_all(&frame) + }); + let connection = plan.connect().expect("test connection must succeed"); + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_millis(500)) + .expect("test opening request must be written") + .read_opening_response(Duration::from_millis(500)) + .expect("test opening response must be valid"); + let result = established + .read_frame(Duration::from_millis(500)) + .map(|(_, frame)| frame); + join_server(server); + result +} + +#[test] +fn public_client_key_guard_rejects_each_noncanonical_shape() { + for invalid_key in [ + "AAAAAAAAAAAAAAAAAAAA==", + "dGhlIHNhbXBsZSBub25jZ!==", + "dGhlIHNhbXBsZSBub25jZR==", + "dGhlIHNhbXBsZSBub25jZQA=", + "dGhlIHNhbXBsZSBub25jZQ=A", + ] { + assert!(matches!( + WebDriverBiDiWebSocketClientKey::new(invalid_key), + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + } +} + +#[test] +fn opening_plan_rejects_plain_transport_for_tls_required_target() { + let (plan, listener) = loopback_plan("wss"); + let server = thread::spawn(move || listener.accept().map(|_| ())); + let connection = plan.connect().expect("test connection must succeed"); + + assert!(matches!( + WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()), + Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired) + )); + join_server(server); +} + +#[test] +fn public_opening_write_guard_rejects_zero_and_over_ceiling_timeouts() { + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT + Duration::from_nanos(1), + ] { + let (plan, listener) = loopback_plan("ws"); + let server = thread::spawn(move || listener.accept().map(|_| ())); + let connection = plan.connect().expect("test connection must succeed"); + let handshake = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid"); + + assert!(matches!( + handshake.write_opening_request(timeout), + Err(WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout, + }) if write_timeout == timeout && maximum_timeout == MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT + )); + join_server(server); + } +} + +#[test] +fn public_opening_response_guard_rejects_zero_and_over_ceiling_timeouts() { + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT + Duration::from_nanos(1), + ] { + let (sent, server) = opening_sent(); + assert!(matches!( + sent.read_opening_response(timeout), + Err(WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout, + maximum_timeout, + }) if response_timeout == timeout && maximum_timeout == MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT + )); + join_server(server); + } +} + +#[test] +fn public_text_frame_guard_rejects_payload_above_reviewed_ceiling() { + let (established, server) = established(); + let oversized = "x".repeat(MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE + 1); + let masking_key = WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]); + + assert!(matches!( + established.write_text_frame(&oversized, masking_key, Duration::from_secs(1)), + Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes, + maximum_bytes, + }) if payload_bytes == oversized.len() && maximum_bytes == MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE + )); + join_server(server); +} + +#[test] +fn close_frame_validation_covers_each_payload_shape_in_unit_build() { + let empty = read_server_frame(&[0x88, 0x00]).expect("empty Close frame must be valid"); + assert_eq!(empty.opcode(), 0x8); + assert!(empty.payload().is_empty()); + + assert!(matches!( + read_server_frame(&[0x88, 0x01, 0x00]), + Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) + )); + + let valid_reason = read_server_frame(&[0x88, 0x04, 0x03, 0xe8, b'o', b'k']) + .expect("valid Close reason must be accepted"); + assert_eq!(valid_reason.payload(), &[0x03, 0xe8, b'o', b'k']); + + assert!(matches!( + read_server_frame(&[0x88, 0x03, 0x03, 0xe8, 0xff]), + Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) + )); +} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs new file mode 100644 index 000000000..0f8bb957e --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -0,0 +1,114 @@ +use std::{net::TcpListener, thread, time::Duration}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; + +use crate::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + webdriver_bidi_websocket_handshake_raw::WebDriverBiDiWebSocketHandshakePlan as RawWebDriverBiDiWebSocketHandshakePlan, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CLIENT_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +#[test] +fn client_key_debug_redacts_client_nonce() { + let client_key = + WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY).expect("test client key must be valid"); + + let debug = format!("{client_key:?}"); + assert!(debug.contains("")); + assert!(!debug.contains(CLIENT_KEY)); +} + +#[test] +fn masking_key_debug_redacts_frame_entropy() { + let masking_key = WebDriverBiDiWebSocketMaskKey::new([17, 34, 51, 68]); + + let debug = format!("{masking_key:?}"); + assert!(debug.contains("")); + assert!(!debug.contains("17")); + assert!(!debug.contains("34")); + assert!(!debug.contains("51")); + assert!(!debug.contains("68")); +} + +#[test] +fn raw_handshake_plan_debug_omits_serialized_request() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || { + listener + .accept() + .map(|_| ()) + .expect("test loopback connection must be accepted"); + }); + + let endpoint = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id(SESSION_ID) + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid") + .connect() + .expect("test connection must succeed"); + let client_key = + WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY).expect("test client key must be valid"); + let handshake = RawWebDriverBiDiWebSocketHandshakePlan::new(connection, client_key) + .expect("test raw handshake plan must be valid"); + + let debug = format!("{handshake:?}"); + assert!(debug.contains("")); + assert!(!debug.contains("request: [")); + assert!(!debug.contains(CLIENT_KEY)); + + drop(handshake); + server.join().expect("test server must not panic"); +} + +#[test] +fn handshake_plan_debug_redacts_client_nonce() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || { + listener + .accept() + .map(|_| ()) + .expect("test loopback connection must be accepted"); + }); + + let endpoint = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id(SESSION_ID) + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid") + .connect() + .expect("test connection must succeed"); + let client_key = + WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY).expect("test client key must be valid"); + let handshake = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key) + .expect("test handshake plan must be valid"); + + let debug = format!("{handshake:?}"); + assert!(debug.contains("WebDriverBiDiWebSocketHandshakePlan")); + assert!(debug.contains("")); + assert!(!debug.contains(CLIENT_KEY)); + + drop(handshake); + server.join().expect("test server must not panic"); +} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 948d80089..37c37e25a 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1,17 +1,22 @@ use std::{ error::Error, fmt, - io::{self, Write}, + io::{self, Read, Write}, net::TcpStream, + thread, time::{Duration, Instant}, }; +use base64::{Engine, engine::general_purpose::STANDARD}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; +use sha1::{Digest, Sha1}; use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; -const REDACTED_WEBSOCKET_CLIENT_NONCE: &str = ""; +const RFC6455_WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const MAX_WEBSOCKET_OPENING_RESPONSE_BYTES: usize = 16 * 1024; +const MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES: usize = 1024 * 1024; /// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. /// @@ -19,6 +24,24 @@ const REDACTED_WEBSOCKET_CLIENT_NONCE: &str = " /// already bounded before this budget is applied. Callers may choose any smaller nonzero deadline. pub const MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT: Duration = Duration::from_secs(5); +/// Maximum wall-clock budget accepted for reading one bounded WebSocket opening response. +/// +/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. Callers may +/// choose any smaller nonzero deadline. +pub const MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Maximum bytes admitted while reading one WebSocket HTTP opening response. +/// +/// The response is consumed only through its terminating `CRLF CRLF`; WebSocket frames are not +/// read or interpreted by this boundary. +pub const MAX_WEBSOCKET_OPENING_RESPONSE_SIZE: usize = MAX_WEBSOCKET_OPENING_RESPONSE_BYTES; + +/// Maximum payload bytes admitted for one WebSocket frame. +pub const MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE: usize = MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES; + +/// Maximum wall-clock budget accepted for one bounded WebSocket frame I/O operation. +pub const MAX_WEBSOCKET_FRAME_TIMEOUT: Duration = Duration::from_secs(5); + fn is_base64_data_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') } @@ -61,17 +84,13 @@ impl Error for WebDriverBiDiWebSocketHandshakeError {} /// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type /// validates only the canonical wire representation, including zero padding bits. It does not /// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce -/// for each connection attempt. Its [`fmt::Debug`] representation deliberately redacts the nonce so -/// diagnostic output cannot disclose handshake material. +/// for each connection attempt. #[derive(Eq, PartialEq)] pub struct WebDriverBiDiWebSocketClientKey(String); impl fmt::Debug for WebDriverBiDiWebSocketClientKey { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_tuple("WebDriverBiDiWebSocketClientKey") - .field(&REDACTED_WEBSOCKET_CLIENT_NONCE) - .finish() + formatter.write_str("") } } @@ -91,6 +110,28 @@ impl WebDriverBiDiWebSocketClientKey { } } +/// Caller-supplied RFC 6455 mask key for one client-to-server frame. +/// +/// RFC 6455 requires every client frame to carry a fresh, unpredictable four-byte key. This type +/// preserves that requirement at the API boundary without inventing an entropy source; callers must +/// obtain a fresh key from an approved randomness source for every frame. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketMaskKey([u8; 4]); + +impl WebDriverBiDiWebSocketMaskKey { + /// Admit one four-byte caller-supplied frame mask key. + #[must_use] + pub const fn new(value: [u8; 4]) -> Self { + Self(value) + } + + /// Borrow the exact four-byte key used on the wire. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 4] { + &self.0 + } +} + /// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. /// /// The plan consumes the verified TCP connection so the opening request cannot be detached from the @@ -98,8 +139,7 @@ impl WebDriverBiDiWebSocketClientKey { /// the fixed WebSocket version-13 request required for the admitted `/session/` resource /// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. /// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary -/// before any WebSocket bytes may be written. Its [`fmt::Debug`] representation omits the serialized -/// request and redacts the client nonce because the request embeds that nonce in `Sec-WebSocket-Key`. +/// before any WebSocket bytes may be written. /// /// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` /// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or @@ -110,17 +150,6 @@ pub struct WebDriverBiDiWebSocketHandshakePlan { request: Vec, } -impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("WebDriverBiDiWebSocketHandshakePlan") - .field("verified_peer", self.connection.verified_peer()) - .field("client_nonce", &REDACTED_WEBSOCKET_CLIENT_NONCE) - .field("request_byte_count", &self.request.len()) - .finish() - } -} - impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( @@ -242,29 +271,1075 @@ impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { } } -impl WebDriverBiDiWebSocketOpeningRequestSent { - /// Borrow the exact verified transport evidence retained with this live stream. - #[must_use] - pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { - &self.transport_evidence +impl WebDriverBiDiWebSocketOpeningRequestSent { + /// Borrow the exact verified transport evidence retained with this live stream. + #[must_use] + pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { + &self.transport_evidence + } + + /// Borrow the exact client key required to validate the later server accept value. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + + /// Return the exact number of opening-request bytes written before success was emitted. + #[must_use] + pub const fn request_byte_count(&self) -> usize { + self.request_byte_count + } + + /// Return the total write deadline configured for this opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.write_timeout + } + + /// Read and validate the bounded RFC 6455 server opening response on this exact stream. + /// + /// Success proves only an HTTP/1.1 `101 Switching Protocols` response with the required + /// `Upgrade`, `Connection`, and client-key-correlated `Sec-WebSocket-Accept` headers. The + /// response body, WebSocket frames, browser process identity, TLS, and browser/Agent authority + /// remain separate boundaries. + pub fn read_opening_response( + self, + response_timeout: Duration, + ) -> Result + { + if response_timeout.is_zero() || response_timeout > MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + }, + ); + } + + let Self { + mut stream, + transport_evidence, + client_key, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + let (response_status, response_byte_count) = + read_opening_response_with_clock(&mut stream, &client_key, response_timeout, &mut now)?; + + Ok(WebDriverBiDiWebSocketEstablished { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }) + } +} + +/// A live verified stream after both RFC 6455 opening messages were validated. +/// +/// This state does not implement WebSocket framing or grant browser, page, policy, or Agent +/// authority. It retains the exact transport evidence and client key so later protocol stages can +/// remain correlated with the verified peer and opening handshake. +pub struct WebDriverBiDiWebSocketEstablished { + pub(crate) stream: TcpStream, + transport_evidence: WebDriverBiDiTcpConnectionEvidence, + client_key: WebDriverBiDiWebSocketClientKey, + response_status: u16, + response_byte_count: usize, + response_timeout: Duration, + request_byte_count: usize, + write_timeout: Duration, +} + +impl fmt::Debug for WebDriverBiDiWebSocketEstablished { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketEstablished") + .field("stream_local_addr", &self.stream.local_addr().ok()) + .field("transport_evidence", &self.transport_evidence) + .field( + "client_key", + &"", + ) + .field("response_status", &self.response_status) + .field("response_byte_count", &self.response_byte_count) + .field("response_timeout", &self.response_timeout) + .field("request_byte_count", &self.request_byte_count) + .field("write_timeout", &self.write_timeout) + .finish() + } +} + +impl WebDriverBiDiWebSocketEstablished { + /// Borrow the exact verified transport evidence retained with this live stream. + #[must_use] + pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { + &self.transport_evidence + } + + /// Borrow the exact client key correlated with the validated server accept value. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + + /// Return the validated HTTP status code, currently always `101` on success. + #[must_use] + pub const fn response_status(&self) -> u16 { + self.response_status + } + + /// Return the number of HTTP opening-response bytes consumed through its header terminator. + #[must_use] + pub const fn response_byte_count(&self) -> usize { + self.response_byte_count + } + + /// Return the total response deadline configured for this opening response. + #[must_use] + pub const fn response_timeout(&self) -> Duration { + self.response_timeout + } + + /// Return the number of request bytes written before the response was read. + #[must_use] + pub const fn request_byte_count(&self) -> usize { + self.request_byte_count + } + + /// Return the total write deadline configured for the preceding opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.write_timeout + } + + /// Write one unfragmented, masked UTF-8 text frame on this verified stream. + /// + /// The operation consumes the established state and returns it only after the complete frame + /// is written and the temporary socket timeout is cleared. The caller must provide a fresh, + /// unpredictable masking key for this frame; it is never exposed in evidence or debug output. + /// This method does not translate JSON, create a BiDi session, or grant browser/Agent authority. + pub fn write_text_frame( + self, + text: &str, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + validate_frame_timeout(frame_timeout)?; + if text.len() > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES { + return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: text.len(), + maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, + }); + } + + let frame = serialize_text_frame(text.as_bytes(), masking_key); + let Self { + mut stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + write_frame_with_clock(&mut stream, &frame, frame_timeout, &mut now)?; + Ok(Self { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }) + } + + /// Read one bounded RFC 6455 frame from this verified stream. + /// + /// Server-to-client frames must be unmasked. Data and continuation frames are returned one at + /// a time so a later message layer can enforce fragmentation and JSON semantics; control frames + /// are returned to that layer for protocol handling. Reserved bits/opcodes, oversized payloads, + /// noncanonical lengths, and incomplete reads fail closed. Close frames additionally enforce the + /// RFC 6455 payload shape and UTF-8 reason contract before the frame is returned. No frame grants + /// browser/Agent authority. + pub fn read_frame( + self, + frame_timeout: Duration, + ) -> Result<(Self, WebDriverBiDiWebSocketFrame), WebDriverBiDiWebSocketFrameError> { + validate_frame_timeout(frame_timeout)?; + let Self { + mut stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + let frame = read_frame_with_clock(&mut stream, frame_timeout, &mut now)?; + Ok(( + Self { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }, + frame, + )) + } +} + +/// One validated WebSocket frame received from the established peer. +#[derive(Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketFrame { + fin: bool, + opcode: u8, + payload: Vec, +} + +impl WebDriverBiDiWebSocketFrame { + /// Return whether this is the final frame in its message. + #[must_use] + pub const fn fin(&self) -> bool { + self.fin + } + + /// Return the RFC 6455 opcode without interpreting application semantics. + #[must_use] + pub const fn opcode(&self) -> u8 { + self.opcode + } + + /// Borrow the bounded, unmasked application payload. + #[must_use] + pub fn payload(&self) -> &[u8] { + &self.payload + } +} + +fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWebSocketFrameError> { + if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { + return Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }); + } + Ok(()) +} + +/// Fail-closed errors while reading or writing one bounded WebSocket frame. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketFrameError { + /// The requested frame I/O deadline was zero or above the reviewed resource ceiling. + InvalidFrameTimeout { + /// Rejected caller-supplied deadline. + frame_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The frame payload exceeded the reviewed memory ceiling. + FrameTooLarge { + /// Rejected payload length in bytes. + payload_bytes: usize, + /// Maximum payload length admitted by this boundary. + maximum_bytes: usize, + }, + /// Applying the operation-local nonblocking read mode failed. + FrameReadModeConfigurationFailed { + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket read timed out before the frame was complete. + FrameReadTimedOut { + /// Number of frame bytes consumed before timeout. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket read failed before the frame was complete. + FrameReadFailed { + /// Number of frame bytes consumed before failure. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The peer ended the stream before the frame was complete. + FrameEnded { + /// Number of frame bytes consumed before EOF. + bytes_read: usize, + }, + /// The frame header or RFC 6455 control-frame payload violated the protocol contract. + MalformedFrame { + /// Stable, non-secret reason for rejection. + reason: &'static str, + }, + /// Applying the operation-local write timeout failed. + FrameWriteModeConfigurationFailed { + /// Number of frame bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write timed out before the frame was complete. + FrameWriteTimedOut { + /// Number of frame bytes written before timeout. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket write failed before the frame was complete. + FrameWriteFailed { + /// Number of frame bytes written before failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The stream reported zero progress before the frame was complete. + FrameWriteZero { + /// Number of frame bytes written before zero progress. + bytes_written: usize, + }, + /// Clearing the temporary write timeout failed before handoff. + FrameWriteCleanupFailed { + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketFrameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFrameTimeout { .. } => formatter + .write_str("WebDriver BiDi WebSocket frame timeout is outside the reviewed bound"), + Self::FrameTooLarge { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame payload exceeded its bound") + } + Self::FrameReadModeConfigurationFailed { .. } => { + formatter.write_str("failed to configure bounded WebSocket frame reads") + } + Self::FrameReadTimedOut { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame read timed out") + } + Self::FrameReadFailed { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame read failed") + } + Self::FrameEnded { .. } => { + formatter.write_str("WebDriver BiDi WebSocket peer ended the frame stream") + } + Self::MalformedFrame { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame was malformed") + } + Self::FrameWriteModeConfigurationFailed { .. } => { + formatter.write_str("failed to configure bounded WebSocket frame writes") + } + Self::FrameWriteTimedOut { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write timed out") + } + Self::FrameWriteFailed { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write failed") + } + Self::FrameWriteZero { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write made no progress") + } + Self::FrameWriteCleanupFailed { .. } => { + formatter.write_str("failed to clear the WebDriver BiDi WebSocket frame timeout") + } + } + } +} + +impl Error for WebDriverBiDiWebSocketFrameError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::FrameReadModeConfigurationFailed { source } + | Self::FrameReadTimedOut { source, .. } + | Self::FrameReadFailed { source, .. } + | Self::FrameWriteModeConfigurationFailed { source, .. } + | Self::FrameWriteTimedOut { source, .. } + | Self::FrameWriteFailed { source, .. } + | Self::FrameWriteCleanupFailed { source } => Some(source), + Self::InvalidFrameTimeout { .. } + | Self::FrameTooLarge { .. } + | Self::FrameEnded { .. } + | Self::MalformedFrame { .. } + | Self::FrameWriteZero { .. } => None, + } + } +} + +/// Fail-closed errors while reading one bounded WebDriver BiDi WebSocket opening response. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketHandshakeResponseError { + /// The requested total response deadline was zero or above the reviewed resource ceiling. + InvalidResponseTimeout { + /// Rejected caller-supplied deadline. + response_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total response deadline elapsed before validation completed. + ResponseDeadlineExceeded { + /// Number of response bytes consumed before the deadline elapsed. + bytes_read: usize, + }, + /// The response exceeded the reviewed header-size ceiling before its terminator was found. + ResponseTooLarge { + /// Number of response bytes consumed before rejection. + bytes_read: usize, + /// Maximum response bytes admitted by this boundary. + maximum_bytes: usize, + }, + /// Applying the operation-local nonblocking read mode failed. + ResponseReadModeConfigurationFailed { + /// Number of response bytes consumed before configuration failed. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket read timed out before the opening response was complete. + ResponseReadTimedOut { + /// Number of response bytes consumed before the timed-out operation. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket read failed before the opening response was complete. + ResponseReadFailed { + /// Number of response bytes consumed before the failure. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The peer closed the stream before sending a complete HTTP header block. + ResponseEndedBeforeHeaders { + /// Number of response bytes consumed before the peer closed the stream. + bytes_read: usize, + }, + /// The HTTP response was not a valid, required WebSocket opening response. + MalformedResponse { + /// Stable, non-secret reason for the rejected response shape. + reason: &'static str, + }, + /// The response's `Sec-WebSocket-Accept` did not correlate with the sent client key. + AcceptMismatch, + /// Restoring blocking mode failed after validation. + ReadModeCleanupFailed { + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketHandshakeResponseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidResponseTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response timeout is outside the reviewed bound", + ), + Self::ResponseDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response exceeded its monotonic deadline", + ), + Self::ResponseTooLarge { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response exceeded its bounded header size", + ), + Self::ResponseReadModeConfigurationFailed { .. } => formatter.write_str( + "failed to configure bounded nonblocking WebDriver BiDi WebSocket response reads", + ), + Self::ResponseReadTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response timed out before completion", + ), + Self::ResponseReadFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response read failed before completion", + ), + Self::ResponseEndedBeforeHeaders { .. } => formatter.write_str( + "WebDriver BiDi WebSocket peer ended the stream before completing response headers", + ), + Self::MalformedResponse { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response was malformed or missing a required header", + ), + Self::AcceptMismatch => formatter.write_str( + "WebDriver BiDi WebSocket opening response accept value did not match the client key", + ), + Self::ReadModeCleanupFailed { .. } => formatter.write_str( + "failed to restore blocking WebDriver BiDi WebSocket response reads before handoff", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketHandshakeResponseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::ResponseReadModeConfigurationFailed { source, .. } + | Self::ResponseReadTimedOut { source, .. } + | Self::ResponseReadFailed { source, .. } + | Self::ReadModeCleanupFailed { source } => Some(source), + Self::InvalidResponseTimeout { .. } + | Self::ResponseDeadlineExceeded { .. } + | Self::ResponseTooLarge { .. } + | Self::ResponseEndedBeforeHeaders { .. } + | Self::MalformedResponse { .. } + | Self::AcceptMismatch => None, + } + } +} + +struct ParsedOpeningResponse { + status_code: u16, + byte_count: usize, +} + +fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String { + let mut digest = Sha1::new(); + digest.update(client_key.as_str().as_bytes()); + digest.update(RFC6455_WEBSOCKET_GUID); + STANDARD.encode(digest.finalize()) +} + +fn is_http_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +fn has_header_token(value: &str, expected: &str) -> bool { + value + .split(',') + .map(str::trim) + .any(|token| token.eq_ignore_ascii_case(expected)) +} + +#[allow(clippy::collapsible_if)] +fn parse_opening_response( + response: &[u8], + client_key: &WebDriverBiDiWebSocketClientKey, +) -> Result { + if !response.ends_with(b"\r\n\r\n") { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response is missing its CRLF header terminator", + }, + ); + } + let response_text = std::str::from_utf8(response).map_err(|_| { + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response headers are not valid UTF-8", + } + })?; + let header_text = &response_text[..response_text.len() - 4]; + let (status_line, header_lines) = header_text + .split_once("\r\n") + .map_or((header_text, ""), |(line, rest)| (line, rest)); + if status_line.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "status line contains a control byte", + }, + ); + } + let status_code = status_line + .strip_prefix("HTTP/1.1 ") + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|value| value.parse::().ok()); + if status_code != Some(101) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "status line is not HTTP/1.1 101", + }, + ); + } + + let mut upgrade_has_websocket = false; + let mut connection_has_upgrade = false; + let mut accept = None; + for line in header_lines.split("\r\n") { + if line.is_empty() + || line + .as_bytes() + .first() + .is_some_and(|byte| matches!(byte, b' ' | b'\t')) + { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header line is empty or folded", + }, + ); + } + let (name, value) = line.split_once(':').ok_or( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header line has no colon", + }, + )?; + if name.is_empty() || !name.bytes().all(is_http_token_byte) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header name is not an HTTP token", + }, + ); + } + let value = value.trim_matches([' ', '\t']); + if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header value contains a control byte", + }, + ); + } + if name.eq_ignore_ascii_case("upgrade") { + upgrade_has_websocket |= has_header_token(value, "websocket"); + } else if name.eq_ignore_ascii_case("connection") { + connection_has_upgrade |= has_header_token(value, "upgrade"); + } else if name.eq_ignore_ascii_case("sec-websocket-accept") { + if accept.is_some() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response repeats the Sec-WebSocket-Accept header", + }, + ); + } + accept = Some(value); + } + } + + if !upgrade_has_websocket { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "Upgrade header does not contain websocket", + }, + ); + } + if !connection_has_upgrade { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "Connection header does not contain Upgrade", + }, + ); + } + let Some(accept) = accept else { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response has no Sec-WebSocket-Accept header", + }, + ); + }; + if accept != expected_accept_value(client_key) { + return Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch); + } + + Ok(ParsedOpeningResponse { + status_code: 101, + byte_count: response.len(), + }) +} + +trait OpeningResponseReader { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()>; + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result; +} + +impl OpeningResponseReader for TcpStream { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { + TcpStream::set_nonblocking(self, nonblocking) + } + + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { + self.read(bytes) + } +} + +fn serialize_text_frame(payload: &[u8], masking_key: WebDriverBiDiWebSocketMaskKey) -> Vec { + let mut frame = Vec::with_capacity(payload.len() + 14); + frame.push(0x81); + match payload.len() { + 0..=125 => frame.push(0x80 | payload.len() as u8), + 126..=65_535 => { + frame.push(0x80 | 126); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } + length => { + frame.push(0x80 | 127); + frame.extend_from_slice(&(length as u64).to_be_bytes()); + } + } + frame.extend_from_slice(masking_key.as_bytes()); + frame.extend( + payload.iter().enumerate().map(|(index, byte)| { + byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()] + }), + ); + frame +} + +trait FrameWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl FrameWriter for TcpStream { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + TcpStream::set_write_timeout(self, timeout) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_frame_with_clock( + writer: &mut dyn FrameWriter, + frame: &[u8], + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + frame_timeout; + let mut bytes_written = 0; + while bytes_written < frame.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source: io::Error::new(io::ErrorKind::TimedOut, "frame write deadline elapsed"), + }); + } + writer + .set_write_timeout(Some(remaining)) + .map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written, + source, + } + })?; + match writer.write_frame_bytes(&frame[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }); + } + Ok(written) => bytes_written += written, + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + continue; + } + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written, + source, + }); + } + } + } + writer + .set_write_timeout(None) + .map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; + Ok(bytes_written) +} + +fn read_frame_with_clock( + reader: &mut dyn OpeningResponseReader, + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + frame_timeout; + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { source } + })?; + let mut bytes_read = 0; + let mut header = [0_u8; 2]; + read_frame_bytes_with_clock(reader, &mut header, &mut bytes_read, deadline, now)?; + let first = header[0]; + let second = header[1]; + if first & 0x70 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "reserved frame bits are not negotiated", + }); + } + let fin = first & 0x80 != 0; + let opcode = first & 0x0f; + match opcode { + 0x0..=0x2 => {} + 0x8..=0xa => { + if !fin { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "control frames must not be fragmented", + }); + } + } + _ => { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame opcode is reserved or unsupported", + }); + } + } + if second & 0x80 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "server-to-client frames must not be masked", + }); + } + let length_code = second & 0x7f; + let payload_length = match length_code { + 0..=125 => u64::from(length_code), + 126 => { + let mut extended = [0_u8; 2]; + read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; + let length = u64::from(u16::from_be_bytes(extended)); + if length < 126 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length encoding is not minimal", + }); + } + length + } + _ => { + let mut extended = [0_u8; 8]; + read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; + if extended[0] & 0x80 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length uses the reserved high bit", + }); + } + let length = u64::from_be_bytes(extended); + if length < 65_536 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length encoding is not minimal", + }); + } + length + } + }; + if payload_length > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64 { + return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: payload_length.min(usize::MAX as u64) as usize, + maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, + }); + } + if opcode >= 0x8 && payload_length > 125 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "control frame payload exceeds 125 bytes", + }); + } + let payload_length = payload_length as usize; + let mut payload = vec![0_u8; payload_length]; + read_frame_bytes_with_clock(reader, &mut payload, &mut bytes_read, deadline, now)?; + if opcode == 0x8 { + if payload.len() == 1 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame payload must be empty or begin with a two-byte status code", + }); + } + if payload.len() > 1 && std::str::from_utf8(&payload[2..]).is_err() { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame reason is not valid UTF-8", + }); + } + } + reader.set_nonblocking(false).map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameReadFailed { bytes_read, source } + })?; + Ok(WebDriverBiDiWebSocketFrame { + fin, + opcode, + payload, + }) +} + +fn read_frame_bytes_with_clock( + reader: &mut dyn OpeningResponseReader, + destination: &mut [u8], + bytes_read: &mut usize, + deadline: Instant, + now: &mut dyn FnMut() -> Instant, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { + let mut offset = 0; + while offset < destination.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: *bytes_read, + source: io::Error::new(io::ErrorKind::TimedOut, "frame read deadline elapsed"), + }); + } + match reader.read_response_bytes(&mut destination[offset..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameEnded { + bytes_read: *bytes_read, + }); + } + Ok(read) if read > destination.len() - offset => { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: *bytes_read, + source: io::Error::new( + io::ErrorKind::InvalidData, + "frame reader returned more bytes than requested", + ), + }); + } + Ok(read) => { + offset += read; + *bytes_read += read; + } + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: *bytes_read, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + } + Err(source) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: *bytes_read, + source, + }); + } + } } + Ok(()) +} - /// Borrow the exact client key required to validate the later server accept value. - #[must_use] - pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - &self.client_key - } +fn read_opening_response_with_clock( + reader: &mut dyn OpeningResponseReader, + client_key: &WebDriverBiDiWebSocketClientKey, + response_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { + let deadline = now() + response_timeout; + let mut response = Vec::new(); - /// Return the exact number of opening-request bytes written before success was emitted. - #[must_use] - pub const fn request_byte_count(&self) -> usize { - self.request_byte_count - } + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 0, + source, + } + })?; - /// Return the total write deadline configured for this opening request. - #[must_use] - pub const fn write_timeout(&self) -> Duration { - self.write_timeout + loop { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: response.len(), + }, + ); + } + if response.len() >= MAX_WEBSOCKET_OPENING_RESPONSE_BYTES { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: response.len(), + maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + }, + ); + } + let mut byte = [0_u8; 1]; + match reader.read_response_bytes(&mut byte) { + Ok(0) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: response.len(), + }, + ); + } + Ok(1) => { + response.push(byte[0]); + if response.ends_with(b"\r\n\r\n") { + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: response.len(), + }, + ); + } + let parsed = parse_opening_response(&response, client_key)?; + reader.set_nonblocking(false).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { + source, + } + })?; + return Ok((parsed.status_code, parsed.byte_count)); + } + } + Ok(_) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: response.len(), + source: io::Error::new( + io::ErrorKind::InvalidData, + "response reader returned more bytes than requested", + ), + }, + ); + } + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: response.len(), + source, + }, + ); + } + thread::sleep(Duration::from_millis(1)); + } + Err(source) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: response.len(), + source, + }, + ); + } + } } } @@ -417,19 +1492,19 @@ fn write_request_with_clock( ); } } - Err(source) if source.kind() == io::ErrorKind::Interrupted => {} - Err(source) + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } if matches!( source.kind(), io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) => - { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written, - source, - }); - } - Err(source) => { + ) { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written, + source, + }); + } return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { bytes_written, source, @@ -452,7 +1527,13 @@ fn write_request_with_clock( #[allow(clippy::expect_used)] mod opening_write_tests { use super::*; - use std::{collections::VecDeque, net::TcpListener, thread}; + use std::{ + collections::VecDeque, + net::{Shutdown, TcpListener}, + thread, + }; + + use originweave_core::WebDriverBiDiWebSocketEndpoint; #[derive(Debug)] enum WriteAction { @@ -504,6 +1585,263 @@ mod opening_write_tests { } } + impl FrameWriter for FakeWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + let error = if timeout.is_some() { + self.timeout_error + } else { + self.clear_timeout_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write_request_bytes(bytes) + } + } + + #[derive(Clone, Debug)] + enum ReadAction { + Byte(u8), + Count(usize), + End, + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeReader { + actions: VecDeque, + mode_error: Option, + cleanup_error: Option, + } + + impl FakeReader { + fn new(actions: impl IntoIterator) -> Self { + Self { + actions: actions.into_iter().collect(), + mode_error: None, + cleanup_error: None, + } + } + } + + impl OpeningResponseReader for FakeReader { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { + let error = if nonblocking { + self.mode_error + } else { + self.cleanup_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { + match self.actions.pop_front().unwrap_or(ReadAction::End) { + ReadAction::Byte(byte) => { + bytes[0] = byte; + Ok(1) + } + ReadAction::Count(count) => Ok(count), + ReadAction::End => Ok(0), + ReadAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + fn client_key() -> WebDriverBiDiWebSocketClientKey { + WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ==") + .expect("test client key must be valid") + } + + fn valid_response() -> Vec { + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec() + } + + fn byte_actions(bytes: &[u8]) -> Vec { + bytes.iter().copied().map(ReadAction::Byte).collect() + } + + fn is_malformed_response(response: &[u8], key: &WebDriverBiDiWebSocketClientKey) -> bool { + matches!( + parse_opening_response(response, key), + Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) + ) + } + + fn read_with_fake( + reader: &mut FakeReader, + now_values: impl IntoIterator, + ) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { + let key = client_key(); + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + read_opening_response_with_clock(reader, &key, Duration::from_secs(1), &mut now) + } + + fn read_frame_with_fake( + reader: &mut FakeReader, + now_values: impl IntoIterator, + ) -> Result { + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + read_frame_with_clock(reader, Duration::from_secs(1), &mut now) + } + + #[test] + fn parser_accepts_case_insensitive_upgrade_tokens_and_rejects_malformed_headers() { + let key = client_key(); + let response = b"HTTP/1.1 101 Switching Protocols\r\nUpGrAdE: WebSocket\r\nConnection: keep-alive, Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Test: retained\r\n\r\n"; + let parsed = parse_opening_response(response, &key).expect("valid response"); + assert_eq!(parsed.status_code, 101); + assert_eq!(parsed.byte_count, response.len()); + assert!(!is_malformed_response(response, &key)); + let same_length_mismatch = String::from_utf8(response.to_vec()) + .expect("valid response fixture") + .replace( + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", + "s3pPLMBiTxaQ9kYGzzhZRbK+xOoX", + ); + assert!(parse_opening_response(same_length_mismatch.as_bytes(), &key).is_err()); + + let malformed_responses = [ + b"HTTP/1.1 101".to_vec(), + vec![0xff, b'\r', b'\n', b'\r', b'\n'], + b"HTTP/1.1 101\0 Switching Protocols\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nBad Header: value\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n: value\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: web\x01socket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nUpgrade: websocket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nConnection: Upgrade\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nSec-WebSocket-Accept: one\r\nSec-WebSocket-Accept: two\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: keep-alive\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n".to_vec(), + ]; + for response in malformed_responses { + assert!(is_malformed_response(&response, &key)); + } + } + + #[test] + fn bounded_response_reader_covers_deadlines_size_io_and_cleanup() { + let start = Instant::now(); + + let mut valid_reader = FakeReader::new(byte_actions(&valid_response())); + let valid = read_with_fake(&mut valid_reader, [start]); + assert!(valid.is_ok()); + + let mut malformed_reader = FakeReader::new(byte_actions(b"HTTP/1.1 200 OK\r\n\r\n")); + assert!(read_with_fake(&mut malformed_reader, [start]).is_err()); + + let mut interrupted_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) + .chain(byte_actions(&valid_response())), + ); + assert!(read_with_fake(&mut interrupted_reader, [start]).is_ok()); + + let mut mode_error_reader = FakeReader::new([]); + mode_error_reader.mode_error = Some(io::ErrorKind::InvalidInput); + assert!(read_with_fake(&mut mode_error_reader, [start]).is_err()); + + let mut ended_reader = FakeReader::new([ReadAction::End]); + assert!(read_with_fake(&mut ended_reader, [start]).is_err()); + + let mut count_reader = FakeReader::new([ReadAction::Count(2)]); + assert!(read_with_fake(&mut count_reader, [start]).is_err()); + + let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(read_with_fake(&mut failed_reader, [start]).is_err()); + + let mut retrying_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::WouldBlock)) + .chain(byte_actions(&valid_response())), + ); + assert!(read_with_fake(&mut retrying_reader, [start]).is_ok()); + + let mut timed_out_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::TimedOut)]); + assert!( + read_with_fake( + &mut timed_out_reader, + [start, start, start + Duration::from_secs(1)] + ) + .is_err() + ); + + let mut deadline_reader = FakeReader::new([ReadAction::End]); + assert!( + read_with_fake( + &mut deadline_reader, + [start, start + Duration::from_secs(1)] + ) + .is_err() + ); + + let mut late_response_reader = FakeReader::new(byte_actions(&valid_response())); + let mut late_response_times = vec![start; valid_response().len() + 1]; + late_response_times.push(start + Duration::from_secs(1)); + assert!(read_with_fake(&mut late_response_reader, late_response_times).is_err()); + + let mut cleanup_reader = FakeReader::new(byte_actions(&valid_response())); + cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); + assert!(read_with_fake(&mut cleanup_reader, [start]).is_err()); + + let mut too_large_reader = FakeReader::new(std::iter::repeat_n( + ReadAction::Byte(b'a'), + MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + )); + assert!(read_with_fake(&mut too_large_reader, [start]).is_err()); + } + + #[test] + fn response_errors_have_deterministic_messages_and_sources() { + let source = io::Error::from(io::ErrorKind::InvalidInput); + let errors = [ + WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: 1, + maximum_bytes: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "test" }, + WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch, + WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { source }, + ]; + for (error, has_source) in errors.iter().zip([ + false, false, false, true, true, true, false, false, false, true, + ]) { + assert!(!error.to_string().is_empty()); + assert_eq!(error.source().is_some(), has_source); + } + } + #[test] fn bounded_writer_completes_partial_and_interrupted_writes() { let mut writer = FakeWriter::new([ @@ -779,4 +2117,363 @@ mod opening_write_tests { assert!(failed.source().is_some()); assert!(cleanup.source().is_some()); } + + #[test] + fn frame_codec_reader_writer_and_errors_are_fully_bounded() { + let masking_key = WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]); + assert_eq!(masking_key.as_bytes(), &[0x37, 0xfa, 0x21, 0x3d]); + for payload in [vec![b'x'; 125], vec![b'x'; 126], vec![b'x'; 65_536]] { + let frame = serialize_text_frame(&payload, masking_key); + assert_eq!(frame[0], 0x81); + assert_ne!(frame[1] & 0x80, 0); + let mask_offset = match payload.len() { + 0..=125 => 2, + 126..=65_535 => 4, + _ => 10, + }; + assert_eq!(&frame[mask_offset..mask_offset + 4], masking_key.as_bytes()); + } + + let start = Instant::now(); + let valid = [0x81, 0x01, b'x']; + let mut valid_reader = FakeReader::new(byte_actions(&valid)); + let valid_frame = read_frame_with_fake(&mut valid_reader, [start]).expect("valid frame"); + assert!(valid_frame.fin()); + assert_eq!(valid_frame.opcode(), 0x1); + assert_eq!(valid_frame.payload(), b"x"); + + let mut ping_reader = FakeReader::new([ReadAction::Byte(0x89), ReadAction::Byte(0)]); + let ping = read_frame_with_fake(&mut ping_reader, [start]).expect("ping frame"); + assert!(ping.fin()); + assert_eq!(ping.opcode(), 0x9); + + let mut continuation_reader = + FakeReader::new([ReadAction::Byte(0x00), ReadAction::Byte(0)]); + let continuation = + read_frame_with_fake(&mut continuation_reader, [start]).expect("continuation frame"); + assert!(!continuation.fin()); + assert_eq!(continuation.opcode(), 0); + + let mut extended_16 = FakeReader::new( + byte_actions(&[0x81, 126, 0, 126]) + .into_iter() + .chain([ReadAction::Count(126)]), + ); + assert_eq!( + read_frame_with_fake(&mut extended_16, [start]) + .expect("extended frame") + .payload() + .len(), + 126 + ); + let mut extended_64 = FakeReader::new( + byte_actions(&[0x81, 127, 0, 0, 0, 0, 0, 1, 0, 0]) + .into_iter() + .chain([ReadAction::Count(65_536)]), + ); + assert_eq!( + read_frame_with_fake(&mut extended_64, [start]) + .expect("large extended frame") + .payload() + .len(), + 65_536 + ); + let mut extended_16_error = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(126), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut extended_16_error, [start]).is_err()); + let mut extended_64_error = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(127), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut extended_64_error, [start]).is_err()); + + let mut oversized_header = vec![0x81, 127]; + oversized_header + .extend_from_slice(&((MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64) + 1).to_be_bytes()); + let mut malformed_readers = vec![ + vec![0xc1, 0], + vec![0x09, 0], + vec![0x83, 0], + vec![0x81, 0x80], + vec![0x81, 126, 0, 1], + vec![0x81, 127, 0x80, 0, 0, 0, 0, 0, 0, 0], + vec![0x81, 127, 0, 0, 0, 0, 0, 0, 0xff, 0xff], + vec![0x89, 126, 0, 126], + oversized_header, + ]; + for bytes in malformed_readers.drain(..) { + let mut reader = FakeReader::new(byte_actions(&bytes)); + assert!(read_frame_with_fake(&mut reader, [start]).is_err()); + } + let mut count_reader = FakeReader::new([ReadAction::Count(3)]); + assert!(read_frame_with_fake(&mut count_reader, [start]).is_err()); + let mut ended_reader = FakeReader::new([ReadAction::Byte(0x81), ReadAction::End]); + assert!(read_frame_with_fake(&mut ended_reader, [start]).is_err()); + let mut interrupted_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) + .chain(byte_actions(&valid)), + ); + assert!(read_frame_with_fake(&mut interrupted_reader, [start]).is_ok()); + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut retrying_reader = FakeReader::new( + std::iter::once(ReadAction::Error(kind)).chain(byte_actions(&valid)), + ); + assert!(read_frame_with_fake(&mut retrying_reader, [start]).is_ok()); + } + let mut payload_error_reader = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(1), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut payload_error_reader, [start]).is_err()); + let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(read_frame_with_fake(&mut failed_reader, [start]).is_err()); + let mut mode_reader = FakeReader::new([]); + mode_reader.mode_error = Some(io::ErrorKind::InvalidInput); + assert!(read_frame_with_fake(&mut mode_reader, [start]).is_err()); + let mut timeout_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::WouldBlock)]); + assert!( + read_frame_with_fake( + &mut timeout_reader, + [start, start, start + Duration::from_secs(1)] + ) + .is_err() + ); + let mut deadline_reader = FakeReader::new([]); + assert!( + read_frame_with_fake( + &mut deadline_reader, + [start, start + Duration::from_secs(1)] + ) + .is_err() + ); + let mut cleanup_reader = FakeReader::new(byte_actions(&valid)); + cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); + assert!(read_frame_with_fake(&mut cleanup_reader, [start]).is_err()); + + let mut writer = FakeWriter::new([ + WriteAction::Count(1), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(99), + ]); + let mut now = || start; + assert_eq!( + write_frame_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now) + .expect("frame write"), + 5 + ); + let mut empty_writer = FakeWriter::new([]); + let mut empty_now = || start; + assert_eq!( + write_frame_with_clock( + &mut empty_writer, + b"", + Duration::from_secs(1), + &mut empty_now + ) + .expect("empty frame write"), + 0 + ); + let mut deadline_writer = FakeWriter::new([]); + let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); + let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); + assert!( + write_frame_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now + ) + .is_err() + ); + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + assert!( + write_frame_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now + ) + .is_err() + ); + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start); + assert!( + write_frame_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now) + .is_err() + ); + } + let mut retrying_writer = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::WouldBlock), + WriteAction::Count(1), + ]); + let mut retrying_now = || start; + assert_eq!( + write_frame_with_clock( + &mut retrying_writer, + b"x", + Duration::from_secs(1), + &mut retrying_now + ) + .expect("retrying frame write"), + 1 + ); + let mut interrupted_writer = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(1), + ]); + let mut interrupted_now = || start; + assert_eq!( + write_frame_with_clock( + &mut interrupted_writer, + b"x", + Duration::from_secs(1), + &mut interrupted_now + ) + .expect("interrupted frame write"), + 1 + ); + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + assert!( + write_frame_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now + ) + .is_err() + ); + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + assert!( + write_frame_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now + ) + .is_err() + ); + let mut cleanup_writer = FakeWriter::new([WriteAction::Count(1)]); + cleanup_writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let mut cleanup_now = || start; + assert!( + write_frame_with_clock( + &mut cleanup_writer, + b"x", + Duration::from_secs(1), + &mut cleanup_now + ) + .is_err() + ); + + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), + ] { + assert!(validate_frame_timeout(timeout).is_err()); + } + let errors = [ + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: 2, + maximum_bytes: 1, + }, + WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketFrameError::FrameEnded { bytes_read: 1 }, + WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "test" }, + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 1 }, + WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + ]; + for (error, has_source) in errors.iter().zip([ + false, false, true, true, true, false, false, true, true, true, false, true, + ]) { + assert!(!error.to_string().is_empty()); + assert_eq!(error.source().is_some(), has_source); + } + } + + #[test] + fn established_frame_write_discards_locally_revoked_streams() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("test server must accept"); + stream + .write_all(&valid_response()) + .expect("test server must write response"); + }); + + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://{address}/session/01234567-89ab-cdef-0123-456789abcdef" + )) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id("01234567-89ab-cdef-0123-456789abcdef") + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let connection = + crate::WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid") + .connect() + .expect("test connection must succeed"); + let sent = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_secs(1)) + .expect("test opening request must be written"); + let established = sent + .read_opening_response(Duration::from_secs(1)) + .expect("test opening response must be valid"); + let _ = established.stream.shutdown(Shutdown::Both); + assert!( + established + .write_text_frame( + "x", + WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), + Duration::from_secs(1), + ) + .is_err() + ); + assert!(server.join().is_ok()); + } } diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs b/crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs new file mode 100644 index 000000000..8f0f437d1 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs @@ -0,0 +1,36 @@ +use std::fmt; + +use crate::webdriver_bidi_websocket_handshake_raw as raw; + +/// Caller-supplied RFC 6455 mask key for one client-to-server frame. +/// +/// RFC 6455 requires every client frame to carry a fresh, unpredictable four-byte key. This +/// public wrapper keeps those bytes available only to the framing boundary while ensuring generic +/// diagnostics cannot render the masking entropy. Callers remain responsible for obtaining a fresh +/// key from an approved randomness source for every client frame. +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketMaskKey(raw::WebDriverBiDiWebSocketMaskKey); + +impl fmt::Debug for WebDriverBiDiWebSocketMaskKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +impl WebDriverBiDiWebSocketMaskKey { + /// Admit one four-byte caller-supplied frame masking key. + #[must_use] + pub const fn new(value: [u8; 4]) -> Self { + Self(raw::WebDriverBiDiWebSocketMaskKey::new(value)) + } + + /// Borrow the exact four-byte key for the reviewed wire-framing boundary. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 4] { + self.0.as_bytes() + } + + pub(crate) const fn into_raw(self) -> raw::WebDriverBiDiWebSocketMaskKey { + self.0 + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs new file mode 100644 index 000000000..91c683339 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs @@ -0,0 +1,80 @@ +//! Nonce-safe adapter around the bounded WebDriver BiDi WebSocket frame transport. +//! +//! The frame-transport implementation predates the opening-handshake diagnostic contract carried by +//! its parent stack. Keep that implementation private here and expose a handshake-plan wrapper whose +//! `Debug` output cannot render the serialized opening request or its `Sec-WebSocket-Key` nonce. + +use std::{fmt, time::Duration}; + +use originweave_core::VerifiedWebDriverBiDiSocketPeer; + +use crate::WebDriverBiDiTcpConnection; + +#[path = "webdriver_bidi_websocket_handshake.rs"] +mod legacy; + +pub use legacy::{ + MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, + MAX_WEBSOCKET_OPENING_RESPONSE_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrame, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakeError, + WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, +}; + +/// Raw frame-transport opening plan with nonce-safe deterministic diagnostics. +/// +/// The wrapped implementation retains the serialized opening request because it must later write +/// those exact bytes to the verified stream. This adapter deliberately keeps that implementation +/// private and exposes only diagnostic metadata: verified peer evidence, an explicit nonce-redaction +/// marker, and the bounded request length. +pub struct WebDriverBiDiWebSocketHandshakePlan(legacy::WebDriverBiDiWebSocketHandshakePlan); + +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketHandshakePlan") + .field("verified_peer", self.0.verified_peer()) + .field("client_key", &"") + .field("request_byte_count", &self.0.request_bytes().len()) + .finish() + } +} + +impl WebDriverBiDiWebSocketHandshakePlan { + /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. + pub fn new( + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + ) -> Result { + legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key).map(Self) + } + + /// Borrow the exact serialized RFC 6455 opening-request bytes. + #[must_use] + pub fn request_bytes(&self) -> &[u8] { + self.0.request_bytes() + } + + /// Borrow the exact client key required for later server-accept correlation. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + self.0.client_key() + } + + /// Borrow the exact peer/session evidence verified before request construction. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + self.0.verified_peer() + } + + /// Write the complete bounded opening request on the exact verified stream. + pub fn write_opening_request( + self, + write_timeout: Duration, + ) -> Result + { + self.0.write_opening_request(write_timeout) + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs new file mode 100644 index 000000000..25f76a566 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -0,0 +1,299 @@ +//! Validated public WebDriver BiDi WebSocket state wrappers. +//! +//! The underlying transport remains responsible for exact-stream I/O. These wrappers preserve the +//! public state machine while adding protocol validation that must run before a received frame is +//! released to callers. + +use std::{fmt, time::Duration}; + +use originweave_core::VerifiedWebDriverBiDiSocketPeer; + +use crate::{ + WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence, + webdriver_bidi_websocket_handshake_raw as raw, + webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey, +}; + +const REUSED_CLIENT_MASK_KEY_REASON: &str = + "client masking key was reused for consecutive frames on this established WebSocket"; + +#[derive(Default)] +struct ClientMaskKeyHistory { + previous_key: Option<[u8; 4]>, +} + +impl ClientMaskKeyHistory { + fn reserve( + &mut self, + masking_key: WebDriverBiDiWebSocketMaskKey, + ) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { + let masking_key = *masking_key.as_bytes(); + if self.previous_key == Some(masking_key) { + return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_CLIENT_MASK_KEY_REASON, + }); + } + self.previous_key = Some(masking_key); + Ok(()) + } +} + +/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. +pub struct WebDriverBiDiWebSocketHandshakePlan(raw::WebDriverBiDiWebSocketHandshakePlan); + +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketHandshakePlan") + .field("verified_peer", self.0.verified_peer()) + .field("client_key", &"") + .field("request_byte_count", &self.0.request_bytes().len()) + .finish() + } +} + +impl WebDriverBiDiWebSocketHandshakePlan { + /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. + pub fn new( + connection: WebDriverBiDiTcpConnection, + client_key: raw::WebDriverBiDiWebSocketClientKey, + ) -> Result { + raw::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key).map(Self) + } + + /// Borrow the exact serialized RFC 6455 opening-request bytes. + #[must_use] + pub fn request_bytes(&self) -> &[u8] { + self.0.request_bytes() + } + + /// Borrow the exact client key that a later server-handshake validator must correlate. + #[must_use] + pub const fn client_key(&self) -> &raw::WebDriverBiDiWebSocketClientKey { + self.0.client_key() + } + + /// Borrow the exact peer/session evidence already verified before request construction. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + self.0.verified_peer() + } + + /// Write the complete bounded opening request on the exact verified stream within one deadline. + pub fn write_opening_request( + self, + write_timeout: Duration, + ) -> Result< + WebDriverBiDiWebSocketOpeningRequestSent, + raw::WebDriverBiDiWebSocketOpeningWriteError, + > { + self.0 + .write_opening_request(write_timeout) + .map(WebDriverBiDiWebSocketOpeningRequestSent) + } +} + +/// A live verified stream after the complete client WebSocket opening request has been written. +pub struct WebDriverBiDiWebSocketOpeningRequestSent(raw::WebDriverBiDiWebSocketOpeningRequestSent); + +impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl WebDriverBiDiWebSocketOpeningRequestSent { + /// Borrow the exact verified transport evidence retained with this live stream. + #[must_use] + pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { + self.0.transport_evidence() + } + + /// Borrow the exact client key required to validate the later server accept value. + #[must_use] + pub const fn client_key(&self) -> &raw::WebDriverBiDiWebSocketClientKey { + self.0.client_key() + } + + /// Return the exact number of opening-request bytes written before success was emitted. + #[must_use] + pub const fn request_byte_count(&self) -> usize { + self.0.request_byte_count() + } + + /// Return the total write deadline configured for this opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.0.write_timeout() + } + + /// Read and validate the bounded RFC 6455 server opening response on this exact stream. + pub fn read_opening_response( + self, + response_timeout: Duration, + ) -> Result + { + self.0.read_opening_response(response_timeout).map(|raw| { + WebDriverBiDiWebSocketEstablished { + raw, + client_mask_keys: ClientMaskKeyHistory::default(), + } + }) + } +} + +/// A live verified stream after both RFC 6455 opening messages were validated. +/// +/// The caller remains responsible for deriving every RFC 6455 masking key from a strong source of +/// entropy. OriginWeave additionally rejects immediate key repetition across adjacent client text or +/// Pong frames as a bounded defense against a stuck or accidentally reused caller value. It does not +/// impose global key uniqueness, because RFC 6455 requires fresh unpredictable selection rather than +/// collision-free values and a 32-bit random key can legitimately recur over a long-lived session. +pub struct WebDriverBiDiWebSocketEstablished { + raw: raw::WebDriverBiDiWebSocketEstablished, + client_mask_keys: ClientMaskKeyHistory, +} + +impl fmt::Debug for WebDriverBiDiWebSocketEstablished { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.raw.fmt(formatter) + } +} + +impl WebDriverBiDiWebSocketEstablished { + /// Borrow the exact verified transport evidence retained with this live stream. + #[must_use] + pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { + self.raw.transport_evidence() + } + + /// Borrow the exact client key correlated with the validated server accept value. + #[must_use] + pub const fn client_key(&self) -> &raw::WebDriverBiDiWebSocketClientKey { + self.raw.client_key() + } + + /// Return the validated HTTP status code, currently always `101` on success. + #[must_use] + pub const fn response_status(&self) -> u16 { + self.raw.response_status() + } + + /// Return the number of HTTP opening-response bytes consumed through its header terminator. + #[must_use] + pub const fn response_byte_count(&self) -> usize { + self.raw.response_byte_count() + } + + /// Return the total response deadline configured for this opening response. + #[must_use] + pub const fn response_timeout(&self) -> Duration { + self.raw.response_timeout() + } + + /// Return the number of request bytes written before the response was read. + #[must_use] + pub const fn request_byte_count(&self) -> usize { + self.raw.request_byte_count() + } + + /// Return the total write deadline configured for the preceding opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.raw.write_timeout() + } + + /// Clone the exact underlying stream for crate-internal fault-injection tests only. + #[cfg(test)] + pub(crate) fn try_clone_stream_for_test(&self) -> std::io::Result { + self.raw.stream.try_clone() + } + + /// Write one unfragmented, masked UTF-8 text frame on this verified stream. + /// + /// The caller-supplied masking key must come from an approved strong randomness source. The + /// immediately preceding successful client text or Pong key is retained so accidental adjacent + /// reuse fails closed before any frame bytes are emitted, without treating random collisions + /// across the entire connection lifetime as protocol failures. Generic diagnostics for the + /// public mask-key value redact its entropy; only this reviewed wire-framing boundary unwraps the + /// exact bytes. + pub fn write_text_frame( + mut self, + text: &str, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + self.client_mask_keys.reserve(masking_key)?; + self.raw = self + .raw + .write_text_frame(text, masking_key.into_raw(), frame_timeout)?; + Ok(self) + } + + /// Write one final masked RFC 6455 Pong control frame on this verified stream. + /// + /// Immediate masking-key reuse is rejected against the same previous-frame guard used by text + /// frames, so switching frame types cannot bypass detection of a stuck caller key. + pub fn write_pong_frame( + mut self, + payload: &[u8], + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + self.client_mask_keys.reserve(masking_key)?; + self.raw = self + .raw + .write_pong_frame(payload, masking_key, frame_timeout)?; + Ok(self) + } + + /// Read one bounded RFC 6455 frame and reject close status codes forbidden on the wire. + pub fn read_frame( + mut self, + frame_timeout: Duration, + ) -> Result<(Self, raw::WebDriverBiDiWebSocketFrame), raw::WebDriverBiDiWebSocketFrameError> + { + let (raw, frame) = self.raw.read_frame(frame_timeout)?; + validate_close_status_code(&frame)?; + self.raw = raw; + Ok((self, frame)) + } +} + +fn validate_close_status_code( + frame: &raw::WebDriverBiDiWebSocketFrame, +) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { + if frame.opcode() != 0x8 || frame.payload().len() < 2 { + return Ok(()); + } + + let status_code = u16::from_be_bytes([frame.payload()[0], frame.payload()[1]]); + if !(1000..=4999).contains(&status_code) || matches!(status_code, 1004 | 1005 | 1006 | 1015) { + return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame status code is not valid on the wire", + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_mask_history_rejects_only_immediate_reuse_without_a_lifetime_cap() { + let first = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let second = WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); + let mut history = ClientMaskKeyHistory::default(); + + assert!(history.reserve(first).is_ok()); + assert!(matches!( + history.reserve(first), + Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_CLIENT_MASK_KEY_REASON + }) + )); + assert!(history.reserve(second).is_ok()); + assert!(history.reserve(first).is_ok()); + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs new file mode 100644 index 000000000..c98a22521 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -0,0 +1,262 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REUSED_MASK_REASON: &str = + "client masking key was reused for consecutive frames on this established WebSocket"; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type UnexpectedBinaryServer = thread::JoinHandle>>; +type EstablishedWithUnexpectedBinaryServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + UnexpectedBinaryServer, +); + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one masked final client text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture rejects 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn require_peer_closed_without_another_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "client emitted a second frame after fail-closed locateNodes command rejection", + )), + Err(error) => Err(io::Error::new( + error.kind(), + format!("client did not close after fail-closed locateNodes rejection: {error}"), + )), + } +} + +fn establish_with_unexpected_binary_response() +-> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_client_text_frame(&mut stream)?; + stream.write_all(&[0x82, 0x00])?; + Ok(command) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn locate_nodes_command_mask_reuse_fails_before_second_wire_write() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let first_frame = read_client_text_frame(&mut stream)?; + require_peer_closed_without_another_frame(&mut stream)?; + Ok(first_frame) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); + let established = + established.write_text_frame("coverage-primer", reused_mask, Duration::from_millis(500))?; + + let error = established.exchange_locate_nodes( + locate_nodes_command()?, + reused_mask, + &mut || None, + Duration::from_millis(500), + ); + assert!(matches!( + error, + Err(WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_MASK_REASON, + } + )) + )); + + let first_frame = server.join().map_err(|_| "test server panicked")??; + assert_eq!(first_frame, b"coverage-primer"); + Ok(()) +} + +#[test] +fn live_binding_wrapper_fails_closed_when_wire_exchange_fails_before_binding() +-> Result<(), Box> { + let (established, server) = establish_with_unexpected_binary_response()?; + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + + let error = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + (semantic_observation_proof()?, target), + &mut registry, + ); + + assert!(matches!( + error, + Err( + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: true, + opcode: 0x2, + } + ) + )); + let command = server.join().map_err(|_| "test server panicked")??; + assert_eq!(command, locate_nodes_command()?.as_json().as_bytes()); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs new file mode 100644 index 000000000..2c91dd6bc --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs @@ -0,0 +1,264 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserSessionId, BrowsingContextId, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type ServerHandle = thread::JoinHandle>>; +type EstablishedFixture = + Result<(SocketAddr, WebDriverBiDiWebSocketEstablished, ServerHandle), Box>; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one masked final client text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture rejects 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn establish() -> EstablishedFixture { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_client_text_frame(&mut stream)?; + let response = RESPONSE_DOCUMENT.as_bytes(); + let response_length = u8::try_from(response.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeds short frame", + ) + })?; + if response_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeds short frame", + )); + } + stream.write_all(&[0x81, response_length])?; + stream.write_all(response)?; + Ok(command) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((local_addr, established, server)) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + origin: &'a Origin, +) -> Result< + ( + BrowserContextOriginEpochDispatchTarget<'a>, + BrowserSessionId, + BrowsingContextId, + ), + Box, +> { + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, origin)?; + Ok(( + BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + origin, + ), + epoch, + ), + session, + context, + )) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn live_websocket_locate_nodes_exchange_binds_wire_nodes_to_current_authority() +-> Result<(), Box> { + let (local_addr, established, server) = establish()?; + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let (target, _session, _context) = current_target(&mut registry, &origin)?; + let expected_epoch = target.expected_epoch(); + + let (established, handles) = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + (semantic_observation_proof()?, target), + &mut registry, + )?; + + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].origin(), &origin); + assert_eq!(handles[0].document_epoch(), expected_epoch); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + let command = server.join().map_err(|_| "test server panicked")??; + assert_eq!(command, locate_nodes_command()?.as_json().as_bytes()); + Ok(()) +} + +#[test] +fn live_exchange_fails_closed_when_document_epoch_changed_before_wire_node_binding() +-> Result<(), Box> { + let (_local_addr, established, server) = establish()?; + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let (target, session, context) = current_target(&mut registry, &origin)?; + let stale_epoch = target.expected_epoch(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin(session, context, &origin)?; + + let error = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + (semantic_observation_proof()?, target), + &mut registry, + ); + + assert!(matches!( + error, + Err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected, + current, + }, + ), + )) if expected == stale_epoch && current == current_epoch + )); + let command = server.join().map_err(|_| "test server panicked")??; + assert_eq!(command, locate_nodes_command()?.as_json().as_bytes()); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_error_contract.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_error_contract.rs new file mode 100644 index 000000000..f2ed6acfd --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_error_contract.rs @@ -0,0 +1,44 @@ +use std::{error::Error as _, time::Duration}; + +use originweave_core::{ + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, +}; +use originweave_network::{ + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiLocateNodesExchangeError, + WebDriverBiDiWebSocketFrameError, +}; + +#[test] +fn downstream_callers_observe_exact_exchange_error_sources() { + let frame = WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + ); + assert!(frame.source().is_some()); + + let document = WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8, + ); + assert!(document.source().is_some()); + + let response = WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ); + assert!(response.source().is_some()); + + let source_free_errors = [ + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout: Duration::from_millis(500), + }, + WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable, + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: false, + opcode: 0x2, + }, + ]; + for error in source_free_errors { + assert!(error.source().is_none(), "{error:?}"); + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs new file mode 100644 index 000000000..84c09eed6 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs @@ -0,0 +1,160 @@ +use std::{ + io::{self, Read, Write}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; + +fn read_client_text_frame(stream: &mut impl Read) -> io::Result<()> { + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + assert_eq!(header[0], 0x81); + assert_ne!(header[1] & 0x80, 0); + + let payload_marker = header[1] & 0x7f; + let payload_length = if payload_marker <= 125 { + usize::from(payload_marker) + } else if payload_marker == 126 { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test command unexpectedly used a 64-bit WebSocket payload length", + )); + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +#[test] +fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let address = listener.local_addr(); + assert!(address.is_ok(), "{address:?}"); + let Ok(address) = address else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(1)))?; + + let mut opening = Vec::new(); + let mut byte = [0_u8; 1]; + while !opening.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte)?; + opening.push(byte[0]); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + + read_client_text_frame(&mut stream)?; + + let response = RESPONSE_DOCUMENT.as_bytes(); + let response_length = u8::try_from(response.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response unexpectedly exceeded short-frame length", + ) + })?; + stream.write_all(&[0x81, response_length])?; + stream.write_all(response)?; + Ok(()) + }); + + let endpoint = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")); + assert!(endpoint.is_ok(), "{endpoint:?}"); + let Ok(endpoint) = endpoint else { + return; + }; + let correlated = endpoint.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + return; + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + return; + }; + let connection_plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(connection_plan.is_ok(), "{connection_plan:?}"); + let Ok(connection_plan) = connection_plan else { + return; + }; + let connection = connection_plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + return; + }; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let handshake = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(handshake.is_ok(), "{handshake:?}"); + let Ok(handshake) = handshake else { + return; + }; + let opening = handshake.write_opening_request(Duration::from_millis(500)); + assert!(opening.is_ok(), "{opening:?}"); + let Ok(opening) = opening else { + return; + }; + let established = opening.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2); + assert!(query.is_ok(), "{query:?}"); + let Ok(query) = query else { + return; + }; + let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query); + assert!(command.is_ok(), "{command:?}"); + let Ok(command) = command else { + return; + }; + + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_secs(6), + ); + assert!(exchanged.is_ok(), "{exchanged:?}"); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(server_io) = server_result { + assert!(server_io.is_ok(), "{server_io:?}"); + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs new file mode 100644 index 000000000..b1c98b61a --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs @@ -0,0 +1,255 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const PING_PAYLOAD: &[u8] = b"ping-between-fragments"; + +type EstablishedFragmentServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_frame( + stream: &mut TcpStream, + expected_first_byte: u8, +) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != expected_first_byte || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client frame did not use the expected final masked opcode", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + let payload_length = u64::from_be_bytes(extended); + usize::try_from(payload_length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client frame length did not fit usize", + ) + })? + } + marker => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected WebSocket length marker {marker}"), + )); + } + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result> { + read_masked_client_frame(stream, 0x81) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn response_fragments() -> io::Result<(Vec, Vec)> { + let response = RESPONSE_DOCUMENT.as_bytes(); + let split = response.len() / 2; + let first = &response[..split]; + let second = &response[split..]; + let first_length = u8::try_from(first.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "first response fragment exceeded one-byte test length", + ) + })?; + let second_length = u8::try_from(second.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "second response fragment exceeded one-byte test length", + ) + })?; + + let mut first_frame = Vec::with_capacity(first.len() + 2); + first_frame.extend_from_slice(&[0x01, first_length]); + first_frame.extend_from_slice(first); + let mut second_frame = Vec::with_capacity(second.len() + 2); + second_frame.extend_from_slice(&[0x80, second_length]); + second_frame.extend_from_slice(second); + Ok((first_frame, second_frame)) +} + +fn establish_with_fragmented_response() -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_text_frame(&mut stream)?; + let (first_frame, second_frame) = response_fragments()?; + stream.write_all(&first_frame)?; + stream.write_all(&second_frame) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn establish_with_ping_between_fragments() -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_text_frame(&mut stream)?; + let (first_frame, second_frame) = response_fragments()?; + stream.write_all(&first_frame)?; + let ping_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test Ping payload exceeded 125 bytes", + ) + })?; + stream.write_all(&[0x89, ping_length])?; + stream.write_all(PING_PAYLOAD)?; + + let pong_payload = read_masked_client_frame(&mut stream, 0x8a)?; + if pong_payload != PING_PAYLOAD { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client Pong did not echo the interleaved Ping payload", + )); + } + stream.write_all(&second_frame) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn assert_successful_exchange( + exchanged: Result< + ( + originweave_network::WebDriverBiDiWebSocketEstablished, + originweave_core::ValidatedWebDriverBiDiLocateNodesResult, + ), + originweave_network::WebDriverBiDiLocateNodesExchangeError, + >, + server: thread::JoinHandle>, +) -> Result<(), Box> { + let server_result = server + .join() + .map_err(|_| io::Error::other("fragmentation regression server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + assert!(exchanged.is_ok(), "{exchanged:?}"); + + let (_, result) = exchanged?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + Ok(()) +} + +#[test] +fn locate_nodes_exchange_reassembles_fragmented_text_response() -> Result<(), Box> { + let (established, server) = establish_with_fragmented_response()?; + let command = locate_nodes_command()?; + let mut no_pong_keys = || None; + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + assert_successful_exchange(exchanged, server) +} + +#[test] +fn locate_nodes_exchange_handles_ping_between_response_fragments() -> Result<(), Box> { + let (established, server) = establish_with_ping_between_fragments()?; + let command = locate_nodes_command()?; + let mut pong_keys = || Some(WebDriverBiDiWebSocketMaskKey::new([0x55, 0x66, 0x77, 0x88])); + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut pong_keys, + Duration::from_millis(500), + ); + assert_successful_exchange(exchanged, server) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs new file mode 100644 index 000000000..eedd13fd8 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs @@ -0,0 +1,305 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiResponseDocumentAdmissionError, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; + +type EstablishedFrameServer = ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client frame payload length did not fit usize", + ) + })? + } + marker => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid client frame payload-length marker {marker}"), + )); + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn server_frame(fin: bool, opcode: u8, payload: &[u8]) -> io::Result> { + let first_byte = if fin { 0x80 | opcode } else { opcode }; + let mut frame = Vec::with_capacity(payload.len() + 10); + frame.push(first_byte); + + if payload.len() <= 125 { + frame.push(u8::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "short server frame length did not fit u8", + ) + })?); + } else if payload.len() <= usize::from(u16::MAX) { + frame.push(126); + frame.extend_from_slice( + &u16::try_from(payload.len()) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "medium server frame length did not fit u16", + ) + })? + .to_be_bytes(), + ); + } else { + frame.push(127); + frame.extend_from_slice( + &u64::try_from(payload.len()) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "large server frame length did not fit u64", + ) + })? + .to_be_bytes(), + ); + } + + frame.extend_from_slice(payload); + Ok(frame) +} + +fn establish_with_frames(frames: Vec>) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + for frame in frames { + stream.write_all(&frame)?; + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn join_server(server: thread::JoinHandle>) -> Result<(), Box> { + server + .join() + .map_err(|_| io::Error::other("fragmentation boundary server panicked"))??; + Ok(()) +} + +#[test] +fn three_fragment_response_reassembles() -> Result<(), Box> { + let response = RESPONSE_DOCUMENT.as_bytes(); + let first_end = response.len() / 3; + let second_end = first_end * 2; + let frames = vec![ + server_frame(false, 0x1, &response[..first_end])?, + server_frame(false, 0x0, &response[first_end..second_end])?, + server_frame(true, 0x0, &response[second_end..])?, + ]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + let (_, result) = exchange?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + Ok(()) +} + +#[test] +fn oversized_initial_fragment_fails_closed() -> Result<(), Box> { + let payload = vec![b'{'; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1]; + let frames = vec![server_frame(false, 0x1, &payload)?]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge + )) + )); + Ok(()) +} + +#[test] +fn fragmented_response_over_budget_fails_closed() -> Result<(), Box> { + let first_payload = vec![b'{'; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES]; + let frames = vec![ + server_frame(false, 0x1, &first_payload)?, + server_frame(true, 0x0, b"}")?, + ]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge + )) + )); + Ok(()) +} + +#[test] +fn fragmented_invalid_utf8_fails_closed() -> Result<(), Box> { + let frames = vec![ + server_frame(false, 0x1, b"{")?, + server_frame(true, 0x0, &[0xff, b'}'])?, + ]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8 + )) + )); + Ok(()) +} + +#[test] +fn excessive_zero_length_fragments_fail_closed() -> Result<(), Box> { + let mut frames = Vec::with_capacity(MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE + 1); + frames.push(server_frame(false, 0x1, b"")?); + for _ in 1..=MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE { + frames.push(server_frame(false, 0x0, b"")?); + } + + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_secs(2), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err( + WebDriverBiDiLocateNodesExchangeError::ResponseFragmentLimitExceeded { + maximum_fragments: MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, + } + ) + )); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs new file mode 100644 index 000000000..15df8cad0 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs @@ -0,0 +1,141 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client frame payload length did not fit usize", + ) + })? + } + marker => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid client frame payload-length marker {marker}"), + )); + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +#[test] +fn second_text_message_during_fragmentation_fails_closed() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + stream.write_all(&[0x01, 0x01, b'{'])?; + stream.write_all(&[0x81, 0x01, b'x']) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x31, 0x32, 0x33, 0x34]), + &mut || None, + Duration::from_millis(500), + ); + + server + .join() + .map_err(|_| io::Error::other("fragmented second-text server panicked"))??; + assert_eq!( + format!("{exchange:?}"), + "Err(UnexpectedResponseFrame { fin: true, opcode: 1 })" + ); + assert!(matches!( + exchange, + Err( + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: true, + opcode: 0x1 + } + ) + )); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs new file mode 100644 index 000000000..cb533c86e --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs @@ -0,0 +1,236 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const PING_PAYLOAD: &[u8] = b"fresh-mask"; +const COMMAND_MASK: WebDriverBiDiWebSocketMaskKey = + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); +const PONG_MASK: WebDriverBiDiWebSocketMaskKey = + WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); +const REUSED_MASK_ERROR: &str = "WebDriver BiDi locateNodes exchange refused a Pong masking key matching the immediately preceding client frame"; + +type EstablishedServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_frame(stream: &mut TcpStream, expected_first_byte: u8) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != expected_first_byte || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client frame did not have the expected final opcode and masking bit", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture does not admit 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn write_ping(stream: &mut TcpStream) -> io::Result<()> { + let payload_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test Ping payload exceeded one-byte length", + ) + })?; + stream.write_all(&[0x89, payload_length])?; + stream.write_all(PING_PAYLOAD) +} + +fn write_response(stream: &mut TcpStream) -> io::Result<()> { + let payload_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeded one-byte length", + ) + })?; + stream.write_all(&[0x81, payload_length])?; + stream.write_all(RESPONSE_DOCUMENT.as_bytes()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn establish_with_ping_sequence( + read_first_pong: bool, +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_frame(&mut stream, 0x81)?; + write_ping(&mut stream)?; + if read_first_pong { + read_masked_client_frame(&mut stream, 0x8a)?; + write_ping(&mut stream)?; + } + thread::sleep(Duration::from_millis(150)); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let client_key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, client_key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn join_server(server: thread::JoinHandle>) -> Result<(), Box> { + let result = server + .join() + .map_err(|_| io::Error::other("masking-key freshness test server panicked"))?; + Ok(result?) +} + +#[test] +fn locate_nodes_exchange_rejects_pong_mask_reused_from_command_frame() -> Result<(), Box> +{ + let (established, server) = establish_with_ping_sequence(false)?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + COMMAND_MASK, + &mut || Some(COMMAND_MASK), + Duration::from_millis(500), + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("reusing the command masking key for Pong unexpectedly succeeded") + })?; + assert_eq!(error.to_string(), REUSED_MASK_ERROR); + join_server(server) +} + +#[test] +fn locate_nodes_exchange_rejects_pong_mask_reused_from_prior_pong() -> Result<(), Box> { + let (established, server) = establish_with_ping_sequence(true)?; + let mut keys = [PONG_MASK, PONG_MASK].into_iter(); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + COMMAND_MASK, + &mut || keys.next(), + Duration::from_millis(500), + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("reusing a prior Pong masking key unexpectedly succeeded") + })?; + assert_eq!(error.to_string(), REUSED_MASK_ERROR); + join_server(server) +} + +#[test] +fn locate_nodes_exchange_allows_non_adjacent_random_mask_collision() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_frame(&mut stream, 0x81)?; + write_ping(&mut stream)?; + read_masked_client_frame(&mut stream, 0x8a)?; + write_ping(&mut stream)?; + read_masked_client_frame(&mut stream, 0x8a)?; + write_response(&mut stream) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let client_key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, client_key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let mut keys = [PONG_MASK, COMMAND_MASK].into_iter(); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + COMMAND_MASK, + &mut || keys.next(), + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("non-adjacent masking-key collision server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + let (_, result) = exchanged?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs new file mode 100644 index 000000000..3e26f30b7 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -0,0 +1,319 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const PING_PAYLOAD: &[u8] = b"keepalive"; + +type EstablishedPingServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_frame( + stream: &mut TcpStream, + expected_first_byte: u8, +) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != expected_first_byte || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client frame did not have the expected final opcode and masking bit", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture does not admit 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + if expected_first_byte == 0x8a && payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Pong payload exceeded the RFC 6455 control-frame bound", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn establish_with_ping(keep_open: Duration) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_frame(&mut stream, 0x81)?; + let ping_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test Ping payload exceeded one-byte length", + ) + })?; + stream.write_all(&[0x89, ping_length])?; + stream.write_all(PING_PAYLOAD)?; + thread::sleep(keep_open); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn join_ping_server(server: thread::JoinHandle>) -> Result<(), Box> { + let result = server + .join() + .map_err(|_| io::Error::other("Ping failure test server panicked"))?; + Ok(result?) +} + +#[test] +fn locate_nodes_exchange_answers_ping_and_ignores_unsolicited_pong_before_response() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<(Vec, Vec)> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_masked_client_frame(&mut stream, 0x81)?; + let ping_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test Ping payload exceeded one-byte length", + ) + })?; + stream.write_all(&[0x89, ping_length])?; + stream.write_all(PING_PAYLOAD)?; + let pong = read_masked_client_frame(&mut stream, 0x8a)?; + stream.write_all(&[0x8a, 0])?; + let response_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeded one-byte length", + ) + })?; + stream.write_all(&[0x81, response_length])?; + stream.write_all(RESPONSE_DOCUMENT.as_bytes())?; + Ok((command, pong)) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let command = locate_nodes_command()?; + let expected_command = command.as_json().as_bytes().to_vec(); + let mut pong_keys = [WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54])].into_iter(); + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || pong_keys.next(), + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("interleaved control-frame test server panicked"))?; + assert!(exchanged.is_ok(), "{exchanged:?}"); + assert!(server_result.is_ok(), "{server_result:?}"); + let (received_command, received_pong) = server_result?; + assert_eq!(received_command, expected_command); + assert_eq!(received_pong, PING_PAYLOAD); + + let (established, result) = exchanged?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + Ok(()) +} + +#[test] +fn locate_nodes_exchange_fails_closed_when_ping_entropy_is_unavailable() +-> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(100))?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + ); + + let error = exchanged + .err() + .ok_or_else(|| io::Error::other("Ping without masking entropy unexpectedly succeeded"))?; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key" + ); + join_ping_server(server) +} + +#[test] +fn locate_nodes_exchange_charges_ping_callback_time_to_exchange_deadline() +-> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(650))?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + thread::sleep(Duration::from_millis(550)); + Some(pong_key) + }, + Duration::from_millis(500), + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("slow Ping callback unexpectedly reset the exchange deadline") + })?; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange exhausted its 500ms end-to-end deadline before the next operation" + ); + join_ping_server(server) +} + +#[test] +fn locate_nodes_exchange_bounds_valid_interleaved_control_frames() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_frame(&mut stream, 0x81)?; + + let mut frames = Vec::with_capacity( + (MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1) * 2 + RESPONSE_DOCUMENT.len() + 2, + ); + for _ in 0..=MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE { + frames.extend_from_slice(&[0x8a, 0]); + } + let response_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeded one-byte length", + ) + })?; + frames.extend_from_slice(&[0x81, response_length]); + frames.extend_from_slice(RESPONSE_DOCUMENT.as_bytes()); + stream.write_all(&frames) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("control-frame limit test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + let error = exchanged + .err() + .ok_or_else(|| io::Error::other("control-frame flood unexpectedly reached response"))?; + assert!(matches!( + error, + WebDriverBiDiLocateNodesExchangeError::ControlFrameLimitExceeded { + maximum_control_frames, + } if maximum_control_frames == MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + )); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_pong_write_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_pong_write_failure.rs new file mode 100644 index 000000000..ec5f8a915 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_pong_write_failure.rs @@ -0,0 +1,153 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{Shutdown, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnection, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn connect(endpoint: &str) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client command was not one final masked text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => u64::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + u64::from(u16::from_be_bytes(extended)) + } + _ => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + u64::from_be_bytes(extended) + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut remaining = usize::try_from(payload_length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client command length cannot fit this test process", + ) + })?; + let mut buffer = [0_u8; 512]; + while remaining != 0 { + let chunk = remaining.min(buffer.len()); + stream.read_exact(&mut buffer[..chunk])?; + remaining -= chunk; + } + Ok(()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +#[test] +fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + stream.write_all(&[0x89, 0])?; + thread::sleep(Duration::from_millis(250)); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let connection = connect(&endpoint)?; + let shutdown_stream = connection.stream().try_clone()?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + let shutdown = shutdown_stream.shutdown(Shutdown::Write); + assert!(shutdown.is_ok(), "{shutdown:?}"); + Some(pong_key) + }, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("Pong write failure test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("locateNodes exchange unexpectedly survived a closed client write half") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { .. } + ) + ), + "{error:?}" + ); + assert!(error.source().is_some()); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs new file mode 100644 index 000000000..38f819f5b --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs @@ -0,0 +1,92 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn exchange_server_frame( + frame: &[u8], +) -> Result, Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let frame = frame.to_vec(); + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut opening = Vec::new(); + let mut byte = [0_u8; 1]; + while !opening.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte)?; + opening.push(byte[0]); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.write_all(&frame) + }); + + let endpoint = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{local_addr}/session/{SESSION_ID}"))?; + let correlated = endpoint.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let client_key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let handshake = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key)?; + let opening = handshake.write_opening_request(Duration::from_millis(500))?; + let established = opening.read_opening_response(Duration::from_millis(500))?; + let result = established + .read_frame(Duration::from_millis(500)) + .map(|_| ()); + + let server_result = server + .join() + .map_err(|_| io::Error::other("close-frame validation test server panicked"))?; + server_result?; + + Ok(result) +} + +#[test] +fn close_frame_enforces_payload_shape_and_utf8_reason() -> Result<(), Box> { + assert!(matches!( + exchange_server_frame(&[0x88, 0x01, 0x00])?, + Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) + )); + assert!(matches!( + exchange_server_frame(&[0x88, 0x04, 0x03, 0xe8, 0xff, 0xff])?, + Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) + )); + + assert!(exchange_server_frame(&[0x88, 0x00])?.is_ok()); + assert!(exchange_server_frame(&[0x88, 0x04, 0x03, 0xe8, b'o', b'k'])?.is_ok()); + Ok(()) +} + +#[test] +fn close_frame_rejects_forbidden_wire_status_codes() -> Result<(), Box> { + for status_code in [999_u16, 1004, 1005, 1006, 1015, 5000] { + let [high, low] = status_code.to_be_bytes(); + assert!(matches!( + exchange_server_frame(&[0x88, 0x02, high, low])?, + Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) + )); + } + + for status_code in [1000_u16, 3000, 4000] { + let [high, low] = status_code.to_be_bytes(); + assert!(exchange_server_frame(&[0x88, 0x02, high, low])?.is_ok()); + } + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs new file mode 100644 index 000000000..d04ff0eb0 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -0,0 +1,345 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const MISMATCHED_RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":8,"result":{"nodes":[]}}"#; + +type ServerHandle = thread::JoinHandle>>; +type EstablishedFixture = + Result<(SocketAddr, WebDriverBiDiWebSocketEstablished, ServerHandle), Box>; + +fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted explicit target") + }; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + unreachable!("asserted connection plan") + }; + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + unreachable!("asserted loopback connection") + }; + connection +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + assert_eq!(header[0], 0x81); + assert_ne!(header[1] & 0x80, 0); + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture does not admit 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn server_frame(first_byte: u8, payload: &[u8]) -> io::Result> { + let payload_length = u8::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response must fit one short WebSocket text frame", + ) + })?; + if payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test response must fit one short WebSocket text frame", + )); + } + let mut frame = vec![first_byte, payload_length]; + frame.extend_from_slice(payload); + Ok(frame) +} + +fn establish_with_server_frame(response_frame: &[u8]) -> EstablishedFixture { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let response_frame = response_frame.to_vec(); + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_client_text_frame(&mut stream)?; + stream.write_all(&response_frame)?; + Ok(command) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((local_addr, established, server)) +} + +fn locate_nodes_command() -> WebDriverBiDiLocateNodesCommand { + let name = "x".repeat(512); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some(&name), 2); + assert!(query.is_ok(), "{query:?}"); + let Ok(query) = query else { + unreachable!("asserted valid test query") + }; + let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query); + assert!(command.is_ok(), "{command:?}"); + let Ok(command) = command else { + unreachable!("asserted valid test command") + }; + command +} + +fn exchange_error( + response_frame: &[u8], + frame_timeout: Duration, + server_must_receive_command: bool, +) -> WebDriverBiDiLocateNodesExchangeError { + let fixture = establish_with_server_frame(response_frame); + assert!(fixture.is_ok(), "{fixture:?}"); + let Ok((_, established, server)) = fixture else { + unreachable!("asserted valid test exchange fixture") + }; + let error = established.exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + frame_timeout, + ); + assert!(error.is_err(), "{error:?}"); + let Err(error) = error else { + unreachable!("asserted failing test exchange") + }; + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + let Ok(server_result) = server_result else { + unreachable!("asserted joined test server") + }; + assert_eq!( + server_result.is_ok(), + server_must_receive_command, + "test server command receipt did not match the exchange boundary" + ); + error +} + +#[test] +fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_result() { + let response_frame = server_frame(0x81, RESPONSE_DOCUMENT.as_bytes()); + assert!(response_frame.is_ok(), "{response_frame:?}"); + let Ok(response_frame) = response_frame else { + return; + }; + let fixture = establish_with_server_frame(&response_frame); + assert!(fixture.is_ok(), "{fixture:?}"); + let Ok((local_addr, established, server)) = fixture else { + return; + }; + + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2); + assert!(query.is_ok(), "{query:?}"); + let Ok(query) = query else { + return; + }; + let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query); + assert!(command.is_ok(), "{command:?}"); + let Ok(command) = command else { + return; + }; + let expected_command = command.as_json().as_bytes().to_vec(); + + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + ); + assert!(exchanged.is_ok(), "{exchanged:?}"); + let Ok((established, result)) = exchanged else { + return; + }; + + assert_eq!(result.command_id(), 7); + assert_eq!(result.browsing_context(), "top-level-context"); + assert_eq!(result.max_node_count(), 2); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .session_id(), + SESSION_ID + ); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(command_result) = server_result { + assert!(command_result.is_ok(), "{command_result:?}"); + if let Ok(actual_command) = command_result { + assert_eq!(actual_command, expected_command); + } + } +} + +#[test] +fn exchange_rejects_binary_or_orphan_continuation_response_frames() { + for (first_byte, expected_fin, expected_opcode) in + [(0x82_u8, true, 0x02_u8), (0x80_u8, true, 0x00_u8)] + { + let response_frame = server_frame(first_byte, &[]); + assert!(response_frame.is_ok(), "{response_frame:?}"); + let Ok(response_frame) = response_frame else { + return; + }; + let fixture = establish_with_server_frame(&response_frame); + assert!(fixture.is_ok(), "{fixture:?}"); + let Ok((_, established, server)) = fixture else { + return; + }; + + let error = established.exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + ); + assert!(error.is_err(), "{error:?}"); + let Err(error) = error else { + unreachable!("asserted invalid response frame failure") + }; + assert!(matches!( + error, + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin, + opcode, + } if fin == expected_fin && opcode == expected_opcode + )); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(command_result) = server_result { + assert!(command_result.is_ok(), "{command_result:?}"); + } + } +} + +#[test] +fn exchange_preserves_frame_document_and_response_admission_boundaries() { + let write_error = exchange_error(&[], Duration::ZERO, false); + assert!(matches!( + write_error, + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + } if exchange_timeout.is_zero() + )); + + let read_error = exchange_error(&[], Duration::from_millis(500), true); + assert!(matches!( + read_error, + WebDriverBiDiLocateNodesExchangeError::Frame(_) + )); + + let invalid_utf8_frame = server_frame(0x81, &[0xff]); + assert!(invalid_utf8_frame.is_ok(), "{invalid_utf8_frame:?}"); + let Ok(invalid_utf8_frame) = invalid_utf8_frame else { + return; + }; + let document_error = exchange_error(&invalid_utf8_frame, Duration::from_millis(500), true); + assert!(matches!( + document_error, + WebDriverBiDiLocateNodesExchangeError::ResponseDocument(_) + )); + + let mismatched_frame = server_frame(0x81, MISMATCHED_RESPONSE_DOCUMENT.as_bytes()); + assert!(mismatched_frame.is_ok(), "{mismatched_frame:?}"); + let Ok(mismatched_frame) = mismatched_frame else { + return; + }; + let response_error = exchange_error(&mismatched_frame, Duration::from_millis(500), true); + assert!(matches!( + response_error, + WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse(_) + )); +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs new file mode 100644 index 000000000..509d303a9 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs @@ -0,0 +1,99 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const FRAME_COUNT: u32 = 65_537; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_one_masked_single_byte_text_frame(stream: &mut TcpStream) -> io::Result<()> { + let mut frame = [0_u8; 7]; + stream.read_exact(&mut frame)?; + if frame[0] != 0x81 || frame[1] != 0x81 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client did not send one final masked single-byte text frame", + )); + } + if frame[6] ^ frame[2] != b'x' { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "masked text payload did not decode to the expected byte", + )); + } + Ok(()) +} + +#[test] +fn established_stream_does_not_gain_a_lifetime_frame_cap_from_reuse_detection() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + for _ in 0..FRAME_COUNT { + read_one_masked_single_byte_text_frame(&mut stream)?; + } + Ok(FRAME_COUNT) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let mut established = written.read_opening_response(Duration::from_millis(500))?; + + for ordinal in 0..FRAME_COUNT { + let key_ordinal = (ordinal % (FRAME_COUNT - 1)) + 1; + let masking_key = WebDriverBiDiWebSocketMaskKey::new(key_ordinal.to_be_bytes()); + established = established.write_text_frame("x", masking_key, Duration::from_millis(500))?; + } + drop(established); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket history-cap test server panicked"))??; + assert_eq!(received, FRAME_COUNT); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs new file mode 100644 index 000000000..4f36bff23 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -0,0 +1,137 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REUSED_MASK_REASON: &str = + "client masking key was reused for consecutive frames on this established WebSocket"; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text(stream: &mut TcpStream) -> io::Result { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client did not send one final masked text frame", + )); + } + let payload_length = usize::from(header[1] & 0x7f); + if payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test text payload unexpectedly used an extended length", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + String::from_utf8(payload).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn require_peer_closed_before_second_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "client emitted a second frame after reusing its masking key", + )), + Err(error) => Err(io::Error::new( + error.kind(), + format!("client did not close after refusing a reused masking key: {error}"), + )), + } +} + +#[test] +fn established_stream_rejects_client_mask_reuse_across_sequential_frames() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let first = read_masked_text(&mut stream)?; + require_peer_closed_before_second_frame(&mut stream)?; + Ok(first) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x21, 0x22, 0x23, 0x24]); + let established = + established.write_text_frame("first-frame", reused_mask, Duration::from_millis(500))?; + let error = + match established.write_text_frame("second-frame", reused_mask, Duration::from_millis(500)) + { + Ok(_) => { + return Err( + io::Error::other("RFC 6455 masking-key reuse unexpectedly succeeded").into(), + ); + } + Err(error) => error, + }; + assert!(matches!( + error, + WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_MASK_REASON + } + )); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket mask-reuse test server panicked"))??; + assert_eq!(received, "first-frame"); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs index 433774dd4..77081e29c 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs @@ -1,5 +1,5 @@ use std::{ - io::{self, Read}, + io::{self, Read, Write}, net::TcpListener, thread, time::Duration, @@ -7,8 +7,10 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, }; @@ -58,6 +60,24 @@ fn read_opening_request(mut stream: std::net::TcpStream) -> io::Result> Ok(request) } +fn read_client_text_frame(mut stream: std::net::TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + assert_eq!(header[0], 0x81); + assert_ne!(header[1] & 0x80, 0); + let payload_length = usize::from(header[1] & 0x7f); + assert!(payload_length < 126); + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + #[test] fn bounded_opening_write_sends_exact_request_and_preserves_transport_evidence() { let listener = TcpListener::bind(("127.0.0.1", 0)); @@ -171,3 +191,462 @@ fn opening_write_rejects_zero_and_excessive_deadlines_before_success_evidence() } } } + +#[test] +fn opening_response_requires_rfc6455_switching_protocols_and_matching_accept() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let mut close_probe = [0_u8; 1]; + let _ = stream.read(&mut close_probe); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + assert_eq!(established.response_status(), 101); + assert!(established.response_byte_count() > 0); + assert!(established.request_byte_count() > 0); + assert_eq!(established.response_timeout(), Duration::from_millis(500)); + assert_eq!(established.write_timeout(), Duration::from_millis(500)); + assert_eq!(established.client_key().as_str(), RFC6455_SAMPLE_KEY); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + let debug = format!("{established:?}"); + assert!(debug.contains("WebDriverBiDiWebSocketEstablished")); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); + drop(established); + assert!(server.join().is_ok()); +} + +#[test] +fn opening_response_rejects_a_mismatched_accept_value() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: invalid\r\n\r\n", + ) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + + assert!(matches!( + written.read_opening_response(Duration::from_millis(500)), + Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch) + )); + assert!(server.join().is_ok()); +} + +#[test] +fn opening_response_rejects_zero_and_excessive_deadlines_before_socket_mode_change() { + for timeout in [ + Duration::ZERO, + originweave_network::MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT + Duration::from_nanos(1), + ] { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + continue; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + continue; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + continue; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + continue; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + continue; + }; + + assert!(matches!( + written.read_opening_response(timeout), + Err(WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout, + maximum_timeout, + }) if response_timeout == timeout + && maximum_timeout + == originweave_network::MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT + )); + assert!(server.join().is_ok()); + } +} + +#[test] +fn established_stream_writes_masked_text_and_reads_unmasked_text_frames() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + read_opening_request(stream.try_clone()?)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let client_payload = read_client_text_frame(stream.try_clone()?)?; + stream.write_all(b"\x89\x00\x81\x08{\"id\":2}")?; + Ok(client_payload) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + + let established = established.write_text_frame( + r#"{"id":1}"#, + WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), + Duration::from_millis(500), + ); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + let ping = established.read_frame(Duration::from_millis(500)); + assert!(ping.is_ok(), "{ping:?}"); + let Ok((established, ping)) = ping else { + return; + }; + assert!(ping.fin()); + assert_eq!(ping.opcode(), 0x9); + assert!(ping.payload().is_empty()); + + let received = established.read_frame(Duration::from_millis(500)); + assert!(received.is_ok(), "{received:?}"); + let Ok((_established, frame)) = received else { + return; + }; + assert!(frame.fin()); + assert_eq!(frame.opcode(), 0x1); + assert_eq!(frame.payload(), br#"{"id":2}"#); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(client_payload) = server_result { + assert!(client_payload.is_ok(), "{client_payload:?}"); + if let Ok(client_payload) = client_payload { + assert_eq!(client_payload, br#"{"id":1}"#); + } + } +} + +#[test] +fn established_stream_rejects_oversized_client_text_frames() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(stream.try_clone()?)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + let payload = "x".repeat(MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE + 1); + let result = established.write_text_frame( + &payload, + WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), + Duration::from_millis(500), + ); + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes, + maximum_bytes, + }) if payload_bytes == MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE + 1 + && maximum_bytes == MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE + )); + assert!(server.join().is_ok()); +} + +#[test] +fn established_stream_propagates_frame_write_failures() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + for _ in 0..2 { + let (mut stream, _) = listener.accept()?; + read_opening_request(stream.try_clone()?)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + } + Ok(()) + }); + + for (frame_timeout, invalid_timeout) in + [(Duration::ZERO, true), (Duration::from_nanos(1), false)] + { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + let result = established.write_text_frame( + "x", + WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), + frame_timeout, + ); + if invalid_timeout { + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) + )); + } else { + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { .. }) + )); + } + } + assert!(server.join().is_ok()); +} + +#[test] +fn established_stream_propagates_frame_read_failures() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(stream.try_clone()?)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.write_all(b"\x81\x01")?; + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + assert!(established.read_frame(Duration::from_millis(500)).is_err()); + assert!(server.join().is_ok()); +} + +#[test] +fn established_stream_rejects_invalid_read_frame_deadline() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(stream.try_clone()?)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + assert!(matches!( + established.read_frame(Duration::ZERO), + Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) + )); + assert!(server.join().is_ok()); +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs new file mode 100644 index 000000000..5fdd81c02 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -0,0 +1,220 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REUSED_MASK_REASON: &str = + "client masking key was reused for consecutive frames on this established WebSocket"; +const MAX_PONG_PAYLOAD_BYTES: usize = 125; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn write_opening_response(stream: &mut TcpStream) -> io::Result<()> { + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + ) +} + +fn read_masked_pong(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x8a || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client did not send one final masked Pong frame", + )); + } + let payload_length = usize::from(header[1] & 0x7f); + if payload_length > MAX_PONG_PAYLOAD_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Pong payload exceeded the RFC 6455 control-frame bound", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn require_peer_closed_without_another_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "client emitted frame bytes after a fail-closed Pong rejection", + )), + Err(error) => Err(io::Error::new( + error.kind(), + format!("client did not close after a fail-closed Pong rejection: {error}"), + )), + } +} + +#[test] +fn established_stream_writes_masked_pong_with_exact_ping_payload() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + read_masked_pong(&mut stream) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let pong_payload = b"peer-keepalive"; + let established = established.write_pong_frame( + pong_payload, + WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]), + Duration::from_millis(500), + )?; + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + drop(established); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket Pong test server panicked"))??; + assert_eq!(received, pong_payload); + Ok(()) +} + +#[test] +fn established_stream_rejects_reused_pong_mask_before_second_wire_write() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + let first_payload = read_masked_pong(&mut stream)?; + require_peer_closed_without_another_frame(&mut stream)?; + Ok(first_payload) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x61, 0x62, 0x63, 0x64]); + let established = + established.write_pong_frame(b"first-pong", reused_mask, Duration::from_millis(500))?; + let error = + match established.write_pong_frame(b"second-pong", reused_mask, Duration::from_millis(500)) + { + Ok(_) => { + return Err(io::Error::other( + "RFC 6455 Pong masking-key reuse unexpectedly succeeded", + ) + .into()); + } + Err(error) => error, + }; + assert!(matches!( + error, + WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_MASK_REASON + } + )); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket Pong mask-reuse test server panicked"))??; + assert_eq!(received, b"first-pong"); + Ok(()) +} + +#[test] +fn established_stream_rejects_oversized_pong_before_wire_write() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + require_peer_closed_without_another_frame(&mut stream) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let oversized = vec![0x41_u8; MAX_PONG_PAYLOAD_BYTES + 1]; + let error = match established.write_pong_frame( + &oversized, + WebDriverBiDiWebSocketMaskKey::new([0x71, 0x72, 0x73, 0x74]), + Duration::from_millis(500), + ) { + Ok(_) => return Err(io::Error::other("oversized Pong unexpectedly succeeded").into()), + Err(error) => error, + }; + assert!(matches!( + error, + WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes, + maximum_bytes: MAX_PONG_PAYLOAD_BYTES, + } if payload_bytes == oversized.len() + )); + + server + .join() + .map_err(|_| io::Error::other("WebSocket oversized-Pong test server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs new file mode 100644 index 000000000..a90fe1d19 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs @@ -0,0 +1,114 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketHandshakeResponseError, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RFC6455_SAMPLE_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; + +type TestResult = Result>; + +fn connect(endpoint: &str) -> TestResult { + let endpoint = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = endpoint.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + Ok(connection) +} + +fn read_opening_request(stream: &mut std::net::TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 256]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "opening request ended before headers", + )); + } + Ok(()) +} + +fn exercise_response( + response: Vec, +) -> TestResult> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(&response)?; + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let result = written + .read_opening_response(Duration::from_millis(500)) + .map(|established| { + drop(established); + }); + match server.join() { + Ok(server_result) => server_result?, + Err(_) => return Err(io::Error::other("loopback fixture thread panicked").into()), + } + Ok(result) +} + +#[test] +fn repeated_list_valued_upgrade_and_connection_lines_are_combined_semantically() -> TestResult<()> { + let response = format!( + "HTTP/1.1 101 Switching Protocols\r\n\ +Upgrade: h2c\r\n\ +Upgrade: websocket\r\n\ +Connection: keep-alive\r\n\ +Connection: Upgrade\r\n\ +Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" + ) + .into_bytes(); + + let result = exercise_response(response)?; + assert!( + result.is_ok(), + "RFC 9110 list-valued fields must combine: {result:?}" + ); + Ok(()) +} + +#[test] +fn repeated_sec_websocket_accept_remains_fail_closed() -> TestResult<()> { + let response = format!( + "HTTP/1.1 101 Switching Protocols\r\n\ +Upgrade: websocket\r\n\ +Connection: Upgrade\r\n\ +Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\ +Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" + ) + .into_bytes(); + + assert!(matches!( + exercise_response(response)?, + Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) + )); + Ok(()) +} diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index dbf3ef731..64ac88ef5 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -1,10 +1,10 @@ # Browser and Agent Protocol Standards Evidence -- **Reviewed:** 2026-08-18 +- **Reviewed:** 2026-08-20 - **Purpose:** primary-source evidence for OriginWeave browser compatibility and adapter boundaries - **Canonical research index:** [`../doctoring.md`](../doctoring.md) -This addendum complements the main doctoring record. The main record already carries the WebDriver BiDi, WARC/ISO 28500 and W3C PROV-O evidence. This addendum records the current primary sources for Manifest V3, Chrome DevTools Protocol, WebMCP and Model Context Protocol so product documentation does not rely on uncited protocol names. +This addendum complements the main doctoring record. The main record already carries the WebDriver BiDi, WARC/ISO 28500 and W3C PROV-O evidence. This addendum records the current primary sources for RFC 6455, Manifest V3, Chrome DevTools Protocol, WebMCP and Model Context Protocol so product documentation does not rely on uncited protocol names. ## WebDriver BiDi @@ -18,6 +18,12 @@ The same reviewed Editor’s Draft defines a closed `ErrorCode` vocabulary that Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). +## RFC 6455 WebSocket opening handshake + +RFC 6455 requires a client opening request to carry a fresh `Sec-WebSocket-Key` and requires a server upgrade response to return HTTP `101`, the `Upgrade: websocket` and `Connection: Upgrade` tokens, and a `Sec-WebSocket-Accept` value derived from that exact client key and the fixed WebSocket GUID. OriginWeave now validates this bounded response on the already peer-verified stream, with duplicate/security-header rejection, a response-size ceiling, and a monotonic deadline. The next boundary admits one frame at a time: client text frames require a caller-supplied fresh mask key and are masked on the wire, server frames must be unmasked, reserved bits/opcodes and nonminimal lengths fail closed, control-frame size and payload ceilings are enforced, and I/O remains bounded. Frame transport does not assemble BiDi messages, authenticate a browser process, or grant browser/Agent authority. + +Primary source: Internet Engineering Task Force, *The WebSocket Protocol* (RFC 6455). + ## Chrome Manifest V3 Chrome's current manifest documentation identifies Manifest V3 as the current extension manifest format and the supported `manifest_version` value. OriginWeave therefore tests its declared extension compatibility against a pinned real Chromium/Chrome-for-Testing build and publishes evidence by exact capability. This is a compatibility target, not a claim of universal Chrome/Web Store/Google-service/codec/DRM equivalence. @@ -89,4 +95,6 @@ World Wide Web Consortium. (2026, July 20). *WebDriver BiDi* (Editor’s Draft). World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ +Fette, I., & Melnikov, A. (2011). *The WebSocket protocol* (RFC 6455). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc6455 + International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html diff --git a/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py new file mode 100644 index 000000000..2b5b4d815 --- /dev/null +++ b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py @@ -0,0 +1,56 @@ +"""Coverage-shape contract for the bounded BiDi locateNodes exchange boundary. + +The production coverage gate is exact across functions, lines, regions, and branches. +Keeping the caller-supplied Pong entropy callback generic monomorphizes the whole +exchange function per closure type, which creates synthetic per-instantiation +coverage holes despite exercising the protocol paths. The callback is therefore a +borrowed trait object at this boundary: its behavior remains stateful and caller +owned without multiplying production coverage regions. + +The final node-binding error conversion must also avoid generating a separate closure +or helper function. The library is linked into both unit and integration-test +harnesses, and either form can acquire an uncovered duplicate instantiation even when +the real fail-closed binding path is exercised. A direct match inside the already +exercised public wrapper keeps the typed error conversion at the causal boundary +without adding another production symbol for coverage to duplicate. +""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE = ( + ROOT + / "crates" + / "originweave-network" + / "src" + / "webdriver_bidi_locate_nodes_exchange.rs" +) + + +class WebDriverBiDiLocateNodesCoverageShapeTests(unittest.TestCase): + """Prevent monomorphization artifacts from invalidating exact coverage evidence.""" + + def test_pong_entropy_callback_is_non_generic_at_exchange_boundary(self) -> None: + source = SOURCE.read_text(encoding="utf-8") + self.assertIn( + "next_pong_key: &mut dyn FnMut() -> Option", + source, + ) + self.assertNotIn( + "next_pong_key: impl FnMut() -> Option", + source, + ) + + def test_node_binding_error_conversion_stays_inside_exercised_wrapper(self) -> None: + source = SOURCE.read_text(encoding="utf-8") + self.assertIn("let handles = match result.bind_current_nodes(", source) + self.assertNotIn("fn map_node_binding_error(", source) + self.assertNotIn(".map_err(map_node_binding_error)?;", source) + self.assertNotIn(".map_err(|error| {", source) + + +if __name__ == "__main__": + unittest.main()