From 1e6c35df6fed66e9c8a0c4cbcc791ff1383fa6b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:26:58 +0900 Subject: [PATCH 01/62] feat(network): add bounded WebSocket frame transport --- CHANGELOG.md | 1 + crates/originweave-network/src/lib.rs | 9 +- .../src/webdriver_bidi_websocket_handshake.rs | 937 +++++++++++++++++- .../webdriver_bidi_websocket_opening_write.rs | 330 +++++- docs/doctoring/browser-agent-protocols.md | 2 +- 5 files changed, 1271 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e3f7a56..b5d031b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ 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 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. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index d42e321ec..139872e26 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -7,7 +7,7 @@ //! `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 and validate its bounded RFC 6455 opening response -//! without implementing WebSocket framing or granting browser, WebSocket, TLS, +//! and one bounded frame at a time without granting browser, WebSocket, TLS, //! policy, or Agent authority. #![forbid(unsafe_code)] @@ -26,9 +26,12 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; pub use webdriver_bidi_websocket_handshake::{ + 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, WebDriverBiDiWebSocketHandshakeError, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrame, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketHandshakeResponseError, - WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningRequestSent, + WebDriverBiDiWebSocketOpeningWriteError, }; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 9aacf0e02..cc2d23bca 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -16,6 +16,7 @@ use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; 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. /// @@ -35,6 +36,12 @@ pub const MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT: Duration = Duration::from_secs /// 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'/') } @@ -97,6 +104,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 @@ -383,6 +412,268 @@ impl WebDriverBiDiWebSocketEstablished { 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. 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 violated RFC 6455 or the no-extension policy. + 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. @@ -693,6 +984,260 @@ impl OpeningResponseReader for TcpStream { } } +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)?; + 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(()) +} + fn read_opening_response_with_clock( reader: &mut dyn OpeningResponseReader, client_key: &WebDriverBiDiWebSocketClientKey, @@ -978,7 +1523,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 { @@ -1030,6 +1581,21 @@ 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), @@ -1109,6 +1675,16 @@ mod opening_write_tests { 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(); @@ -1537,4 +2113,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/tests/webdriver_bidi_websocket_opening_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs index 9d688cd2f..77081e29c 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs @@ -7,9 +7,11 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketOpeningWriteError, + MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -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)); @@ -326,3 +346,307 @@ fn opening_response_rejects_zero_and_excessive_deadlines_before_socket_mode_chan 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/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index a50323f13..64ac88ef5 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -20,7 +20,7 @@ Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working ## 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. This proves only the RFC 6455 opening exchange; it does not authenticate a browser process, implement WebSocket frames, or grant browser/Agent authority. +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). From 2b02e019eaefc8a6114651268a1b8acd22518953 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:36:22 -0700 Subject: [PATCH 02/62] test(network): carry revoked-stream portability fix into frame stack --- .../tests/webdriver_bidi_websocket_handshake.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 8717d5a82..e74ae7355 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -117,13 +117,20 @@ fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { }; let write = plan.write_opening_request(Duration::from_secs(1)); - assert!(matches!( - write, + let failed_closed_without_writing = match write { Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { bytes_written: 0, .. - }) - )); + }) => true, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + source, + }, + ) => source.kind() == std::io::ErrorKind::InvalidInput, + _ => false, + }; + assert!(failed_closed_without_writing); let server_result = server.join(); assert!(server_result.is_ok(), "{server_result:?}"); From 84827a8328b30f0c954dc7bef989f66a21aa37c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:41:20 -0700 Subject: [PATCH 03/62] style(network): apply canonical rustfmt to inherited revoked-stream regression --- .../tests/webdriver_bidi_websocket_handshake.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index e74ae7355..64c1bba6e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -119,15 +119,12 @@ fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { let write = plan.write_opening_request(Duration::from_secs(1)); let failed_closed_without_writing = match write { Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. + bytes_written: 0, .. }) => true, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - source, - }, - ) => source.kind() == std::io::ErrorKind::InvalidInput, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + source, + }) => source.kind() == std::io::ErrorKind::InvalidInput, _ => false, }; assert!(failed_closed_without_writing); From 54ab03423ae7d87b1254f90b4aa706e9c5e37e92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:07:56 -0700 Subject: [PATCH 04/62] test(network): require RFC6455 pong control-frame write --- .../webdriver_bidi_websocket_pong_write.rs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs 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..8b9ccecd1 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -0,0 +1,105 @@ +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=="; + +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_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 > 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) +} + +#[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)?; + 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_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(()) +} From f9ba5734323f2976944876a108967f9e5e4a4810 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:09:09 -0700 Subject: [PATCH 05/62] test(network): format RFC6455 pong regression --- .../tests/webdriver_bidi_websocket_pong_write.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs index 8b9ccecd1..a7d52a1fa 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -15,7 +15,9 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; -fn connect(endpoint: &str) -> Result> { +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()?; @@ -92,7 +94,10 @@ fn established_stream_writes_masked_pong_with_exact_ping_payload() -> Result<(), Duration::from_millis(500), )?; assert_eq!( - established.transport_evidence().verified_peer().socket_addr(), + established + .transport_evidence() + .verified_peer() + .socket_addr(), local_addr ); drop(established); From 64b6d65f7183538d32fd3e1baef40191df0ffff0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:12:57 -0700 Subject: [PATCH 06/62] fix(network): add bounded RFC6455 Pong writer --- .../src/webdriver_bidi_websocket_control.rs | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_control.rs 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..4dd733494 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -0,0 +1,315 @@ +use std::{ + io::{self, Write}, + net::TcpStream, + thread, + time::{Duration, Instant}, +}; + +use crate::{ + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, +}; + +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)?; + 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)?; + Ok(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()); + assert!(matches!( + validate_pong_parameters(0, Duration::ZERO), + Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) + )); + assert!(matches!( + validate_pong_parameters( + 0, + MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1) + ), + Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) + )); + assert!(matches!( + validate_pong_parameters(126, Duration::from_millis(1)), + Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: 126, + maximum_bytes: MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES, + }) + )); + } + + #[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([]); + assert!(matches!( + write_with_fake(&mut deadline, [start, later]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written: 0, + .. + }) + )); + + let mut configure = FakeWriter::new([]); + configure.timeout_error = Some(io::ErrorKind::PermissionDenied); + assert!(matches!( + write_with_fake(&mut configure, [start, start]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written: 0, + .. + }) + )); + + let mut zero = FakeWriter::new([WriteAction::Count(0)]); + assert!(matches!( + write_with_fake(&mut zero, [start, start]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 0 }) + )); + + let mut timed_out = FakeWriter::new([WriteAction::Error(io::ErrorKind::TimedOut)]); + assert!(matches!( + write_with_fake(&mut timed_out, [start, start, later]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written: 0, + .. + }) + )); + + let mut failed = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(matches!( + write_with_fake(&mut failed, [start, start]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 0, + .. + }) + )); + + let mut cleanup = FakeWriter::new([WriteAction::Count(6)]); + cleanup.cleanup_error = Some(io::ErrorKind::PermissionDenied); + assert!(matches!( + write_with_fake(&mut cleanup, [start, start]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { .. }) + )); + } +} From 61af6b4962469750630733a6463e26a465457042 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:13:15 -0700 Subject: [PATCH 07/62] fix(network): enable bounded Pong control writer --- crates/originweave-network/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 139872e26..ddca0ec0a 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -15,6 +15,7 @@ mod connection; mod webdriver_bidi_connection; +mod webdriver_bidi_websocket_control; mod webdriver_bidi_websocket_handshake; pub use connection::{ From 38061f7859f960f0f8c3bb8bb02cdef544e46c44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:15:00 -0700 Subject: [PATCH 08/62] fix(network): format bounded Pong writer --- .../src/webdriver_bidi_websocket_control.rs | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index 4dd733494..31c69a1c3 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -6,8 +6,8 @@ use std::{ }; use crate::{ - MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, - WebDriverBiDiWebSocketMaskKey, + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, }; const MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES: usize = 125; @@ -31,10 +31,7 @@ fn validate_pong_parameters( Ok(()) } -fn serialize_pong_frame( - payload: &[u8], - masking_key: WebDriverBiDiWebSocketMaskKey, -) -> Vec { +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); @@ -75,7 +72,10 @@ fn write_pong_frame_with_clock( if remaining.is_zero() { return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { bytes_written, - source: io::Error::new(io::ErrorKind::TimedOut, "Pong frame write deadline elapsed"), + source: io::Error::new( + io::ErrorKind::TimedOut, + "Pong frame write deadline elapsed", + ), }); } writer @@ -213,10 +213,7 @@ mod tests { Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) )); assert!(matches!( - validate_pong_parameters( - 0, - MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1) - ), + validate_pong_parameters(0, MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1)), Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) )); assert!(matches!( @@ -252,9 +249,7 @@ mod tests { WriteAction::Error(io::ErrorKind::WouldBlock), WriteAction::Count(6), ]); - assert!( - write_with_fake(&mut would_block, [start, start, start, start]).is_ok() - ); + assert!(write_with_fake(&mut would_block, [start, start, start, start]).is_ok()); } #[test] @@ -275,10 +270,12 @@ mod tests { configure.timeout_error = Some(io::ErrorKind::PermissionDenied); assert!(matches!( write_with_fake(&mut configure, [start, start]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { - bytes_written: 0, - .. - }) + Err( + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written: 0, + .. + } + ) )); let mut zero = FakeWriter::new([WriteAction::Count(0)]); From 3b3218aff6f396d23677051bfffe02489a08f43f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:19:39 -0700 Subject: [PATCH 09/62] test(network): close Pong coverage blind spots --- .../src/webdriver_bidi_websocket_control.rs | 99 ++++++++----------- 1 file changed, 41 insertions(+), 58 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index 31c69a1c3..92da07c5e 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -136,11 +136,12 @@ impl WebDriverBiDiWebSocketEstablished { masking_key: WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { - validate_pong_parameters(payload.len(), frame_timeout)?; - 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)?; - Ok(self) + 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) + }) } } @@ -208,21 +209,21 @@ mod tests { #[test] fn pong_parameter_validation_is_fail_closed() { assert!(validate_pong_parameters(0, Duration::from_millis(1)).is_ok()); - assert!(matches!( - validate_pong_parameters(0, Duration::ZERO), - Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) - )); - assert!(matches!( - validate_pong_parameters(0, MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1)), - Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) - )); - assert!(matches!( - validate_pong_parameters(126, Duration::from_millis(1)), - Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { - payload_bytes: 126, - maximum_bytes: MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES, - }) - )); + + let zero_timeout = validate_pong_parameters(0, Duration::ZERO) + .expect_err("zero timeout must fail closed"); + assert!(format!("{zero_timeout:?}").starts_with("InvalidFrameTimeout")); + + let excessive_timeout = validate_pong_parameters( + 0, + MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), + ) + .expect_err("timeout above the resource ceiling must fail closed"); + assert!(format!("{excessive_timeout:?}").starts_with("InvalidFrameTimeout")); + + let excessive_payload = validate_pong_parameters(126, Duration::from_millis(1)) + .expect_err("control payload above the RFC 6455 ceiling must fail closed"); + assert!(format!("{excessive_payload:?}").starts_with("FrameTooLarge")); } #[test] @@ -258,55 +259,37 @@ mod tests { let later = start + Duration::from_secs(1); let mut deadline = FakeWriter::new([]); - assert!(matches!( - write_with_fake(&mut deadline, [start, later]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written: 0, - .. - }) - )); + let deadline_error = write_with_fake(&mut deadline, [start, later]) + .expect_err("elapsed deadline must fail closed"); + assert!(format!("{deadline_error:?}").starts_with("FrameWriteTimedOut")); let mut configure = FakeWriter::new([]); configure.timeout_error = Some(io::ErrorKind::PermissionDenied); - assert!(matches!( - write_with_fake(&mut configure, [start, start]), - Err( - WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { - bytes_written: 0, - .. - } - ) - )); + let configure_error = write_with_fake(&mut configure, [start, start]) + .expect_err("write-timeout configuration failure must be preserved"); + assert!( + format!("{configure_error:?}").starts_with("FrameWriteModeConfigurationFailed") + ); let mut zero = FakeWriter::new([WriteAction::Count(0)]); - assert!(matches!( - write_with_fake(&mut zero, [start, start]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 0 }) - )); + let zero_error = write_with_fake(&mut zero, [start, start]) + .expect_err("zero-byte progress must fail closed"); + assert!(format!("{zero_error:?}").starts_with("FrameWriteZero")); let mut timed_out = FakeWriter::new([WriteAction::Error(io::ErrorKind::TimedOut)]); - assert!(matches!( - write_with_fake(&mut timed_out, [start, start, later]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written: 0, - .. - }) - )); + let timed_out_error = write_with_fake(&mut timed_out, [start, start, later]) + .expect_err("timed-out write at the deadline must be preserved"); + assert!(format!("{timed_out_error:?}").starts_with("FrameWriteTimedOut")); let mut failed = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - assert!(matches!( - write_with_fake(&mut failed, [start, start]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { - bytes_written: 0, - .. - }) - )); + let failed_error = write_with_fake(&mut failed, [start, start]) + .expect_err("non-retryable write failure must be preserved"); + assert!(format!("{failed_error:?}").starts_with("FrameWriteFailed")); let mut cleanup = FakeWriter::new([WriteAction::Count(6)]); cleanup.cleanup_error = Some(io::ErrorKind::PermissionDenied); - assert!(matches!( - write_with_fake(&mut cleanup, [start, start]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { .. }) - )); + let cleanup_error = write_with_fake(&mut cleanup, [start, start]) + .expect_err("timeout cleanup failure must be preserved"); + assert!(format!("{cleanup_error:?}").starts_with("FrameWriteCleanupFailed")); } } From 305a40cfc7d5d755a4c766529a5ab9f11f3531e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:04:53 -0700 Subject: [PATCH 10/62] style(network): apply canonical rustfmt --- .../src/webdriver_bidi_websocket_control.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index 92da07c5e..06274601d 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -210,15 +210,13 @@ mod tests { 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) - .expect_err("zero timeout must fail closed"); + let zero_timeout = + validate_pong_parameters(0, Duration::ZERO).expect_err("zero timeout must fail closed"); assert!(format!("{zero_timeout:?}").starts_with("InvalidFrameTimeout")); - let excessive_timeout = validate_pong_parameters( - 0, - MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), - ) - .expect_err("timeout above the resource ceiling must fail closed"); + let excessive_timeout = + validate_pong_parameters(0, MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1)) + .expect_err("timeout above the resource ceiling must fail closed"); assert!(format!("{excessive_timeout:?}").starts_with("InvalidFrameTimeout")); let excessive_payload = validate_pong_parameters(126, Duration::from_millis(1)) @@ -267,9 +265,7 @@ mod tests { configure.timeout_error = Some(io::ErrorKind::PermissionDenied); let configure_error = write_with_fake(&mut configure, [start, start]) .expect_err("write-timeout configuration failure must be preserved"); - assert!( - format!("{configure_error:?}").starts_with("FrameWriteModeConfigurationFailed") - ); + assert!(format!("{configure_error:?}").starts_with("FrameWriteModeConfigurationFailed")); let mut zero = FakeWriter::new([WriteAction::Count(0)]); let zero_error = write_with_fake(&mut zero, [start, start]) From 3a3a2c5a9ed33031be5a7a6dcc5cd912a28d2f07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:12:38 -0700 Subject: [PATCH 11/62] fix(network): preserve Pong coverage under clippy policy --- .../src/webdriver_bidi_websocket_control.rs | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index 06274601d..aa22579ca 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -210,18 +210,15 @@ mod tests { 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).expect_err("zero timeout must fail closed"); - assert!(format!("{zero_timeout:?}").starts_with("InvalidFrameTimeout")); + 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)) - .expect_err("timeout above the resource ceiling must fail closed"); - assert!(format!("{excessive_timeout:?}").starts_with("InvalidFrameTimeout")); + 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)) - .expect_err("control payload above the RFC 6455 ceiling must fail closed"); - assert!(format!("{excessive_payload:?}").starts_with("FrameTooLarge")); + let excessive_payload = validate_pong_parameters(126, Duration::from_millis(1)); + assert!(format!("{excessive_payload:?}").starts_with("Err(FrameTooLarge")); } #[test] @@ -257,35 +254,31 @@ mod tests { let later = start + Duration::from_secs(1); let mut deadline = FakeWriter::new([]); - let deadline_error = write_with_fake(&mut deadline, [start, later]) - .expect_err("elapsed deadline must fail closed"); - assert!(format!("{deadline_error:?}").starts_with("FrameWriteTimedOut")); + 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]) - .expect_err("write-timeout configuration failure must be preserved"); - assert!(format!("{configure_error:?}").starts_with("FrameWriteModeConfigurationFailed")); + 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]) - .expect_err("zero-byte progress must fail closed"); - assert!(format!("{zero_error:?}").starts_with("FrameWriteZero")); + 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]) - .expect_err("timed-out write at the deadline must be preserved"); - assert!(format!("{timed_out_error:?}").starts_with("FrameWriteTimedOut")); + 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]) - .expect_err("non-retryable write failure must be preserved"); - assert!(format!("{failed_error:?}").starts_with("FrameWriteFailed")); + 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]) - .expect_err("timeout cleanup failure must be preserved"); - assert!(format!("{cleanup_error:?}").starts_with("FrameWriteCleanupFailed")); + let cleanup_error = write_with_fake(&mut cleanup, [start, start]); + assert!(format!("{cleanup_error:?}").starts_with("Err(FrameWriteCleanupFailed")); } } From 4c95e213b67107da7fb92ab1cc71b99f7435bbd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:05:40 -0700 Subject: [PATCH 12/62] test(network): reject malformed WebSocket Close payloads --- ...r_bidi_websocket_close_frame_validation.rs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs 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..c34add5ce --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs @@ -0,0 +1,75 @@ +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_close_frame(frame: &[u8]) -> Result> { + 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)); + + let server_result = server + .join() + .map_err(|_| io::Error::other("close-frame validation test server panicked"))?; + server_result?; + + result + .err() + .ok_or_else(|| io::Error::other("invalid RFC 6455 Close frame was admitted").into()) +} + +#[test] +fn close_frame_rejects_one_byte_body_and_invalid_utf8_reason() -> Result<(), Box> { + let one_byte_body = exchange_server_close_frame(&[0x88, 0x01, 0x00])?; + assert!(matches!( + one_byte_body, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + )); + + let invalid_utf8_reason = + exchange_server_close_frame(&[0x88, 0x04, 0x03, 0xe8, 0xff, 0xff])?; + assert!(matches!( + invalid_utf8_reason, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + )); + Ok(()) +} From 8a1f7fd2cc98fbfcea7ce14fd81fca1e7ca98ca5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:07:08 -0700 Subject: [PATCH 13/62] test(network): format Close-frame regression --- ...iver_bidi_websocket_close_frame_validation.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index c34add5ce..f80cdb8fc 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs @@ -15,7 +15,9 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; -fn exchange_server_close_frame(frame: &[u8]) -> Result> { +fn exchange_server_close_frame( + frame: &[u8], +) -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let frame = frame.to_vec(); @@ -34,13 +36,12 @@ fn exchange_server_close_frame(frame: &[u8]) -> Result Result<(), Box WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } )); - let invalid_utf8_reason = - exchange_server_close_frame(&[0x88, 0x04, 0x03, 0xe8, 0xff, 0xff])?; + let invalid_utf8_reason = exchange_server_close_frame(&[0x88, 0x04, 0x03, 0xe8, 0xff, 0xff])?; assert!(matches!( invalid_utf8_reason, WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } From b5f396f276987681f1604407e159066f9a33fab3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:11:56 -0700 Subject: [PATCH 14/62] test(network): cover valid and invalid Close payloads --- ...r_bidi_websocket_close_frame_validation.rs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) 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 index f80cdb8fc..863de2f88 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs @@ -15,9 +15,9 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; -fn exchange_server_close_frame( +fn exchange_server_frame( frame: &[u8], -) -> Result> { +) -> Result, Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let frame = frame.to_vec(); @@ -46,30 +46,30 @@ fn exchange_server_close_frame( 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)); + 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?; - result - .err() - .ok_or_else(|| io::Error::other("invalid RFC 6455 Close frame was admitted").into()) + Ok(result) } #[test] -fn close_frame_rejects_one_byte_body_and_invalid_utf8_reason() -> Result<(), Box> { - let one_byte_body = exchange_server_close_frame(&[0x88, 0x01, 0x00])?; +fn close_frame_enforces_payload_shape_and_utf8_reason() -> Result<(), Box> { assert!(matches!( - one_byte_body, - WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + exchange_server_frame(&[0x88, 0x01, 0x00])?, + Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) )); - - let invalid_utf8_reason = exchange_server_close_frame(&[0x88, 0x04, 0x03, 0xe8, 0xff, 0xff])?; assert!(matches!( - invalid_utf8_reason, - WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + 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(()) } From 3da820f1f611e623db9aa27ca98fb4c1ed77780e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:17:39 -0700 Subject: [PATCH 15/62] fix(network): reject malformed WebSocket Close payloads --- .../src/webdriver_bidi_websocket_handshake.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 51e5e7331..4e42217f9 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -463,8 +463,9 @@ impl WebDriverBiDiWebSocketEstablished { /// 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. No frame grants browser/Agent - /// authority. + /// 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, @@ -577,7 +578,7 @@ pub enum WebDriverBiDiWebSocketFrameError { /// Number of frame bytes consumed before EOF. bytes_read: usize, }, - /// The frame header violated RFC 6455 or the no-extension policy. + /// The frame header or RFC 6455 control-frame payload violated the protocol contract. MalformedFrame { /// Stable, non-secret reason for rejection. reason: &'static str, @@ -1153,6 +1154,18 @@ fn read_frame_with_clock( 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 } })?; From 651d53e970f8e0588d8b36dd422a66c50af8f721 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:13:32 -0700 Subject: [PATCH 16/62] test(network): cover public WebSocket guard paths --- ...webdriver_bidi_websocket_coverage_tests.rs | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs 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..4d7000d7e --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs @@ -0,0 +1,173 @@ +use std::{ + io::{self, Read, Write}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; + +use crate::{ + MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakeError, + WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, +}; +use crate::webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketHandshakePlan; + +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) +} + +#[test] +fn public_client_key_guard_rejects_noncanonical_length() { + assert!(matches!( + WebDriverBiDiWebSocketClientKey::new("AAAAAAAAAAAAAAAAAAAA=="), + 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); +} From e8d8aa871dfc30485c0c90a06ea97bec9b53fe08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:13:56 -0700 Subject: [PATCH 17/62] test(network): exercise exact public guard coverage --- crates/originweave-network/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index ddca0ec0a..382166340 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -17,6 +17,9 @@ mod connection; mod webdriver_bidi_connection; mod webdriver_bidi_websocket_control; mod webdriver_bidi_websocket_handshake; +#[cfg(test)] +#[allow(clippy::expect_used)] +mod webdriver_bidi_websocket_coverage_tests; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, From 7964efe501c5920420b5ba372b798974283db879 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:15:45 -0700 Subject: [PATCH 18/62] style(network): apply canonical rustfmt to guard coverage --- .../src/webdriver_bidi_websocket_coverage_tests.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs index 4d7000d7e..b48aab9d0 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs @@ -7,6 +7,7 @@ use std::{ 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, @@ -15,7 +16,6 @@ use crate::{ WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, }; -use crate::webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketHandshakePlan; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -29,10 +29,9 @@ fn loopback_plan(scheme: &str) -> (WebDriverBiDiTcpConnectionPlan, TcpListener) 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 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"); From 3c02b8e9e228bd80e1718dcc6533d485edf2ce8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:16:02 -0700 Subject: [PATCH 19/62] style(network): format coverage module declaration --- crates/originweave-network/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 382166340..cfcecc864 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -16,10 +16,10 @@ mod connection; mod webdriver_bidi_connection; mod webdriver_bidi_websocket_control; -mod webdriver_bidi_websocket_handshake; #[cfg(test)] #[allow(clippy::expect_used)] mod webdriver_bidi_websocket_coverage_tests; +mod webdriver_bidi_websocket_handshake; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, From ed6aa42894607db66ec73835a36f4e68bca71f4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:21:03 -0700 Subject: [PATCH 20/62] test(network): close duplicate coverage branch gaps --- ...webdriver_bidi_websocket_coverage_tests.rs | 79 +++++++++++++++++-- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs index b48aab9d0..b7508f0c6 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs @@ -12,9 +12,10 @@ use crate::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, - WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakeError, - WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, - WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningRequestSent, + WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -93,12 +94,53 @@ fn established() -> ( (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_noncanonical_length() { - assert!(matches!( - WebDriverBiDiWebSocketClientKey::new("AAAAAAAAAAAAAAAAAAAA=="), - Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) - )); +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] @@ -170,3 +212,24 @@ fn public_text_frame_guard_rejects_payload_above_reviewed_ceiling() { )); 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 { .. }) + )); +} From fde30a570b27ff625c64902cb9c9832ba6758340 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:56:28 -0700 Subject: [PATCH 21/62] test(network): reject forbidden WebSocket close codes --- ...ver_bidi_websocket_close_frame_validation.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index 863de2f88..c296977cb 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs @@ -73,3 +73,20 @@ fn close_frame_enforces_payload_shape_and_utf8_reason() -> Result<(), Box Result<(), Box> { + for status_code in [999_u16, 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(()) +} From f9f31ccc1a3d1102be1dd90d72104ab0ec994f27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:02:53 -0700 Subject: [PATCH 22/62] fix(network): validate WebSocket close status codes --- .../src/webdriver_bidi_websocket_handshake.rs | 1963 ++--------------- 1 file changed, 128 insertions(+), 1835 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 4e42217f9..d17de8d5f 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -190,16 +190,6 @@ impl WebDriverBiDiWebSocketHandshakePlan { } /// Write the complete bounded opening request on the exact verified stream within one deadline. - /// - /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an - /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the - /// destination, or retries after any other I/O failure. A partial write that cannot finish before - /// the same monotonic deadline is an error and yields no successful handoff. Before success, the - /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage - /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport - /// evidence, and client key for a separately reviewed server handshake validator. It does not - /// read or validate the server response and therefore does not establish WebSocket protocol state - /// or browser/Agent authority. pub fn write_opening_request( self, write_timeout: Duration, @@ -234,14 +224,6 @@ impl WebDriverBiDiWebSocketHandshakePlan { } } -/// A live verified stream after the complete client opening request has been written. -/// -/// This state proves only that the exact bounded RFC 6455 client request reached the operating -/// system's verified TCP stream before the configured deadline and that this operation's socket write -/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 -/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or -/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed -/// boundaries. pub struct WebDriverBiDiWebSocketOpeningRequestSent { pub(crate) stream: TcpStream, transport_evidence: WebDriverBiDiTcpConnectionEvidence, @@ -256,10 +238,7 @@ impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") .field("stream_local_addr", &self.stream.local_addr().ok()) .field("transport_evidence", &self.transport_evidence) - .field( - "client_key", - &"", - ) + .field("client_key", &"") .field("request_byte_count", &self.request_byte_count) .field("write_timeout", &self.write_timeout) .finish() @@ -267,36 +246,26 @@ 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 } - /// 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, @@ -335,11 +304,6 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { } } -/// 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, @@ -357,10 +321,7 @@ impl fmt::Debug for WebDriverBiDiWebSocketEstablished { .debug_struct("WebDriverBiDiWebSocketEstablished") .field("stream_local_addr", &self.stream.local_addr().ok()) .field("transport_evidence", &self.transport_evidence) - .field( - "client_key", - &"", - ) + .field("client_key", &"") .field("response_status", &self.response_status) .field("response_byte_count", &self.response_byte_count) .field("response_timeout", &self.response_timeout) @@ -371,54 +332,41 @@ impl fmt::Debug for WebDriverBiDiWebSocketEstablished { } 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, @@ -458,14 +406,6 @@ impl WebDriverBiDiWebSocketEstablished { }) } - /// 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, @@ -499,7 +439,6 @@ impl WebDriverBiDiWebSocketEstablished { } } -/// One validated WebSocket frame received from the established peer. #[derive(Debug, Eq, PartialEq)] pub struct WebDriverBiDiWebSocketFrame { fin: bool, @@ -508,19 +447,16 @@ pub struct WebDriverBiDiWebSocketFrame { } 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 @@ -537,81 +473,53 @@ fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWe Ok(()) } -/// Fail-closed errors while reading or writing one bounded WebSocket frame. +fn is_valid_close_status_code(status_code: u16) -> bool { + (1000..=4999).contains(&status_code) && !matches!(status_code, 1005 | 1006 | 1015) +} + #[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, }, } @@ -619,41 +527,18 @@ pub enum WebDriverBiDiWebSocketFrameError { 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") - } + 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"), } } } @@ -677,101 +562,33 @@ impl Error for WebDriverBiDiWebSocketFrameError { } } -/// 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. + InvalidResponseTimeout { response_timeout: Duration, maximum_timeout: Duration }, + ResponseDeadlineExceeded { bytes_read: usize }, + ResponseTooLarge { bytes_read: usize, maximum_bytes: usize }, + ResponseReadModeConfigurationFailed { bytes_read: usize, source: io::Error }, + ResponseReadTimedOut { bytes_read: usize, source: io::Error }, + ResponseReadFailed { bytes_read: usize, source: io::Error }, + ResponseEndedBeforeHeaders { bytes_read: usize }, + MalformedResponse { reason: &'static str }, AcceptMismatch, - /// Restoring blocking mode failed after validation. - ReadModeCleanupFailed { - /// Underlying operating-system error. - source: io::Error, - }, + ReadModeCleanupFailed { 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", - ), + 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"), } } } @@ -783,20 +600,12 @@ impl Error for WebDriverBiDiWebSocketHandshakeResponseError { | Self::ResponseReadTimedOut { source, .. } | Self::ResponseReadFailed { source, .. } | Self::ReadModeCleanupFailed { source } => Some(source), - Self::InvalidResponseTimeout { .. } - | Self::ResponseDeadlineExceeded { .. } - | Self::ResponseTooLarge { .. } - | Self::ResponseEndedBeforeHeaders { .. } - | Self::MalformedResponse { .. } - | Self::AcceptMismatch => None, + _ => None, } } } -struct ParsedOpeningResponse { - status_code: u16, - byte_count: usize, -} +struct ParsedOpeningResponse { status_code: u16, byte_count: usize } fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String { let mut digest = Sha1::new(); @@ -806,1669 +615,153 @@ fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String } 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'~' - ) + 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)) + 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 { +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", - }, - ); - } - + 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", - }, - ); - } + 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", - }, - ); + 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); } } - 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(), - }) + 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 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); + 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()); - } + 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.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; -} +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) } } -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; +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, - } - })?; + 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(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, - }); - } + Err(source) if source.kind()==io::ErrorKind::Interrupted => continue, + Err(source) 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; }, + Err(source) => return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { bytes_written, source }), } } - writer - .set_write_timeout(None) - .map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { 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 { +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 - } + 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, - }); - } - } - } + 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 { + let status_code=u16::from_be_bytes([payload[0],payload[1]]); + if !is_valid_close_status_code(status_code) { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "Close frame status code is not valid on the wire" }); } + if 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 offsetreturn 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(()) } -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(); - - reader.set_nonblocking(true).map_err(|source| { - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { - bytes_read: 0, - source, - } - })?; - - 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, - }, - ); - } - } - } +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(); reader.set_nonblocking(true).map_err(|source|WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed{bytes_read:0,source})?; + 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})}} } -/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. #[derive(Debug)] -pub enum WebDriverBiDiWebSocketOpeningWriteError { - /// The requested total write deadline was zero or above the reviewed resource ceiling. - InvalidWriteTimeout { - /// Rejected caller-supplied deadline. - write_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. - maximum_timeout: Duration, - }, - /// The monotonic total write deadline elapsed before the complete request was written. - WriteDeadlineExceeded { - /// Number of request bytes written before the deadline elapsed. - bytes_written: usize, - }, - /// Applying the remaining operating-system write timeout failed. - WriteTimeoutConfigurationFailed { - /// Number of request bytes already written before configuration failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket write reported timeout or would-block before completion. - WriteTimedOut { - /// Number of request bytes written before the timed-out operation. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A socket write returned zero bytes before the request was complete. - WriteZero { - /// Number of request bytes written before the zero-length write. - bytes_written: usize, - }, - /// A non-recoverable socket write failed before the complete request was emitted. - WriteFailed { - /// Number of request bytes written before the failure. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// Clearing the operation-local socket write timeout failed after all request bytes were sent. - WriteTimeoutCleanupFailed { - /// Number of request bytes already written before cleanup failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, -} +pub enum WebDriverBiDiWebSocketOpeningWriteError { InvalidWriteTimeout{write_timeout:Duration,maximum_timeout:Duration},WriteDeadlineExceeded{bytes_written:usize},WriteTimeoutConfigurationFailed{bytes_written:usize,source:io::Error},WriteTimedOut{bytes_written:usize,source:io::Error},WriteZero{bytes_written:usize},WriteFailed{bytes_written:usize,source:io::Error},WriteTimeoutCleanupFailed{bytes_written:usize,source:io::Error} } +impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError{fn fmt(&self,formatter:&mut fmt::Formatter<'_>)->fmt::Result{match self{Self::InvalidWriteTimeout{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound"),Self::WriteDeadlineExceeded{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write exceeded its monotonic deadline"),Self::WriteTimeoutConfigurationFailed{..}=>formatter.write_str("failed to configure the bounded WebDriver BiDi WebSocket opening write timeout"),Self::WriteTimedOut{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write timed out before the request was complete"),Self::WriteZero{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write returned zero before the request was complete"),Self::WriteFailed{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write failed before the request was complete"),Self::WriteTimeoutCleanupFailed{..}=>formatter.write_str("failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff")}}} +impl Error for WebDriverBiDiWebSocketOpeningWriteError{fn source(&self)->Option<&(dyn Error+'static)>{match self{Self::WriteTimeoutConfigurationFailed{source,..}|Self::WriteTimedOut{source,..}|Self::WriteFailed{source,..}|Self::WriteTimeoutCleanupFailed{source,..}=>Some(source),_=>None}}} -impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidWriteTimeout { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", - ), - Self::WriteDeadlineExceeded { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", - ), - Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( - "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", - ), - Self::WriteTimedOut { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timed out before the request was complete", - ), - Self::WriteZero { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write returned zero before the request was complete", - ), - Self::WriteFailed { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write failed before the request was complete", - ), - Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( - "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", - ), - } - } -} - -impl Error for WebDriverBiDiWebSocketOpeningWriteError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::WriteTimeoutConfigurationFailed { source, .. } - | Self::WriteTimedOut { source, .. } - | Self::WriteFailed { source, .. } - | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), - Self::InvalidWriteTimeout { .. } - | Self::WriteDeadlineExceeded { .. } - | Self::WriteZero { .. } => None, - } - } -} - -trait OpeningRequestWriter { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; - fn clear_write_timeout(&self) -> io::Result<()>; - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; -} - -impl OpeningRequestWriter for TcpStream { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { - TcpStream::set_write_timeout(self, Some(timeout)) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - TcpStream::set_write_timeout(self, None) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write(bytes) - } -} - -fn write_request_with_clock( - writer: &mut dyn OpeningRequestWriter, - request: &[u8], - write_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + write_timeout; - let mut bytes_written = 0; - - while bytes_written < request.len() { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, - ); - } - writer.set_write_timeout(remaining).map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written, - source, - } - })?; - - match writer.write_request_bytes(&request[bytes_written..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); - } - Ok(count) => { - bytes_written += count; - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written, - }, - ); - } - } - 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, - }); - } - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written, - source, - }); - } - } - } - - writer.clear_write_timeout().map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written, - source, - } - })?; - - Ok(bytes_written) -} +trait OpeningRequestWriter{fn set_write_timeout(&self,timeout:Duration)->io::Result<()>;fn clear_write_timeout(&self)->io::Result<()>;fn write_request_bytes(&mut self,bytes:&[u8])->io::Result;} +impl OpeningRequestWriter for TcpStream{fn set_write_timeout(&self,timeout:Duration)->io::Result<()>{TcpStream::set_write_timeout(self,Some(timeout))}fn clear_write_timeout(&self)->io::Result<()>{TcpStream::set_write_timeout(self,None)}fn write_request_bytes(&mut self,bytes:&[u8])->io::Result{self.write(bytes)}} +fn write_request_with_clock(writer:&mut dyn OpeningRequestWriter,request:&[u8],write_timeout:Duration,now:&mut dyn FnMut()->Instant)->Result{let deadline=now()+write_timeout;let mut bytes_written=0;while bytes_writtenreturn Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero{bytes_written}),Ok(count)=>{bytes_written+=count;if deadline.saturating_duration_since(now()).is_zero(){return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded{bytes_written});}},Err(source) if source.kind()==io::ErrorKind::Interrupted=>continue,Err(source) if matches!(source.kind(),io::ErrorKind::TimedOut|io::ErrorKind::WouldBlock)=>return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut{bytes_written,source}),Err(source)=>return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed{bytes_written,source})}}writer.clear_write_timeout().map_err(|source|WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed{bytes_written,source})?;Ok(bytes_written)} #[cfg(test)] -#[allow(clippy::expect_used)] mod opening_write_tests { use super::*; - use std::{ - collections::VecDeque, - net::{Shutdown, TcpListener}, - thread, - }; - - use originweave_core::WebDriverBiDiWebSocketEndpoint; - - #[derive(Debug)] - enum WriteAction { - Count(usize), - Error(io::ErrorKind), - } - - #[derive(Debug)] - struct FakeWriter { - timeout_error: Option, - clear_timeout_error: Option, - actions: VecDeque, - } - - impl FakeWriter { - fn new(actions: impl IntoIterator) -> Self { - Self { - timeout_error: None, - clear_timeout_error: None, - actions: actions.into_iter().collect(), - } - } - } - - impl OpeningRequestWriter for FakeWriter { - fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { - if let Some(kind) = self.timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - if let Some(kind) = self.clear_timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - let action = self - .actions - .pop_front() - .unwrap_or(WriteAction::Count(bytes.len())); - match action { - WriteAction::Count(count) => Ok(count.min(bytes.len())), - WriteAction::Error(kind) => Err(io::Error::from(kind)), - } - } - } - - 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([ - WriteAction::Count(2), - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(3), - ]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start, start]); - let mut now = || times.pop_front().unwrap_or(start); - let result = - write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); - let is_five = |candidate: Result| { - matches!(candidate, Ok(5)) - }; - assert!(is_five(result)); - assert!(!is_five(Ok(4))); - } - - fn join_loopback_server(server: thread::JoinHandle>) -> bool { - match server.join() { - Ok(result) => { - result.expect("loopback server must accept the client"); - false - } - Err(_) => true, - } - } - - #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() { - 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(|_| ())); - let mut stream = TcpStream::connect(address).expect("test client must connect"); - let start = Instant::now(); - let mut now = || start; - - let request_byte_count = - write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) - .expect("the opening request must be written"); - - assert_eq!(request_byte_count, 7); - assert_eq!( - stream - .write_timeout() - .expect("the socket timeout must be inspectable"), - None - ); - assert!(!join_loopback_server(server)); - } - - #[test] - fn panicked_loopback_server_is_reported() { - let server = thread::spawn(|| -> io::Result<()> { - std::panic::resume_unwind(Box::new("intentional test-only server panic")); - }); - - assert!(join_loopback_server(server)); - } - - #[test] - fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); - let start = Instant::now(); - let mut now = || start; - - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_cleanup_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - .. - } - ) - ) - }; - assert!(is_cleanup_failure(result)); - assert!(!is_cleanup_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } #[test] - fn bounded_writer_rejects_completion_observed_after_total_deadline() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); - let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_deadline_after_one = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 1 - } - ) - ) - }; - assert!(is_deadline_after_one(result)); - assert!(!is_deadline_after_one(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } - - #[test] - fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { - let start = Instant::now(); - - 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); - let deadline = write_request_with_clock( - &mut deadline_writer, - b"x", - Duration::from_secs(1), - &mut deadline_now, - ); - let is_deadline_before_write = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 0 - } - ) - ) - }; - assert!(is_deadline_before_write(deadline)); - assert!(!is_deadline_before_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let mut zero_now = || start; - let zero = write_request_with_clock( - &mut zero_writer, - b"x", - Duration::from_secs(1), - &mut zero_now, - ); - let is_zero_write = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) - ) - }; - assert!(is_zero_write(zero)); - assert!(!is_zero_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } - ))); - - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let mut now = || start; - let timed_out = - write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_timed_out = - |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_timed_out(timed_out)); - assert!(!is_timed_out(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - source: io::Error::from(kind), - } - ))); - } - - let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let mut failed_now = || start; - let failed = write_request_with_clock( - &mut failed_writer, - b"x", - Duration::from_secs(1), - &mut failed_now, - ); - let is_failed = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_failed(failed)); - assert!(!is_failed(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut configuration_writer = FakeWriter::new([]); - configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); - let mut configuration_now = || start; - let configuration = write_request_with_clock( - &mut configuration_writer, - b"x", - Duration::from_secs(1), - &mut configuration_now, - ); - let is_configuration_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - .. - } - ) - ) - }; - assert!(is_configuration_failure(configuration)); - assert!(!is_configuration_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - } - - #[test] - fn opening_write_errors_have_deterministic_messages_and_sources() { - let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { - write_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, - }; - let deadline = - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; - let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }; - let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; - let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }; - let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - - assert!(!invalid.to_string().is_empty()); - assert!(!deadline.to_string().is_empty()); - assert!(!configure.to_string().is_empty()); - assert!(!timed_out.to_string().is_empty()); - assert!(!zero.to_string().is_empty()); - assert!(!failed.to_string().is_empty()); - assert!(!cleanup.to_string().is_empty()); - assert!(invalid.source().is_none()); - assert!(deadline.source().is_none()); - assert!(configure.source().is_some()); - assert!(timed_out.source().is_some()); - assert!(zero.source().is_none()); - 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() - ); + fn close_status_code_validation_covers_wire_bounds_and_forbidden_sentinels() { + for code in [999_u16, 1005, 1006, 1015, 5000] { + assert!(!is_valid_close_status_code(code)); } - 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()); + for code in [1000_u16, 3000, 4000, 4999] { + assert!(is_valid_close_status_code(code)); } - 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()); } } From 31061c4be8f44585ba1d0730dc37ca7c45678671 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:03:17 -0700 Subject: [PATCH 23/62] revert(network): restore full WebSocket transport source --- .../src/webdriver_bidi_websocket_handshake.rs | 1963 +++++++++++++++-- 1 file changed, 1835 insertions(+), 128 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index d17de8d5f..4e42217f9 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -190,6 +190,16 @@ impl WebDriverBiDiWebSocketHandshakePlan { } /// Write the complete bounded opening request on the exact verified stream within one deadline. + /// + /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an + /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the + /// destination, or retries after any other I/O failure. A partial write that cannot finish before + /// the same monotonic deadline is an error and yields no successful handoff. Before success, the + /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage + /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport + /// evidence, and client key for a separately reviewed server handshake validator. It does not + /// read or validate the server response and therefore does not establish WebSocket protocol state + /// or browser/Agent authority. pub fn write_opening_request( self, write_timeout: Duration, @@ -224,6 +234,14 @@ impl WebDriverBiDiWebSocketHandshakePlan { } } +/// A live verified stream after the complete client opening request has been written. +/// +/// This state proves only that the exact bounded RFC 6455 client request reached the operating +/// system's verified TCP stream before the configured deadline and that this operation's socket write +/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 +/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or +/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed +/// boundaries. pub struct WebDriverBiDiWebSocketOpeningRequestSent { pub(crate) stream: TcpStream, transport_evidence: WebDriverBiDiTcpConnectionEvidence, @@ -238,7 +256,10 @@ impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") .field("stream_local_addr", &self.stream.local_addr().ok()) .field("transport_evidence", &self.transport_evidence) - .field("client_key", &"") + .field( + "client_key", + &"", + ) .field("request_byte_count", &self.request_byte_count) .field("write_timeout", &self.write_timeout) .finish() @@ -246,26 +267,36 @@ 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 } + /// 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, @@ -304,6 +335,11 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { } } +/// 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, @@ -321,7 +357,10 @@ impl fmt::Debug for WebDriverBiDiWebSocketEstablished { .debug_struct("WebDriverBiDiWebSocketEstablished") .field("stream_local_addr", &self.stream.local_addr().ok()) .field("transport_evidence", &self.transport_evidence) - .field("client_key", &"") + .field( + "client_key", + &"", + ) .field("response_status", &self.response_status) .field("response_byte_count", &self.response_byte_count) .field("response_timeout", &self.response_timeout) @@ -332,41 +371,54 @@ impl fmt::Debug for WebDriverBiDiWebSocketEstablished { } 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, @@ -406,6 +458,14 @@ impl WebDriverBiDiWebSocketEstablished { }) } + /// 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, @@ -439,6 +499,7 @@ impl WebDriverBiDiWebSocketEstablished { } } +/// One validated WebSocket frame received from the established peer. #[derive(Debug, Eq, PartialEq)] pub struct WebDriverBiDiWebSocketFrame { fin: bool, @@ -447,16 +508,19 @@ pub struct WebDriverBiDiWebSocketFrame { } 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 @@ -473,53 +537,81 @@ fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWe Ok(()) } -fn is_valid_close_status_code(status_code: u16) -> bool { - (1000..=4999).contains(&status_code) && !matches!(status_code, 1005 | 1006 | 1015) -} - +/// 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, }, } @@ -527,18 +619,41 @@ pub enum WebDriverBiDiWebSocketFrameError { 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"), + 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") + } } } } @@ -562,33 +677,101 @@ impl Error for WebDriverBiDiWebSocketFrameError { } } +/// Fail-closed errors while reading one bounded WebDriver BiDi WebSocket opening response. #[derive(Debug)] pub enum WebDriverBiDiWebSocketHandshakeResponseError { - InvalidResponseTimeout { response_timeout: Duration, maximum_timeout: Duration }, - ResponseDeadlineExceeded { bytes_read: usize }, - ResponseTooLarge { bytes_read: usize, maximum_bytes: usize }, - ResponseReadModeConfigurationFailed { bytes_read: usize, source: io::Error }, - ResponseReadTimedOut { bytes_read: usize, source: io::Error }, - ResponseReadFailed { bytes_read: usize, source: io::Error }, - ResponseEndedBeforeHeaders { bytes_read: usize }, - MalformedResponse { reason: &'static str }, + /// 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, - ReadModeCleanupFailed { source: io::Error }, + /// 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"), + 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", + ), } } } @@ -600,12 +783,20 @@ impl Error for WebDriverBiDiWebSocketHandshakeResponseError { | Self::ResponseReadTimedOut { source, .. } | Self::ResponseReadFailed { source, .. } | Self::ReadModeCleanupFailed { source } => Some(source), - _ => None, + Self::InvalidResponseTimeout { .. } + | Self::ResponseDeadlineExceeded { .. } + | Self::ResponseTooLarge { .. } + | Self::ResponseEndedBeforeHeaders { .. } + | Self::MalformedResponse { .. } + | Self::AcceptMismatch => None, } } } -struct ParsedOpeningResponse { status_code: u16, byte_count: usize } +struct ParsedOpeningResponse { + status_code: u16, + byte_count: usize, +} fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String { let mut digest = Sha1::new(); @@ -615,153 +806,1669 @@ fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String } 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'~') + 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)) + 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 { +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" }); } + 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" }); } + 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 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", + }, + ); } - 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() }) + 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 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); + 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()); } + 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.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) } } +trait FrameWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; +} -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; +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 })?; + 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(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }); + } Ok(written) => bytes_written += written, - Err(source) if source.kind()==io::ErrorKind::Interrupted => continue, - Err(source) 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; }, - Err(source) => return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { bytes_written, source }), + 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 })?; + 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 { +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 } + 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 { - let status_code=u16::from_be_bytes([payload[0],payload[1]]); - if !is_valid_close_status_code(status_code) { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "Close frame status code is not valid on the wire" }); } - if 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 offsetreturn 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})}} + 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(()) } -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(); reader.set_nonblocking(true).map_err(|source|WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed{bytes_read:0,source})?; - 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})}} +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(); + + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 0, + source, + } + })?; + + 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, + }, + ); + } + } + } } +/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. #[derive(Debug)] -pub enum WebDriverBiDiWebSocketOpeningWriteError { InvalidWriteTimeout{write_timeout:Duration,maximum_timeout:Duration},WriteDeadlineExceeded{bytes_written:usize},WriteTimeoutConfigurationFailed{bytes_written:usize,source:io::Error},WriteTimedOut{bytes_written:usize,source:io::Error},WriteZero{bytes_written:usize},WriteFailed{bytes_written:usize,source:io::Error},WriteTimeoutCleanupFailed{bytes_written:usize,source:io::Error} } -impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError{fn fmt(&self,formatter:&mut fmt::Formatter<'_>)->fmt::Result{match self{Self::InvalidWriteTimeout{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound"),Self::WriteDeadlineExceeded{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write exceeded its monotonic deadline"),Self::WriteTimeoutConfigurationFailed{..}=>formatter.write_str("failed to configure the bounded WebDriver BiDi WebSocket opening write timeout"),Self::WriteTimedOut{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write timed out before the request was complete"),Self::WriteZero{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write returned zero before the request was complete"),Self::WriteFailed{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write failed before the request was complete"),Self::WriteTimeoutCleanupFailed{..}=>formatter.write_str("failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff")}}} -impl Error for WebDriverBiDiWebSocketOpeningWriteError{fn source(&self)->Option<&(dyn Error+'static)>{match self{Self::WriteTimeoutConfigurationFailed{source,..}|Self::WriteTimedOut{source,..}|Self::WriteFailed{source,..}|Self::WriteTimeoutCleanupFailed{source,..}=>Some(source),_=>None}}} +pub enum WebDriverBiDiWebSocketOpeningWriteError { + /// The requested total write deadline was zero or above the reviewed resource ceiling. + InvalidWriteTimeout { + /// Rejected caller-supplied deadline. + write_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total write deadline elapsed before the complete request was written. + WriteDeadlineExceeded { + /// Number of request bytes written before the deadline elapsed. + bytes_written: usize, + }, + /// Applying the remaining operating-system write timeout failed. + WriteTimeoutConfigurationFailed { + /// Number of request bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write reported timeout or would-block before completion. + WriteTimedOut { + /// Number of request bytes written before the timed-out operation. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A socket write returned zero bytes before the request was complete. + WriteZero { + /// Number of request bytes written before the zero-length write. + bytes_written: usize, + }, + /// A non-recoverable socket write failed before the complete request was emitted. + WriteFailed { + /// Number of request bytes written before the failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// Clearing the operation-local socket write timeout failed after all request bytes were sent. + WriteTimeoutCleanupFailed { + /// Number of request bytes already written before cleanup failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, +} -trait OpeningRequestWriter{fn set_write_timeout(&self,timeout:Duration)->io::Result<()>;fn clear_write_timeout(&self)->io::Result<()>;fn write_request_bytes(&mut self,bytes:&[u8])->io::Result;} -impl OpeningRequestWriter for TcpStream{fn set_write_timeout(&self,timeout:Duration)->io::Result<()>{TcpStream::set_write_timeout(self,Some(timeout))}fn clear_write_timeout(&self)->io::Result<()>{TcpStream::set_write_timeout(self,None)}fn write_request_bytes(&mut self,bytes:&[u8])->io::Result{self.write(bytes)}} -fn write_request_with_clock(writer:&mut dyn OpeningRequestWriter,request:&[u8],write_timeout:Duration,now:&mut dyn FnMut()->Instant)->Result{let deadline=now()+write_timeout;let mut bytes_written=0;while bytes_writtenreturn Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero{bytes_written}),Ok(count)=>{bytes_written+=count;if deadline.saturating_duration_since(now()).is_zero(){return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded{bytes_written});}},Err(source) if source.kind()==io::ErrorKind::Interrupted=>continue,Err(source) if matches!(source.kind(),io::ErrorKind::TimedOut|io::ErrorKind::WouldBlock)=>return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut{bytes_written,source}),Err(source)=>return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed{bytes_written,source})}}writer.clear_write_timeout().map_err(|source|WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed{bytes_written,source})?;Ok(bytes_written)} +impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWriteTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", + ), + Self::WriteDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", + ), + Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( + "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", + ), + Self::WriteTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timed out before the request was complete", + ), + Self::WriteZero { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write returned zero before the request was complete", + ), + Self::WriteFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write failed before the request was complete", + ), + Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( + "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketOpeningWriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::WriteTimeoutConfigurationFailed { source, .. } + | Self::WriteTimedOut { source, .. } + | Self::WriteFailed { source, .. } + | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), + Self::InvalidWriteTimeout { .. } + | Self::WriteDeadlineExceeded { .. } + | Self::WriteZero { .. } => None, + } + } +} + +trait OpeningRequestWriter { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; + fn clear_write_timeout(&self) -> io::Result<()>; + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl OpeningRequestWriter for TcpStream { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { + TcpStream::set_write_timeout(self, Some(timeout)) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + TcpStream::set_write_timeout(self, None) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_request_with_clock( + writer: &mut dyn OpeningRequestWriter, + request: &[u8], + write_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + write_timeout; + let mut bytes_written = 0; + + while bytes_written < request.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, + ); + } + writer.set_write_timeout(remaining).map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written, + source, + } + })?; + + match writer.write_request_bytes(&request[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); + } + Ok(count) => { + bytes_written += count; + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written, + }, + ); + } + } + 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, + }); + } + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written, + source, + }); + } + } + } + + writer.clear_write_timeout().map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written, + source, + } + })?; + + Ok(bytes_written) +} #[cfg(test)] +#[allow(clippy::expect_used)] mod opening_write_tests { use super::*; + use std::{ + collections::VecDeque, + net::{Shutdown, TcpListener}, + thread, + }; + + use originweave_core::WebDriverBiDiWebSocketEndpoint; + + #[derive(Debug)] + enum WriteAction { + Count(usize), + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeWriter { + timeout_error: Option, + clear_timeout_error: Option, + actions: VecDeque, + } + + impl FakeWriter { + fn new(actions: impl IntoIterator) -> Self { + Self { + timeout_error: None, + clear_timeout_error: None, + actions: actions.into_iter().collect(), + } + } + } + + impl OpeningRequestWriter for FakeWriter { + fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { + if let Some(kind) = self.timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + if let Some(kind) = self.clear_timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + let action = self + .actions + .pop_front() + .unwrap_or(WriteAction::Count(bytes.len())); + match action { + WriteAction::Count(count) => Ok(count.min(bytes.len())), + WriteAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + 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([ + WriteAction::Count(2), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(3), + ]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start, start]); + let mut now = || times.pop_front().unwrap_or(start); + let result = + write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); + let is_five = |candidate: Result| { + matches!(candidate, Ok(5)) + }; + assert!(is_five(result)); + assert!(!is_five(Ok(4))); + } + + fn join_loopback_server(server: thread::JoinHandle>) -> bool { + match server.join() { + Ok(result) => { + result.expect("loopback server must accept the client"); + false + } + Err(_) => true, + } + } + + #[test] + fn bounded_writer_clears_real_socket_timeout_before_success() { + 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(|_| ())); + let mut stream = TcpStream::connect(address).expect("test client must connect"); + let start = Instant::now(); + let mut now = || start; + + let request_byte_count = + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) + .expect("the opening request must be written"); + + assert_eq!(request_byte_count, 7); + assert_eq!( + stream + .write_timeout() + .expect("the socket timeout must be inspectable"), + None + ); + assert!(!join_loopback_server(server)); + } + + #[test] + fn panicked_loopback_server_is_reported() { + let server = thread::spawn(|| -> io::Result<()> { + std::panic::resume_unwind(Box::new("intentional test-only server panic")); + }); + + assert!(join_loopback_server(server)); + } + + #[test] + fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let start = Instant::now(); + let mut now = || start; + + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_cleanup_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + } + ) + ) + }; + assert!(is_cleanup_failure(result)); + assert!(!is_cleanup_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } #[test] - fn close_status_code_validation_covers_wire_bounds_and_forbidden_sentinels() { - for code in [999_u16, 1005, 1006, 1015, 5000] { - assert!(!is_valid_close_status_code(code)); + fn bounded_writer_rejects_completion_observed_after_total_deadline() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_deadline_after_one = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + } + ) + ) + }; + assert!(is_deadline_after_one(result)); + assert!(!is_deadline_after_one(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { + let start = Instant::now(); + + 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); + let deadline = write_request_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now, + ); + let is_deadline_before_write = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + } + ) + ) + }; + assert!(is_deadline_before_write(deadline)); + assert!(!is_deadline_before_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + let zero = write_request_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now, + ); + let is_zero_write = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) + ) + }; + assert!(is_zero_write(zero)); + assert!(!is_zero_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } + ))); + + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut now = || start; + let timed_out = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_timed_out = + |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_timed_out(timed_out)); + assert!(!is_timed_out(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + source: io::Error::from(kind), + } + ))); + } + + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + let failed = write_request_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now, + ); + let is_failed = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_failed(failed)); + assert!(!is_failed(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + let configuration = write_request_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now, + ); + let is_configuration_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + .. + } + ) + ) + }; + assert!(is_configuration_failure(configuration)); + assert!(!is_configuration_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + } + + #[test] + fn opening_write_errors_have_deterministic_messages_and_sources() { + let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }; + let deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; + let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }; + let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; + let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }; + let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + + assert!(!invalid.to_string().is_empty()); + assert!(!deadline.to_string().is_empty()); + assert!(!configure.to_string().is_empty()); + assert!(!timed_out.to_string().is_empty()); + assert!(!zero.to_string().is_empty()); + assert!(!failed.to_string().is_empty()); + assert!(!cleanup.to_string().is_empty()); + assert!(invalid.source().is_none()); + assert!(deadline.source().is_none()); + assert!(configure.source().is_some()); + assert!(timed_out.source().is_some()); + assert!(zero.source().is_none()); + 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() + ); } - for code in [1000_u16, 3000, 4000, 4999] { - assert!(is_valid_close_status_code(code)); + 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()); } } From 21df42e07741b5aaede7f35bec0ab8ed2f082508 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:06:56 -0700 Subject: [PATCH 24/62] fix(network): reject forbidden WebSocket close codes --- .../src/webdriver_bidi_websocket_handshake.rs | 2392 +--------------- .../transport_impl.rs | 2474 +++++++++++++++++ 2 files changed, 2538 insertions(+), 2328 deletions(-) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 4e42217f9..82ef2b92c 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1,268 +1,83 @@ -use std::{ - error::Error, - fmt, - io::{self, Read, Write}, - net::TcpStream, - thread, - time::{Duration, Instant}, -}; +//! Public WebDriver BiDi WebSocket transport façade. +//! +//! The frame transport implementation remains isolated in a private module. This façade preserves +//! the reviewed public API while enforcing RFC 6455 close-status validity before any received Close +//! frame is handed to a caller. + +use std::{fmt, time::Duration}; -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 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. -/// -/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. The request is -/// 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'/') -} +#[path = "webdriver_bidi_websocket_handshake/transport_impl.rs"] +mod transport_impl; -fn is_canonical_16_byte_base64(value: &str) -> bool { - let bytes = value.as_bytes(); - bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH - && bytes[..22].iter().copied().all(is_base64_data_byte) - && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') - && bytes[22] == b'=' - && bytes[23] == b'=' -} +pub use transport_impl::{ + 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, + WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, +}; -/// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. -#[derive(Debug, Eq, PartialEq)] -pub enum WebDriverBiDiWebSocketHandshakeError { - /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. - InvalidClientKey, - /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. - TlsRequired, -} +/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. +pub struct WebDriverBiDiWebSocketHandshakePlan(transport_impl::WebDriverBiDiWebSocketHandshakePlan); -impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidClientKey => formatter.write_str( - "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", - ), - Self::TlsRequired => formatter.write_str( - "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", - ), - } + self.0.fmt(formatter) } } -impl Error for WebDriverBiDiWebSocketHandshakeError {} - -/// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. -/// -/// 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. -#[derive(Debug, Eq, PartialEq)] -pub struct WebDriverBiDiWebSocketClientKey(String); - -impl WebDriverBiDiWebSocketClientKey { - /// Admit one canonical base64 client key representing exactly 16 bytes. - pub fn new(value: &str) -> Result { - if !is_canonical_16_byte_base64(value) { - return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); - } - Ok(Self(value.to_owned())) - } - - /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -/// 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 -/// socket peer/session evidence that authorized its exact loopback destination. It serializes only -/// 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. -/// -/// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` -/// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or -/// Agent-authority grant. -#[derive(Debug)] -pub struct WebDriverBiDiWebSocketHandshakePlan { - connection: WebDriverBiDiTcpConnection, - client_key: WebDriverBiDiWebSocketClientKey, - request: Vec, -} - impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( connection: WebDriverBiDiTcpConnection, client_key: WebDriverBiDiWebSocketClientKey, ) -> Result { - if connection.verified_peer().requires_tls() { - return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); - } - - let peer = connection.verified_peer(); - let request = format!( - "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", - peer.session_id(), - peer.socket_addr(), - client_key.as_str(), - ) - .into_bytes(); - - Ok(Self { - connection, - client_key, - request, - }) + transport_impl::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.request + 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) -> &WebDriverBiDiWebSocketClientKey { - &self.client_key + 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.connection.verified_peer() + self.0.verified_peer() } /// Write the complete bounded opening request on the exact verified stream within one deadline. - /// - /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an - /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the - /// destination, or retries after any other I/O failure. A partial write that cannot finish before - /// the same monotonic deadline is an error and yields no successful handoff. Before success, the - /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage - /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport - /// evidence, and client key for a separately reviewed server handshake validator. It does not - /// read or validate the server response and therefore does not establish WebSocket protocol state - /// or browser/Agent authority. pub fn write_opening_request( self, write_timeout: Duration, ) -> Result { - if write_timeout.is_zero() || write_timeout > MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { - write_timeout, - maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, - }, - ); - } - - let Self { - connection, - client_key, - request, - } = self; - let (mut stream, transport_evidence) = connection.into_parts(); - let mut now = Instant::now; - let request_byte_count = - write_request_with_clock(&mut stream, &request, write_timeout, &mut now)?; - - Ok(WebDriverBiDiWebSocketOpeningRequestSent { - stream, - transport_evidence, - client_key, - request_byte_count, - write_timeout, - }) + self.0 + .write_opening_request(write_timeout) + .map(WebDriverBiDiWebSocketOpeningRequestSent) } } -/// A live verified stream after the complete client opening request has been written. -/// -/// This state proves only that the exact bounded RFC 6455 client request reached the operating -/// system's verified TCP stream before the configured deadline and that this operation's socket write -/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 -/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or -/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed -/// boundaries. -pub struct WebDriverBiDiWebSocketOpeningRequestSent { - pub(crate) stream: TcpStream, - transport_evidence: WebDriverBiDiTcpConnectionEvidence, - client_key: WebDriverBiDiWebSocketClientKey, - request_byte_count: usize, - write_timeout: Duration, -} +/// A live verified stream after the complete client WebSocket opening request has been written. +pub struct WebDriverBiDiWebSocketOpeningRequestSent( + transport_impl::WebDriverBiDiWebSocketOpeningRequestSent, +); impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") - .field("stream_local_addr", &self.stream.local_addr().ok()) - .field("transport_evidence", &self.transport_evidence) - .field( - "client_key", - &"", - ) - .field("request_byte_count", &self.request_byte_count) - .field("write_timeout", &self.write_timeout) - .finish() + self.0.fmt(formatter) } } @@ -270,103 +85,45 @@ 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 + 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) -> &WebDriverBiDiWebSocketClientKey { - &self.client_key + 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.request_byte_count + 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.write_timeout + self.0.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, - }) + self.0 + .read_opening_response(response_timeout) + .map(WebDriverBiDiWebSocketEstablished) } } /// 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, -} +pub struct WebDriverBiDiWebSocketEstablished(transport_impl::WebDriverBiDiWebSocketEstablished); 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() + self.0.fmt(formatter) } } @@ -374,2101 +131,80 @@ 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 + self.0.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 + self.0.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 + self.0.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 + self.0.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 + self.0.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 + self.0.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 + self.0.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, - }) + self.0 + .write_text_frame(text, masking_key, frame_timeout) + .map(Self) } - /// 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. + /// Read one bounded RFC 6455 frame and reject close status codes forbidden on the wire. 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") - } - } + let (established, frame) = self.0.read_frame(frame_timeout)?; + validate_close_status_code(&frame)?; + Ok((Self(established), frame)) } } -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, - }); - } - } +fn validate_close_status_code( + frame: &WebDriverBiDiWebSocketFrame, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { + if frame.opcode() != 0x8 || frame.payload().len() < 2 { + return Ok(()); } - 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 { + let status_code = u16::from_be_bytes([frame.payload()[0], frame.payload()[1]]); + if !(1000..=4999).contains(&status_code) || matches!(status_code, 1005 | 1006 | 1015) { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "control frame payload exceeds 125 bytes", + reason: "Close frame status code is not valid on the wire", }); } - 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(()) } - -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(); - - reader.set_nonblocking(true).map_err(|source| { - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { - bytes_read: 0, - source, - } - })?; - - 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, - }, - ); - } - } - } -} - -/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. -#[derive(Debug)] -pub enum WebDriverBiDiWebSocketOpeningWriteError { - /// The requested total write deadline was zero or above the reviewed resource ceiling. - InvalidWriteTimeout { - /// Rejected caller-supplied deadline. - write_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. - maximum_timeout: Duration, - }, - /// The monotonic total write deadline elapsed before the complete request was written. - WriteDeadlineExceeded { - /// Number of request bytes written before the deadline elapsed. - bytes_written: usize, - }, - /// Applying the remaining operating-system write timeout failed. - WriteTimeoutConfigurationFailed { - /// Number of request bytes already written before configuration failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket write reported timeout or would-block before completion. - WriteTimedOut { - /// Number of request bytes written before the timed-out operation. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A socket write returned zero bytes before the request was complete. - WriteZero { - /// Number of request bytes written before the zero-length write. - bytes_written: usize, - }, - /// A non-recoverable socket write failed before the complete request was emitted. - WriteFailed { - /// Number of request bytes written before the failure. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// Clearing the operation-local socket write timeout failed after all request bytes were sent. - WriteTimeoutCleanupFailed { - /// Number of request bytes already written before cleanup failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, -} - -impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidWriteTimeout { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", - ), - Self::WriteDeadlineExceeded { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", - ), - Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( - "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", - ), - Self::WriteTimedOut { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timed out before the request was complete", - ), - Self::WriteZero { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write returned zero before the request was complete", - ), - Self::WriteFailed { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write failed before the request was complete", - ), - Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( - "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", - ), - } - } -} - -impl Error for WebDriverBiDiWebSocketOpeningWriteError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::WriteTimeoutConfigurationFailed { source, .. } - | Self::WriteTimedOut { source, .. } - | Self::WriteFailed { source, .. } - | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), - Self::InvalidWriteTimeout { .. } - | Self::WriteDeadlineExceeded { .. } - | Self::WriteZero { .. } => None, - } - } -} - -trait OpeningRequestWriter { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; - fn clear_write_timeout(&self) -> io::Result<()>; - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; -} - -impl OpeningRequestWriter for TcpStream { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { - TcpStream::set_write_timeout(self, Some(timeout)) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - TcpStream::set_write_timeout(self, None) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write(bytes) - } -} - -fn write_request_with_clock( - writer: &mut dyn OpeningRequestWriter, - request: &[u8], - write_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + write_timeout; - let mut bytes_written = 0; - - while bytes_written < request.len() { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, - ); - } - writer.set_write_timeout(remaining).map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written, - source, - } - })?; - - match writer.write_request_bytes(&request[bytes_written..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); - } - Ok(count) => { - bytes_written += count; - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written, - }, - ); - } - } - 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, - }); - } - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written, - source, - }); - } - } - } - - writer.clear_write_timeout().map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written, - source, - } - })?; - - Ok(bytes_written) -} - -#[cfg(test)] -#[allow(clippy::expect_used)] -mod opening_write_tests { - use super::*; - use std::{ - collections::VecDeque, - net::{Shutdown, TcpListener}, - thread, - }; - - use originweave_core::WebDriverBiDiWebSocketEndpoint; - - #[derive(Debug)] - enum WriteAction { - Count(usize), - Error(io::ErrorKind), - } - - #[derive(Debug)] - struct FakeWriter { - timeout_error: Option, - clear_timeout_error: Option, - actions: VecDeque, - } - - impl FakeWriter { - fn new(actions: impl IntoIterator) -> Self { - Self { - timeout_error: None, - clear_timeout_error: None, - actions: actions.into_iter().collect(), - } - } - } - - impl OpeningRequestWriter for FakeWriter { - fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { - if let Some(kind) = self.timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - if let Some(kind) = self.clear_timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - let action = self - .actions - .pop_front() - .unwrap_or(WriteAction::Count(bytes.len())); - match action { - WriteAction::Count(count) => Ok(count.min(bytes.len())), - WriteAction::Error(kind) => Err(io::Error::from(kind)), - } - } - } - - 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([ - WriteAction::Count(2), - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(3), - ]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start, start]); - let mut now = || times.pop_front().unwrap_or(start); - let result = - write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); - let is_five = |candidate: Result| { - matches!(candidate, Ok(5)) - }; - assert!(is_five(result)); - assert!(!is_five(Ok(4))); - } - - fn join_loopback_server(server: thread::JoinHandle>) -> bool { - match server.join() { - Ok(result) => { - result.expect("loopback server must accept the client"); - false - } - Err(_) => true, - } - } - - #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() { - 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(|_| ())); - let mut stream = TcpStream::connect(address).expect("test client must connect"); - let start = Instant::now(); - let mut now = || start; - - let request_byte_count = - write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) - .expect("the opening request must be written"); - - assert_eq!(request_byte_count, 7); - assert_eq!( - stream - .write_timeout() - .expect("the socket timeout must be inspectable"), - None - ); - assert!(!join_loopback_server(server)); - } - - #[test] - fn panicked_loopback_server_is_reported() { - let server = thread::spawn(|| -> io::Result<()> { - std::panic::resume_unwind(Box::new("intentional test-only server panic")); - }); - - assert!(join_loopback_server(server)); - } - - #[test] - fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); - let start = Instant::now(); - let mut now = || start; - - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_cleanup_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - .. - } - ) - ) - }; - assert!(is_cleanup_failure(result)); - assert!(!is_cleanup_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } - - #[test] - fn bounded_writer_rejects_completion_observed_after_total_deadline() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); - let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_deadline_after_one = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 1 - } - ) - ) - }; - assert!(is_deadline_after_one(result)); - assert!(!is_deadline_after_one(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } - - #[test] - fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { - let start = Instant::now(); - - 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); - let deadline = write_request_with_clock( - &mut deadline_writer, - b"x", - Duration::from_secs(1), - &mut deadline_now, - ); - let is_deadline_before_write = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 0 - } - ) - ) - }; - assert!(is_deadline_before_write(deadline)); - assert!(!is_deadline_before_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let mut zero_now = || start; - let zero = write_request_with_clock( - &mut zero_writer, - b"x", - Duration::from_secs(1), - &mut zero_now, - ); - let is_zero_write = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) - ) - }; - assert!(is_zero_write(zero)); - assert!(!is_zero_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } - ))); - - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let mut now = || start; - let timed_out = - write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_timed_out = - |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_timed_out(timed_out)); - assert!(!is_timed_out(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - source: io::Error::from(kind), - } - ))); - } - - let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let mut failed_now = || start; - let failed = write_request_with_clock( - &mut failed_writer, - b"x", - Duration::from_secs(1), - &mut failed_now, - ); - let is_failed = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_failed(failed)); - assert!(!is_failed(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut configuration_writer = FakeWriter::new([]); - configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); - let mut configuration_now = || start; - let configuration = write_request_with_clock( - &mut configuration_writer, - b"x", - Duration::from_secs(1), - &mut configuration_now, - ); - let is_configuration_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - .. - } - ) - ) - }; - assert!(is_configuration_failure(configuration)); - assert!(!is_configuration_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - } - - #[test] - fn opening_write_errors_have_deterministic_messages_and_sources() { - let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { - write_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, - }; - let deadline = - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; - let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }; - let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; - let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }; - let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - - assert!(!invalid.to_string().is_empty()); - assert!(!deadline.to_string().is_empty()); - assert!(!configure.to_string().is_empty()); - assert!(!timed_out.to_string().is_empty()); - assert!(!zero.to_string().is_empty()); - assert!(!failed.to_string().is_empty()); - assert!(!cleanup.to_string().is_empty()); - assert!(invalid.source().is_none()); - assert!(deadline.source().is_none()); - assert!(configure.source().is_some()); - assert!(timed_out.source().is_some()); - assert!(zero.source().is_none()); - 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_handshake/transport_impl.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs new file mode 100644 index 000000000..4e42217f9 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs @@ -0,0 +1,2474 @@ +use std::{ + error::Error, + fmt, + 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 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. +/// +/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. The request is +/// 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'/') +} + +fn is_canonical_16_byte_base64(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH + && bytes[..22].iter().copied().all(is_base64_data_byte) + && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') + && bytes[22] == b'=' + && bytes[23] == b'=' +} + +/// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. +#[derive(Debug, Eq, PartialEq)] +pub enum WebDriverBiDiWebSocketHandshakeError { + /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. + InvalidClientKey, + /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. + TlsRequired, +} + +impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidClientKey => formatter.write_str( + "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", + ), + Self::TlsRequired => formatter.write_str( + "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketHandshakeError {} + +/// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. +/// +/// 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. +#[derive(Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketClientKey(String); + +impl WebDriverBiDiWebSocketClientKey { + /// Admit one canonical base64 client key representing exactly 16 bytes. + pub fn new(value: &str) -> Result { + if !is_canonical_16_byte_base64(value) { + return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); + } + Ok(Self(value.to_owned())) + } + + /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// 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 +/// socket peer/session evidence that authorized its exact loopback destination. It serializes only +/// 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. +/// +/// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` +/// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or +/// Agent-authority grant. +#[derive(Debug)] +pub struct WebDriverBiDiWebSocketHandshakePlan { + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + request: Vec, +} + +impl WebDriverBiDiWebSocketHandshakePlan { + /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. + pub fn new( + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + ) -> Result { + if connection.verified_peer().requires_tls() { + return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); + } + + let peer = connection.verified_peer(); + let request = format!( + "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", + peer.session_id(), + peer.socket_addr(), + client_key.as_str(), + ) + .into_bytes(); + + Ok(Self { + connection, + client_key, + request, + }) + } + + /// Borrow the exact serialized RFC 6455 opening-request bytes. + #[must_use] + pub fn request_bytes(&self) -> &[u8] { + &self.request + } + + /// Borrow the exact client key that a later server-handshake validator must correlate. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + + /// Borrow the exact peer/session evidence already verified before request construction. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + self.connection.verified_peer() + } + + /// Write the complete bounded opening request on the exact verified stream within one deadline. + /// + /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an + /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the + /// destination, or retries after any other I/O failure. A partial write that cannot finish before + /// the same monotonic deadline is an error and yields no successful handoff. Before success, the + /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage + /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport + /// evidence, and client key for a separately reviewed server handshake validator. It does not + /// read or validate the server response and therefore does not establish WebSocket protocol state + /// or browser/Agent authority. + pub fn write_opening_request( + self, + write_timeout: Duration, + ) -> Result + { + if write_timeout.is_zero() || write_timeout > MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }, + ); + } + + let Self { + connection, + client_key, + request, + } = self; + let (mut stream, transport_evidence) = connection.into_parts(); + let mut now = Instant::now; + let request_byte_count = + write_request_with_clock(&mut stream, &request, write_timeout, &mut now)?; + + Ok(WebDriverBiDiWebSocketOpeningRequestSent { + stream, + transport_evidence, + client_key, + request_byte_count, + write_timeout, + }) + } +} + +/// A live verified stream after the complete client opening request has been written. +/// +/// This state proves only that the exact bounded RFC 6455 client request reached the operating +/// system's verified TCP stream before the configured deadline and that this operation's socket write +/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 +/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or +/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed +/// boundaries. +pub struct WebDriverBiDiWebSocketOpeningRequestSent { + pub(crate) stream: TcpStream, + transport_evidence: WebDriverBiDiTcpConnectionEvidence, + client_key: WebDriverBiDiWebSocketClientKey, + request_byte_count: usize, + write_timeout: Duration, +} + +impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") + .field("stream_local_addr", &self.stream.local_addr().ok()) + .field("transport_evidence", &self.transport_evidence) + .field( + "client_key", + &"", + ) + .field("request_byte_count", &self.request_byte_count) + .field("write_timeout", &self.write_timeout) + .finish() + } +} + +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(()) +} + +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(); + + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 0, + source, + } + })?; + + 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, + }, + ); + } + } + } +} + +/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketOpeningWriteError { + /// The requested total write deadline was zero or above the reviewed resource ceiling. + InvalidWriteTimeout { + /// Rejected caller-supplied deadline. + write_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total write deadline elapsed before the complete request was written. + WriteDeadlineExceeded { + /// Number of request bytes written before the deadline elapsed. + bytes_written: usize, + }, + /// Applying the remaining operating-system write timeout failed. + WriteTimeoutConfigurationFailed { + /// Number of request bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write reported timeout or would-block before completion. + WriteTimedOut { + /// Number of request bytes written before the timed-out operation. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A socket write returned zero bytes before the request was complete. + WriteZero { + /// Number of request bytes written before the zero-length write. + bytes_written: usize, + }, + /// A non-recoverable socket write failed before the complete request was emitted. + WriteFailed { + /// Number of request bytes written before the failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// Clearing the operation-local socket write timeout failed after all request bytes were sent. + WriteTimeoutCleanupFailed { + /// Number of request bytes already written before cleanup failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWriteTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", + ), + Self::WriteDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", + ), + Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( + "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", + ), + Self::WriteTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timed out before the request was complete", + ), + Self::WriteZero { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write returned zero before the request was complete", + ), + Self::WriteFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write failed before the request was complete", + ), + Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( + "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketOpeningWriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::WriteTimeoutConfigurationFailed { source, .. } + | Self::WriteTimedOut { source, .. } + | Self::WriteFailed { source, .. } + | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), + Self::InvalidWriteTimeout { .. } + | Self::WriteDeadlineExceeded { .. } + | Self::WriteZero { .. } => None, + } + } +} + +trait OpeningRequestWriter { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; + fn clear_write_timeout(&self) -> io::Result<()>; + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl OpeningRequestWriter for TcpStream { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { + TcpStream::set_write_timeout(self, Some(timeout)) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + TcpStream::set_write_timeout(self, None) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_request_with_clock( + writer: &mut dyn OpeningRequestWriter, + request: &[u8], + write_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + write_timeout; + let mut bytes_written = 0; + + while bytes_written < request.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, + ); + } + writer.set_write_timeout(remaining).map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written, + source, + } + })?; + + match writer.write_request_bytes(&request[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); + } + Ok(count) => { + bytes_written += count; + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written, + }, + ); + } + } + 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, + }); + } + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written, + source, + }); + } + } + } + + writer.clear_write_timeout().map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written, + source, + } + })?; + + Ok(bytes_written) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod opening_write_tests { + use super::*; + use std::{ + collections::VecDeque, + net::{Shutdown, TcpListener}, + thread, + }; + + use originweave_core::WebDriverBiDiWebSocketEndpoint; + + #[derive(Debug)] + enum WriteAction { + Count(usize), + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeWriter { + timeout_error: Option, + clear_timeout_error: Option, + actions: VecDeque, + } + + impl FakeWriter { + fn new(actions: impl IntoIterator) -> Self { + Self { + timeout_error: None, + clear_timeout_error: None, + actions: actions.into_iter().collect(), + } + } + } + + impl OpeningRequestWriter for FakeWriter { + fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { + if let Some(kind) = self.timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + if let Some(kind) = self.clear_timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + let action = self + .actions + .pop_front() + .unwrap_or(WriteAction::Count(bytes.len())); + match action { + WriteAction::Count(count) => Ok(count.min(bytes.len())), + WriteAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + 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([ + WriteAction::Count(2), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(3), + ]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start, start]); + let mut now = || times.pop_front().unwrap_or(start); + let result = + write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); + let is_five = |candidate: Result| { + matches!(candidate, Ok(5)) + }; + assert!(is_five(result)); + assert!(!is_five(Ok(4))); + } + + fn join_loopback_server(server: thread::JoinHandle>) -> bool { + match server.join() { + Ok(result) => { + result.expect("loopback server must accept the client"); + false + } + Err(_) => true, + } + } + + #[test] + fn bounded_writer_clears_real_socket_timeout_before_success() { + 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(|_| ())); + let mut stream = TcpStream::connect(address).expect("test client must connect"); + let start = Instant::now(); + let mut now = || start; + + let request_byte_count = + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) + .expect("the opening request must be written"); + + assert_eq!(request_byte_count, 7); + assert_eq!( + stream + .write_timeout() + .expect("the socket timeout must be inspectable"), + None + ); + assert!(!join_loopback_server(server)); + } + + #[test] + fn panicked_loopback_server_is_reported() { + let server = thread::spawn(|| -> io::Result<()> { + std::panic::resume_unwind(Box::new("intentional test-only server panic")); + }); + + assert!(join_loopback_server(server)); + } + + #[test] + fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let start = Instant::now(); + let mut now = || start; + + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_cleanup_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + } + ) + ) + }; + assert!(is_cleanup_failure(result)); + assert!(!is_cleanup_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_rejects_completion_observed_after_total_deadline() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_deadline_after_one = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + } + ) + ) + }; + assert!(is_deadline_after_one(result)); + assert!(!is_deadline_after_one(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { + let start = Instant::now(); + + 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); + let deadline = write_request_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now, + ); + let is_deadline_before_write = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + } + ) + ) + }; + assert!(is_deadline_before_write(deadline)); + assert!(!is_deadline_before_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + let zero = write_request_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now, + ); + let is_zero_write = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) + ) + }; + assert!(is_zero_write(zero)); + assert!(!is_zero_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } + ))); + + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut now = || start; + let timed_out = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_timed_out = + |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_timed_out(timed_out)); + assert!(!is_timed_out(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + source: io::Error::from(kind), + } + ))); + } + + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + let failed = write_request_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now, + ); + let is_failed = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_failed(failed)); + assert!(!is_failed(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + let configuration = write_request_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now, + ); + let is_configuration_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + .. + } + ) + ) + }; + assert!(is_configuration_failure(configuration)); + assert!(!is_configuration_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + } + + #[test] + fn opening_write_errors_have_deterministic_messages_and_sources() { + let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }; + let deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; + let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }; + let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; + let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }; + let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + + assert!(!invalid.to_string().is_empty()); + assert!(!deadline.to_string().is_empty()); + assert!(!configure.to_string().is_empty()); + assert!(!timed_out.to_string().is_empty()); + assert!(!zero.to_string().is_empty()); + assert!(!failed.to_string().is_empty()); + assert!(!cleanup.to_string().is_empty()); + assert!(invalid.source().is_none()); + assert!(deadline.source().is_none()); + assert!(configure.source().is_some()); + assert!(timed_out.source().is_some()); + assert!(zero.source().is_none()); + 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()); + } +} From e07aaf0cc0fbdcdc2d3fb382697106f9e1181f89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:11:13 -0700 Subject: [PATCH 25/62] fix(network): validate public WebSocket close frames --- crates/originweave-network/src/lib.rs | 13 +- .../src/webdriver_bidi_websocket_control.rs | 5 +- .../src/webdriver_bidi_websocket_handshake.rs | 2392 +++++++++++++++- .../transport_impl.rs | 2474 ----------------- .../src/webdriver_bidi_websocket_validated.rs | 211 ++ 5 files changed, 2550 insertions(+), 2545 deletions(-) delete mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_validated.rs diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index cfcecc864..87d909823 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -20,6 +20,7 @@ mod webdriver_bidi_websocket_control; #[allow(clippy::expect_used)] mod webdriver_bidi_websocket_coverage_tests; mod webdriver_bidi_websocket_handshake; +mod webdriver_bidi_websocket_validated; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, @@ -33,9 +34,11 @@ pub use webdriver_bidi_websocket_handshake::{ 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, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketHandshakeResponseError, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningRequestSent, - WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, +}; +pub use webdriver_bidi_websocket_validated::{ + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningRequestSent, }; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index aa22579ca..4bd645d66 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -6,8 +6,9 @@ use std::{ }; use crate::{ - MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, - WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, + webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketEstablished, }; const MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES: usize = 125; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 82ef2b92c..4e42217f9 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1,83 +1,268 @@ -//! Public WebDriver BiDi WebSocket transport façade. -//! -//! The frame transport implementation remains isolated in a private module. This façade preserves -//! the reviewed public API while enforcing RFC 6455 close-status validity before any received Close -//! frame is handed to a caller. - -use std::{fmt, time::Duration}; +use std::{ + error::Error, + fmt, + 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}; -#[path = "webdriver_bidi_websocket_handshake/transport_impl.rs"] -mod transport_impl; +const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +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; -pub use transport_impl::{ - 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, - WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, - WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, -}; +/// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. +/// +/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. The request is +/// 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); -/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. -pub struct WebDriverBiDiWebSocketHandshakePlan(transport_impl::WebDriverBiDiWebSocketHandshakePlan); +/// 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'/') +} -impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { +fn is_canonical_16_byte_base64(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH + && bytes[..22].iter().copied().all(is_base64_data_byte) + && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') + && bytes[22] == b'=' + && bytes[23] == b'=' +} + +/// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. +#[derive(Debug, Eq, PartialEq)] +pub enum WebDriverBiDiWebSocketHandshakeError { + /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. + InvalidClientKey, + /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. + TlsRequired, +} + +impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(formatter) + match self { + Self::InvalidClientKey => formatter.write_str( + "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", + ), + Self::TlsRequired => formatter.write_str( + "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", + ), + } } } +impl Error for WebDriverBiDiWebSocketHandshakeError {} + +/// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. +/// +/// 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. +#[derive(Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketClientKey(String); + +impl WebDriverBiDiWebSocketClientKey { + /// Admit one canonical base64 client key representing exactly 16 bytes. + pub fn new(value: &str) -> Result { + if !is_canonical_16_byte_base64(value) { + return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); + } + Ok(Self(value.to_owned())) + } + + /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// 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 +/// socket peer/session evidence that authorized its exact loopback destination. It serializes only +/// 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. +/// +/// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` +/// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or +/// Agent-authority grant. +#[derive(Debug)] +pub struct WebDriverBiDiWebSocketHandshakePlan { + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + request: Vec, +} + impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( connection: WebDriverBiDiTcpConnection, client_key: WebDriverBiDiWebSocketClientKey, ) -> Result { - transport_impl::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key).map(Self) + if connection.verified_peer().requires_tls() { + return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); + } + + let peer = connection.verified_peer(); + let request = format!( + "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", + peer.session_id(), + peer.socket_addr(), + client_key.as_str(), + ) + .into_bytes(); + + Ok(Self { + connection, + client_key, + request, + }) } /// Borrow the exact serialized RFC 6455 opening-request bytes. #[must_use] pub fn request_bytes(&self) -> &[u8] { - self.0.request_bytes() + &self.request } /// Borrow the exact client key that a later server-handshake validator must correlate. #[must_use] pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - self.0.client_key() + &self.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() + self.connection.verified_peer() } /// Write the complete bounded opening request on the exact verified stream within one deadline. + /// + /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an + /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the + /// destination, or retries after any other I/O failure. A partial write that cannot finish before + /// the same monotonic deadline is an error and yields no successful handoff. Before success, the + /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage + /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport + /// evidence, and client key for a separately reviewed server handshake validator. It does not + /// read or validate the server response and therefore does not establish WebSocket protocol state + /// or browser/Agent authority. pub fn write_opening_request( self, write_timeout: Duration, ) -> Result { - self.0 - .write_opening_request(write_timeout) - .map(WebDriverBiDiWebSocketOpeningRequestSent) + if write_timeout.is_zero() || write_timeout > MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }, + ); + } + + let Self { + connection, + client_key, + request, + } = self; + let (mut stream, transport_evidence) = connection.into_parts(); + let mut now = Instant::now; + let request_byte_count = + write_request_with_clock(&mut stream, &request, write_timeout, &mut now)?; + + Ok(WebDriverBiDiWebSocketOpeningRequestSent { + stream, + transport_evidence, + client_key, + request_byte_count, + write_timeout, + }) } } -/// A live verified stream after the complete client WebSocket opening request has been written. -pub struct WebDriverBiDiWebSocketOpeningRequestSent( - transport_impl::WebDriverBiDiWebSocketOpeningRequestSent, -); +/// A live verified stream after the complete client opening request has been written. +/// +/// This state proves only that the exact bounded RFC 6455 client request reached the operating +/// system's verified TCP stream before the configured deadline and that this operation's socket write +/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 +/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or +/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed +/// boundaries. +pub struct WebDriverBiDiWebSocketOpeningRequestSent { + pub(crate) stream: TcpStream, + transport_evidence: WebDriverBiDiTcpConnectionEvidence, + client_key: WebDriverBiDiWebSocketClientKey, + request_byte_count: usize, + write_timeout: Duration, +} impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(formatter) + formatter + .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") + .field("stream_local_addr", &self.stream.local_addr().ok()) + .field("transport_evidence", &self.transport_evidence) + .field( + "client_key", + &"", + ) + .field("request_byte_count", &self.request_byte_count) + .field("write_timeout", &self.write_timeout) + .finish() } } @@ -85,45 +270,103 @@ 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() + &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.0.client_key() + &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.0.request_byte_count() + self.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() + 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 { - self.0 - .read_opening_response(response_timeout) - .map(WebDriverBiDiWebSocketEstablished) + 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. -pub struct WebDriverBiDiWebSocketEstablished(transport_impl::WebDriverBiDiWebSocketEstablished); +/// +/// 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 { - self.0.fmt(formatter) + 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() } } @@ -131,80 +374,2101 @@ impl WebDriverBiDiWebSocketEstablished { /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { - self.0.transport_evidence() + &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.0.client_key() + &self.client_key } /// Return the validated HTTP status code, currently always `101` on success. #[must_use] pub const fn response_status(&self) -> u16 { - self.0.response_status() + 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.0.response_byte_count() + self.response_byte_count } /// Return the total response deadline configured for this opening response. #[must_use] pub const fn response_timeout(&self) -> Duration { - self.0.response_timeout() + 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.0.request_byte_count() + 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.0.write_timeout() + 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 { - self.0 - .write_text_frame(text, masking_key, frame_timeout) - .map(Self) + 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 and reject close status codes forbidden on the wire. + /// 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> { - let (established, frame) = self.0.read_frame(frame_timeout)?; - validate_close_status_code(&frame)?; - Ok((Self(established), frame)) + 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, + )) } } -fn validate_close_status_code( - frame: &WebDriverBiDiWebSocketFrame, -) -> Result<(), WebDriverBiDiWebSocketFrameError> { - if frame.opcode() != 0x8 || frame.payload().len() < 2 { - return Ok(()); +/// 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", + ), + } + } +} - let status_code = u16::from_be_bytes([frame.payload()[0], frame.payload()[1]]); - if !(1000..=4999).contains(&status_code) || matches!(status_code, 1005 | 1006 | 1015) { +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: "Close frame status code is not valid on the wire", + 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(()) } + +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(); + + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 0, + source, + } + })?; + + 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, + }, + ); + } + } + } +} + +/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketOpeningWriteError { + /// The requested total write deadline was zero or above the reviewed resource ceiling. + InvalidWriteTimeout { + /// Rejected caller-supplied deadline. + write_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total write deadline elapsed before the complete request was written. + WriteDeadlineExceeded { + /// Number of request bytes written before the deadline elapsed. + bytes_written: usize, + }, + /// Applying the remaining operating-system write timeout failed. + WriteTimeoutConfigurationFailed { + /// Number of request bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write reported timeout or would-block before completion. + WriteTimedOut { + /// Number of request bytes written before the timed-out operation. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A socket write returned zero bytes before the request was complete. + WriteZero { + /// Number of request bytes written before the zero-length write. + bytes_written: usize, + }, + /// A non-recoverable socket write failed before the complete request was emitted. + WriteFailed { + /// Number of request bytes written before the failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// Clearing the operation-local socket write timeout failed after all request bytes were sent. + WriteTimeoutCleanupFailed { + /// Number of request bytes already written before cleanup failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWriteTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", + ), + Self::WriteDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", + ), + Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( + "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", + ), + Self::WriteTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timed out before the request was complete", + ), + Self::WriteZero { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write returned zero before the request was complete", + ), + Self::WriteFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write failed before the request was complete", + ), + Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( + "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketOpeningWriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::WriteTimeoutConfigurationFailed { source, .. } + | Self::WriteTimedOut { source, .. } + | Self::WriteFailed { source, .. } + | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), + Self::InvalidWriteTimeout { .. } + | Self::WriteDeadlineExceeded { .. } + | Self::WriteZero { .. } => None, + } + } +} + +trait OpeningRequestWriter { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; + fn clear_write_timeout(&self) -> io::Result<()>; + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl OpeningRequestWriter for TcpStream { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { + TcpStream::set_write_timeout(self, Some(timeout)) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + TcpStream::set_write_timeout(self, None) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_request_with_clock( + writer: &mut dyn OpeningRequestWriter, + request: &[u8], + write_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + write_timeout; + let mut bytes_written = 0; + + while bytes_written < request.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, + ); + } + writer.set_write_timeout(remaining).map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written, + source, + } + })?; + + match writer.write_request_bytes(&request[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); + } + Ok(count) => { + bytes_written += count; + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written, + }, + ); + } + } + 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, + }); + } + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written, + source, + }); + } + } + } + + writer.clear_write_timeout().map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written, + source, + } + })?; + + Ok(bytes_written) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod opening_write_tests { + use super::*; + use std::{ + collections::VecDeque, + net::{Shutdown, TcpListener}, + thread, + }; + + use originweave_core::WebDriverBiDiWebSocketEndpoint; + + #[derive(Debug)] + enum WriteAction { + Count(usize), + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeWriter { + timeout_error: Option, + clear_timeout_error: Option, + actions: VecDeque, + } + + impl FakeWriter { + fn new(actions: impl IntoIterator) -> Self { + Self { + timeout_error: None, + clear_timeout_error: None, + actions: actions.into_iter().collect(), + } + } + } + + impl OpeningRequestWriter for FakeWriter { + fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { + if let Some(kind) = self.timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + if let Some(kind) = self.clear_timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + let action = self + .actions + .pop_front() + .unwrap_or(WriteAction::Count(bytes.len())); + match action { + WriteAction::Count(count) => Ok(count.min(bytes.len())), + WriteAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + 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([ + WriteAction::Count(2), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(3), + ]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start, start]); + let mut now = || times.pop_front().unwrap_or(start); + let result = + write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); + let is_five = |candidate: Result| { + matches!(candidate, Ok(5)) + }; + assert!(is_five(result)); + assert!(!is_five(Ok(4))); + } + + fn join_loopback_server(server: thread::JoinHandle>) -> bool { + match server.join() { + Ok(result) => { + result.expect("loopback server must accept the client"); + false + } + Err(_) => true, + } + } + + #[test] + fn bounded_writer_clears_real_socket_timeout_before_success() { + 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(|_| ())); + let mut stream = TcpStream::connect(address).expect("test client must connect"); + let start = Instant::now(); + let mut now = || start; + + let request_byte_count = + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) + .expect("the opening request must be written"); + + assert_eq!(request_byte_count, 7); + assert_eq!( + stream + .write_timeout() + .expect("the socket timeout must be inspectable"), + None + ); + assert!(!join_loopback_server(server)); + } + + #[test] + fn panicked_loopback_server_is_reported() { + let server = thread::spawn(|| -> io::Result<()> { + std::panic::resume_unwind(Box::new("intentional test-only server panic")); + }); + + assert!(join_loopback_server(server)); + } + + #[test] + fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let start = Instant::now(); + let mut now = || start; + + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_cleanup_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + } + ) + ) + }; + assert!(is_cleanup_failure(result)); + assert!(!is_cleanup_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_rejects_completion_observed_after_total_deadline() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_deadline_after_one = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + } + ) + ) + }; + assert!(is_deadline_after_one(result)); + assert!(!is_deadline_after_one(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { + let start = Instant::now(); + + 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); + let deadline = write_request_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now, + ); + let is_deadline_before_write = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + } + ) + ) + }; + assert!(is_deadline_before_write(deadline)); + assert!(!is_deadline_before_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + let zero = write_request_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now, + ); + let is_zero_write = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) + ) + }; + assert!(is_zero_write(zero)); + assert!(!is_zero_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } + ))); + + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut now = || start; + let timed_out = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_timed_out = + |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_timed_out(timed_out)); + assert!(!is_timed_out(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + source: io::Error::from(kind), + } + ))); + } + + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + let failed = write_request_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now, + ); + let is_failed = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_failed(failed)); + assert!(!is_failed(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + let configuration = write_request_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now, + ); + let is_configuration_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + .. + } + ) + ) + }; + assert!(is_configuration_failure(configuration)); + assert!(!is_configuration_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + } + + #[test] + fn opening_write_errors_have_deterministic_messages_and_sources() { + let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }; + let deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; + let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }; + let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; + let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }; + let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + + assert!(!invalid.to_string().is_empty()); + assert!(!deadline.to_string().is_empty()); + assert!(!configure.to_string().is_empty()); + assert!(!timed_out.to_string().is_empty()); + assert!(!zero.to_string().is_empty()); + assert!(!failed.to_string().is_empty()); + assert!(!cleanup.to_string().is_empty()); + assert!(invalid.source().is_none()); + assert!(deadline.source().is_none()); + assert!(configure.source().is_some()); + assert!(timed_out.source().is_some()); + assert!(zero.source().is_none()); + 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_handshake/transport_impl.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs deleted file mode 100644 index 4e42217f9..000000000 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs +++ /dev/null @@ -1,2474 +0,0 @@ -use std::{ - error::Error, - fmt, - 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 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. -/// -/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. The request is -/// 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'/') -} - -fn is_canonical_16_byte_base64(value: &str) -> bool { - let bytes = value.as_bytes(); - bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH - && bytes[..22].iter().copied().all(is_base64_data_byte) - && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') - && bytes[22] == b'=' - && bytes[23] == b'=' -} - -/// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. -#[derive(Debug, Eq, PartialEq)] -pub enum WebDriverBiDiWebSocketHandshakeError { - /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. - InvalidClientKey, - /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. - TlsRequired, -} - -impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidClientKey => formatter.write_str( - "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", - ), - Self::TlsRequired => formatter.write_str( - "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", - ), - } - } -} - -impl Error for WebDriverBiDiWebSocketHandshakeError {} - -/// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. -/// -/// 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. -#[derive(Debug, Eq, PartialEq)] -pub struct WebDriverBiDiWebSocketClientKey(String); - -impl WebDriverBiDiWebSocketClientKey { - /// Admit one canonical base64 client key representing exactly 16 bytes. - pub fn new(value: &str) -> Result { - if !is_canonical_16_byte_base64(value) { - return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); - } - Ok(Self(value.to_owned())) - } - - /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -/// 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 -/// socket peer/session evidence that authorized its exact loopback destination. It serializes only -/// 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. -/// -/// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` -/// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or -/// Agent-authority grant. -#[derive(Debug)] -pub struct WebDriverBiDiWebSocketHandshakePlan { - connection: WebDriverBiDiTcpConnection, - client_key: WebDriverBiDiWebSocketClientKey, - request: Vec, -} - -impl WebDriverBiDiWebSocketHandshakePlan { - /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. - pub fn new( - connection: WebDriverBiDiTcpConnection, - client_key: WebDriverBiDiWebSocketClientKey, - ) -> Result { - if connection.verified_peer().requires_tls() { - return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); - } - - let peer = connection.verified_peer(); - let request = format!( - "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", - peer.session_id(), - peer.socket_addr(), - client_key.as_str(), - ) - .into_bytes(); - - Ok(Self { - connection, - client_key, - request, - }) - } - - /// Borrow the exact serialized RFC 6455 opening-request bytes. - #[must_use] - pub fn request_bytes(&self) -> &[u8] { - &self.request - } - - /// Borrow the exact client key that a later server-handshake validator must correlate. - #[must_use] - pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - &self.client_key - } - - /// Borrow the exact peer/session evidence already verified before request construction. - #[must_use] - pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { - self.connection.verified_peer() - } - - /// Write the complete bounded opening request on the exact verified stream within one deadline. - /// - /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an - /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the - /// destination, or retries after any other I/O failure. A partial write that cannot finish before - /// the same monotonic deadline is an error and yields no successful handoff. Before success, the - /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage - /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport - /// evidence, and client key for a separately reviewed server handshake validator. It does not - /// read or validate the server response and therefore does not establish WebSocket protocol state - /// or browser/Agent authority. - pub fn write_opening_request( - self, - write_timeout: Duration, - ) -> Result - { - if write_timeout.is_zero() || write_timeout > MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { - write_timeout, - maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, - }, - ); - } - - let Self { - connection, - client_key, - request, - } = self; - let (mut stream, transport_evidence) = connection.into_parts(); - let mut now = Instant::now; - let request_byte_count = - write_request_with_clock(&mut stream, &request, write_timeout, &mut now)?; - - Ok(WebDriverBiDiWebSocketOpeningRequestSent { - stream, - transport_evidence, - client_key, - request_byte_count, - write_timeout, - }) - } -} - -/// A live verified stream after the complete client opening request has been written. -/// -/// This state proves only that the exact bounded RFC 6455 client request reached the operating -/// system's verified TCP stream before the configured deadline and that this operation's socket write -/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 -/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or -/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed -/// boundaries. -pub struct WebDriverBiDiWebSocketOpeningRequestSent { - pub(crate) stream: TcpStream, - transport_evidence: WebDriverBiDiTcpConnectionEvidence, - client_key: WebDriverBiDiWebSocketClientKey, - request_byte_count: usize, - write_timeout: Duration, -} - -impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") - .field("stream_local_addr", &self.stream.local_addr().ok()) - .field("transport_evidence", &self.transport_evidence) - .field( - "client_key", - &"", - ) - .field("request_byte_count", &self.request_byte_count) - .field("write_timeout", &self.write_timeout) - .finish() - } -} - -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(()) -} - -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(); - - reader.set_nonblocking(true).map_err(|source| { - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { - bytes_read: 0, - source, - } - })?; - - 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, - }, - ); - } - } - } -} - -/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. -#[derive(Debug)] -pub enum WebDriverBiDiWebSocketOpeningWriteError { - /// The requested total write deadline was zero or above the reviewed resource ceiling. - InvalidWriteTimeout { - /// Rejected caller-supplied deadline. - write_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. - maximum_timeout: Duration, - }, - /// The monotonic total write deadline elapsed before the complete request was written. - WriteDeadlineExceeded { - /// Number of request bytes written before the deadline elapsed. - bytes_written: usize, - }, - /// Applying the remaining operating-system write timeout failed. - WriteTimeoutConfigurationFailed { - /// Number of request bytes already written before configuration failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket write reported timeout or would-block before completion. - WriteTimedOut { - /// Number of request bytes written before the timed-out operation. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A socket write returned zero bytes before the request was complete. - WriteZero { - /// Number of request bytes written before the zero-length write. - bytes_written: usize, - }, - /// A non-recoverable socket write failed before the complete request was emitted. - WriteFailed { - /// Number of request bytes written before the failure. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// Clearing the operation-local socket write timeout failed after all request bytes were sent. - WriteTimeoutCleanupFailed { - /// Number of request bytes already written before cleanup failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, -} - -impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidWriteTimeout { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", - ), - Self::WriteDeadlineExceeded { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", - ), - Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( - "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", - ), - Self::WriteTimedOut { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timed out before the request was complete", - ), - Self::WriteZero { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write returned zero before the request was complete", - ), - Self::WriteFailed { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write failed before the request was complete", - ), - Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( - "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", - ), - } - } -} - -impl Error for WebDriverBiDiWebSocketOpeningWriteError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::WriteTimeoutConfigurationFailed { source, .. } - | Self::WriteTimedOut { source, .. } - | Self::WriteFailed { source, .. } - | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), - Self::InvalidWriteTimeout { .. } - | Self::WriteDeadlineExceeded { .. } - | Self::WriteZero { .. } => None, - } - } -} - -trait OpeningRequestWriter { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; - fn clear_write_timeout(&self) -> io::Result<()>; - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; -} - -impl OpeningRequestWriter for TcpStream { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { - TcpStream::set_write_timeout(self, Some(timeout)) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - TcpStream::set_write_timeout(self, None) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write(bytes) - } -} - -fn write_request_with_clock( - writer: &mut dyn OpeningRequestWriter, - request: &[u8], - write_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + write_timeout; - let mut bytes_written = 0; - - while bytes_written < request.len() { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, - ); - } - writer.set_write_timeout(remaining).map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written, - source, - } - })?; - - match writer.write_request_bytes(&request[bytes_written..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); - } - Ok(count) => { - bytes_written += count; - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written, - }, - ); - } - } - 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, - }); - } - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written, - source, - }); - } - } - } - - writer.clear_write_timeout().map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written, - source, - } - })?; - - Ok(bytes_written) -} - -#[cfg(test)] -#[allow(clippy::expect_used)] -mod opening_write_tests { - use super::*; - use std::{ - collections::VecDeque, - net::{Shutdown, TcpListener}, - thread, - }; - - use originweave_core::WebDriverBiDiWebSocketEndpoint; - - #[derive(Debug)] - enum WriteAction { - Count(usize), - Error(io::ErrorKind), - } - - #[derive(Debug)] - struct FakeWriter { - timeout_error: Option, - clear_timeout_error: Option, - actions: VecDeque, - } - - impl FakeWriter { - fn new(actions: impl IntoIterator) -> Self { - Self { - timeout_error: None, - clear_timeout_error: None, - actions: actions.into_iter().collect(), - } - } - } - - impl OpeningRequestWriter for FakeWriter { - fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { - if let Some(kind) = self.timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - if let Some(kind) = self.clear_timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - let action = self - .actions - .pop_front() - .unwrap_or(WriteAction::Count(bytes.len())); - match action { - WriteAction::Count(count) => Ok(count.min(bytes.len())), - WriteAction::Error(kind) => Err(io::Error::from(kind)), - } - } - } - - 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([ - WriteAction::Count(2), - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(3), - ]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start, start]); - let mut now = || times.pop_front().unwrap_or(start); - let result = - write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); - let is_five = |candidate: Result| { - matches!(candidate, Ok(5)) - }; - assert!(is_five(result)); - assert!(!is_five(Ok(4))); - } - - fn join_loopback_server(server: thread::JoinHandle>) -> bool { - match server.join() { - Ok(result) => { - result.expect("loopback server must accept the client"); - false - } - Err(_) => true, - } - } - - #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() { - 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(|_| ())); - let mut stream = TcpStream::connect(address).expect("test client must connect"); - let start = Instant::now(); - let mut now = || start; - - let request_byte_count = - write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) - .expect("the opening request must be written"); - - assert_eq!(request_byte_count, 7); - assert_eq!( - stream - .write_timeout() - .expect("the socket timeout must be inspectable"), - None - ); - assert!(!join_loopback_server(server)); - } - - #[test] - fn panicked_loopback_server_is_reported() { - let server = thread::spawn(|| -> io::Result<()> { - std::panic::resume_unwind(Box::new("intentional test-only server panic")); - }); - - assert!(join_loopback_server(server)); - } - - #[test] - fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); - let start = Instant::now(); - let mut now = || start; - - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_cleanup_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - .. - } - ) - ) - }; - assert!(is_cleanup_failure(result)); - assert!(!is_cleanup_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } - - #[test] - fn bounded_writer_rejects_completion_observed_after_total_deadline() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); - let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_deadline_after_one = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 1 - } - ) - ) - }; - assert!(is_deadline_after_one(result)); - assert!(!is_deadline_after_one(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } - - #[test] - fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { - let start = Instant::now(); - - 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); - let deadline = write_request_with_clock( - &mut deadline_writer, - b"x", - Duration::from_secs(1), - &mut deadline_now, - ); - let is_deadline_before_write = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 0 - } - ) - ) - }; - assert!(is_deadline_before_write(deadline)); - assert!(!is_deadline_before_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let mut zero_now = || start; - let zero = write_request_with_clock( - &mut zero_writer, - b"x", - Duration::from_secs(1), - &mut zero_now, - ); - let is_zero_write = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) - ) - }; - assert!(is_zero_write(zero)); - assert!(!is_zero_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } - ))); - - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let mut now = || start; - let timed_out = - write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_timed_out = - |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_timed_out(timed_out)); - assert!(!is_timed_out(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - source: io::Error::from(kind), - } - ))); - } - - let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let mut failed_now = || start; - let failed = write_request_with_clock( - &mut failed_writer, - b"x", - Duration::from_secs(1), - &mut failed_now, - ); - let is_failed = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_failed(failed)); - assert!(!is_failed(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut configuration_writer = FakeWriter::new([]); - configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); - let mut configuration_now = || start; - let configuration = write_request_with_clock( - &mut configuration_writer, - b"x", - Duration::from_secs(1), - &mut configuration_now, - ); - let is_configuration_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - .. - } - ) - ) - }; - assert!(is_configuration_failure(configuration)); - assert!(!is_configuration_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - } - - #[test] - fn opening_write_errors_have_deterministic_messages_and_sources() { - let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { - write_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, - }; - let deadline = - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; - let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }; - let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; - let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }; - let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - - assert!(!invalid.to_string().is_empty()); - assert!(!deadline.to_string().is_empty()); - assert!(!configure.to_string().is_empty()); - assert!(!timed_out.to_string().is_empty()); - assert!(!zero.to_string().is_empty()); - assert!(!failed.to_string().is_empty()); - assert!(!cleanup.to_string().is_empty()); - assert!(invalid.source().is_none()); - assert!(deadline.source().is_none()); - assert!(configure.source().is_some()); - assert!(timed_out.source().is_some()); - assert!(zero.source().is_none()); - 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_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs new file mode 100644 index 000000000..8d35740d4 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -0,0 +1,211 @@ +//! 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 as raw, +}; + +/// 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 { + self.0.fmt(formatter) + } +} + +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 + { + 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(WebDriverBiDiWebSocketEstablished) + } +} + +/// A live verified stream after both RFC 6455 opening messages were validated. +pub struct WebDriverBiDiWebSocketEstablished(raw::WebDriverBiDiWebSocketEstablished); + +impl fmt::Debug for WebDriverBiDiWebSocketEstablished { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.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.0.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.0.client_key() + } + + /// Return the validated HTTP status code, currently always `101` on success. + #[must_use] + pub const fn response_status(&self) -> u16 { + self.0.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.0.response_byte_count() + } + + /// Return the total response deadline configured for this opening response. + #[must_use] + pub const fn response_timeout(&self) -> Duration { + self.0.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.0.request_byte_count() + } + + /// Return the total write deadline configured for the preceding opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.0.write_timeout() + } + + /// Write one unfragmented, masked UTF-8 text frame on this verified stream. + pub fn write_text_frame( + self, + text: &str, + masking_key: raw::WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + self.0 + .write_text_frame(text, masking_key, frame_timeout) + .map(Self) + } + + /// Write one final masked RFC 6455 Pong control frame on this verified stream. + pub fn write_pong_frame( + self, + payload: &[u8], + masking_key: raw::WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + self.0 + .write_pong_frame(payload, masking_key, frame_timeout) + .map(Self) + } + + /// Read one bounded RFC 6455 frame and reject close status codes forbidden on the wire. + pub fn read_frame( + self, + frame_timeout: Duration, + ) -> Result<(Self, raw::WebDriverBiDiWebSocketFrame), raw::WebDriverBiDiWebSocketFrameError> { + let (established, frame) = self.0.read_frame(frame_timeout)?; + validate_close_status_code(&frame)?; + Ok((Self(established), 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, 1005 | 1006 | 1015) { + return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame status code is not valid on the wire", + }); + } + Ok(()) +} From d6231ce3eafb6480af71e886b780d726fec5f272 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:13:28 -0700 Subject: [PATCH 26/62] style(network): apply canonical Rust formatting --- .../src/webdriver_bidi_websocket_control.rs | 3 +-- .../src/webdriver_bidi_websocket_validated.rs | 9 ++++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index 4bd645d66..e76919253 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -6,8 +6,7 @@ use std::{ }; use crate::{ - MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError, - WebDriverBiDiWebSocketMaskKey, + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketEstablished, }; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index 8d35740d4..f36459d88 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -53,8 +53,10 @@ impl WebDriverBiDiWebSocketHandshakePlan { pub fn write_opening_request( self, write_timeout: Duration, - ) -> Result - { + ) -> Result< + WebDriverBiDiWebSocketOpeningRequestSent, + raw::WebDriverBiDiWebSocketOpeningWriteError, + > { self.0 .write_opening_request(write_timeout) .map(WebDriverBiDiWebSocketOpeningRequestSent) @@ -187,7 +189,8 @@ impl WebDriverBiDiWebSocketEstablished { pub fn read_frame( self, frame_timeout: Duration, - ) -> Result<(Self, raw::WebDriverBiDiWebSocketFrame), raw::WebDriverBiDiWebSocketFrameError> { + ) -> Result<(Self, raw::WebDriverBiDiWebSocketFrame), raw::WebDriverBiDiWebSocketFrameError> + { let (established, frame) = self.0.read_frame(frame_timeout)?; validate_close_status_code(&frame)?; Ok((Self(established), frame)) From bc280a272e916540b48f07da4dcb1ec44dd49470 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:16:14 -0700 Subject: [PATCH 27/62] fix(network): route public handshake through validated state --- crates/originweave-network/src/lib.rs | 12 +++++++----- .../src/webdriver_bidi_websocket_control.rs | 2 +- .../src/webdriver_bidi_websocket_validated.rs | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 87d909823..e969d3401 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -19,8 +19,10 @@ mod webdriver_bidi_websocket_control; #[cfg(test)] #[allow(clippy::expect_used)] mod webdriver_bidi_websocket_coverage_tests; +#[path = "webdriver_bidi_websocket_validated.rs"] mod webdriver_bidi_websocket_handshake; -mod webdriver_bidi_websocket_validated; +#[path = "webdriver_bidi_websocket_handshake.rs"] +mod webdriver_bidi_websocket_handshake_raw; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, @@ -31,6 +33,10 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; 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, @@ -38,7 +44,3 @@ pub use webdriver_bidi_websocket_handshake::{ WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, }; -pub use webdriver_bidi_websocket_validated::{ - WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketOpeningRequestSent, -}; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index e76919253..d9f7b42af 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -7,7 +7,7 @@ use std::{ use crate::{ MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, - webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketEstablished, + webdriver_bidi_websocket_handshake_raw::WebDriverBiDiWebSocketEstablished, }; const MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES: usize = 125; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index f36459d88..dc537d0f5 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -10,7 +10,7 @@ use originweave_core::VerifiedWebDriverBiDiSocketPeer; use crate::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence, - webdriver_bidi_websocket_handshake as raw, + webdriver_bidi_websocket_handshake_raw as raw, }; /// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. From 2496a6611c72b61049f5eb8e0df65a4a316e6dd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:33:38 -0700 Subject: [PATCH 28/62] test(network): require WebSocket plan debug redaction --- .../webdriver_bidi_websocket_debug_tests.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs 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..9a1571cd8 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -0,0 +1,52 @@ +use std::{net::TcpListener, thread, time::Duration}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; + +use crate::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CLIENT_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +#[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"); +} From 7ee53f3d0d505d07a61065e9cd6fd4d0a3abc490 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:34:00 -0700 Subject: [PATCH 29/62] test(network): exercise WebSocket plan debug boundary --- crates/originweave-network/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index e969d3401..c3aa6cce8 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -19,6 +19,9 @@ 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_handshake.rs"] From 3420aa47e00f0d44d17e2b096da1f05cb28f3e6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:35:18 -0700 Subject: [PATCH 30/62] test(network): format WebSocket debug regression --- .../src/webdriver_bidi_websocket_debug_tests.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs index 9a1571cd8..61cba1ca9 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -23,10 +23,9 @@ fn handshake_plan_debug_redacts_client_nonce() { .expect("test loopback connection must be accepted"); }); - let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://{address}/session/{SESSION_ID}" - )) - .expect("test endpoint must be valid"); + 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"); @@ -37,8 +36,8 @@ fn handshake_plan_debug_redacts_client_nonce() { .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 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"); From 65477acbeeee5a1310e3005d0e827dd2bfce8e1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:03:06 -0700 Subject: [PATCH 31/62] fix(network): redact WebSocket client nonce in debug output --- .../src/webdriver_bidi_websocket_validated.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index dc537d0f5..d32eab98d 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -18,7 +18,12 @@ pub struct WebDriverBiDiWebSocketHandshakePlan(raw::WebDriverBiDiWebSocketHandsh impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(formatter) + formatter + .debug_struct("WebDriverBiDiWebSocketHandshakePlan") + .field("verified_peer", self.0.verified_peer()) + .field("client_key", &"") + .field("request_byte_count", &self.0.request_bytes().len()) + .finish() } } From 02b43e971dc4e49c992488c365d4e955747faeed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:41:16 -0700 Subject: [PATCH 32/62] test(network): reject reserved WebSocket close code 1004 --- .../tests/webdriver_bidi_websocket_close_frame_validation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index c296977cb..38f819f5b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs @@ -76,7 +76,7 @@ fn close_frame_enforces_payload_shape_and_utf8_reason() -> Result<(), Box Result<(), Box> { - for status_code in [999_u16, 1005, 1006, 1015, 5000] { + 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])?, From 11faa7fbbdf3c8a924ff3a544d915c0a318da285 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:43:48 -0700 Subject: [PATCH 33/62] fix(network): reject reserved WebSocket close code 1004 --- .../src/webdriver_bidi_websocket_validated.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index d32eab98d..1cfc4a888 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -210,7 +210,9 @@ fn validate_close_status_code( } let status_code = u16::from_be_bytes([frame.payload()[0], frame.payload()[1]]); - if !(1000..=4999).contains(&status_code) || matches!(status_code, 1005 | 1006 | 1015) { + 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", }); From e9dda451d7411ce5869415cf0866069efbdde359 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:47:00 -0700 Subject: [PATCH 34/62] style(network): apply canonical WebSocket close formatting --- .../src/webdriver_bidi_websocket_validated.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index 1cfc4a888..d6e85f064 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -210,9 +210,7 @@ fn validate_close_status_code( } 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) - { + 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", }); From f7c1cec4533edff4c4173ebd74e972efb6e1da17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:36:11 -0700 Subject: [PATCH 35/62] test(network): reject WebSocket mask reuse across frames --- ...driver_bidi_websocket_masking_key_reuse.rs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs 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..a19e9c003 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -0,0 +1,129 @@ +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=="; + +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 = established + .write_text_frame("second-frame", reused_mask, Duration::from_millis(500)) + .expect_err("RFC 6455 masking keys must not be reused on one live connection"); + assert!(matches!( + error, + WebDriverBiDiWebSocketFrameError::ClientMaskingKeyReused + )); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket mask-reuse test server panicked"))??; + assert_eq!(received, "first-frame"); + Ok(()) +} From 51a42de53a841970d1b74f4a71b03a4dc67749c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:40:52 -0700 Subject: [PATCH 36/62] test(network): apply canonical mask-reuse formatting --- .../webdriver_bidi_websocket_masking_key_reuse.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) 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 index a19e9c003..1822aee55 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -87,8 +87,8 @@ fn require_peer_closed_before_second_frame(stream: &mut TcpStream) -> io::Result } #[test] -fn established_stream_rejects_client_mask_reuse_across_sequential_frames( -) -> Result<(), Box> { +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 { @@ -108,11 +108,8 @@ fn established_stream_rejects_client_mask_reuse_across_sequential_frames( 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 established = + established.write_text_frame("first-frame", reused_mask, Duration::from_millis(500))?; let error = established .write_text_frame("second-frame", reused_mask, Duration::from_millis(500)) .expect_err("RFC 6455 masking keys must not be reused on one live connection"); From a3d6628a9a36a60a6f2babc89e316a99e4cb1698 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:47:41 -0700 Subject: [PATCH 37/62] test(network): reproduce WebSocket mask reuse on live stream --- .../tests/webdriver_bidi_websocket_masking_key_reuse.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 1822aee55..51a2b457f 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -15,6 +15,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REUSED_MASK_REASON: &str = "client masking key was already used on this established WebSocket"; fn connect( endpoint: &str, @@ -115,7 +116,9 @@ fn established_stream_rejects_client_mask_reuse_across_sequential_frames() .expect_err("RFC 6455 masking keys must not be reused on one live connection"); assert!(matches!( error, - WebDriverBiDiWebSocketFrameError::ClientMaskingKeyReused + WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_MASK_REASON + } )); let received = server From 019f6af329c6abdd4aba06b2ef9db6dea23e1848 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:49:33 -0700 Subject: [PATCH 38/62] test(network): apply canonical mask-reuse RED formatting --- .../tests/webdriver_bidi_websocket_masking_key_reuse.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 51a2b457f..cf38f3286 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -15,7 +15,8 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; -const REUSED_MASK_REASON: &str = "client masking key was already used on this established WebSocket"; +const REUSED_MASK_REASON: &str = + "client masking key was already used on this established WebSocket"; fn connect( endpoint: &str, From 49a8143c9af6cf5bb40ab22bbac283c1b1d03eaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:39:17 -0700 Subject: [PATCH 39/62] fix(network): reject reused WebSocket masking keys --- .../src/webdriver_bidi_websocket_validated.rs | 137 +++++++++++++++--- 1 file changed, 113 insertions(+), 24 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index d6e85f064..32ae2b90c 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -4,7 +4,7 @@ //! public state machine while adding protocol validation that must run before a received frame is //! released to callers. -use std::{fmt, time::Duration}; +use std::{collections::BTreeSet, fmt, time::Duration}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; @@ -13,6 +13,38 @@ use crate::{ webdriver_bidi_websocket_handshake_raw as raw, }; +const MAX_TRACKED_CLIENT_MASK_KEYS: usize = 65_536; +const REUSED_CLIENT_MASK_KEY_REASON: &str = + "client masking key was already used on this established WebSocket"; +const CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON: &str = + "client masking-key history reached its reviewed per-connection bound"; + +#[derive(Default)] +struct ClientMaskKeyHistory { + used_keys: BTreeSet<[u8; 4]>, +} + +impl ClientMaskKeyHistory { + fn reserve( + &mut self, + masking_key: raw::WebDriverBiDiWebSocketMaskKey, + ) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { + let masking_key = *masking_key.as_bytes(); + if self.used_keys.contains(&masking_key) { + return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_CLIENT_MASK_KEY_REASON, + }); + } + if self.used_keys.len() >= LIMIT { + return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON, + }); + } + self.used_keys.insert(masking_key); + Ok(()) + } +} + /// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. pub struct WebDriverBiDiWebSocketHandshakePlan(raw::WebDriverBiDiWebSocketHandshakePlan); @@ -108,18 +140,28 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { response_timeout: Duration, ) -> Result { - self.0 - .read_opening_response(response_timeout) - .map(WebDriverBiDiWebSocketEstablished) + 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. -pub struct WebDriverBiDiWebSocketEstablished(raw::WebDriverBiDiWebSocketEstablished); +/// +/// Successful outbound client frames retain a bounded exact history of their RFC 6455 masking keys +/// so the same four-byte key cannot be emitted twice on one established connection. The history is +/// capped at 65,536 keys; exhausting that bound fails closed before another client frame is written. +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.0.fmt(formatter) + self.raw.fmt(formatter) } } @@ -127,78 +169,91 @@ impl WebDriverBiDiWebSocketEstablished { /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { - self.0.transport_evidence() + 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.0.client_key() + 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.0.response_status() + 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.0.response_byte_count() + 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.0.response_timeout() + 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.0.request_byte_count() + 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.0.write_timeout() + self.raw.write_timeout() } /// Write one unfragmented, masked UTF-8 text frame on this verified stream. + /// + /// The caller-supplied masking key is reserved before any frame bytes are emitted. Reuse of any + /// key previously used by a successful client text or Pong frame on this established connection + /// fails closed. The exact history is bounded; reaching the reviewed history ceiling also fails + /// closed rather than silently forgetting older keys. pub fn write_text_frame( - self, + mut self, text: &str, masking_key: raw::WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { - self.0 - .write_text_frame(text, masking_key, frame_timeout) - .map(Self) + self.client_mask_keys.reserve(masking_key)?; + self.raw = self + .raw + .write_text_frame(text, masking_key, frame_timeout)?; + Ok(self) } /// Write one final masked RFC 6455 Pong control frame on this verified stream. + /// + /// Masking-key reuse is rejected against the same bounded history used by text frames so + /// switching frame types cannot bypass the RFC 6455 freshness boundary. pub fn write_pong_frame( - self, + mut self, payload: &[u8], masking_key: raw::WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { - self.0 - .write_pong_frame(payload, masking_key, frame_timeout) - .map(Self) + 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( - self, + mut self, frame_timeout: Duration, ) -> Result<(Self, raw::WebDriverBiDiWebSocketFrame), raw::WebDriverBiDiWebSocketFrameError> { - let (established, frame) = self.0.read_frame(frame_timeout)?; + let (raw, frame) = self.raw.read_frame(frame_timeout)?; validate_close_status_code(&frame)?; - Ok((Self(established), frame)) + self.raw = raw; + Ok((self, frame)) } } @@ -217,3 +272,37 @@ fn validate_close_status_code( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_mask_history_rejects_reuse_and_fails_closed_at_its_bound() { + let first = raw::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let second = raw::WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); + let third = raw::WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); + let mut history = ClientMaskKeyHistory::<2>::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!(matches!( + history.reserve(third), + Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON + }) + )); + assert!(matches!( + history.reserve(first), + Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_CLIENT_MASK_KEY_REASON + }) + )); + } +} From 318c9ddb27d76a8c123a5c8aa50fb66cf038bb36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:42:26 -0700 Subject: [PATCH 40/62] test(network): satisfy strict mask-reuse contracts --- ...webdriver_bidi_websocket_masking_key_reuse.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 index cf38f3286..0dff73214 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -112,9 +112,19 @@ fn established_stream_rejects_client_mask_reuse_across_sequential_frames() 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 = established - .write_text_frame("second-frame", reused_mask, Duration::from_millis(500)) - .expect_err("RFC 6455 masking keys must not be reused on one live connection"); + 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 { From 14e55dad3cb865b179c3cca094a42a4c334f49c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:43:58 -0700 Subject: [PATCH 41/62] test(network): apply canonical mask-reuse formatting --- ...driver_bidi_websocket_masking_key_reuse.rs | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) 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 index 0dff73214..82f9472dc 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -112,19 +112,16 @@ fn established_stream_rejects_client_mask_reuse_across_sequential_frames() 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, - }; + 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 { From 93e5ec950f8c5c047340ce05fde6903aa77db991 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:15:02 -0700 Subject: [PATCH 42/62] test(network): cover fail-closed Pong rejection paths --- .../webdriver_bidi_websocket_pong_write.rs | 123 +++++++++++++++++- 1 file changed, 118 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs index a7d52a1fa..1beee2a0d 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -9,11 +9,15 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + 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 already used on this established WebSocket"; +const MAX_PONG_PAYLOAD_BYTES: usize = 125; fn connect( endpoint: &str, @@ -42,6 +46,12 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { 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]; @@ -53,7 +63,7 @@ fn read_masked_pong(stream: &mut TcpStream) -> io::Result> { )); } let payload_length = usize::from(header[1] & 0x7f); - if payload_length > 125 { + if payload_length > MAX_PONG_PAYLOAD_BYTES { return Err(io::Error::new( io::ErrorKind::InvalidData, "Pong payload exceeded the RFC 6455 control-frame bound", @@ -69,6 +79,22 @@ fn read_masked_pong(stream: &mut TcpStream) -> io::Result> { 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))?; @@ -76,9 +102,7 @@ fn established_stream_writes_masked_pong_with_exact_ping_payload() -> Result<(), 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", - )?; + write_opening_response(&mut stream)?; read_masked_pong(&mut stream) }); @@ -108,3 +132,92 @@ fn established_stream_writes_masked_pong_with_exact_ping_payload() -> Result<(), 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(()) +} From d7699777164442ff36165825b1e50869a2639659 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:00:31 -0700 Subject: [PATCH 43/62] test(network): apply canonical pong rustfmt --- .../webdriver_bidi_websocket_pong_write.rs | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs index 1beee2a0d..1dff3681a 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -153,22 +153,19 @@ fn established_stream_rejects_reused_pong_mask_before_second_wire_write() 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") + 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, - }; + } + Err(error) => error, + }; assert!(matches!( error, WebDriverBiDiWebSocketFrameError::MalformedFrame { From 8385467af36dcceb2a1408d4ed7c924ea60d3743 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:40:42 -0700 Subject: [PATCH 44/62] test(network): prove WebSocket client nonce Debug redaction --- .../src/webdriver_bidi_websocket_debug_tests.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs index 61cba1ca9..991b239c5 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -10,6 +10,16 @@ use crate::{ 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 handshake_plan_debug_redacts_client_nonce() { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); From 22bf20ec77be81858e627b9b1316af5e2fc92a82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:47:14 -0700 Subject: [PATCH 45/62] fix(network): redact WebSocket client nonce Debug --- .../src/webdriver_bidi_websocket_handshake.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 4e42217f9..92524564e 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -85,9 +85,15 @@ impl Error for WebDriverBiDiWebSocketHandshakeError {} /// 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. -#[derive(Debug, Eq, PartialEq)] +#[derive(Eq, PartialEq)] pub struct WebDriverBiDiWebSocketClientKey(String); +impl fmt::Debug for WebDriverBiDiWebSocketClientKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + impl WebDriverBiDiWebSocketClientKey { /// Admit one canonical base64 client key representing exactly 16 bytes. pub fn new(value: &str) -> Result { From 24d52f66a93d5118f8e8143c3e69ea0c4e19001a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:50:03 -0700 Subject: [PATCH 46/62] test(network): reject raw WebSocket request Debug exposure --- .../webdriver_bidi_websocket_debug_tests.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs index 991b239c5..3ed8b9061 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -5,6 +5,7 @@ use originweave_core::WebDriverBiDiWebSocketEndpoint; use crate::{ WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + webdriver_bidi_websocket_handshake_raw::WebDriverBiDiWebSocketHandshakePlan as RawWebDriverBiDiWebSocketHandshakePlan, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -20,6 +21,46 @@ fn client_key_debug_redacts_client_nonce() { assert!(!debug.contains(CLIENT_KEY)); } +#[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"); From 02952da6f4c942fe990f7350dd0af9a98cbc1f99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:13:11 -0700 Subject: [PATCH 47/62] test(network): redact WebSocket masking-key diagnostics --- .../src/webdriver_bidi_websocket_debug_tests.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs index 3ed8b9061..0f8bb957e 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -4,7 +4,7 @@ use originweave_core::WebDriverBiDiWebSocketEndpoint; use crate::{ WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, webdriver_bidi_websocket_handshake_raw::WebDriverBiDiWebSocketHandshakePlan as RawWebDriverBiDiWebSocketHandshakePlan, }; @@ -21,6 +21,18 @@ fn client_key_debug_redacts_client_nonce() { 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"); From 5fe9db91ef3d9bc2c6ff71163f617cf035296560 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:32:06 -0700 Subject: [PATCH 48/62] fix(network): redact public WebSocket masking-key diagnostics --- .../src/webdriver_bidi_websocket_mask_key.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs 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 + } +} From c051c157a43db63c6f6ee1f91a362b69d0fb641d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:32:33 -0700 Subject: [PATCH 49/62] fix(network): expose redacted WebSocket mask-key wrapper --- crates/originweave-network/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 30048eff0..eafdac09d 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -26,6 +26,7 @@ mod webdriver_bidi_websocket_debug_tests; 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, @@ -45,5 +46,6 @@ pub use webdriver_bidi_websocket_handshake_raw::{ MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketOpeningWriteError, }; +pub use webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey; From cbf3dcce94c2906edc2971a3804a366a5818f60c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:33:39 -0700 Subject: [PATCH 50/62] fix(network): route mask entropy through redacted public type --- .../src/webdriver_bidi_websocket_validated.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index 32ae2b90c..f4d23a63f 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -11,6 +11,7 @@ use originweave_core::VerifiedWebDriverBiDiSocketPeer; use crate::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence, webdriver_bidi_websocket_handshake_raw as raw, + webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey, }; const MAX_TRACKED_CLIENT_MASK_KEYS: usize = 65_536; @@ -27,7 +28,7 @@ struct ClientMaskKeyHistory { impl ClientMaskKeyHistory { fn reserve( &mut self, - masking_key: raw::WebDriverBiDiWebSocketMaskKey, + masking_key: WebDriverBiDiWebSocketMaskKey, ) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { let masking_key = *masking_key.as_bytes(); if self.used_keys.contains(&masking_key) { @@ -213,17 +214,18 @@ impl WebDriverBiDiWebSocketEstablished { /// The caller-supplied masking key is reserved before any frame bytes are emitted. Reuse of any /// key previously used by a successful client text or Pong frame on this established connection /// fails closed. The exact history is bounded; reaching the reviewed history ceiling also fails - /// closed rather than silently forgetting older keys. + /// closed rather than silently forgetting older keys. 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: raw::WebDriverBiDiWebSocketMaskKey, + masking_key: WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { self.client_mask_keys.reserve(masking_key)?; self.raw = self .raw - .write_text_frame(text, masking_key, frame_timeout)?; + .write_text_frame(text, masking_key.into_raw(), frame_timeout)?; Ok(self) } @@ -234,7 +236,7 @@ impl WebDriverBiDiWebSocketEstablished { pub fn write_pong_frame( mut self, payload: &[u8], - masking_key: raw::WebDriverBiDiWebSocketMaskKey, + masking_key: WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { self.client_mask_keys.reserve(masking_key)?; @@ -279,9 +281,9 @@ mod tests { #[test] fn client_mask_history_rejects_reuse_and_fails_closed_at_its_bound() { - let first = raw::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); - let second = raw::WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); - let third = raw::WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); + let first = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let second = WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); + let third = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); let mut history = ClientMaskKeyHistory::<2>::default(); assert!(history.reserve(first).is_ok()); From b7e02876e57dfed0302a89cab9539cc272f9010b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 06:13:32 -0700 Subject: [PATCH 51/62] test(network): prove legacy handshake debug redacts nonce --- .../webdriver_bidi_websocket_raw_redacted.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs index 91c683339..16d5fe0da 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs @@ -78,3 +78,56 @@ impl WebDriverBiDiWebSocketHandshakePlan { self.0.write_opening_request(write_timeout) } } + +#[cfg(test)] +mod tests { + use std::{net::TcpListener, thread}; + + use originweave_core::WebDriverBiDiWebSocketEndpoint; + + use super::*; + use crate::WebDriverBiDiTcpConnectionPlan; + + const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + const CLIENT_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + + #[test] + fn legacy_handshake_plan_debug_redacts_serialized_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 = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key) + .expect("test legacy 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"); + } +} From 01e6ae499762026f5681eb1e3e24f831e03409e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 06:15:29 -0700 Subject: [PATCH 52/62] test(network): apply canonical nonce-redaction regression formatting --- .../src/webdriver_bidi_websocket_raw_redacted.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs index 16d5fe0da..2807e56db 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs @@ -117,8 +117,8 @@ mod tests { .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 client_key = WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY) + .expect("test client key must be valid"); let handshake = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key) .expect("test legacy handshake plan must be valid"); From 9a6ccf8503bd3c7238d2b0e1606981bc188d9ce3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 06:37:34 -0700 Subject: [PATCH 53/62] fix(network): redact legacy websocket handshake debug --- .../src/webdriver_bidi_websocket_handshake.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 92524564e..5c349ba19 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -144,13 +144,23 @@ impl WebDriverBiDiWebSocketMaskKey { /// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` /// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or /// Agent-authority grant. -#[derive(Debug)] pub struct WebDriverBiDiWebSocketHandshakePlan { connection: WebDriverBiDiTcpConnection, client_key: WebDriverBiDiWebSocketClientKey, 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_key", &"") + .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( From 34f9065628befc50b29c87a9ff7225feb126f404 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 06:45:49 -0700 Subject: [PATCH 54/62] test(network): reproduce masking history lifetime ceiling --- ..._websocket_masking_key_history_capacity.rs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs 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..b9aaef346 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs @@ -0,0 +1,102 @@ +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 masking_key = WebDriverBiDiWebSocketMaskKey::new((ordinal + 1).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(()) +} From d98f97c73f8d73482d641ff08d5153b58dab2289 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:05:58 -0700 Subject: [PATCH 55/62] test(network): format masking-key capacity regression --- ...webdriver_bidi_websocket_masking_key_history_capacity.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 index b9aaef346..8f42acb7c 100644 --- 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 @@ -86,11 +86,7 @@ fn established_stream_does_not_gain_a_lifetime_frame_cap_from_reuse_detection() for ordinal in 0..FRAME_COUNT { let masking_key = WebDriverBiDiWebSocketMaskKey::new((ordinal + 1).to_be_bytes()); - established = established.write_text_frame( - "x", - masking_key, - Duration::from_millis(500), - )?; + established = established.write_text_frame("x", masking_key, Duration::from_millis(500))?; } drop(established); From 608abfe58add537df61104eff86dc20f780ef6bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:09:58 -0700 Subject: [PATCH 56/62] fix(network): remove WebSocket masking-key lifetime cap --- .../src/webdriver_bidi_websocket_validated.rs | 65 +++++++------------ 1 file changed, 24 insertions(+), 41 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index f4d23a63f..e9039c89e 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -4,7 +4,7 @@ //! public state machine while adding protocol validation that must run before a received frame is //! released to callers. -use std::{collections::BTreeSet, fmt, time::Duration}; +use std::{fmt, time::Duration}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; @@ -14,34 +14,26 @@ use crate::{ webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey, }; -const MAX_TRACKED_CLIENT_MASK_KEYS: usize = 65_536; const REUSED_CLIENT_MASK_KEY_REASON: &str = - "client masking key was already used on this established WebSocket"; -const CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON: &str = - "client masking-key history reached its reviewed per-connection bound"; + "client masking key was reused for consecutive frames on this established WebSocket"; #[derive(Default)] -struct ClientMaskKeyHistory { - used_keys: BTreeSet<[u8; 4]>, +struct ClientMaskKeyHistory { + previous_key: Option<[u8; 4]>, } -impl ClientMaskKeyHistory { +impl ClientMaskKeyHistory { fn reserve( &mut self, masking_key: WebDriverBiDiWebSocketMaskKey, ) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { let masking_key = *masking_key.as_bytes(); - if self.used_keys.contains(&masking_key) { + if self.previous_key == Some(masking_key) { return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: REUSED_CLIENT_MASK_KEY_REASON, }); } - if self.used_keys.len() >= LIMIT { - return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON, - }); - } - self.used_keys.insert(masking_key); + self.previous_key = Some(masking_key); Ok(()) } } @@ -152,12 +144,14 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { /// A live verified stream after both RFC 6455 opening messages were validated. /// -/// Successful outbound client frames retain a bounded exact history of their RFC 6455 masking keys -/// so the same four-byte key cannot be emitted twice on one established connection. The history is -/// capped at 65,536 keys; exhausting that bound fails closed before another client frame is written. +/// 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, + client_mask_keys: ClientMaskKeyHistory, } impl fmt::Debug for WebDriverBiDiWebSocketEstablished { @@ -211,11 +205,12 @@ impl WebDriverBiDiWebSocketEstablished { /// Write one unfragmented, masked UTF-8 text frame on this verified stream. /// - /// The caller-supplied masking key is reserved before any frame bytes are emitted. Reuse of any - /// key previously used by a successful client text or Pong frame on this established connection - /// fails closed. The exact history is bounded; reaching the reviewed history ceiling also fails - /// closed rather than silently forgetting older keys. Generic diagnostics for the public mask-key - /// value redact its entropy; only this reviewed wire-framing boundary unwraps the exact bytes. + /// 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, @@ -231,8 +226,8 @@ impl WebDriverBiDiWebSocketEstablished { /// Write one final masked RFC 6455 Pong control frame on this verified stream. /// - /// Masking-key reuse is rejected against the same bounded history used by text frames so - /// switching frame types cannot bypass the RFC 6455 freshness boundary. + /// 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], @@ -280,11 +275,10 @@ mod tests { use super::*; #[test] - fn client_mask_history_rejects_reuse_and_fails_closed_at_its_bound() { + 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 third = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); - let mut history = ClientMaskKeyHistory::<2>::default(); + let mut history = ClientMaskKeyHistory::default(); assert!(history.reserve(first).is_ok()); assert!(matches!( @@ -294,17 +288,6 @@ mod tests { }) )); assert!(history.reserve(second).is_ok()); - assert!(matches!( - history.reserve(third), - Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON - }) - )); - assert!(matches!( - history.reserve(first), - Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: REUSED_CLIENT_MASK_KEY_REASON - }) - )); + assert!(history.reserve(first).is_ok()); } } From e8e7eff976f0c34627af1930b055a36b253c5782 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:10:36 -0700 Subject: [PATCH 57/62] test(network): align consecutive mask-reuse evidence --- .../tests/webdriver_bidi_websocket_masking_key_reuse.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 82f9472dc..4f36bff23 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -16,7 +16,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const REUSED_MASK_REASON: &str = - "client masking key was already used on this established WebSocket"; + "client masking key was reused for consecutive frames on this established WebSocket"; fn connect( endpoint: &str, From 245ae04a34e81e24e4fd30099e2eacb9d82628a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:12:34 -0700 Subject: [PATCH 58/62] test(network): exercise non-global mask reuse on wire --- .../webdriver_bidi_websocket_masking_key_history_capacity.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 8f42acb7c..509d303a9 100644 --- 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 @@ -85,7 +85,8 @@ fn established_stream_does_not_gain_a_lifetime_frame_cap_from_reuse_detection() let mut established = written.read_opening_response(Duration::from_millis(500))?; for ordinal in 0..FRAME_COUNT { - let masking_key = WebDriverBiDiWebSocketMaskKey::new((ordinal + 1).to_be_bytes()); + 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); From a77e1ce18c615a2ed4ec5234b6541a8369581309 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:18:18 -0700 Subject: [PATCH 59/62] test(network): align Pong mask-reuse regression --- .../tests/webdriver_bidi_websocket_pong_write.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs index 1dff3681a..5fdd81c02 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -16,7 +16,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const REUSED_MASK_REASON: &str = - "client masking key was already used on this established WebSocket"; + "client masking key was reused for consecutive frames on this established WebSocket"; const MAX_PONG_PAYLOAD_BYTES: usize = 125; fn connect( From b47c4fdde02b9c2cdcdc3ccf25076e29c37b2feb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:21:32 -0700 Subject: [PATCH 60/62] test(network): satisfy strict Clippy in redaction regression --- .../webdriver_bidi_websocket_raw_redacted.rs | 45 +++++++------------ 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs index 2807e56db..5f940f559 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs @@ -81,7 +81,7 @@ impl WebDriverBiDiWebSocketHandshakePlan { #[cfg(test)] mod tests { - use std::{net::TcpListener, thread}; + use std::{error::Error, io, net::TcpListener, thread}; use originweave_core::WebDriverBiDiWebSocketEndpoint; @@ -92,35 +92,19 @@ mod tests { const CLIENT_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; #[test] - fn legacy_handshake_plan_debug_redacts_serialized_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"); - }); + fn legacy_handshake_plan_debug_redacts_serialized_client_nonce() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let address = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { listener.accept().map(|_| ()) }); 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 = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key) - .expect("test legacy handshake plan must be valid"); + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/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(CLIENT_KEY)?; + let handshake = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key)?; let debug = format!("{handshake:?}"); assert!(debug.contains("")); @@ -128,6 +112,9 @@ mod tests { assert!(!debug.contains(CLIENT_KEY)); drop(handshake); - server.join().expect("test server must not panic"); + server + .join() + .map_err(|_| io::Error::other("test WebSocket debug server panicked"))??; + Ok(()) } } From 9e550a4563c1025c944898b943cc6607f2ffd46f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:23:56 -0700 Subject: [PATCH 61/62] style(network): apply canonical Rust formatting --- .../src/webdriver_bidi_websocket_raw_redacted.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs index 5f940f559..d25606bc8 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs @@ -101,8 +101,8 @@ mod tests { WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/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 connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; let client_key = WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY)?; let handshake = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key)?; From c1bc7e78f3a9debf4f517fb6b5f11dd67be4ad92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 08:09:24 -0700 Subject: [PATCH 62/62] test(network): remove duplicate nonce-redaction unit --- .../webdriver_bidi_websocket_raw_redacted.rs | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs index d25606bc8..91c683339 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs @@ -78,43 +78,3 @@ impl WebDriverBiDiWebSocketHandshakePlan { self.0.write_opening_request(write_timeout) } } - -#[cfg(test)] -mod tests { - use std::{error::Error, io, net::TcpListener, thread}; - - use originweave_core::WebDriverBiDiWebSocketEndpoint; - - use super::*; - use crate::WebDriverBiDiTcpConnectionPlan; - - const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; - const CLIENT_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; - - #[test] - fn legacy_handshake_plan_debug_redacts_serialized_client_nonce() -> Result<(), Box> { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let address = listener.local_addr()?; - let server = thread::spawn(move || -> io::Result<()> { listener.accept().map(|_| ()) }); - - let endpoint = - WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/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(CLIENT_KEY)?; - let handshake = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key)?; - - let debug = format!("{handshake:?}"); - assert!(debug.contains("")); - assert!(!debug.contains("request: [")); - assert!(!debug.contains(CLIENT_KEY)); - - drop(handshake); - server - .join() - .map_err(|_| io::Error::other("test WebSocket debug server panicked"))??; - Ok(()) - } -}