From a315a87f72b6e3e85914ed3d4bdf7d6118de5042 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:11:37 -0700 Subject: [PATCH 001/211] test(network): require bounded BiDi WebSocket opening write --- .../webdriver_bidi_websocket_opening_write.rs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs new file mode 100644 index 000000000..994cd6980 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs @@ -0,0 +1,164 @@ +use std::{ + io::{self, Read}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketOpeningWriteError, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted explicit target") + }; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + unreachable!("asserted connection plan") + }; + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + unreachable!("asserted loopback connection") + }; + connection +} + +fn read_opening_request(mut stream: std::net::TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +#[test] +fn bounded_opening_write_sends_exact_request_and_preserves_transport_evidence() { + 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 || { + let accepted = listener.accept()?; + read_opening_request(accepted.0) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let expected = format!( + "GET /session/{SESSION_ID} HTTP/1.1\r\nHost: {local_addr}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {RFC6455_SAMPLE_KEY}\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + let write_timeout = Duration::from_millis(500); + let written = plan.write_opening_request(write_timeout); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + + assert_eq!(written.request_byte_count(), expected.len()); + assert_eq!(written.write_timeout(), write_timeout); + assert_eq!(written.client_key().as_str(), RFC6455_SAMPLE_KEY); + assert_eq!(written.transport_evidence().verified_peer().socket_addr(), local_addr); + assert_eq!(written.transport_evidence().verified_peer().session_id(), SESSION_ID); + assert_eq!(written.transport_evidence().attempt_number(), 1); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(received) = server_result { + assert!(received.is_ok(), "{received:?}"); + if let Ok(received) = received { + assert_eq!(received, expected.as_bytes()); + } + } +} + +#[test] +fn opening_write_rejects_zero_and_excessive_deadlines_before_success_evidence() { + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT + Duration::from_nanos(1), + ] { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + continue; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + continue; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + continue; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + continue; + }; + + let result = plan.write_opening_request(timeout); + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }) if write_timeout == timeout + )); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } + } +} From b09318363062ef9de7ffb8e49cf9bd3bc5f70062 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:14:12 -0700 Subject: [PATCH 002/211] feat(network): bound BiDi WebSocket opening write --- .../src/webdriver_bidi_websocket_handshake.rs | 451 +++++++++++++++++- 1 file changed, 448 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 6e896800d..e6b86aa27 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1,11 +1,23 @@ -use std::fmt; +use std::{ + error::Error, + fmt, + io::{self, Write}, + net::TcpStream, + time::{Duration, Instant}, +}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; -use crate::WebDriverBiDiTcpConnection; +use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +/// 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); + fn is_base64_data_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') } @@ -41,7 +53,7 @@ impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { } } -impl std::error::Error for WebDriverBiDiWebSocketHandshakeError {} +impl Error for WebDriverBiDiWebSocketHandshakeError {} /// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. /// @@ -130,4 +142,437 @@ impl WebDriverBiDiWebSocketHandshakePlan { 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. 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 request_byte_count = write_request_with_clock( + &mut stream, + &request, + write_timeout, + Instant::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. 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 + } +} + +/// 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, + }, +} + +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", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketOpeningWriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::WriteTimeoutConfigurationFailed { source, .. } + | Self::WriteTimedOut { source, .. } + | Self::WriteFailed { source, .. } => Some(source), + Self::InvalidWriteTimeout { .. } + | Self::WriteDeadlineExceeded { .. } + | Self::WriteZero { .. } => None, + } + } +} + +trait OpeningRequestWriter { + fn set_write_timeout(&self, timeout: Duration) -> 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 write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_request_with_clock( + writer: &mut W, + request: &[u8], + write_timeout: Duration, + mut now: N, +) -> Result +where + W: OpeningRequestWriter, + N: FnMut() -> Instant, +{ + 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, + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + 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, + }); + } + } + } + + Ok(bytes_written) +} + +#[cfg(test)] +mod opening_write_tests { + use super::*; + use std::{collections::VecDeque, error::Error as _}; + + #[derive(Debug)] + enum WriteAction { + Count(usize), + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeWriter { + timeout_error: Option, + actions: VecDeque, + configured_timeouts: Vec, + } + + impl FakeWriter { + fn new(actions: impl IntoIterator) -> Self { + Self { + timeout_error: None, + actions: actions.into_iter().collect(), + configured_timeouts: Vec::new(), + } + } + } + + 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 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)), + } + } + } + + #[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 result = write_request_with_clock( + &mut writer, + b"hello", + Duration::from_secs(1), + || times.pop_front().unwrap_or(start), + ); + assert_eq!(result, Ok(5)); + } + + #[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 deadline = write_request_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + || deadline_times.pop_front().unwrap_or(start), + ); + assert!(matches!( + deadline, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + }) + )); + + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let zero = write_request_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + || start, + ); + assert!(matches!( + zero, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) + )); + + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let timed_out = write_request_with_clock( + &mut writer, + b"x", + Duration::from_secs(1), + || start, + ); + assert!(matches!( + timed_out, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 0, + .. + }) + )); + } + + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let failed = write_request_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + || start, + ); + assert!(matches!( + failed, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + )); + + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let configuration = write_request_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + || start, + ); + assert!(matches!( + configuration, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + 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), + }; + + 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!(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()); + } } From 15356dd07d8ffd5cb23ebd145a7ec7817f406154 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:14:26 -0700 Subject: [PATCH 003/211] feat(network): export bounded WebSocket opening write state --- crates/originweave-network/src/lib.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index a77d9b794..289dc4681 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -4,8 +4,9 @@ //! without hostname resolution or proxy inheritance, verifies operating-system //! peers before exposing transport I/O, and emits credential-free evidence. //! It also bridges a session-correlated WebDriver BiDi loopback target from -//! `originweave-core` into one bounded exact TCP connection and can bind an inert -//! RFC 6455 opening request to an already-verified plain BiDi stream without +//! `originweave-core` into one bounded exact TCP connection, binds an RFC 6455 +//! opening request to that verified plain stream, and can write that exact request +//! under one bounded deadline without claiming a completed WebSocket handshake or //! granting browser, WebSocket, TLS, policy, or Agent authority. #![forbid(unsafe_code)] @@ -25,5 +26,6 @@ pub use webdriver_bidi_connection::{ }; pub use webdriver_bidi_websocket_handshake::{ WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, - WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketOpeningRequestSent, + WebDriverBiDiWebSocketOpeningWriteError, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, }; From e88a4a5b564421086e77f72292b307943a3869e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:18:18 -0700 Subject: [PATCH 004/211] style(network): apply canonical Rust formatting --- .../src/webdriver_bidi_websocket_handshake.rs | 110 ++++++++---------- 1 file changed, 47 insertions(+), 63 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index e6b86aa27..aa4d5cc96 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -158,10 +158,12 @@ impl WebDriverBiDiWebSocketHandshakePlan { ) -> 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, - }); + return Err( + WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }, + ); } let Self { @@ -170,12 +172,8 @@ impl WebDriverBiDiWebSocketHandshakePlan { request, } = self; let (mut stream, transport_evidence) = connection.into_parts(); - let request_byte_count = write_request_with_clock( - &mut stream, - &request, - write_timeout, - Instant::now, - )?; + let request_byte_count = + write_request_with_clock(&mut stream, &request, write_timeout, Instant::now)?; Ok(WebDriverBiDiWebSocketOpeningRequestSent { stream, @@ -208,7 +206,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() @@ -353,9 +354,9 @@ where while bytes_written < request.len() { let remaining = deadline.saturating_duration_since(now()); if remaining.is_zero() { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written, - }); + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, + ); } writer.set_write_timeout(remaining).map_err(|source| { WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { @@ -366,9 +367,7 @@ where match writer.write_request_bytes(&request[bytes_written..]) { Ok(0) => { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { - bytes_written, - }); + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); } Ok(count) => bytes_written += count, Err(source) if source.kind() == io::ErrorKind::Interrupted => {} @@ -410,7 +409,6 @@ mod opening_write_tests { struct FakeWriter { timeout_error: Option, actions: VecDeque, - configured_timeouts: Vec, } impl FakeWriter { @@ -418,7 +416,6 @@ mod opening_write_tests { Self { timeout_error: None, actions: actions.into_iter().collect(), - configured_timeouts: Vec::new(), } } } @@ -432,7 +429,10 @@ mod opening_write_tests { } fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - let action = self.actions.pop_front().unwrap_or(WriteAction::Count(bytes.len())); + 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)), @@ -449,12 +449,10 @@ mod opening_write_tests { ]); let start = Instant::now(); let mut times = VecDeque::from([start, start, start, start]); - let result = write_request_with_clock( - &mut writer, - b"hello", - Duration::from_secs(1), - || times.pop_front().unwrap_or(start), - ); + let result = + write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), || { + times.pop_front().unwrap_or(start) + }); assert_eq!(result, Ok(5)); } @@ -464,26 +462,20 @@ mod opening_write_tests { let mut deadline_writer = FakeWriter::new([]); let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); - let deadline = write_request_with_clock( - &mut deadline_writer, - b"x", - Duration::from_secs(1), - || deadline_times.pop_front().unwrap_or(start), - ); + let deadline = + write_request_with_clock(&mut deadline_writer, b"x", Duration::from_secs(1), || { + deadline_times.pop_front().unwrap_or(start) + }); assert!(matches!( deadline, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 0 - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } + ) )); let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let zero = write_request_with_clock( - &mut zero_writer, - b"x", - Duration::from_secs(1), - || start, - ); + let zero = + write_request_with_clock(&mut zero_writer, b"x", Duration::from_secs(1), || start); assert!(matches!( zero, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) @@ -491,12 +483,8 @@ mod opening_write_tests { for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let timed_out = write_request_with_clock( - &mut writer, - b"x", - Duration::from_secs(1), - || start, - ); + let timed_out = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || start); assert!(matches!( timed_out, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { @@ -507,12 +495,8 @@ mod opening_write_tests { } let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let failed = write_request_with_clock( - &mut failed_writer, - b"x", - Duration::from_secs(1), - || start, - ); + let failed = + write_request_with_clock(&mut failed_writer, b"x", Duration::from_secs(1), || start); assert!(matches!( failed, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { @@ -531,10 +515,12 @@ mod opening_write_tests { ); assert!(matches!( configuration, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - .. - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + .. + } + ) )); } @@ -544,14 +530,12 @@ mod opening_write_tests { write_timeout: Duration::ZERO, maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, }; - let deadline = WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + let deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; + let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), }; - 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), From 89198f28dbc88d42bf9a93739582dda2b91852e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:18:37 -0700 Subject: [PATCH 005/211] style(network): format WebSocket opening exports --- crates/originweave-network/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 289dc4681..fc72c341d 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -25,7 +25,7 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; pub use webdriver_bidi_websocket_handshake::{ - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketOpeningRequestSent, - WebDriverBiDiWebSocketOpeningWriteError, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, }; From 6f24ec2d3e90732894c875a288f53bf0fbd787f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:19:16 -0700 Subject: [PATCH 006/211] test(network): verify opening-write evidence redaction --- .../webdriver_bidi_websocket_opening_write.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) 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 994cd6980..433774dd4 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,9 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketOpeningWriteError, - MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -101,9 +101,18 @@ fn bounded_opening_write_sends_exact_request_and_preserves_transport_evidence() assert_eq!(written.request_byte_count(), expected.len()); assert_eq!(written.write_timeout(), write_timeout); assert_eq!(written.client_key().as_str(), RFC6455_SAMPLE_KEY); - assert_eq!(written.transport_evidence().verified_peer().socket_addr(), local_addr); - assert_eq!(written.transport_evidence().verified_peer().session_id(), SESSION_ID); + assert_eq!( + written.transport_evidence().verified_peer().socket_addr(), + local_addr + ); + assert_eq!( + written.transport_evidence().verified_peer().session_id(), + SESSION_ID + ); assert_eq!(written.transport_evidence().attempt_number(), 1); + let debug = format!("{written:?}"); + assert!(debug.contains("WebDriverBiDiWebSocketOpeningRequestSent")); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); let server_result = server.join(); assert!(server_result.is_ok(), "{server_result:?}"); From 25a95006fe2bc1e547bf8e663a374cdf589d7037 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:29:32 -0700 Subject: [PATCH 007/211] test(network): enforce post-write opening deadline --- .../src/webdriver_bidi_websocket_handshake.rs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index aa4d5cc96..9cdd4260b 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -397,7 +397,7 @@ where #[cfg(test)] mod opening_write_tests { use super::*; - use std::{collections::VecDeque, error::Error as _}; + use std::collections::VecDeque; #[derive(Debug)] enum WriteAction { @@ -453,7 +453,30 @@ mod opening_write_tests { write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), || { times.pop_front().unwrap_or(start) }); - assert_eq!(result, Ok(5)); + assert!(matches!(result, Ok(5))); + } + + #[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 result = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || { + times + .pop_front() + .unwrap_or(start + Duration::from_secs(1)) + }); + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + }) + )); } #[test] From 2ba07695ac68dd37204321f48db52ea8a533fb63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:35:07 -0700 Subject: [PATCH 008/211] style(network): format opening deadline regression --- .../src/webdriver_bidi_websocket_handshake.rs | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 9cdd4260b..54fdce46d 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -460,22 +460,15 @@ mod opening_write_tests { 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 result = - write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || { - times - .pop_front() - .unwrap_or(start + Duration::from_secs(1)) - }); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || { + times.pop_front().unwrap_or(start + Duration::from_secs(1)) + }); assert!(matches!( result, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 1 - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 } + ) )); } From b94bf131046dd2ae0964744a6d23ee5c1a59a3c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:38:59 -0700 Subject: [PATCH 009/211] fix(network): enforce opening write total deadline --- .../src/webdriver_bidi_websocket_handshake.rs | 11 ++++++++++- 1 file changed, 10 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 54fdce46d..e5c00fadd 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -369,7 +369,16 @@ where Ok(0) => { return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); } - Ok(count) => bytes_written += count, + 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 => {} Err(source) if matches!( From 96c3704fa4449c43b754c7663c2d31228220acdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 10:10:13 -0700 Subject: [PATCH 010/211] test(network): cover revoked BiDi opening write --- .../webdriver_bidi_websocket_handshake.rs | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 264f5a46c..8717d5a82 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -1,9 +1,14 @@ -use std::{net::TcpListener, thread, time::Duration}; +use std::{ + net::{Shutdown, TcpListener}, + thread, + time::Duration, +}; use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -81,6 +86,52 @@ fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { } } +#[test] +fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { + 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 || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let shutdown = connection.stream().shutdown(Shutdown::Both); + assert!(shutdown.is_ok(), "{shutdown:?}"); + + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let write = plan.write_opening_request(Duration::from_secs(1)); + assert!(matches!( + write, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + )); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + #[test] fn handshake_errors_render_actionable_fail_closed_messages() { assert_eq!( From c4cd29e62c181c1f17b62d73810a56a2421c6d74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:16:03 -0700 Subject: [PATCH 011/211] fix(network): unify opening-write coverage control flow --- .../src/webdriver_bidi_websocket_handshake.rs | 77 ++++++++++++------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index e5c00fadd..cffff2a92 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -172,8 +172,9 @@ impl WebDriverBiDiWebSocketHandshakePlan { 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, Instant::now)?; + write_request_with_clock(&mut stream, &request, write_timeout, &mut now)?; Ok(WebDriverBiDiWebSocketOpeningRequestSent { stream, @@ -338,16 +339,12 @@ impl OpeningRequestWriter for TcpStream { } } -fn write_request_with_clock( - writer: &mut W, +fn write_request_with_clock( + writer: &mut dyn OpeningRequestWriter, request: &[u8], write_timeout: Duration, - mut now: N, -) -> Result -where - W: OpeningRequestWriter, - N: FnMut() -> Instant, -{ + now: &mut dyn FnMut() -> Instant, +) -> Result { let deadline = now() + write_timeout; let mut bytes_written = 0; @@ -458,10 +455,13 @@ mod opening_write_tests { ]); let start = Instant::now(); let mut times = VecDeque::from([start, start, start, start]); - let result = - write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), || { - times.pop_front().unwrap_or(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, + ); assert!(matches!(result, Ok(5))); } @@ -470,9 +470,13 @@ mod opening_write_tests { 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 result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || { - times.pop_front().unwrap_or(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, + ); assert!(matches!( result, Err( @@ -487,10 +491,13 @@ mod opening_write_tests { let mut deadline_writer = FakeWriter::new([]); let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); - let deadline = - write_request_with_clock(&mut deadline_writer, b"x", Duration::from_secs(1), || { - deadline_times.pop_front().unwrap_or(start) - }); + 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, + ); assert!(matches!( deadline, Err( @@ -499,8 +506,13 @@ mod opening_write_tests { )); let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let zero = - write_request_with_clock(&mut zero_writer, b"x", Duration::from_secs(1), || start); + let mut zero_now = || start; + let zero = write_request_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now, + ); assert!(matches!( zero, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) @@ -508,8 +520,13 @@ mod opening_write_tests { for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let timed_out = - write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || start); + let mut now = || start; + let timed_out = write_request_with_clock( + &mut writer, + b"x", + Duration::from_secs(1), + &mut now, + ); assert!(matches!( timed_out, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { @@ -520,8 +537,13 @@ mod opening_write_tests { } let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let failed = - write_request_with_clock(&mut failed_writer, b"x", Duration::from_secs(1), || start); + let mut failed_now = || start; + let failed = write_request_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now, + ); assert!(matches!( failed, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { @@ -532,11 +554,12 @@ mod opening_write_tests { 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), - || start, + &mut configuration_now, ); assert!(matches!( configuration, From 35815f3c6c7b100f6af855431f27a1f8042bcf44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:19:40 -0700 Subject: [PATCH 012/211] style(network): apply canonical rustfmt --- .../src/webdriver_bidi_websocket_handshake.rs | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index cffff2a92..ca96679a4 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -456,12 +456,8 @@ mod opening_write_tests { 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 result = + write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); assert!(matches!(result, Ok(5))); } @@ -471,12 +467,7 @@ mod opening_write_tests { 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 result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); assert!(matches!( result, Err( @@ -521,12 +512,8 @@ mod opening_write_tests { 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 timed_out = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); assert!(matches!( timed_out, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { From 193f1159eab42ce61d62e51f267cb9198597d95f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:11:49 -0700 Subject: [PATCH 013/211] test(network): exercise BiDi opening assertion branches --- .../src/webdriver_bidi_websocket_handshake.rs | 126 ++++++++++++------ 1 file changed, 87 insertions(+), 39 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index ca96679a4..e10435315 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -458,7 +458,11 @@ mod opening_write_tests { 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); - assert!(matches!(result, Ok(5))); + let is_five = |candidate: Result| { + matches!(candidate, Ok(5)) + }; + assert!(is_five(result)); + assert!(!is_five(Ok(4))); } #[test] @@ -468,12 +472,19 @@ mod opening_write_tests { 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); - assert!(matches!( - result, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 } - ) - )); + 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] @@ -489,12 +500,19 @@ mod opening_write_tests { Duration::from_secs(1), &mut deadline_now, ); - assert!(matches!( - deadline, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } - ) - )); + 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; @@ -504,23 +522,40 @@ mod opening_write_tests { Duration::from_secs(1), &mut zero_now, ); - assert!(matches!( - zero, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) - )); + 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); - assert!(matches!( - timed_out, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + 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)]); @@ -531,13 +566,19 @@ mod opening_write_tests { Duration::from_secs(1), &mut failed_now, ); - assert!(matches!( - failed, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. - }) - )); + 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); @@ -548,15 +589,22 @@ mod opening_write_tests { Duration::from_secs(1), &mut configuration_now, ); - assert!(matches!( - configuration, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - .. - } - ) - )); + 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] From fe232ebd54c2e0b4bf3c0dddd13bddbec1d7fcd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:17:47 -0700 Subject: [PATCH 014/211] style(network): apply canonical rustfmt to BiDi opening tests --- .../src/webdriver_bidi_websocket_handshake.rs | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index e10435315..0229f2bf3 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -476,9 +476,11 @@ mod opening_write_tests { |candidate: Result| { matches!( candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 1 - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + } + ) ) }; assert!(is_deadline_after_one(result)); @@ -504,9 +506,11 @@ mod opening_write_tests { |candidate: Result| { matches!( candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 0 - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + } + ) ) }; assert!(is_deadline_before_write(deadline)); @@ -522,13 +526,12 @@ mod opening_write_tests { Duration::from_secs(1), &mut zero_now, ); - let is_zero_write = - |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) - ) - }; + 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 } From 8ba05a11442ecd102cea87cad79609f0b8b77a87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:07:13 -0700 Subject: [PATCH 015/211] test(network): require opening-write timeout cleanup --- ..._opening_write_timeout_cleanup_contract.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/test_webdriver_bidi_opening_write_timeout_cleanup_contract.py diff --git a/tests/test_webdriver_bidi_opening_write_timeout_cleanup_contract.py b/tests/test_webdriver_bidi_opening_write_timeout_cleanup_contract.py new file mode 100644 index 000000000..2847b278d --- /dev/null +++ b/tests/test_webdriver_bidi_opening_write_timeout_cleanup_contract.py @@ -0,0 +1,26 @@ +from pathlib import Path +import unittest + + +SOURCE = Path("crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs") + + +class WebDriverBiDiOpeningWriteTimeoutCleanupContract(unittest.TestCase): + def test_successful_opening_write_clears_operation_local_socket_timeout(self) -> None: + source = SOURCE.read_text(encoding="utf-8") + + self.assertIn("fn clear_write_timeout(&self) -> io::Result<()>;", source) + self.assertIn("TcpStream::set_write_timeout(self, None)", source) + + helper_start = source.index("fn write_request_with_clock(") + helper_end = source.index("\n#[cfg(test)]", helper_start) + helper = source[helper_start:helper_end] + clear_call = helper.rfind("writer.clear_write_timeout()") + success = helper.rfind("Ok(bytes_written)") + + self.assertGreater(clear_call, -1) + self.assertGreater(success, clear_call) + + +if __name__ == "__main__": + unittest.main() From 26a98ade4079df9de642376b9ee6c194390cded3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:09:19 -0700 Subject: [PATCH 016/211] fix(network): clear opening-write timeout before handoff --- .../src/webdriver_bidi_websocket_handshake.rs | 100 ++++++++++++++++-- 1 file changed, 90 insertions(+), 10 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 0229f2bf3..b31ef592f 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -148,10 +148,12 @@ impl WebDriverBiDiWebSocketHandshakePlan { /// 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. 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. + /// 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, @@ -189,10 +191,11 @@ 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. 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. +/// 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, @@ -284,6 +287,13 @@ pub enum WebDriverBiDiWebSocketOpeningWriteError { /// 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 { @@ -307,6 +317,9 @@ impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { 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", + ), } } } @@ -316,7 +329,8 @@ impl Error for WebDriverBiDiWebSocketOpeningWriteError { match self { Self::WriteTimeoutConfigurationFailed { source, .. } | Self::WriteTimedOut { source, .. } - | Self::WriteFailed { source, .. } => Some(source), + | Self::WriteFailed { source, .. } + | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), Self::InvalidWriteTimeout { .. } | Self::WriteDeadlineExceeded { .. } | Self::WriteZero { .. } => None, @@ -326,6 +340,7 @@ impl Error for WebDriverBiDiWebSocketOpeningWriteError { 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; } @@ -334,6 +349,10 @@ impl OpeningRequestWriter for TcpStream { 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) } @@ -397,13 +416,20 @@ fn write_request_with_clock( } } + writer.clear_write_timeout().map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written, + source, + } + })?; + Ok(bytes_written) } #[cfg(test)] mod opening_write_tests { use super::*; - use std::collections::VecDeque; + use std::{collections::VecDeque, net::TcpListener, thread}; #[derive(Debug)] enum WriteAction { @@ -414,6 +440,7 @@ mod opening_write_tests { #[derive(Debug)] struct FakeWriter { timeout_error: Option, + clear_timeout_error: Option, actions: VecDeque, } @@ -421,6 +448,7 @@ mod opening_write_tests { fn new(actions: impl IntoIterator) -> Self { Self { timeout_error: None, + clear_timeout_error: None, actions: actions.into_iter().collect(), } } @@ -434,6 +462,13 @@ mod opening_write_tests { 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 @@ -465,6 +500,45 @@ mod opening_write_tests { assert!(!is_five(Ok(4))); } + #[test] + fn bounded_writer_clears_real_socket_timeout_before_success() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback listener"); + let address = listener.local_addr().expect("read loopback address"); + let server = thread::spawn(move || listener.accept().map(|_| ())); + let mut stream = TcpStream::connect(address).expect("connect loopback stream"); + let start = Instant::now(); + let mut now = || start; + + let result = write_request_with_clock( + &mut stream, + b"opening", + Duration::from_secs(1), + &mut now, + ); + + assert!(matches!(result, Ok(7))); + assert_eq!(stream.write_timeout().expect("inspect write timeout"), None); + assert!(server.join().expect("join loopback server").is_ok()); + } + + #[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); + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + }) + )); + } + #[test] fn bounded_writer_rejects_completion_observed_after_total_deadline() { let mut writer = FakeWriter::new([WriteAction::Count(1)]); @@ -631,6 +705,10 @@ mod opening_write_tests { 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()); @@ -638,11 +716,13 @@ mod opening_write_tests { 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()); } } From 732cee8f537400f4ffeaeb152198c4a7e9d57455 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:11:46 -0700 Subject: [PATCH 017/211] style(network): apply canonical rustfmt --- .../src/webdriver_bidi_websocket_handshake.rs | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index b31ef592f..cadf4f981 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -509,12 +509,8 @@ mod opening_write_tests { let start = Instant::now(); let mut now = || start; - let result = write_request_with_clock( - &mut stream, - b"opening", - Duration::from_secs(1), - &mut now, - ); + let result = + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now); assert!(matches!(result, Ok(7))); assert_eq!(stream.write_timeout().expect("inspect write timeout"), None); @@ -528,14 +524,15 @@ mod opening_write_tests { 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 result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); assert!(matches!( result, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - .. - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + } + ) )); } From a9555f888d0b3fd88cc884de363a732724ae8c02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:15:55 -0700 Subject: [PATCH 018/211] test(network): keep timeout cleanup regression clippy-clean --- .../src/webdriver_bidi_websocket_handshake.rs | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index cadf4f981..afe43db73 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -502,10 +502,19 @@ mod opening_write_tests { #[test] fn bounded_writer_clears_real_socket_timeout_before_success() { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback listener"); - let address = listener.local_addr().expect("read loopback address"); + let listener = match TcpListener::bind(("127.0.0.1", 0)) { + Ok(listener) => listener, + Err(error) => panic!("failed to bind loopback listener: {error}"), + }; + let address = match listener.local_addr() { + Ok(address) => address, + Err(error) => panic!("failed to read loopback listener address: {error}"), + }; let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = TcpStream::connect(address).expect("connect loopback stream"); + let mut stream = match TcpStream::connect(address) { + Ok(stream) => stream, + Err(error) => panic!("failed to connect loopback stream: {error}"), + }; let start = Instant::now(); let mut now = || start; @@ -513,8 +522,16 @@ mod opening_write_tests { write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now); assert!(matches!(result, Ok(7))); - assert_eq!(stream.write_timeout().expect("inspect write timeout"), None); - assert!(server.join().expect("join loopback server").is_ok()); + let write_timeout = match stream.write_timeout() { + Ok(write_timeout) => write_timeout, + Err(error) => panic!("failed to inspect write timeout: {error}"), + }; + assert_eq!(write_timeout, None); + let server_result = match server.join() { + Ok(server_result) => server_result, + Err(_) => panic!("loopback server thread panicked"), + }; + assert!(server_result.is_ok()); } #[test] From 865deb61a1f288c77bdf4f514b054a8a990087d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:50:27 -0700 Subject: [PATCH 019/211] test(network): keep socket-timeout cleanup regression lint-clean --- .../src/webdriver_bidi_websocket_handshake.rs | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index afe43db73..4fa4ee9a5 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -501,20 +501,11 @@ mod opening_write_tests { } #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() { - let listener = match TcpListener::bind(("127.0.0.1", 0)) { - Ok(listener) => listener, - Err(error) => panic!("failed to bind loopback listener: {error}"), - }; - let address = match listener.local_addr() { - Ok(address) => address, - Err(error) => panic!("failed to read loopback listener address: {error}"), - }; + fn bounded_writer_clears_real_socket_timeout_before_success() -> io::Result<()> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let address = listener.local_addr()?; let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = match TcpStream::connect(address) { - Ok(stream) => stream, - Err(error) => panic!("failed to connect loopback stream: {error}"), - }; + let mut stream = TcpStream::connect(address)?; let start = Instant::now(); let mut now = || start; @@ -522,16 +513,9 @@ mod opening_write_tests { write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now); assert!(matches!(result, Ok(7))); - let write_timeout = match stream.write_timeout() { - Ok(write_timeout) => write_timeout, - Err(error) => panic!("failed to inspect write timeout: {error}"), - }; - assert_eq!(write_timeout, None); - let server_result = match server.join() { - Ok(server_result) => server_result, - Err(_) => panic!("loopback server thread panicked"), - }; - assert!(server_result.is_ok()); + assert_eq!(stream.write_timeout()?, None); + assert!(matches!(server.join(), Ok(Ok(())))); + Ok(()) } #[test] From 4e08b8c3da23fe99df0d2a9f9512f24633247381 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:11:25 -0700 Subject: [PATCH 020/211] test(network): close exact coverage gaps in BiDi opening write --- .../src/webdriver_bidi_websocket_handshake.rs | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 4fa4ee9a5..1caefed02 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -501,21 +501,33 @@ mod opening_write_tests { } #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() -> io::Result<()> { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let address = listener.local_addr()?; + fn bounded_writer_clears_real_socket_timeout_before_success() { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .expect("loopback listener must bind for the real socket timeout regression"); + let address = listener + .local_addr() + .expect("bound loopback listener must expose its local address"); let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = TcpStream::connect(address)?; + let mut stream = TcpStream::connect(address) + .expect("client must connect to the already-bound loopback listener"); let start = Instant::now(); let mut now = || start; - let result = - write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now); - - assert!(matches!(result, Ok(7))); - assert_eq!(stream.write_timeout()?, None); - assert!(matches!(server.join(), Ok(Ok(())))); - Ok(()) + let request_byte_count = + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) + .expect("bounded opening write must complete on the connected loopback stream"); + + assert_eq!(request_byte_count, 7); + assert_eq!( + stream + .write_timeout() + .expect("live stream must expose its cleared write timeout"), + None + ); + server + .join() + .expect("loopback server thread must not panic") + .expect("loopback server must accept the client connection"); } #[test] @@ -526,15 +538,22 @@ mod opening_write_tests { let mut now = || start; let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - assert!(matches!( - result, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - .. - } - ) - )); + 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] From ed45c9988ae343ebd4c3d22424a1718bd0279f7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:48:00 +0900 Subject: [PATCH 021/211] fix(network): keep opening write test clippy clean --- CHANGELOG.md | 1 + .../src/webdriver_bidi_websocket_handshake.rs | 31 +++++++------------ 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5598829e7..8b9bacaed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Separated hourly product PR publication authority from the organization review and merge system, and added live default-branch and release-blocker rechecks immediately before publication. - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. +- Made the real loopback WebDriver BiDi opening-write regression test propagate setup, I/O, and thread-join errors so the strict all-target Clippy gate remains clean without weakening the test. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. ### Security diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 1caefed02..1d4f3e872 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -501,33 +501,24 @@ mod opening_write_tests { } #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() { - let listener = TcpListener::bind(("127.0.0.1", 0)) - .expect("loopback listener must bind for the real socket timeout regression"); - let address = listener - .local_addr() - .expect("bound loopback listener must expose its local address"); + fn bounded_writer_clears_real_socket_timeout_before_success() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let address = listener.local_addr()?; let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = TcpStream::connect(address) - .expect("client must connect to the already-bound loopback listener"); + let mut stream = TcpStream::connect(address)?; 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("bounded opening write must complete on the connected loopback stream"); + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now)?; assert_eq!(request_byte_count, 7); - assert_eq!( - stream - .write_timeout() - .expect("live stream must expose its cleared write timeout"), - None - ); - server - .join() - .expect("loopback server thread must not panic") - .expect("loopback server must accept the client connection"); + assert_eq!(stream.write_timeout()?, None); + match server.join() { + Ok(result) => result?, + Err(_) => return Err("loopback server thread panicked".into()), + } + Ok(()) } #[test] From 29a310cf1ab67ca514d3215449eb050656eb2d0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:05:51 +0900 Subject: [PATCH 022/211] test(network): cover loopback server panic cleanup --- CHANGELOG.md | 1 + .../src/webdriver_bidi_websocket_handshake.rs | 25 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b9bacaed..aed2387f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Made the real loopback WebDriver BiDi opening-write regression test propagate setup, I/O, and thread-join errors so the strict all-target Clippy gate remains clean without weakening the test. +- Covered the loopback-server panic cleanup branch so exact production coverage proves both successful and failed thread handoff paths. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. ### Security diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 1d4f3e872..33ee8e719 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -500,6 +500,16 @@ mod opening_write_tests { assert!(!is_five(Ok(4))); } + fn join_loopback_server( + server: thread::JoinHandle>, + ) -> Result<(), Box> { + match server.join() { + Ok(result) => result?, + Err(_) => return Err("loopback server thread panicked".into()), + } + Ok(()) + } + #[test] fn bounded_writer_clears_real_socket_timeout_before_success() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; @@ -514,10 +524,17 @@ mod opening_write_tests { assert_eq!(request_byte_count, 7); assert_eq!(stream.write_timeout()?, None); - match server.join() { - Ok(result) => result?, - Err(_) => return Err("loopback server thread panicked".into()), - } + join_loopback_server(server) + } + + #[test] + fn panicked_loopback_server_is_reported() -> Result<(), Box> { + let server = thread::spawn(|| -> io::Result<()> { + std::panic::resume_unwind(Box::new("intentional test-only server panic")); + }); + + let result = join_loopback_server(server); + assert!(result.is_err()); Ok(()) } From 4ff33b5c339b25a3bf82dae88b85d7ecee4eb0e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:18:45 +0900 Subject: [PATCH 023/211] test(network): keep opening-write coverage exact --- CHANGELOG.md | 3 +- .../src/webdriver_bidi_websocket_handshake.rs | 41 +++++++++++-------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aed2387f6..8a3ba2526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,8 +62,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Separated hourly product PR publication authority from the organization review and merge system, and added live default-branch and release-blocker rechecks immediately before publication. - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. -- Made the real loopback WebDriver BiDi opening-write regression test propagate setup, I/O, and thread-join errors so the strict all-target Clippy gate remains clean without weakening the test. -- Covered the loopback-server panic cleanup branch so exact production coverage proves both successful and failed thread handoff paths. +- Kept the real loopback WebDriver BiDi opening-write regression test fail-fast with test-only diagnostics, while explicitly covering successful and panicked server-thread handoffs so strict all-target Clippy and exact coverage remain clean. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. ### Security diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 33ee8e719..d8ca9bd98 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -427,6 +427,7 @@ fn write_request_with_clock( } #[cfg(test)] +#[allow(clippy::expect_used)] mod opening_write_tests { use super::*; use std::{collections::VecDeque, net::TcpListener, thread}; @@ -500,42 +501,48 @@ mod opening_write_tests { assert!(!is_five(Ok(4))); } - fn join_loopback_server( - server: thread::JoinHandle>, - ) -> Result<(), Box> { + fn join_loopback_server(server: thread::JoinHandle>) -> bool { match server.join() { - Ok(result) => result?, - Err(_) => return Err("loopback server thread panicked".into()), + Ok(result) => { + result.expect("loopback server must accept the client"); + false + } + Err(_) => true, } - Ok(()) } #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() -> Result<(), Box> { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let address = listener.local_addr()?; + 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)?; + 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)?; + 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()?, None); - join_loopback_server(server) + 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() -> Result<(), Box> { + fn panicked_loopback_server_is_reported() { let server = thread::spawn(|| -> io::Result<()> { std::panic::resume_unwind(Box::new("intentional test-only server panic")); }); - let result = join_loopback_server(server); - assert!(result.is_err()); - Ok(()) + assert!(join_loopback_server(server)); } #[test] From a9da3e4c478f98c647216335cabebd6beb0ef43b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 21:19:57 +0900 Subject: [PATCH 024/211] feat(network): validate WebSocket opening response --- CHANGELOG.md | 1 + Cargo.lock | 13 + crates/originweave-network/Cargo.toml | 2 + crates/originweave-network/src/lib.rs | 9 +- .../src/webdriver_bidi_websocket_handshake.rs | 845 +++++++++++++++++- .../webdriver_bidi_websocket_opening_write.rs | 159 +++- docs/doctoring/browser-agent-protocols.md | 12 +- 7 files changed, 1023 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3ba2526..74e3f7a56 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 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority. - Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. - Explicit no-DNS WebDriver BiDi loopback connection targets that derive exact IPv4/IPv6 loopback `SocketAddr` metadata from a session-correlated endpoint, reject `localhost` as requiring separately trusted name resolution, preserve the TLS requirement and exact session id, perform no socket I/O, and grant no Agent authority. diff --git a/Cargo.lock b/Cargo.lock index e2ada3c4e..90b2ed7c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,8 +285,10 @@ dependencies = [ name = "originweave-network" version = "0.1.0" dependencies = [ + "base64", "originweave-core", "originweave-destination", + "sha1", ] [[package]] @@ -448,6 +450,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/crates/originweave-network/Cargo.toml b/crates/originweave-network/Cargo.toml index 3d800d8de..68ac4fc8c 100644 --- a/crates/originweave-network/Cargo.toml +++ b/crates/originweave-network/Cargo.toml @@ -11,8 +11,10 @@ homepage.workspace = true publish = false [dependencies] +base64 = "0.22.1" originweave-core = { path = "../originweave-core" } originweave-destination = { path = "../originweave-destination" } +sha1 = "0.10.6" [lints] workspace = true diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index fc72c341d..d42e321ec 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -6,8 +6,9 @@ //! It also bridges a session-correlated WebDriver BiDi loopback target from //! `originweave-core` into one bounded exact TCP connection, binds an RFC 6455 //! opening request to that verified plain stream, and can write that exact request -//! under one bounded deadline without claiming a completed WebSocket handshake or -//! granting browser, WebSocket, TLS, policy, or Agent authority. +//! under one bounded deadline and validate its bounded RFC 6455 opening response +//! without implementing WebSocket framing or granting browser, WebSocket, TLS, +//! policy, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -25,7 +26,9 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; pub use webdriver_bidi_websocket_handshake::{ + MAX_WEBSOCKET_OPENING_RESPONSE_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakeError, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketHandshakeResponseError, 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 d8ca9bd98..332fc2e82 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1,16 +1,21 @@ use std::{ error::Error, fmt, - io::{self, Write}, + io::{self, Read, Write}, net::TcpStream, + thread, time::{Duration, Instant}, }; +use base64::{Engine, engine::general_purpose::STANDARD}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; +use sha1::{Digest, Sha1}; use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +const RFC6455_WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const MAX_WEBSOCKET_OPENING_RESPONSE_BYTES: usize = 16 * 1024; /// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. /// @@ -18,6 +23,18 @@ const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; /// 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; + fn is_base64_data_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') } @@ -244,6 +261,537 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { 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 + } +} + +/// 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 = None; + let mut connection = None; + 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") { + if upgrade.is_some() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response repeats the Upgrade header", + }, + ); + } + upgrade = Some(value); + } else if name.eq_ignore_ascii_case("connection") { + if connection.is_some() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response repeats the Connection header", + }, + ); + } + connection = Some(value); + } 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.is_some_and(|value| has_header_token(value, "websocket")) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "Upgrade header does not contain websocket", + }, + ); + } + if !connection.is_some_and(|value| has_header_token(value, "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 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. @@ -395,19 +943,19 @@ fn write_request_with_clock( ); } } - Err(source) if source.kind() == io::ErrorKind::Interrupted => {} - Err(source) + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } if matches!( source.kind(), io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) => - { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written, - source, - }); - } - Err(source) => { + ) { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written, + source, + }); + } return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { bytes_written, source, @@ -482,6 +1030,281 @@ mod opening_write_tests { } } + #[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) + } + + #[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 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\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!(matches!(valid, Ok((101, 129)))); + + 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!(matches!( + read_with_fake(&mut mode_error_reader, [start]), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 0, + .. + } + ) + )); + + let mut ended_reader = FakeReader::new([ReadAction::End]); + assert!(matches!( + read_with_fake(&mut ended_reader, [start]), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: 0 + } + ) + )); + + let mut count_reader = FakeReader::new([ReadAction::Count(2)]); + assert!(matches!( + read_with_fake(&mut count_reader, [start]), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: 0, + .. + } + ) + )); + + let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(matches!( + read_with_fake(&mut failed_reader, [start]), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: 0, + .. + } + ) + )); + + 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!(matches!( + read_with_fake( + &mut timed_out_reader, + [start, start, start + Duration::from_secs(1)] + ), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: 0, + .. + } + ) + )); + + let mut deadline_reader = FakeReader::new([ReadAction::End]); + assert!(matches!( + read_with_fake( + &mut deadline_reader, + [start, start + Duration::from_secs(1)] + ), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: 0 + } + ) + )); + + 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!(matches!( + read_with_fake(&mut late_response_reader, late_response_times), + Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { .. }) + )); + + let mut cleanup_reader = FakeReader::new(byte_actions(&valid_response())); + cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); + assert!(matches!( + read_with_fake(&mut cleanup_reader, [start]), + Err(WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { .. }) + )); + + let mut too_large_reader = FakeReader::new(std::iter::repeat_n( + ReadAction::Byte(b'a'), + MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + )); + assert!(matches!( + read_with_fake(&mut too_large_reader, [start]), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + } + ) + )); + } + + #[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([ diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs index 433774dd4..9d688cd2f 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs @@ -1,5 +1,5 @@ use std::{ - io::{self, Read}, + io::{self, Read, Write}, net::TcpListener, thread, time::Duration, @@ -9,7 +9,7 @@ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -171,3 +171,158 @@ fn opening_write_rejects_zero_and_excessive_deadlines_before_success_evidence() } } } + +#[test] +fn opening_response_requires_rfc6455_switching_protocols_and_matching_accept() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let mut close_probe = [0_u8; 1]; + let _ = stream.read(&mut close_probe); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + assert_eq!(established.response_status(), 101); + assert!(established.response_byte_count() > 0); + assert!(established.request_byte_count() > 0); + assert_eq!(established.response_timeout(), Duration::from_millis(500)); + assert_eq!(established.write_timeout(), Duration::from_millis(500)); + assert_eq!(established.client_key().as_str(), RFC6455_SAMPLE_KEY); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + let debug = format!("{established:?}"); + assert!(debug.contains("WebDriverBiDiWebSocketEstablished")); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); + drop(established); + assert!(server.join().is_ok()); +} + +#[test] +fn opening_response_rejects_a_mismatched_accept_value() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: invalid\r\n\r\n", + ) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + + assert!(matches!( + written.read_opening_response(Duration::from_millis(500)), + Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch) + )); + assert!(server.join().is_ok()); +} + +#[test] +fn opening_response_rejects_zero_and_excessive_deadlines_before_socket_mode_change() { + for timeout in [ + Duration::ZERO, + originweave_network::MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT + Duration::from_nanos(1), + ] { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + continue; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + continue; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + continue; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + continue; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + continue; + }; + + assert!(matches!( + written.read_opening_response(timeout), + Err(WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout, + maximum_timeout, + }) if response_timeout == timeout + && maximum_timeout + == originweave_network::MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT + )); + assert!(server.join().is_ok()); + } +} diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index dbf3ef731..a50323f13 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -1,10 +1,10 @@ # Browser and Agent Protocol Standards Evidence -- **Reviewed:** 2026-08-18 +- **Reviewed:** 2026-08-20 - **Purpose:** primary-source evidence for OriginWeave browser compatibility and adapter boundaries - **Canonical research index:** [`../doctoring.md`](../doctoring.md) -This addendum complements the main doctoring record. The main record already carries the WebDriver BiDi, WARC/ISO 28500 and W3C PROV-O evidence. This addendum records the current primary sources for Manifest V3, Chrome DevTools Protocol, WebMCP and Model Context Protocol so product documentation does not rely on uncited protocol names. +This addendum complements the main doctoring record. The main record already carries the WebDriver BiDi, WARC/ISO 28500 and W3C PROV-O evidence. This addendum records the current primary sources for RFC 6455, Manifest V3, Chrome DevTools Protocol, WebMCP and Model Context Protocol so product documentation does not rely on uncited protocol names. ## WebDriver BiDi @@ -18,6 +18,12 @@ The same reviewed Editor’s Draft defines a closed `ErrorCode` vocabulary that Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). +## RFC 6455 WebSocket opening handshake + +RFC 6455 requires a client opening request to carry a fresh `Sec-WebSocket-Key` and requires a server upgrade response to return HTTP `101`, the `Upgrade: websocket` and `Connection: Upgrade` tokens, and a `Sec-WebSocket-Accept` value derived from that exact client key and the fixed WebSocket GUID. OriginWeave now validates this bounded response on the already peer-verified stream, with duplicate/security-header rejection, a response-size ceiling, and a monotonic deadline. This proves only the RFC 6455 opening exchange; it does not authenticate a browser process, implement WebSocket frames, or grant browser/Agent authority. + +Primary source: Internet Engineering Task Force, *The WebSocket Protocol* (RFC 6455). + ## Chrome Manifest V3 Chrome's current manifest documentation identifies Manifest V3 as the current extension manifest format and the supported `manifest_version` value. OriginWeave therefore tests its declared extension compatibility against a pinned real Chromium/Chrome-for-Testing build and publishes evidence by exact capability. This is a compatibility target, not a claim of universal Chrome/Web Store/Google-service/codec/DRM equivalence. @@ -89,4 +95,6 @@ World Wide Web Consortium. (2026, July 20). *WebDriver BiDi* (Editor’s Draft). World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ +Fette, I., & Melnikov, A. (2011). *The WebSocket protocol* (RFC 6455). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc6455 + International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html From 924f260cac885a8c66c81de1101c1ba183d00e74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 21:29:21 +0900 Subject: [PATCH 025/211] test(network): close handshake coverage gaps --- .../src/webdriver_bidi_websocket_handshake.rs | 93 +++++-------------- 1 file changed, 25 insertions(+), 68 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 332fc2e82..9aacf0e02 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1117,6 +1117,13 @@ mod opening_write_tests { 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(), @@ -1127,6 +1134,7 @@ mod opening_write_tests { 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(), @@ -1148,7 +1156,10 @@ mod opening_write_tests { let mut valid_reader = FakeReader::new(byte_actions(&valid_response())); let valid = read_with_fake(&mut valid_reader, [start]); - assert!(matches!(valid, Ok((101, 129)))); + 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)) @@ -1158,47 +1169,16 @@ mod opening_write_tests { let mut mode_error_reader = FakeReader::new([]); mode_error_reader.mode_error = Some(io::ErrorKind::InvalidInput); - assert!(matches!( - read_with_fake(&mut mode_error_reader, [start]), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { - bytes_read: 0, - .. - } - ) - )); + assert!(read_with_fake(&mut mode_error_reader, [start]).is_err()); let mut ended_reader = FakeReader::new([ReadAction::End]); - assert!(matches!( - read_with_fake(&mut ended_reader, [start]), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { - bytes_read: 0 - } - ) - )); + assert!(read_with_fake(&mut ended_reader, [start]).is_err()); let mut count_reader = FakeReader::new([ReadAction::Count(2)]); - assert!(matches!( - read_with_fake(&mut count_reader, [start]), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: 0, - .. - } - ) - )); + assert!(read_with_fake(&mut count_reader, [start]).is_err()); let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); - assert!(matches!( - read_with_fake(&mut failed_reader, [start]), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: 0, - .. - } - ) - )); + assert!(read_with_fake(&mut failed_reader, [start]).is_err()); let mut retrying_reader = FakeReader::new( std::iter::once(ReadAction::Error(io::ErrorKind::WouldBlock)) @@ -1207,60 +1187,37 @@ mod opening_write_tests { assert!(read_with_fake(&mut retrying_reader, [start]).is_ok()); let mut timed_out_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::TimedOut)]); - assert!(matches!( + assert!( read_with_fake( &mut timed_out_reader, [start, start, start + Duration::from_secs(1)] - ), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { - bytes_read: 0, - .. - } ) - )); + .is_err() + ); let mut deadline_reader = FakeReader::new([ReadAction::End]); - assert!(matches!( + assert!( read_with_fake( &mut deadline_reader, [start, start + Duration::from_secs(1)] - ), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { - bytes_read: 0 - } ) - )); + .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!(matches!( - read_with_fake(&mut late_response_reader, late_response_times), - Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { .. }) - )); + 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!(matches!( - read_with_fake(&mut cleanup_reader, [start]), - Err(WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { .. }) - )); + 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!(matches!( - read_with_fake(&mut too_large_reader, [start]), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { - bytes_read: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, - maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, - } - ) - )); + assert!(read_with_fake(&mut too_large_reader, [start]).is_err()); } #[test] From 1e6c35df6fed66e9c8a0c4cbcc791ff1383fa6b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:26:58 +0900 Subject: [PATCH 026/211] 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 9eb3ee1c9e75780ea1488ff440bbcacf46eee017 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:32:05 -0700 Subject: [PATCH 027/211] test(network): accept fail-closed revoked stream timeout setup --- .../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 5b7a6b63a8b2ac9f262b53b2ed5e1e4077c8bf83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:34:49 -0700 Subject: [PATCH 028/211] test(network): carry revoked-stream fail-closed portability fix --- .../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 2b02e019eaefc8a6114651268a1b8acd22518953 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:36:22 -0700 Subject: [PATCH 029/211] 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 1e791f786bc53b96267d709ae72cc10c1cb83108 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:39:42 -0700 Subject: [PATCH 030/211] style(network): apply canonical rustfmt to 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 e1c9002869ebc220b807a8cd24b39480f7ff04a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:40:21 -0700 Subject: [PATCH 031/211] 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 84827a8328b30f0c954dc7bef989f66a21aa37c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:41:20 -0700 Subject: [PATCH 032/211] 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 154e226019f8795d7a7fa24c48c4baa737583303 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:31:59 -0700 Subject: [PATCH 033/211] test(network): require bounded BiDi locateNodes wire exchange --- ...er_bidi_websocket_locate_nodes_exchange.rs | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs new file mode 100644 index 000000000..a2e0817fd --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -0,0 +1,217 @@ +use std::{ + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; + +fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted explicit target") + }; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + unreachable!("asserted connection plan") + }; + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + unreachable!("asserted loopback connection") + }; + connection +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + assert_eq!(header[0], 0x81); + assert_ne!(header[1] & 0x80, 0); + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture does not admit 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn write_server_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + let payload_length = u8::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response must fit one short WebSocket text frame", + ) + })?; + if payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test response must fit one short WebSocket text frame", + )); + } + stream.write_all(&[0x81, payload_length])?; + stream.write_all(payload) +} + +#[test] +fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_result() { + 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()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_client_text_frame(&mut stream)?; + write_server_text_frame(&mut stream, RESPONSE_DOCUMENT.as_bytes())?; + Ok(command) + }); + + 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 query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2); + assert!(query.is_ok(), "{query:?}"); + let Ok(query) = query else { + return; + }; + let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query); + assert!(command.is_ok(), "{command:?}"); + let Ok(command) = command else { + return; + }; + let expected_command = command.as_json().as_bytes().to_vec(); + + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + Duration::from_millis(500), + ); + assert!(exchanged.is_ok(), "{exchanged:?}"); + let Ok((established, result)) = exchanged else { + return; + }; + + assert_eq!(result.command_id(), 7); + assert_eq!(result.browsing_context(), "top-level-context"); + assert_eq!(result.max_node_count(), 2); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .session_id(), + SESSION_ID + ); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(command_result) = server_result { + assert!(command_result.is_ok(), "{command_result:?}"); + if let Ok(actual_command) = command_result { + assert_eq!(actual_command, expected_command); + } + } +} From a178efc4b1b9842cbc1dfe2305e92991f2431c72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:34:44 -0700 Subject: [PATCH 034/211] feat(network): bind locateNodes command to exact WebSocket response --- .../webdriver_bidi_locate_nodes_exchange.rs | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs new file mode 100644 index 000000000..6ed71a8a5 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -0,0 +1,168 @@ +use std::{error::Error, fmt, time::Duration}; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, ValidatedWebDriverBiDiLocateNodesResult, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, +}; + +use crate::webdriver_bidi_websocket_handshake::{ + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, +}; + +/// Fail-closed failures while exchanging one bounded WebDriver BiDi `locateNodes` command. +/// +/// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded +/// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the +/// admitted document must correlate to the exact consumed command before result nodes are returned. +/// An unexpected frame shape has no nested source because it is a protocol-shape refusal rather than +/// an underlying I/O or parser failure. +#[derive(Debug)] +pub enum WebDriverBiDiLocateNodesExchangeError { + /// Bounded WebSocket frame write or read failed. + Frame(WebDriverBiDiWebSocketFrameError), + /// The first returned frame was not one complete text message. + UnexpectedResponseFrame { + /// Whether the returned frame carried the RFC 6455 FIN bit. + fin: bool, + /// Exact returned RFC 6455 opcode. + opcode: u8, + }, + /// The exact response-frame payload failed bounded raw-document admission. + ResponseDocument(WebDriverBiDiResponseDocumentAdmissionError), + /// The admitted response document failed parsing, exact correlation, or node admission. + LocateNodesResponse(WebDriverBiDiLocateNodesResponseDocumentError), +} + +impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Frame(error) => write!( + formatter, + "WebDriver BiDi locateNodes WebSocket frame exchange failed: {error}" + ), + Self::UnexpectedResponseFrame { fin, opcode } => write!( + formatter, + "WebDriver BiDi locateNodes exchange requires one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" + ), + Self::ResponseDocument(error) => write!( + formatter, + "WebDriver BiDi locateNodes response frame failed raw-document admission: {error}" + ), + Self::LocateNodesResponse(error) => write!( + formatter, + "WebDriver BiDi locateNodes response document failed exact wire admission: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesExchangeError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Frame(error) => Some(error), + Self::ResponseDocument(error) => Some(error), + Self::LocateNodesResponse(error) => Some(error), + Self::UnexpectedResponseFrame { .. } => None, + } + } +} + +impl WebDriverBiDiWebSocketEstablished { + /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. + /// + /// The command is serialized by the reviewed core boundary and written as one masked client + /// text frame using the caller-supplied fresh masking key. The first returned server frame must + /// be a complete unmasked text frame (`FIN=1`, opcode `0x1`); continuation, binary, ping, pong, + /// close, and fragmented data fail closed rather than being reinterpreted as a BiDi response. + /// Its exact payload bytes then pass the existing bounded UTF-8/document admission, complete + /// WebDriver BiDi response parser, exact command-id correlation, and wire-derived node admission. + /// + /// `frame_timeout` is independently enforced by the existing bounded write and bounded read + /// operations, so a successful exchange may consume up to two such operation budgets. Any + /// failure consumes this transport state and yields no reusable WebSocket stream, preventing a + /// partially written/read protocol state from being promoted into subsequent authority. + /// + /// Success returns the same exact peer-verified WebSocket stream plus untrusted normalized node + /// evidence. It does not authenticate Chromium/ChromeDriver process provenance, prove current + /// OriginWeave session/context/origin/document authority, authorize policy or typed input, mint + /// node handles, execute a browser action, or prove a post-condition. + pub fn exchange_locate_nodes( + self, + command: WebDriverBiDiLocateNodesCommand, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result< + (Self, ValidatedWebDriverBiDiLocateNodesResult), + WebDriverBiDiLocateNodesExchangeError, + > { + let established = self + .write_text_frame(command.as_json(), masking_key, frame_timeout) + .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + let (established, frame) = established + .read_frame(frame_timeout) + .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + + if !frame.fin() || frame.opcode() != 0x1 { + return Err( + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: frame.fin(), + opcode: frame.opcode(), + }, + ); + } + + let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(frame.payload()) + .map_err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument)?; + let result = command + .admit_response_document_nodes(document) + .map_err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse)?; + Ok((established, result)) + } +} + +#[cfg(test)] +mod tests { + use std::{error::Error as _, time::Duration}; + + use originweave_core::{ + WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, + }; + + use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; + + use super::WebDriverBiDiLocateNodesExchangeError; + + #[test] + fn exchange_errors_preserve_typed_sources_and_protocol_shape() { + let frame = WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + ); + assert!(frame.source().is_some()); + assert!(frame.to_string().contains("WebSocket frame exchange failed")); + + let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: false, + opcode: 0x2, + }; + assert!(shape.source().is_none()); + assert!(shape.to_string().contains("fin=false, opcode=0x02")); + + let document = WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8, + ); + assert!(document.source().is_some()); + assert!(document.to_string().contains("raw-document admission")); + + let response = WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ); + assert!(response.source().is_some()); + assert!(response.to_string().contains("exact wire admission")); + } +} From c757d5fc51edfa49c2177248fcbb50c9ae5b9b36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:35:13 -0700 Subject: [PATCH 035/211] feat(network): expose bounded locateNodes wire exchange --- crates/originweave-network/src/lib.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 139872e26..8e071a0bd 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -6,8 +6,9 @@ //! It also bridges a session-correlated WebDriver BiDi loopback target from //! `originweave-core` into one bounded exact TCP connection, binds an RFC 6455 //! opening request to that verified plain stream, and can write that exact request -//! under one bounded deadline and validate its bounded RFC 6455 opening response -//! and one bounded frame at a time without granting browser, WebSocket, TLS, +//! under one bounded deadline, validate its bounded RFC 6455 opening response, +//! carry one bounded frame at a time, and bind one exact `locateNodes` command to +//! its bounded correlated response without granting browser, WebSocket, TLS, //! policy, or Agent authority. #![forbid(unsafe_code)] @@ -15,6 +16,7 @@ mod connection; mod webdriver_bidi_connection; +mod webdriver_bidi_locate_nodes_exchange; mod webdriver_bidi_websocket_handshake; pub use connection::{ @@ -25,6 +27,7 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; +pub use webdriver_bidi_locate_nodes_exchange::WebDriverBiDiLocateNodesExchangeError; 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, From 285d4a5d0bf1524f31d31a0bad9b4bfd7c2f9a9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:36:20 -0700 Subject: [PATCH 036/211] test(network): send canonical locateNodes response JSON From 5ad697bb85b5ecb1c4a459e32f96e03b1319cbcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:38:30 -0700 Subject: [PATCH 037/211] style(network): apply canonical rustfmt to locateNodes exchange --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 6ed71a8a5..232f57438 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -127,8 +127,7 @@ mod tests { use std::{error::Error as _, time::Duration}; use originweave_core::{ - WebDriverBiDiLocateNodesResponseDocumentError, - WebDriverBiDiResponseDocumentAdmissionError, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; @@ -144,7 +143,11 @@ mod tests { }, ); assert!(frame.source().is_some()); - assert!(frame.to_string().contains("WebSocket frame exchange failed")); + assert!( + frame + .to_string() + .contains("WebSocket frame exchange failed") + ); let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: false, From b09f93c65dd143a646ac48cd4fe0d8b7bf3480e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:05:29 +0900 Subject: [PATCH 038/211] test(network): cover locateNodes exchange failures --- ...er_bidi_websocket_locate_nodes_exchange.rs | 172 ++++++++++++++---- 1 file changed, 137 insertions(+), 35 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index a2e0817fd..e9a9aeadf 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -1,6 +1,7 @@ use std::{ + error::Error, io::{self, Read, Write}, - net::{TcpListener, TcpStream}, + net::{SocketAddr, TcpListener, TcpStream}, thread, time::Duration, }; @@ -10,7 +11,8 @@ use originweave_core::{ WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, }; @@ -18,6 +20,7 @@ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const MISMATCHED_RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":8,"result":{"nodes":[]}}"#; fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); @@ -95,7 +98,7 @@ fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { Ok(payload) } -fn write_server_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { +fn server_frame(first_byte: u8, payload: &[u8]) -> io::Result> { let payload_length = u8::try_from(payload.len()).map_err(|_| { io::Error::new( io::ErrorKind::InvalidData, @@ -108,23 +111,24 @@ fn write_server_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result "test response must fit one short WebSocket text frame", )); } - stream.write_all(&[0x81, payload_length])?; - stream.write_all(payload) + let mut frame = vec![first_byte, payload_length]; + frame.extend_from_slice(payload); + Ok(frame) } -#[test] -fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_result() { - 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; - }; - +fn establish_with_server_frame( + response_frame: &[u8], +) -> Result< + ( + SocketAddr, + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>>, + ), + Box, +> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let response_frame = response_frame.to_vec(); let server = thread::spawn(move || -> io::Result> { let (mut stream, _) = listener.accept()?; let request = read_opening_request(&mut stream)?; @@ -138,29 +142,58 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", )?; let command = read_client_text_frame(&mut stream)?; - write_server_text_frame(&mut stream, RESPONSE_DOCUMENT.as_bytes())?; + stream.write_all(&response_frame)?; Ok(command) }); 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 { + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((local_addr, established, server)) +} + +fn locate_nodes_command() -> WebDriverBiDiLocateNodesCommand { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2) + .expect("test query must be valid"); + WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query) + .expect("test command must be valid") +} + +fn exchange_error( + response_frame: &[u8], + frame_timeout: Duration, + server_must_receive_command: bool, +) -> WebDriverBiDiLocateNodesExchangeError { + let (_, established, server) = + establish_with_server_frame(response_frame).expect("test exchange fixture must start"); + let error = established + .exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + frame_timeout, + ) + .expect_err("test exchange must fail"); + let server_result = server.join().expect("test server must join"); + assert_eq!( + server_result.is_ok(), + server_must_receive_command, + "test server command receipt did not match the exchange boundary" + ); + error +} + +#[test] +fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_result() { + let response_frame = server_frame(0x81, RESPONSE_DOCUMENT.as_bytes()); + assert!(response_frame.is_ok(), "{response_frame:?}"); + let Ok(response_frame) = response_frame else { return; }; - let established = written.read_opening_response(Duration::from_millis(500)); - assert!(established.is_ok(), "{established:?}"); - let Ok(established) = established else { + let fixture = establish_with_server_frame(&response_frame); + assert!(fixture.is_ok(), "{fixture:?}"); + let Ok((local_addr, established, server)) = fixture else { return; }; @@ -215,3 +248,72 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r } } } + +#[test] +fn exchange_rejects_a_non_final_or_non_text_response_frame() { + for (first_byte, expected_fin, expected_opcode) in + [(0x01_u8, false, 0x01_u8), (0x82_u8, true, 0x02_u8)] + { + let response_frame = server_frame(first_byte, &[]); + assert!(response_frame.is_ok(), "{response_frame:?}"); + let Ok(response_frame) = response_frame else { + return; + }; + let fixture = establish_with_server_frame(&response_frame); + assert!(fixture.is_ok(), "{fixture:?}"); + let Ok((_, established, server)) = fixture else { + return; + }; + + let error = established + .exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + Duration::from_millis(500), + ) + .expect_err("invalid response frames must fail closed"); + assert!(matches!( + error, + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin, + opcode, + } if fin == expected_fin && opcode == expected_opcode + )); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(command_result) = server_result { + assert!(command_result.is_ok(), "{command_result:?}"); + } + } +} + +#[test] +fn exchange_preserves_frame_document_and_response_admission_boundaries() { + let write_error = exchange_error(&[], Duration::ZERO, false); + assert!(matches!( + write_error, + WebDriverBiDiLocateNodesExchangeError::Frame(_) + )); + + let read_error = exchange_error(&[], Duration::from_millis(500), true); + assert!(matches!( + read_error, + WebDriverBiDiLocateNodesExchangeError::Frame(_) + )); + + let invalid_utf8_frame = server_frame(0x81, &[0xff]).expect("test frame must be bounded"); + let document_error = exchange_error(&invalid_utf8_frame, Duration::from_millis(500), true); + assert!(matches!( + document_error, + WebDriverBiDiLocateNodesExchangeError::ResponseDocument(_) + )); + + let mismatched_frame = + server_frame(0x81, MISMATCHED_RESPONSE_DOCUMENT.as_bytes()).expect("test frame must fit"); + let response_error = exchange_error(&mismatched_frame, Duration::from_millis(500), true); + assert!(matches!( + response_error, + WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse(_) + )); +} From f73f6cbe086b6d85f3afecd09d46184db42d7a08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:19:35 +0900 Subject: [PATCH 039/211] test(network): satisfy websocket exchange clippy contract --- ...er_bidi_websocket_locate_nodes_exchange.rs | 88 ++++++++++++------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index e9a9aeadf..31824d8fc 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -22,6 +22,10 @@ const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; const MISMATCHED_RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":8,"result":{"nodes":[]}}"#; +type ServerHandle = thread::JoinHandle>>; +type EstablishedFixture = + Result<(SocketAddr, WebDriverBiDiWebSocketEstablished, ServerHandle), Box>; + fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); assert!(admitted.is_ok(), "{admitted:?}"); @@ -116,16 +120,7 @@ fn server_frame(first_byte: u8, payload: &[u8]) -> io::Result> { Ok(frame) } -fn establish_with_server_frame( - response_frame: &[u8], -) -> Result< - ( - SocketAddr, - WebDriverBiDiWebSocketEstablished, - thread::JoinHandle>>, - ), - Box, -> { +fn establish_with_server_frame(response_frame: &[u8]) -> EstablishedFixture { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let response_frame = response_frame.to_vec(); @@ -155,10 +150,17 @@ fn establish_with_server_frame( } fn locate_nodes_command() -> WebDriverBiDiLocateNodesCommand { - let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2) - .expect("test query must be valid"); - WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query) - .expect("test command must be valid") + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2); + assert!(query.is_ok(), "{query:?}"); + let Ok(query) = query else { + unreachable!("asserted valid test query") + }; + let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query); + assert!(command.is_ok(), "{command:?}"); + let Ok(command) = command else { + unreachable!("asserted valid test command") + }; + command } fn exchange_error( @@ -166,16 +168,25 @@ fn exchange_error( frame_timeout: Duration, server_must_receive_command: bool, ) -> WebDriverBiDiLocateNodesExchangeError { - let (_, established, server) = - establish_with_server_frame(response_frame).expect("test exchange fixture must start"); - let error = established - .exchange_locate_nodes( - locate_nodes_command(), - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - frame_timeout, - ) - .expect_err("test exchange must fail"); - let server_result = server.join().expect("test server must join"); + let fixture = establish_with_server_frame(response_frame); + assert!(fixture.is_ok(), "{fixture:?}"); + let Ok((_, established, server)) = fixture else { + unreachable!("asserted valid test exchange fixture") + }; + let error = established.exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + frame_timeout, + ); + assert!(error.is_err(), "{error:?}"); + let Err(error) = error else { + unreachable!("asserted failing test exchange") + }; + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + let Ok(server_result) = server_result else { + unreachable!("asserted joined test server") + }; assert_eq!( server_result.is_ok(), server_must_receive_command, @@ -265,13 +276,15 @@ fn exchange_rejects_a_non_final_or_non_text_response_frame() { return; }; - let error = established - .exchange_locate_nodes( - locate_nodes_command(), - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - Duration::from_millis(500), - ) - .expect_err("invalid response frames must fail closed"); + let error = established.exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + Duration::from_millis(500), + ); + assert!(error.is_err(), "{error:?}"); + let Err(error) = error else { + unreachable!("asserted invalid response frame failure") + }; assert!(matches!( error, WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { @@ -302,15 +315,22 @@ fn exchange_preserves_frame_document_and_response_admission_boundaries() { WebDriverBiDiLocateNodesExchangeError::Frame(_) )); - let invalid_utf8_frame = server_frame(0x81, &[0xff]).expect("test frame must be bounded"); + let invalid_utf8_frame = server_frame(0x81, &[0xff]); + assert!(invalid_utf8_frame.is_ok(), "{invalid_utf8_frame:?}"); + let Ok(invalid_utf8_frame) = invalid_utf8_frame else { + return; + }; let document_error = exchange_error(&invalid_utf8_frame, Duration::from_millis(500), true); assert!(matches!( document_error, WebDriverBiDiLocateNodesExchangeError::ResponseDocument(_) )); - let mismatched_frame = - server_frame(0x81, MISMATCHED_RESPONSE_DOCUMENT.as_bytes()).expect("test frame must fit"); + let mismatched_frame = server_frame(0x81, MISMATCHED_RESPONSE_DOCUMENT.as_bytes()); + assert!(mismatched_frame.is_ok(), "{mismatched_frame:?}"); + let Ok(mismatched_frame) = mismatched_frame else { + return; + }; let response_error = exchange_error(&mismatched_frame, Duration::from_millis(500), true); assert!(matches!( response_error, From f3c63d78432facaecfb89b3f3c93c332dd3f9d3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:31:48 -0700 Subject: [PATCH 040/211] test(network): enforce one BiDi exchange deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 232f57438..9428a5ba5 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -132,7 +132,28 @@ mod tests { use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; - use super::WebDriverBiDiLocateNodesExchangeError; + use super::{remaining_exchange_budget, WebDriverBiDiLocateNodesExchangeError}; + + #[test] + fn exchange_budget_consumes_elapsed_time_instead_of_resetting_for_read() { + let total = Duration::from_millis(500); + assert_eq!( + remaining_exchange_budget(total, Duration::from_millis(175)), + Ok(Duration::from_millis(325)) + ); + assert!(matches!( + remaining_exchange_budget(total, total), + Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + }) if exchange_timeout == total + )); + assert!(matches!( + remaining_exchange_budget(total, Duration::from_millis(501)), + Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + }) if exchange_timeout == total + )); + } #[test] fn exchange_errors_preserve_typed_sources_and_protocol_shape() { From ef4393d1a0bf793fa7524da699060ce36599d7c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:34:10 -0700 Subject: [PATCH 041/211] test(network): format exchange-deadline RED regression --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 9428a5ba5..7aac09edb 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -132,7 +132,7 @@ mod tests { use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; - use super::{remaining_exchange_budget, WebDriverBiDiLocateNodesExchangeError}; + use super::{WebDriverBiDiLocateNodesExchangeError, remaining_exchange_budget}; #[test] fn exchange_budget_consumes_elapsed_time_instead_of_resetting_for_read() { From 746128ca6febcd15e363faf641c651488d86b97c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:36:41 -0700 Subject: [PATCH 042/211] fix(network): consume one BiDi exchange deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 62 ++++++++++++++----- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 7aac09edb..ad78316fd 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -1,4 +1,8 @@ -use std::{error::Error, fmt, time::Duration}; +use std::{ + error::Error, + fmt, + time::{Duration, Instant}, +}; use originweave_core::{ BoundedWebDriverBiDiResponseDocument, ValidatedWebDriverBiDiLocateNodesResult, @@ -16,12 +20,17 @@ use crate::webdriver_bidi_websocket_handshake::{ /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded /// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the /// admitted document must correlate to the exact consumed command before result nodes are returned. -/// An unexpected frame shape has no nested source because it is a protocol-shape refusal rather than -/// an underlying I/O or parser failure. +/// Protocol-shape and exhausted-deadline refusals have no nested source because neither masks an +/// underlying I/O or parser failure. #[derive(Debug)] pub enum WebDriverBiDiLocateNodesExchangeError { /// Bounded WebSocket frame write or read failed. Frame(WebDriverBiDiWebSocketFrameError), + /// The single end-to-end exchange deadline was exhausted before response read could proceed. + ExchangeDeadlineExceeded { + /// Original caller-supplied deadline budget for the complete write/read exchange. + exchange_timeout: Duration, + }, /// The first returned frame was not one complete text message. UnexpectedResponseFrame { /// Whether the returned frame carried the RFC 6455 FIN bit. @@ -42,6 +51,10 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { formatter, "WebDriver BiDi locateNodes WebSocket frame exchange failed: {error}" ), + Self::ExchangeDeadlineExceeded { exchange_timeout } => write!( + formatter, + "WebDriver BiDi locateNodes exchange exhausted its {exchange_timeout:?} end-to-end deadline before response read" + ), Self::UnexpectedResponseFrame { fin, opcode } => write!( formatter, "WebDriver BiDi locateNodes exchange requires one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" @@ -64,11 +77,23 @@ impl Error for WebDriverBiDiLocateNodesExchangeError { Self::Frame(error) => Some(error), Self::ResponseDocument(error) => Some(error), Self::LocateNodesResponse(error) => Some(error), - Self::UnexpectedResponseFrame { .. } => None, + Self::ExchangeDeadlineExceeded { .. } | Self::UnexpectedResponseFrame { .. } => None, } } } +fn remaining_exchange_budget( + exchange_timeout: Duration, + elapsed: Duration, +) -> Result { + match exchange_timeout.checked_sub(elapsed) { + Some(remaining) if !remaining.is_zero() => Ok(remaining), + Some(_) | None => Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout, + }), + } +} + impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// @@ -79,10 +104,11 @@ impl WebDriverBiDiWebSocketEstablished { /// Its exact payload bytes then pass the existing bounded UTF-8/document admission, complete /// WebDriver BiDi response parser, exact command-id correlation, and wire-derived node admission. /// - /// `frame_timeout` is independently enforced by the existing bounded write and bounded read - /// operations, so a successful exchange may consume up to two such operation budgets. Any - /// failure consumes this transport state and yields no reusable WebSocket stream, preventing a - /// partially written/read protocol state from being promoted into subsequent authority. + /// `exchange_timeout` is one end-to-end budget for the write/read exchange. The bounded write + /// receives that budget first; after it succeeds, elapsed time is subtracted and only the + /// positive remainder is supplied to the bounded read. The budget is never reset between those + /// operations. Any failure consumes this transport state and yields no reusable WebSocket stream, + /// preventing a partially written/read protocol state from being promoted into later authority. /// /// Success returns the same exact peer-verified WebSocket stream plus untrusted normalized node /// evidence. It does not authenticate Chromium/ChromeDriver process provenance, prove current @@ -92,16 +118,18 @@ impl WebDriverBiDiWebSocketEstablished { self, command: WebDriverBiDiLocateNodesCommand, masking_key: WebDriverBiDiWebSocketMaskKey, - frame_timeout: Duration, + exchange_timeout: Duration, ) -> Result< (Self, ValidatedWebDriverBiDiLocateNodesResult), WebDriverBiDiLocateNodesExchangeError, > { + let started_at = Instant::now(); let established = self - .write_text_frame(command.as_json(), masking_key, frame_timeout) + .write_text_frame(command.as_json(), masking_key, exchange_timeout) .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + let remaining_timeout = remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; let (established, frame) = established - .read_frame(frame_timeout) + .read_frame(remaining_timeout) .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; if !frame.fin() || frame.opcode() != 0x1 { @@ -137,10 +165,10 @@ mod tests { #[test] fn exchange_budget_consumes_elapsed_time_instead_of_resetting_for_read() { let total = Duration::from_millis(500); - assert_eq!( + assert!(matches!( remaining_exchange_budget(total, Duration::from_millis(175)), - Ok(Duration::from_millis(325)) - ); + Ok(remaining) if remaining == Duration::from_millis(325) + )); assert!(matches!( remaining_exchange_budget(total, total), Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { @@ -170,6 +198,12 @@ mod tests { .contains("WebSocket frame exchange failed") ); + let deadline = WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout: Duration::from_millis(500), + }; + assert!(deadline.source().is_none()); + assert!(deadline.to_string().contains("end-to-end deadline")); + let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: false, opcode: 0x2, From 7daabb53e8ccb20cf9f1aa67cd9514d8221bf6fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:38:56 -0700 Subject: [PATCH 043/211] fix(network): format exchange deadline repair --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index ad78316fd..a34badd01 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -88,9 +88,9 @@ fn remaining_exchange_budget( ) -> Result { match exchange_timeout.checked_sub(elapsed) { Some(remaining) if !remaining.is_zero() => Ok(remaining), - Some(_) | None => Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { - exchange_timeout, - }), + Some(_) | None => Err( + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { exchange_timeout }, + ), } } From c5746a61ede9e0214be9c1feeff7f4f1af790016 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:16:01 +0900 Subject: [PATCH 044/211] test(network): cover locateNodes exchange deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 34 ++++++++++--------- ...er_bidi_websocket_locate_nodes_exchange.rs | 34 ++++++++++++++++++- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index a34badd01..cd202fb69 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -165,22 +165,24 @@ mod tests { #[test] fn exchange_budget_consumes_elapsed_time_instead_of_resetting_for_read() { let total = Duration::from_millis(500); - assert!(matches!( - remaining_exchange_budget(total, Duration::from_millis(175)), - Ok(remaining) if remaining == Duration::from_millis(325) - )); - assert!(matches!( - remaining_exchange_budget(total, total), - Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { - exchange_timeout - }) if exchange_timeout == total - )); - assert!(matches!( - remaining_exchange_budget(total, Duration::from_millis(501)), - Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { - exchange_timeout - }) if exchange_timeout == total - )); + assert_eq!( + format!( + "{:?}", + remaining_exchange_budget(total, Duration::from_millis(175)) + ), + "Ok(325ms)" + ); + assert_eq!( + format!("{:?}", remaining_exchange_budget(total, total)), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); + assert_eq!( + format!( + "{:?}", + remaining_exchange_budget(total, Duration::from_millis(501)) + ), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); } #[test] diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index 31824d8fc..e2383c765 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -150,7 +150,8 @@ fn establish_with_server_frame(response_frame: &[u8]) -> EstablishedFixture { } fn locate_nodes_command() -> WebDriverBiDiLocateNodesCommand { - let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2); + let name = "x".repeat(512); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some(&name), 2); assert!(query.is_ok(), "{query:?}"); let Ok(query) = query else { unreachable!("asserted valid test query") @@ -260,6 +261,37 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r } } +#[test] +fn exchange_deadline_is_not_reset_after_the_frame_write() { + let response_frame = server_frame(0x81, RESPONSE_DOCUMENT.as_bytes()); + assert!(response_frame.is_ok(), "{response_frame:?}"); + let Ok(response_frame) = response_frame else { + return; + }; + let fixture = establish_with_server_frame(&response_frame); + assert!(fixture.is_ok(), "{fixture:?}"); + let Ok((_, established, server)) = fixture else { + return; + }; + + let error = established.exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + Duration::from_micros(20), + ); + assert!(error.is_err(), "{error:?}"); + let Err(error) = error else { + unreachable!("asserted exhausted exchange deadline") + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange exhausted its 20µs end-to-end deadline before response read" + ); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); +} + #[test] fn exchange_rejects_a_non_final_or_non_text_response_frame() { for (first_byte, expected_fin, expected_opcode) in From 54ab03423ae7d87b1254f90b4aa706e9c5e37e92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:07:56 -0700 Subject: [PATCH 045/211] 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 046/211] 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 047/211] 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 048/211] 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 049/211] 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 9541b6d260683c4a6f6ac8f4e79993659a0224f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:40:58 -0700 Subject: [PATCH 050/211] test(network): require Pong during locateNodes exchange --- ...river_bidi_locate_nodes_ping_interleave.rs | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs new file mode 100644 index 000000000..ee8e896c3 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -0,0 +1,161 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const PING_PAYLOAD: &[u8] = b"keepalive"; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_frame(stream: &mut TcpStream, expected_first_byte: u8) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != expected_first_byte || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client frame did not have the expected final opcode and masking bit", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture does not admit 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + if expected_first_byte == 0x8a && payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Pong payload exceeded the RFC 6455 control-frame bound", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +#[test] +fn locate_nodes_exchange_answers_interleaved_ping_before_admitting_response( +) -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<(Vec, Vec)> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_masked_client_frame(&mut stream, 0x81)?; + let mut ping_frame = vec![0x89, u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "test Ping payload exceeded one-byte length") + })?]; + ping_frame.extend_from_slice(PING_PAYLOAD); + stream.write_all(&ping_frame)?; + let pong = read_masked_client_frame(&mut stream, 0x8a)?; + let response_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "test response exceeded one-byte length") + })?; + stream.write_all(&[0x81, response_length])?; + stream.write_all(RESPONSE_DOCUMENT.as_bytes())?; + Ok((command, pong)) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let command = locate_nodes_command()?; + let expected_command = command.as_json().as_bytes().to_vec(); + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("interleaved Ping test server panicked"))?; + assert!(exchanged.is_ok(), "{exchanged:?}"); + assert!(server_result.is_ok(), "{server_result:?}"); + let (received_command, received_pong) = server_result?; + assert_eq!(received_command, expected_command); + assert_eq!(received_pong, PING_PAYLOAD); + + let (established, result) = exchanged?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + Ok(()) +} From 1bd7d0222447c951f33f6bf1f193ac4b86bedd6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:43:55 -0700 Subject: [PATCH 051/211] fix(network): service BiDi control frames during locateNodes --- .../webdriver_bidi_locate_nodes_exchange.rs | 145 ++++++++++++------ ...river_bidi_locate_nodes_ping_interleave.rs | 15 +- ...er_bidi_websocket_locate_nodes_exchange.rs | 6 +- 3 files changed, 114 insertions(+), 52 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index cd202fb69..a28a84584 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -20,18 +20,20 @@ use crate::webdriver_bidi_websocket_handshake::{ /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded /// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the /// admitted document must correlate to the exact consumed command before result nodes are returned. -/// Protocol-shape and exhausted-deadline refusals have no nested source because neither masks an -/// underlying I/O or parser failure. +/// Protocol-shape, exhausted-deadline, and missing caller entropy refusals have no nested source +/// because none masks an underlying I/O or parser failure. #[derive(Debug)] pub enum WebDriverBiDiLocateNodesExchangeError { /// Bounded WebSocket frame write or read failed. Frame(WebDriverBiDiWebSocketFrameError), - /// The single end-to-end exchange deadline was exhausted before response read could proceed. + /// The single end-to-end exchange deadline was exhausted before the next operation could proceed. ExchangeDeadlineExceeded { - /// Original caller-supplied deadline budget for the complete write/read exchange. + /// Original caller-supplied deadline budget for the complete exchange. exchange_timeout: Duration, }, - /// The first returned frame was not one complete text message. + /// A server Ping required a fresh client masking key, but the caller supplied none. + PongMaskingKeyUnavailable, + /// The returned frame was neither an admissible control frame nor one complete text response. UnexpectedResponseFrame { /// Whether the returned frame carried the RFC 6455 FIN bit. fin: bool, @@ -53,11 +55,14 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { ), Self::ExchangeDeadlineExceeded { exchange_timeout } => write!( formatter, - "WebDriver BiDi locateNodes exchange exhausted its {exchange_timeout:?} end-to-end deadline before response read" + "WebDriver BiDi locateNodes exchange exhausted its {exchange_timeout:?} end-to-end deadline before the next operation" + ), + Self::PongMaskingKeyUnavailable => formatter.write_str( + "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key", ), Self::UnexpectedResponseFrame { fin, opcode } => write!( formatter, - "WebDriver BiDi locateNodes exchange requires one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" + "WebDriver BiDi locateNodes exchange requires control handling or one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" ), Self::ResponseDocument(error) => write!( formatter, @@ -77,7 +82,9 @@ impl Error for WebDriverBiDiLocateNodesExchangeError { Self::Frame(error) => Some(error), Self::ResponseDocument(error) => Some(error), Self::LocateNodesResponse(error) => Some(error), - Self::ExchangeDeadlineExceeded { .. } | Self::UnexpectedResponseFrame { .. } => None, + Self::ExchangeDeadlineExceeded { .. } + | Self::PongMaskingKeyUnavailable + | Self::UnexpectedResponseFrame { .. } => None, } } } @@ -94,59 +101,88 @@ fn remaining_exchange_budget( } } +fn next_pong_masking_key( + next_key: &mut dyn FnMut() -> Option, +) -> Result { + next_key().ok_or(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) +} + impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// /// The command is serialized by the reviewed core boundary and written as one masked client - /// text frame using the caller-supplied fresh masking key. The first returned server frame must - /// be a complete unmasked text frame (`FIN=1`, opcode `0x1`); continuation, binary, ping, pong, - /// close, and fragmented data fail closed rather than being reinterpreted as a BiDi response. - /// Its exact payload bytes then pass the existing bounded UTF-8/document admission, complete - /// WebDriver BiDi response parser, exact command-id correlation, and wire-derived node admission. + /// text frame using `command_masking_key`. Valid server Ping frames are answered with a masked + /// Pong carrying the exact Ping application data, while unsolicited valid Pong frames are + /// consumed without changing BiDi state. Each Ping obtains a fresh unpredictable client mask + /// from `next_pong_masking_key`; exhausting that caller-owned entropy source fails closed and + /// consumes the transport rather than reusing a masking key. Close, binary, continuation, + /// fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// - /// `exchange_timeout` is one end-to-end budget for the write/read exchange. The bounded write - /// receives that budget first; after it succeeds, elapsed time is subtracted and only the - /// positive remainder is supplied to the bounded read. The budget is never reset between those - /// operations. Any failure consumes this transport state and yields no reusable WebSocket stream, - /// preventing a partially written/read protocol state from being promoted into later authority. + /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, + /// and response read. Elapsed time is subtracted before every subsequent operation and the budget + /// is never reset. The underlying frame boundary independently caps each frame at its existing + /// size ceiling, while the single exchange deadline bounds a peer that sends repeated valid + /// control frames. Any failure consumes this transport state and yields no reusable WebSocket + /// stream, preventing a partially written/read protocol state from becoming later authority. /// - /// Success returns the same exact peer-verified WebSocket stream plus untrusted normalized node - /// evidence. It does not authenticate Chromium/ChromeDriver process provenance, prove current - /// OriginWeave session/context/origin/document authority, authorize policy or typed input, mint - /// node handles, execute a browser action, or prove a post-condition. + /// The final complete text payload passes the existing bounded UTF-8/document admission, + /// complete WebDriver BiDi response parser, exact command-id correlation, and wire-derived node + /// admission. Success returns the same exact peer-verified WebSocket stream plus untrusted + /// normalized node evidence. It does not authenticate Chromium/ChromeDriver process provenance, + /// prove current OriginWeave session/context/origin/document authority, authorize policy or typed + /// input, mint node handles, execute a browser action, or prove a post-condition. pub fn exchange_locate_nodes( self, command: WebDriverBiDiLocateNodesCommand, - masking_key: WebDriverBiDiWebSocketMaskKey, + command_masking_key: WebDriverBiDiWebSocketMaskKey, + mut next_pong_masking_key: impl FnMut() -> Option, exchange_timeout: Duration, ) -> Result< (Self, ValidatedWebDriverBiDiLocateNodesResult), WebDriverBiDiLocateNodesExchangeError, > { let started_at = Instant::now(); - let established = self - .write_text_frame(command.as_json(), masking_key, exchange_timeout) - .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; - let remaining_timeout = remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; - let (established, frame) = established - .read_frame(remaining_timeout) + let mut established = self + .write_text_frame(command.as_json(), command_masking_key, exchange_timeout) .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; - if !frame.fin() || frame.opcode() != 0x1 { - return Err( - WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { - fin: frame.fin(), - opcode: frame.opcode(), - }, - ); - } + loop { + let remaining_timeout = + remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; + let (next_established, frame) = established + .read_frame(remaining_timeout) + .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + established = next_established; - let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(frame.payload()) - .map_err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument)?; - let result = command - .admit_response_document_nodes(document) - .map_err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse)?; - Ok((established, result)) + match frame.opcode() { + 0x9 => { + let masking_key = next_pong_masking_key(&mut next_pong_masking_key)?; + let remaining_timeout = + remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; + established = established + .write_pong_frame(frame.payload(), masking_key, remaining_timeout) + .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + } + 0xa => {} + 0x1 if frame.fin() => { + let document = + BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(frame.payload()) + .map_err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument)?; + let result = command + .admit_response_document_nodes(document) + .map_err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse)?; + return Ok((established, result)); + } + _ => { + return Err( + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: frame.fin(), + opcode: frame.opcode(), + }, + ); + } + } + } } } @@ -160,10 +196,12 @@ mod tests { use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; - use super::{WebDriverBiDiLocateNodesExchangeError, remaining_exchange_budget}; + use super::{ + WebDriverBiDiLocateNodesExchangeError, next_pong_masking_key, remaining_exchange_budget, + }; #[test] - fn exchange_budget_consumes_elapsed_time_instead_of_resetting_for_read() { + fn exchange_budget_consumes_elapsed_time_instead_of_resetting() { let total = Duration::from_millis(500); assert_eq!( format!( @@ -185,6 +223,19 @@ mod tests { ); } + #[test] + fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { + let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let mut available = || Some(expected); + assert_eq!(next_pong_masking_key(&mut available).ok(), Some(expected)); + + let mut unavailable = || None; + assert!(matches!( + next_pong_masking_key(&mut unavailable), + Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) + )); + } + #[test] fn exchange_errors_preserve_typed_sources_and_protocol_shape() { let frame = WebDriverBiDiLocateNodesExchangeError::Frame( @@ -206,6 +257,10 @@ mod tests { assert!(deadline.source().is_none()); assert!(deadline.to_string().contains("end-to-end deadline")); + let missing_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable; + assert!(missing_mask.source().is_none()); + assert!(missing_mask.to_string().contains("fresh caller-supplied Pong masking key")); + let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: false, opcode: 0x2, diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index ee8e896c3..05f1ce576 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -99,7 +99,7 @@ fn locate_nodes_command() -> Result Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; @@ -110,12 +110,13 @@ fn locate_nodes_exchange_answers_interleaved_ping_before_admitting_response( b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", )?; let command = read_masked_client_frame(&mut stream, 0x81)?; - let mut ping_frame = vec![0x89, u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + let ping_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { io::Error::new(io::ErrorKind::InvalidData, "test Ping payload exceeded one-byte length") - })?]; - ping_frame.extend_from_slice(PING_PAYLOAD); - stream.write_all(&ping_frame)?; + })?; + stream.write_all(&[0x89, ping_length])?; + stream.write_all(PING_PAYLOAD)?; let pong = read_masked_client_frame(&mut stream, 0x8a)?; + stream.write_all(&[0x8a, 0])?; let response_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { io::Error::new(io::ErrorKind::InvalidData, "test response exceeded one-byte length") })?; @@ -131,15 +132,17 @@ fn locate_nodes_exchange_answers_interleaved_ping_before_admitting_response( let established = written.read_opening_response(Duration::from_millis(500))?; let command = locate_nodes_command()?; let expected_command = command.as_json().as_bytes().to_vec(); + let mut pong_keys = [WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54])].into_iter(); let exchanged = established.exchange_locate_nodes( command, WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + || pong_keys.next(), Duration::from_millis(500), ); let server_result = server .join() - .map_err(|_| io::Error::other("interleaved Ping test server panicked"))?; + .map_err(|_| io::Error::other("interleaved control-frame test server panicked"))?; assert!(exchanged.is_ok(), "{exchanged:?}"); assert!(server_result.is_ok(), "{server_result:?}"); let (received_command, received_pong) = server_result?; diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index e2383c765..62e4e95bb 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -177,6 +177,7 @@ fn exchange_error( let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + || None, frame_timeout, ); assert!(error.is_err(), "{error:?}"); @@ -224,6 +225,7 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r let exchanged = established.exchange_locate_nodes( command, WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + || None, Duration::from_millis(500), ); assert!(exchanged.is_ok(), "{exchanged:?}"); @@ -277,6 +279,7 @@ fn exchange_deadline_is_not_reset_after_the_frame_write() { let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + || None, Duration::from_micros(20), ); assert!(error.is_err(), "{error:?}"); @@ -285,7 +288,7 @@ fn exchange_deadline_is_not_reset_after_the_frame_write() { }; assert_eq!( error.to_string(), - "WebDriver BiDi locateNodes exchange exhausted its 20µs end-to-end deadline before response read" + "WebDriver BiDi locateNodes exchange exhausted its 20µs end-to-end deadline before the next operation" ); let server_result = server.join(); @@ -311,6 +314,7 @@ fn exchange_rejects_a_non_final_or_non_text_response_frame() { let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + || None, Duration::from_millis(500), ); assert!(error.is_err(), "{error:?}"); From 10649054d7dd287038bbf9f2d72b3be216523a7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:48:46 -0700 Subject: [PATCH 052/211] fix(network): repair Pong exchange build contract --- .../webdriver_bidi_locate_nodes_exchange.rs | 12 ++++++++---- ...river_bidi_locate_nodes_ping_interleave.rs | 19 ++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index a28a84584..c6b38dc57 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -114,7 +114,7 @@ impl WebDriverBiDiWebSocketEstablished { /// text frame using `command_masking_key`. Valid server Ping frames are answered with a masked /// Pong carrying the exact Ping application data, while unsolicited valid Pong frames are /// consumed without changing BiDi state. Each Ping obtains a fresh unpredictable client mask - /// from `next_pong_masking_key`; exhausting that caller-owned entropy source fails closed and + /// from `next_pong_key`; exhausting that caller-owned entropy source fails closed and /// consumes the transport rather than reusing a masking key. Close, binary, continuation, /// fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// @@ -135,7 +135,7 @@ impl WebDriverBiDiWebSocketEstablished { self, command: WebDriverBiDiLocateNodesCommand, command_masking_key: WebDriverBiDiWebSocketMaskKey, - mut next_pong_masking_key: impl FnMut() -> Option, + mut next_pong_key: impl FnMut() -> Option, exchange_timeout: Duration, ) -> Result< (Self, ValidatedWebDriverBiDiLocateNodesResult), @@ -156,7 +156,7 @@ impl WebDriverBiDiWebSocketEstablished { match frame.opcode() { 0x9 => { - let masking_key = next_pong_masking_key(&mut next_pong_masking_key)?; + let masking_key = next_pong_masking_key(&mut next_pong_key)?; let remaining_timeout = remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; established = established @@ -259,7 +259,11 @@ mod tests { let missing_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable; assert!(missing_mask.source().is_none()); - assert!(missing_mask.to_string().contains("fresh caller-supplied Pong masking key")); + assert!( + missing_mask + .to_string() + .contains("fresh caller-supplied Pong masking key") + ); let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: false, diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 05f1ce576..006efcb72 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -48,7 +48,10 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn read_masked_client_frame(stream: &mut TcpStream, expected_first_byte: u8) -> io::Result> { +fn read_masked_client_frame( + stream: &mut TcpStream, + expected_first_byte: u8, +) -> io::Result> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; let mut header = [0_u8; 2]; stream.read_exact(&mut header)?; @@ -99,8 +102,8 @@ fn locate_nodes_command() -> Result Result<(), Box> { +fn locate_nodes_exchange_answers_ping_and_ignores_unsolicited_pong_before_response() +-> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<(Vec, Vec)> { @@ -111,14 +114,20 @@ fn locate_nodes_exchange_answers_ping_and_ignores_unsolicited_pong_before_respon )?; let command = read_masked_client_frame(&mut stream, 0x81)?; let ping_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "test Ping payload exceeded one-byte length") + io::Error::new( + io::ErrorKind::InvalidData, + "test Ping payload exceeded one-byte length", + ) })?; stream.write_all(&[0x89, ping_length])?; stream.write_all(PING_PAYLOAD)?; let pong = read_masked_client_frame(&mut stream, 0x8a)?; stream.write_all(&[0x8a, 0])?; let response_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "test response exceeded one-byte length") + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeded one-byte length", + ) })?; stream.write_all(&[0x81, response_length])?; stream.write_all(RESPONSE_DOCUMENT.as_bytes())?; From 4c5e51db0f22c3c366a87d18439db74e54098e28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:20:24 -0700 Subject: [PATCH 053/211] test(network): require non-generic locateNodes entropy callback --- ...driver_bidi_locate_nodes_coverage_shape.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/test_webdriver_bidi_locate_nodes_coverage_shape.py diff --git a/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py new file mode 100644 index 000000000..18eec0d6d --- /dev/null +++ b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py @@ -0,0 +1,42 @@ +"""Coverage-shape contract for the bounded BiDi locateNodes exchange boundary. + +The production coverage gate is exact across functions, lines, regions, and branches. +Keeping the caller-supplied Pong entropy callback generic monomorphizes the whole +exchange function per closure type, which creates synthetic per-instantiation +coverage holes despite exercising the protocol paths. The callback is therefore a +borrowed trait object at this boundary: its behavior remains stateful and caller +owned without multiplying production coverage regions. +""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE = ( + ROOT + / "crates" + / "originweave-network" + / "src" + / "webdriver_bidi_locate_nodes_exchange.rs" +) + + +class WebDriverBiDiLocateNodesCoverageShapeTests(unittest.TestCase): + """Prevent callback monomorphization from invalidating exact coverage evidence.""" + + def test_pong_entropy_callback_is_non_generic_at_exchange_boundary(self) -> None: + source = SOURCE.read_text(encoding="utf-8") + self.assertIn( + "next_pong_key: &mut dyn FnMut() -> Option", + source, + ) + self.assertNotIn( + "next_pong_key: impl FnMut() -> Option", + source, + ) + + +if __name__ == "__main__": + unittest.main() From 3801e904ac71d49b0b55557c34092d15677b17f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:04:44 -0700 Subject: [PATCH 054/211] fix(network): erase locateNodes entropy callback type --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index c6b38dc57..0e38d97ff 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -135,7 +135,7 @@ impl WebDriverBiDiWebSocketEstablished { self, command: WebDriverBiDiLocateNodesCommand, command_masking_key: WebDriverBiDiWebSocketMaskKey, - mut next_pong_key: impl FnMut() -> Option, + next_pong_key: &mut dyn FnMut() -> Option, exchange_timeout: Duration, ) -> Result< (Self, ValidatedWebDriverBiDiLocateNodesResult), @@ -156,7 +156,7 @@ impl WebDriverBiDiWebSocketEstablished { match frame.opcode() { 0x9 => { - let masking_key = next_pong_masking_key(&mut next_pong_key)?; + let masking_key = next_pong_masking_key(next_pong_key)?; let remaining_timeout = remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; established = established From 3a9aa8b36c1dae7042302960652dc337f0bd73a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:05:58 -0700 Subject: [PATCH 055/211] test(network): borrow locateNodes entropy callbacks --- .../webdriver_bidi_websocket_locate_nodes_exchange.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index 62e4e95bb..7df73abdf 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -177,7 +177,7 @@ fn exchange_error( let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - || None, + &mut || None, frame_timeout, ); assert!(error.is_err(), "{error:?}"); @@ -225,7 +225,7 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r let exchanged = established.exchange_locate_nodes( command, WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - || None, + &mut || None, Duration::from_millis(500), ); assert!(exchanged.is_ok(), "{exchanged:?}"); @@ -279,7 +279,7 @@ fn exchange_deadline_is_not_reset_after_the_frame_write() { let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - || None, + &mut || None, Duration::from_micros(20), ); assert!(error.is_err(), "{error:?}"); @@ -314,7 +314,7 @@ fn exchange_rejects_a_non_final_or_non_text_response_frame() { let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - || None, + &mut || None, Duration::from_millis(500), ); assert!(error.is_err(), "{error:?}"); From 06018f2db4058c6930704a4107b1b07934df8155 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:06:42 -0700 Subject: [PATCH 056/211] test(network): borrow Ping entropy callback --- .../tests/webdriver_bidi_locate_nodes_ping_interleave.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 006efcb72..333958705 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -145,7 +145,7 @@ fn locate_nodes_exchange_answers_ping_and_ignores_unsolicited_pong_before_respon let exchanged = established.exchange_locate_nodes( command, WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - || pong_keys.next(), + &mut || pong_keys.next(), Duration::from_millis(500), ); From 3b3218aff6f396d23677051bfffe02489a08f43f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:19:39 -0700 Subject: [PATCH 057/211] 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 cd2ca572701f666fcbb29bc104ccfe59dd383379 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:24:32 -0700 Subject: [PATCH 058/211] test(network): accept repeated WebSocket list fields --- ...bdriver_bidi_websocket_repeated_headers.rs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs new file mode 100644 index 000000000..9dd35c80f --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs @@ -0,0 +1,109 @@ +use std::{ + io::{self, Read, Write}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketHandshakeResponseError, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RFC6455_SAMPLE_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; + +fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { + let endpoint = WebDriverBiDiWebSocketEndpoint::new(endpoint).expect("valid loopback endpoint"); + let correlated = endpoint + .correlate_session_id(SESSION_ID) + .expect("matching session id"); + let target = correlated + .into_explicit_connect_target() + .expect("explicit loopback target"); + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("bounded connection plan") + .connect() + .expect("loopback connection") +} + +fn read_opening_request(stream: &mut std::net::TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 256]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "opening request ended before headers", + )); + } + Ok(()) +} + +fn exercise_response(response: Vec) -> Result<(), WebDriverBiDiWebSocketHandshakeResponseError> { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback fixture"); + let local_addr = listener.local_addr().expect("fixture address"); + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(&response)?; + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY).expect("valid RFC key"); + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key) + .expect("opening handshake plan"); + let written = plan + .write_opening_request(Duration::from_millis(500)) + .expect("opening request write"); + let result = written + .read_opening_response(Duration::from_millis(500)) + .map(|established| { + drop(established); + }); + assert!(server.join().expect("fixture thread join").is_ok()); + result +} + +#[test] +fn repeated_list_valued_upgrade_and_connection_lines_are_combined_semantically() { + let response = format!( + "HTTP/1.1 101 Switching Protocols\r\n\ +Upgrade: h2c\r\n\ +Upgrade: websocket\r\n\ +Connection: keep-alive\r\n\ +Connection: Upgrade\r\n\ +Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" + ) + .into_bytes(); + + let result = exercise_response(response); + assert!(result.is_ok(), "RFC 9110 list-valued fields must combine: {result:?}"); +} + +#[test] +fn repeated_sec_websocket_accept_remains_fail_closed() { + let response = format!( + "HTTP/1.1 101 Switching Protocols\r\n\ +Upgrade: websocket\r\n\ +Connection: Upgrade\r\n\ +Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\ +Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" + ) + .into_bytes(); + + assert!(matches!( + exercise_response(response), + Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) + )); +} From 305a40cfc7d5d755a4c766529a5ab9f11f3531e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:04:53 -0700 Subject: [PATCH 059/211] 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 75147b6a36f4bc33fbf504275354c35b41da7ae5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:13:40 -0700 Subject: [PATCH 060/211] fix(network): combine repeated WebSocket list fields --- .../src/webdriver_bidi_websocket_handshake.rs | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 9aacf0e02..3c826e626 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -581,8 +581,8 @@ fn parse_opening_response( ); } - let mut upgrade = None; - let mut connection = None; + 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() @@ -618,23 +618,9 @@ fn parse_opening_response( ); } if name.eq_ignore_ascii_case("upgrade") { - if upgrade.is_some() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response repeats the Upgrade header", - }, - ); - } - upgrade = Some(value); + upgrade_has_websocket |= has_header_token(value, "websocket"); } else if name.eq_ignore_ascii_case("connection") { - if connection.is_some() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response repeats the Connection header", - }, - ); - } - connection = Some(value); + connection_has_upgrade |= has_header_token(value, "upgrade"); } else if name.eq_ignore_ascii_case("sec-websocket-accept") { if accept.is_some() { return Err( @@ -647,14 +633,14 @@ fn parse_opening_response( } } - if !upgrade.is_some_and(|value| has_header_token(value, "websocket")) { + if !upgrade_has_websocket { return Err( WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "Upgrade header does not contain websocket", }, ); } - if !connection.is_some_and(|value| has_header_token(value, "upgrade")) { + if !connection_has_upgrade { return Err( WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "Connection header does not contain Upgrade", From 20830e9cfee9947c16c8056f801091891cda44a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:14:05 -0700 Subject: [PATCH 061/211] style(network): apply canonical rustfmt --- .../tests/webdriver_bidi_websocket_repeated_headers.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs index 9dd35c80f..89f1228af 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs @@ -49,7 +49,9 @@ fn read_opening_request(stream: &mut std::net::TcpStream) -> io::Result<()> { Ok(()) } -fn exercise_response(response: Vec) -> Result<(), WebDriverBiDiWebSocketHandshakeResponseError> { +fn exercise_response( + response: Vec, +) -> Result<(), WebDriverBiDiWebSocketHandshakeResponseError> { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback fixture"); let local_addr = listener.local_addr().expect("fixture address"); let server = thread::spawn(move || -> io::Result<()> { @@ -88,7 +90,10 @@ Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" .into_bytes(); let result = exercise_response(response); - assert!(result.is_ok(), "RFC 9110 list-valued fields must combine: {result:?}"); + assert!( + result.is_ok(), + "RFC 9110 list-valued fields must combine: {result:?}" + ); } #[test] From edd1107a2884d750afd31f9f37a938baced737dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:18:22 -0700 Subject: [PATCH 062/211] test(network): satisfy strict result handling --- ...bdriver_bidi_websocket_repeated_headers.rs | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs index 89f1228af..a90fe1d19 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs @@ -1,4 +1,5 @@ use std::{ + error::Error, io::{self, Read, Write}, net::TcpListener, thread, @@ -15,18 +16,15 @@ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const RFC6455_SAMPLE_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; -fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { - let endpoint = WebDriverBiDiWebSocketEndpoint::new(endpoint).expect("valid loopback endpoint"); - let correlated = endpoint - .correlate_session_id(SESSION_ID) - .expect("matching session id"); - let target = correlated - .into_explicit_connect_target() - .expect("explicit loopback target"); - WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) - .expect("bounded connection plan") - .connect() - .expect("loopback connection") +type TestResult = Result>; + +fn connect(endpoint: &str) -> TestResult { + let endpoint = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = endpoint.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + Ok(connection) } fn read_opening_request(stream: &mut std::net::TcpStream) -> io::Result<()> { @@ -51,9 +49,9 @@ fn read_opening_request(stream: &mut std::net::TcpStream) -> io::Result<()> { fn exercise_response( response: Vec, -) -> Result<(), WebDriverBiDiWebSocketHandshakeResponseError> { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback fixture"); - let local_addr = listener.local_addr().expect("fixture address"); +) -> TestResult> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; @@ -62,23 +60,23 @@ fn exercise_response( }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY).expect("valid RFC key"); - let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key) - .expect("opening handshake plan"); - let written = plan - .write_opening_request(Duration::from_millis(500)) - .expect("opening request write"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; let result = written .read_opening_response(Duration::from_millis(500)) .map(|established| { drop(established); }); - assert!(server.join().expect("fixture thread join").is_ok()); - result + match server.join() { + Ok(server_result) => server_result?, + Err(_) => return Err(io::Error::other("loopback fixture thread panicked").into()), + } + Ok(result) } #[test] -fn repeated_list_valued_upgrade_and_connection_lines_are_combined_semantically() { +fn repeated_list_valued_upgrade_and_connection_lines_are_combined_semantically() -> TestResult<()> { let response = format!( "HTTP/1.1 101 Switching Protocols\r\n\ Upgrade: h2c\r\n\ @@ -89,15 +87,16 @@ Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" ) .into_bytes(); - let result = exercise_response(response); + let result = exercise_response(response)?; assert!( result.is_ok(), "RFC 9110 list-valued fields must combine: {result:?}" ); + Ok(()) } #[test] -fn repeated_sec_websocket_accept_remains_fail_closed() { +fn repeated_sec_websocket_accept_remains_fail_closed() -> TestResult<()> { let response = format!( "HTTP/1.1 101 Switching Protocols\r\n\ Upgrade: websocket\r\n\ @@ -108,7 +107,8 @@ Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" .into_bytes(); assert!(matches!( - exercise_response(response), + exercise_response(response)?, Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) )); + Ok(()) } From 3a3a2c5a9ed33031be5a7a6dcc5cd912a28d2f07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:12:38 -0700 Subject: [PATCH 063/211] 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 946971ecd29070779cbca7a5b59379fd9ef8384e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:38:33 -0700 Subject: [PATCH 064/211] test(network): cover Ping failure exits --- .../webdriver_bidi_locate_nodes_exchange.rs | 217 +++++++++++++++++- 1 file changed, 209 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 0e38d97ff..00298e7c6 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -188,18 +188,147 @@ impl WebDriverBiDiWebSocketEstablished { #[cfg(test)] mod tests { - use std::{error::Error as _, time::Duration}; + use std::{ + error::Error as _, + io::{self, Read, Write}, + net::{Shutdown, TcpListener, TcpStream}, + thread, + time::Duration, + }; use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, + WebDriverBiDiWebSocketEndpoint, }; - use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; + use crate::{ + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + }; use super::{ - WebDriverBiDiLocateNodesExchangeError, next_pong_masking_key, remaining_exchange_budget, + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiWebSocketEstablished, + next_pong_masking_key, remaining_exchange_budget, }; + const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + const PING_PAYLOAD: &[u8] = b"x"; + + fn connect( + endpoint: &str, + ) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) + } + + fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) + } + + fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client command was not one final masked text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "client command length overflowed") + })? + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) + } + + fn locate_nodes_command( + ) -> Result> { + let query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) + } + + fn establish_with_ping( + keep_open: Duration, + ) -> Result< + ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, + ), + Box, + > { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + stream.write_all(&[0x89, PING_PAYLOAD.len() as u8])?; + stream.write_all(PING_PAYLOAD)?; + thread::sleep(keep_open); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) + } + + fn join_server( + server: thread::JoinHandle>, + ) -> Result<(), Box> { + let result = server + .join() + .map_err(|_| io::Error::other("Ping failure test server panicked"))?; + Ok(result?) + } + #[test] fn exchange_budget_consumes_elapsed_time_instead_of_resetting() { let total = Duration::from_millis(500); @@ -225,15 +354,87 @@ mod tests { #[test] fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { - let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let expected = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); let mut available = || Some(expected); assert_eq!(next_pong_masking_key(&mut available).ok(), Some(expected)); let mut unavailable = || None; - assert!(matches!( - next_pong_masking_key(&mut unavailable), - Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) - )); + let unavailable_error = next_pong_masking_key(&mut unavailable); + assert_eq!( + format!("{unavailable_error:?}"), + "Err(PongMaskingKeyUnavailable)" + ); + } + + #[test] + fn ping_exchange_fails_closed_when_pong_entropy_is_unavailable( + ) -> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(100))?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_secs(1), + ); + let error = exchanged.err().ok_or_else(|| { + io::Error::other("Ping without caller entropy unexpectedly succeeded") + })?; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key" + ); + join_server(server) + } + + #[test] + fn ping_exchange_charges_entropy_callback_time_to_the_end_to_end_deadline( + ) -> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(650))?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + thread::sleep(Duration::from_millis(550)); + Some(pong_key) + }, + Duration::from_millis(500), + ); + let error = exchanged.err().ok_or_else(|| { + io::Error::other("slow Pong entropy callback unexpectedly reset the exchange deadline") + })?; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange exhausted its 500ms end-to-end deadline before the next operation" + ); + join_server(server) + } + + #[test] + fn ping_exchange_preserves_pong_write_failure_as_the_first_causal_error( + ) -> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(100))?; + let shutdown_stream = established.stream.try_clone()?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + assert!(shutdown_stream.shutdown(Shutdown::Both).is_ok()); + Some(pong_key) + }, + Duration::from_secs(1), + ); + let error = exchanged + .err() + .ok_or_else(|| io::Error::other("revoked Pong stream unexpectedly remained usable"))?; + assert!( + error + .to_string() + .starts_with("WebDriver BiDi locateNodes WebSocket frame exchange failed:") + ); + assert!(error.source().is_some()); + join_server(server) } #[test] From 66eb0193ff97873bd8002fadad6d8a6465aa2022 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:47:21 -0700 Subject: [PATCH 065/211] test(network): keep transport helpers outside production coverage --- .../webdriver_bidi_locate_nodes_exchange.rs | 217 +----------------- 1 file changed, 8 insertions(+), 209 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 00298e7c6..0e38d97ff 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -188,147 +188,18 @@ impl WebDriverBiDiWebSocketEstablished { #[cfg(test)] mod tests { - use std::{ - error::Error as _, - io::{self, Read, Write}, - net::{Shutdown, TcpListener, TcpStream}, - thread, - time::Duration, - }; + use std::{error::Error as _, time::Duration}; use originweave_core::{ - WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, - WebDriverBiDiWebSocketEndpoint, }; - use crate::{ - MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketFrameError, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, - }; + use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; use super::{ - WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiWebSocketEstablished, - next_pong_masking_key, remaining_exchange_budget, + WebDriverBiDiLocateNodesExchangeError, next_pong_masking_key, remaining_exchange_budget, }; - const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; - const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; - const PING_PAYLOAD: &[u8] = b"x"; - - fn connect( - endpoint: &str, - ) -> Result> { - let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; - let correlated = admitted.correlate_session_id(SESSION_ID)?; - let target = correlated.into_explicit_connect_target()?; - let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; - Ok(plan.connect()?) - } - - fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { - stream.set_read_timeout(Some(Duration::from_secs(2)))?; - let mut request = Vec::new(); - let mut buffer = [0_u8; 512]; - while !request.ends_with(b"\r\n\r\n") { - let count = stream.read(&mut buffer)?; - if count == 0 { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "client opening request ended before the header terminator", - )); - } - request.extend_from_slice(&buffer[..count]); - } - Ok(()) - } - - fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { - stream.set_read_timeout(Some(Duration::from_secs(2)))?; - let mut header = [0_u8; 2]; - stream.read_exact(&mut header)?; - if header[0] != 0x81 || header[1] & 0x80 == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "client command was not one final masked text frame", - )); - } - let payload_length = match header[1] & 0x7f { - value @ 0..=125 => usize::from(value), - 126 => { - let mut extended = [0_u8; 2]; - stream.read_exact(&mut extended)?; - usize::from(u16::from_be_bytes(extended)) - } - 127 => { - let mut extended = [0_u8; 8]; - stream.read_exact(&mut extended)?; - usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "client command length overflowed") - })? - } - _ => unreachable!("7-bit WebSocket payload marker"), - }; - let mut mask = [0_u8; 4]; - stream.read_exact(&mut mask)?; - let mut payload = vec![0_u8; payload_length]; - stream.read_exact(&mut payload)?; - Ok(()) - } - - fn locate_nodes_command( - ) -> Result> { - let query = - WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; - Ok(WebDriverBiDiLocateNodesCommand::new( - 7, - "top-level-context", - &query, - )?) - } - - fn establish_with_ping( - keep_open: Duration, - ) -> Result< - ( - WebDriverBiDiWebSocketEstablished, - thread::JoinHandle>, - ), - Box, - > { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let local_addr = listener.local_addr()?; - let server = thread::spawn(move || -> io::Result<()> { - let (mut stream, _) = listener.accept()?; - read_opening_request(&mut stream)?; - stream.write_all( - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", - )?; - read_masked_client_text_frame(&mut stream)?; - stream.write_all(&[0x89, PING_PAYLOAD.len() as u8])?; - stream.write_all(PING_PAYLOAD)?; - thread::sleep(keep_open); - Ok(()) - }); - - let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; - let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; - let written = plan.write_opening_request(Duration::from_millis(500))?; - let established = written.read_opening_response(Duration::from_millis(500))?; - Ok((established, server)) - } - - fn join_server( - server: thread::JoinHandle>, - ) -> Result<(), Box> { - let result = server - .join() - .map_err(|_| io::Error::other("Ping failure test server panicked"))?; - Ok(result?) - } - #[test] fn exchange_budget_consumes_elapsed_time_instead_of_resetting() { let total = Duration::from_millis(500); @@ -354,87 +225,15 @@ mod tests { #[test] fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { - let expected = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); let mut available = || Some(expected); assert_eq!(next_pong_masking_key(&mut available).ok(), Some(expected)); let mut unavailable = || None; - let unavailable_error = next_pong_masking_key(&mut unavailable); - assert_eq!( - format!("{unavailable_error:?}"), - "Err(PongMaskingKeyUnavailable)" - ); - } - - #[test] - fn ping_exchange_fails_closed_when_pong_entropy_is_unavailable( - ) -> Result<(), Box> { - let (established, server) = establish_with_ping(Duration::from_millis(100))?; - let exchanged = established.exchange_locate_nodes( - locate_nodes_command()?, - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - &mut || None, - Duration::from_secs(1), - ); - let error = exchanged.err().ok_or_else(|| { - io::Error::other("Ping without caller entropy unexpectedly succeeded") - })?; - assert_eq!( - error.to_string(), - "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key" - ); - join_server(server) - } - - #[test] - fn ping_exchange_charges_entropy_callback_time_to_the_end_to_end_deadline( - ) -> Result<(), Box> { - let (established, server) = establish_with_ping(Duration::from_millis(650))?; - let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); - let exchanged = established.exchange_locate_nodes( - locate_nodes_command()?, - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - &mut || { - thread::sleep(Duration::from_millis(550)); - Some(pong_key) - }, - Duration::from_millis(500), - ); - let error = exchanged.err().ok_or_else(|| { - io::Error::other("slow Pong entropy callback unexpectedly reset the exchange deadline") - })?; - assert_eq!( - error.to_string(), - "WebDriver BiDi locateNodes exchange exhausted its 500ms end-to-end deadline before the next operation" - ); - join_server(server) - } - - #[test] - fn ping_exchange_preserves_pong_write_failure_as_the_first_causal_error( - ) -> Result<(), Box> { - let (established, server) = establish_with_ping(Duration::from_millis(100))?; - let shutdown_stream = established.stream.try_clone()?; - let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); - let exchanged = established.exchange_locate_nodes( - locate_nodes_command()?, - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - &mut || { - assert!(shutdown_stream.shutdown(Shutdown::Both).is_ok()); - Some(pong_key) - }, - Duration::from_secs(1), - ); - let error = exchanged - .err() - .ok_or_else(|| io::Error::other("revoked Pong stream unexpectedly remained usable"))?; - assert!( - error - .to_string() - .starts_with("WebDriver BiDi locateNodes WebSocket frame exchange failed:") - ); - assert!(error.source().is_some()); - join_server(server) + assert!(matches!( + next_pong_masking_key(&mut unavailable), + Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) + )); } #[test] From a507fed5c3d838fc409bfcb94c8c54d6819ba573 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:48:05 -0700 Subject: [PATCH 066/211] test(network): exercise Ping failure boundaries externally --- ...river_bidi_locate_nodes_ping_interleave.rs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 333958705..656dea145 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -101,6 +101,51 @@ fn locate_nodes_command() -> Result Result< + ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, + ), + Box, +> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_frame(&mut stream, 0x81)?; + let ping_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test Ping payload exceeded one-byte length", + ) + })?; + stream.write_all(&[0x89, ping_length])?; + stream.write_all(PING_PAYLOAD)?; + thread::sleep(keep_open); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn join_ping_server(server: thread::JoinHandle>) -> Result<(), Box> { + let result = server + .join() + .map_err(|_| io::Error::other("Ping failure test server panicked"))?; + Ok(result?) +} + #[test] fn locate_nodes_exchange_answers_ping_and_ignores_unsolicited_pong_before_response() -> Result<(), Box> { @@ -171,3 +216,49 @@ fn locate_nodes_exchange_answers_ping_and_ignores_unsolicited_pong_before_respon ); Ok(()) } + +#[test] +fn locate_nodes_exchange_fails_closed_when_ping_entropy_is_unavailable() +-> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(100))?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + ); + + let error = exchanged + .err() + .ok_or_else(|| io::Error::other("Ping without masking entropy unexpectedly succeeded"))?; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key" + ); + join_ping_server(server) +} + +#[test] +fn locate_nodes_exchange_charges_ping_callback_time_to_exchange_deadline() +-> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(650))?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + thread::sleep(Duration::from_millis(550)); + Some(pong_key) + }, + Duration::from_millis(500), + ); + + let error = exchanged + .err() + .ok_or_else(|| io::Error::other("slow Ping callback unexpectedly reset the exchange deadline"))?; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange exhausted its 500ms end-to-end deadline before the next operation" + ); + join_ping_server(server) +} From faffcab0047ba7402e52316b6ffc4fc02ef53afa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:06:53 -0700 Subject: [PATCH 067/211] test(network): format BiDi ping deadline regression --- .../tests/webdriver_bidi_locate_nodes_ping_interleave.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 656dea145..7a53d2c78 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -253,9 +253,9 @@ fn locate_nodes_exchange_charges_ping_callback_time_to_exchange_deadline() Duration::from_millis(500), ); - let error = exchanged - .err() - .ok_or_else(|| io::Error::other("slow Ping callback unexpectedly reset the exchange deadline"))?; + let error = exchanged.err().ok_or_else(|| { + io::Error::other("slow Ping callback unexpectedly reset the exchange deadline") + })?; assert_eq!( error.to_string(), "WebDriver BiDi locateNodes exchange exhausted its 500ms end-to-end deadline before the next operation" From 60385f419209a98ddb1f0ab8719b4478bf8d160c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:09:24 -0700 Subject: [PATCH 068/211] test(network): factor BiDi ping fixture type --- ...webdriver_bidi_locate_nodes_ping_interleave.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 7a53d2c78..1c69aec40 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -21,6 +21,11 @@ const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; const PING_PAYLOAD: &[u8] = b"keepalive"; +type EstablishedPingServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + fn connect( endpoint: &str, ) -> Result> { @@ -101,15 +106,7 @@ fn locate_nodes_command() -> Result Result< - ( - originweave_network::WebDriverBiDiWebSocketEstablished, - thread::JoinHandle>, - ), - Box, -> { +fn establish_with_ping(keep_open: Duration) -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { From 0bad232930ab0ec0f9b0b17bb186a50c7295741b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:10:33 -0700 Subject: [PATCH 069/211] test(network): close exact BiDi exchange coverage gaps --- .../webdriver_bidi_locate_nodes_exchange.rs | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 0e38d97ff..dcfed72b9 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -107,6 +107,12 @@ fn next_pong_masking_key( next_key().ok_or(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) } +fn map_established_frame_result( + result: Result, +) -> Result { + result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) +} + impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// @@ -142,9 +148,11 @@ impl WebDriverBiDiWebSocketEstablished { WebDriverBiDiLocateNodesExchangeError, > { let started_at = Instant::now(); - let mut established = self - .write_text_frame(command.as_json(), command_masking_key, exchange_timeout) - .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + let mut established = map_established_frame_result(self.write_text_frame( + command.as_json(), + command_masking_key, + exchange_timeout, + ))?; loop { let remaining_timeout = @@ -159,9 +167,11 @@ impl WebDriverBiDiWebSocketEstablished { let masking_key = next_pong_masking_key(next_pong_key)?; let remaining_timeout = remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; - established = established - .write_pong_frame(frame.payload(), masking_key, remaining_timeout) - .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + established = map_established_frame_result(established.write_pong_frame( + frame.payload(), + masking_key, + remaining_timeout, + ))?; } 0xa => {} 0x1 if frame.fin() => { @@ -230,10 +240,10 @@ mod tests { assert_eq!(next_pong_masking_key(&mut available).ok(), Some(expected)); let mut unavailable = || None; - assert!(matches!( - next_pong_masking_key(&mut unavailable), - Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) - )); + assert_eq!( + format!("{:?}", next_pong_masking_key(&mut unavailable)), + "Err(PongMaskingKeyUnavailable)" + ); } #[test] From 7d7c7d8e6dd25d88418f0c86998b51a5b7f34f31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:09:50 -0700 Subject: [PATCH 070/211] test(network): cover locateNodes Pong write failure --- ..._nodes_exchange_transport_failure_tests.rs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs new file mode 100644 index 000000000..faccb4420 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -0,0 +1,153 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{Shutdown, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; + +use crate::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnection, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn connect(endpoint: &str) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client command was not one final masked text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => u64::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + u64::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + u64::from_be_bytes(extended) + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut remaining = usize::try_from(payload_length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client command length cannot fit this test process", + ) + })?; + let mut buffer = [0_u8; 512]; + while remaining != 0 { + let chunk = remaining.min(buffer.len()); + stream.read_exact(&mut buffer[..chunk])?; + remaining -= chunk; + } + Ok(()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +#[test] +fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + stream.write_all(&[0x89, 0])?; + thread::sleep(Duration::from_millis(250)); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let shutdown_stream = established.stream.try_clone()?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + let shutdown = shutdown_stream.shutdown(Shutdown::Write); + assert!(shutdown.is_ok(), "{shutdown:?}"); + Some(pong_key) + }, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("Pong write failure test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("locateNodes exchange unexpectedly survived a closed client write half") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { .. } + ) + ), + "{error:?}" + ); + assert!(error.source().is_some()); + Ok(()) +} From ad6659be2e57307225883c37866399939fa18f05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:10:31 -0700 Subject: [PATCH 071/211] test(network): wire Pong write failure regression --- crates/originweave-network/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index a09dae0d3..f84d440c3 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -17,6 +17,8 @@ mod connection; mod webdriver_bidi_connection; mod webdriver_bidi_locate_nodes_exchange; +#[cfg(test)] +mod webdriver_bidi_locate_nodes_exchange_transport_failure_tests; mod webdriver_bidi_websocket_control; mod webdriver_bidi_websocket_handshake; From f48f4dd9a61324e3c7bc575b33a1a955231919cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:14:32 -0700 Subject: [PATCH 072/211] test(network): make client frame parser exhaustive --- ...driver_bidi_locate_nodes_exchange_transport_failure_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index faccb4420..14c5cad2a 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -64,7 +64,7 @@ fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { stream.read_exact(&mut extended)?; u64::from(u16::from_be_bytes(extended)) } - 127 => { + _ => { let mut extended = [0_u8; 8]; stream.read_exact(&mut extended)?; u64::from_be_bytes(extended) From c6daa6da49c5574b6d76f41198f8c4ca9dc939e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:28:25 -0700 Subject: [PATCH 073/211] test(network): cover public locateNodes error sources --- ...di_locate_nodes_exchange_error_contract.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_error_contract.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_error_contract.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_error_contract.rs new file mode 100644 index 000000000..f2ed6acfd --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_error_contract.rs @@ -0,0 +1,44 @@ +use std::{error::Error as _, time::Duration}; + +use originweave_core::{ + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, +}; +use originweave_network::{ + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiLocateNodesExchangeError, + WebDriverBiDiWebSocketFrameError, +}; + +#[test] +fn downstream_callers_observe_exact_exchange_error_sources() { + let frame = WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + ); + assert!(frame.source().is_some()); + + let document = WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8, + ); + assert!(document.source().is_some()); + + let response = WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ); + assert!(response.source().is_some()); + + let source_free_errors = [ + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout: Duration::from_millis(500), + }, + WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable, + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: false, + opcode: 0x2, + }, + ]; + for error in source_free_errors { + assert!(error.source().is_none(), "{error:?}"); + } +} From 623f1cbdac5b8881e1142a092619ad3b7e45a703 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 14:20:25 -0700 Subject: [PATCH 074/211] test(network): cover locateNodes Pong write failure downstream --- ...er_bidi_locate_nodes_pong_write_failure.rs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_pong_write_failure.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_pong_write_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_pong_write_failure.rs new file mode 100644 index 000000000..ec5f8a915 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_pong_write_failure.rs @@ -0,0 +1,153 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{Shutdown, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnection, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn connect(endpoint: &str) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client command was not one final masked text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => u64::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + u64::from(u16::from_be_bytes(extended)) + } + _ => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + u64::from_be_bytes(extended) + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut remaining = usize::try_from(payload_length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client command length cannot fit this test process", + ) + })?; + let mut buffer = [0_u8; 512]; + while remaining != 0 { + let chunk = remaining.min(buffer.len()); + stream.read_exact(&mut buffer[..chunk])?; + remaining -= chunk; + } + Ok(()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +#[test] +fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + stream.write_all(&[0x89, 0])?; + thread::sleep(Duration::from_millis(250)); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let connection = connect(&endpoint)?; + let shutdown_stream = connection.stream().try_clone()?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + let shutdown = shutdown_stream.shutdown(Shutdown::Write); + assert!(shutdown.is_ok(), "{shutdown:?}"); + Some(pong_key) + }, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("Pong write failure test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("locateNodes exchange unexpectedly survived a closed client write half") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { .. } + ) + ), + "{error:?}" + ); + assert!(error.source().is_some()); + Ok(()) +} From b84bebb7d902d3dd4204f4e69c5b133e439fdddb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:10:05 -0700 Subject: [PATCH 075/211] test(network): bound locateNodes control-frame interleave --- ...river_bidi_locate_nodes_ping_interleave.rs | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 1c69aec40..59d77b4a5 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -11,8 +11,9 @@ use originweave_core::{ WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -259,3 +260,60 @@ fn locate_nodes_exchange_charges_ping_callback_time_to_exchange_deadline() ); join_ping_server(server) } + +#[test] +fn locate_nodes_exchange_bounds_valid_interleaved_control_frames() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_frame(&mut stream, 0x81)?; + + let mut frames = Vec::with_capacity( + (MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1) * 2 + RESPONSE_DOCUMENT.len() + 2, + ); + for _ in 0..=MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE { + frames.extend_from_slice(&[0x8a, 0]); + } + let response_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeded one-byte length", + ) + })?; + frames.extend_from_slice(&[0x81, response_length]); + frames.extend_from_slice(RESPONSE_DOCUMENT.as_bytes()); + stream.write_all(&frames) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("control-frame limit test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + let error = exchanged + .err() + .ok_or_else(|| io::Error::other("control-frame flood unexpectedly reached response"))?; + assert!(matches!( + error, + WebDriverBiDiLocateNodesExchangeError::ControlFrameLimitExceeded { + maximum_control_frames, + } if maximum_control_frames == MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + )); + Ok(()) +} From d572b598766a63103310b227a51a7f5e22cfcfaa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:12:18 -0700 Subject: [PATCH 076/211] test(network): align control-frame RED formatting --- .../tests/webdriver_bidi_locate_nodes_ping_interleave.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 59d77b4a5..3e26f30b7 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -11,9 +11,9 @@ use originweave_core::{ WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; From 6cc3fb5dbb4ee6cc713b0b74605b9213c13264c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:14:26 -0700 Subject: [PATCH 077/211] fix(network): bound locateNodes control-frame interleave --- .../webdriver_bidi_locate_nodes_exchange.rs | 61 ++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index dcfed72b9..5bb944080 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -15,13 +15,21 @@ use crate::webdriver_bidi_websocket_handshake::{ WebDriverBiDiWebSocketMaskKey, }; +/// Maximum number of valid RFC 6455 Ping/Pong control frames one `locateNodes` exchange will process. +/// +/// RFC 6455 permits control frames to be interleaved with data frames; this OriginWeave-owned +/// resource budget prevents a peer from turning that permission into an unbounded control-frame loop +/// before the correlated BiDi response arrives. The end-to-end exchange deadline remains an +/// independent wall-clock bound. +pub const MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE: usize = 64; + /// Fail-closed failures while exchanging one bounded WebDriver BiDi `locateNodes` command. /// /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded /// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the /// admitted document must correlate to the exact consumed command before result nodes are returned. -/// Protocol-shape, exhausted-deadline, and missing caller entropy refusals have no nested source -/// because none masks an underlying I/O or parser failure. +/// Protocol-shape, resource-budget, exhausted-deadline, and missing caller entropy refusals have no +/// nested source because none masks an underlying I/O or parser failure. #[derive(Debug)] pub enum WebDriverBiDiLocateNodesExchangeError { /// Bounded WebSocket frame write or read failed. @@ -31,6 +39,11 @@ pub enum WebDriverBiDiLocateNodesExchangeError { /// Original caller-supplied deadline budget for the complete exchange. exchange_timeout: Duration, }, + /// The peer exceeded the local resource budget for interleaved Ping/Pong frames. + ControlFrameLimitExceeded { + /// Maximum number of control frames admitted for one exchange. + maximum_control_frames: usize, + }, /// A server Ping required a fresh client masking key, but the caller supplied none. PongMaskingKeyUnavailable, /// The returned frame was neither an admissible control frame nor one complete text response. @@ -57,6 +70,12 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { formatter, "WebDriver BiDi locateNodes exchange exhausted its {exchange_timeout:?} end-to-end deadline before the next operation" ), + Self::ControlFrameLimitExceeded { + maximum_control_frames, + } => write!( + formatter, + "WebDriver BiDi locateNodes exchange exceeded the maximum {maximum_control_frames} interleaved control frames" + ), Self::PongMaskingKeyUnavailable => formatter.write_str( "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key", ), @@ -83,6 +102,7 @@ impl Error for WebDriverBiDiLocateNodesExchangeError { Self::ResponseDocument(error) => Some(error), Self::LocateNodesResponse(error) => Some(error), Self::ExchangeDeadlineExceeded { .. } + | Self::ControlFrameLimitExceeded { .. } | Self::PongMaskingKeyUnavailable | Self::UnexpectedResponseFrame { .. } => None, } @@ -127,8 +147,10 @@ impl WebDriverBiDiWebSocketEstablished { /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, /// and response read. Elapsed time is subtracted before every subsequent operation and the budget /// is never reset. The underlying frame boundary independently caps each frame at its existing - /// size ceiling, while the single exchange deadline bounds a peer that sends repeated valid - /// control frames. Any failure consumes this transport state and yields no reusable WebSocket + /// size ceiling. In addition, at most [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid + /// Ping/Pong frames are processed before the exchange fails closed, so RFC 6455 control-frame + /// interleaving cannot create an unbounded iteration budget even when the wall-clock deadline has + /// not yet expired. Any failure consumes this transport state and yields no reusable WebSocket /// stream, preventing a partially written/read protocol state from becoming later authority. /// /// The final complete text payload passes the existing bounded UTF-8/document admission, @@ -153,6 +175,7 @@ impl WebDriverBiDiWebSocketEstablished { command_masking_key, exchange_timeout, ))?; + let mut control_frame_count = 0_usize; loop { let remaining_timeout = @@ -161,8 +184,21 @@ impl WebDriverBiDiWebSocketEstablished { .read_frame(remaining_timeout) .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; established = next_established; + let opcode = frame.opcode(); + + if matches!(opcode, 0x9 | 0xa) { + if control_frame_count == MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE { + return Err( + WebDriverBiDiLocateNodesExchangeError::ControlFrameLimitExceeded { + maximum_control_frames: + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + }, + ); + } + control_frame_count += 1; + } - match frame.opcode() { + match opcode { 0x9 => { let masking_key = next_pong_masking_key(next_pong_key)?; let remaining_timeout = @@ -187,7 +223,7 @@ impl WebDriverBiDiWebSocketEstablished { return Err( WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: frame.fin(), - opcode: frame.opcode(), + opcode, }, ); } @@ -207,7 +243,8 @@ mod tests { use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; use super::{ - WebDriverBiDiLocateNodesExchangeError, next_pong_masking_key, remaining_exchange_budget, + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + next_pong_masking_key, remaining_exchange_budget, }; #[test] @@ -267,6 +304,16 @@ mod tests { assert!(deadline.source().is_none()); assert!(deadline.to_string().contains("end-to-end deadline")); + let control_limit = WebDriverBiDiLocateNodesExchangeError::ControlFrameLimitExceeded { + maximum_control_frames: MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + }; + assert!(control_limit.source().is_none()); + assert!( + control_limit + .to_string() + .contains("maximum 64 interleaved control frames") + ); + let missing_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable; assert!(missing_mask.source().is_none()); assert!( From e5ff620383db1476bb9759f9ee46517c778961e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:15:55 -0700 Subject: [PATCH 078/211] style(network): align control-frame bound formatting --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 5bb944080..00b69ba31 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -190,8 +190,7 @@ impl WebDriverBiDiWebSocketEstablished { if control_frame_count == MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE { return Err( WebDriverBiDiLocateNodesExchangeError::ControlFrameLimitExceeded { - maximum_control_frames: - MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + maximum_control_frames: MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, }, ); } From 504e921cbfad64f520bdd65ac8c71cc620715464 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:17:29 -0700 Subject: [PATCH 079/211] fix(network): export locateNodes control-frame budget --- 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 f84d440c3..76919b266 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -30,7 +30,9 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; -pub use webdriver_bidi_locate_nodes_exchange::WebDriverBiDiLocateNodesExchangeError; +pub use webdriver_bidi_locate_nodes_exchange::{ + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, +}; 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, From dd4935cfeee17dd0d4a73bc8e7e0150cc2648d65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:03:01 -0700 Subject: [PATCH 080/211] docs: record bounded locateNodes WebSocket exchange --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d031b43..5a0167c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,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 WebDriver BiDi `browsingContext.locateNodes` exchange over the established peer-verified WebSocket stream: the exact consumed command is written as one masked text frame, one end-to-end deadline and a 64-frame Ping/Pong budget bound the exchange, each Pong requires a fresh caller-supplied masking key, and only one final bounded text response may pass raw-document admission, exact command correlation, and node-result admission; fragmentation, binary/continuation/close shapes, exhausted entropy, and over-budget control traffic fail closed without granting browser, origin, policy, typed-input, or Agent authority. - Bounded RFC 6455 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority. - Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. From ded60bc510f893e4ecaeb39046c73cc705d29551 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:50:06 -0700 Subject: [PATCH 081/211] test(network): reproduce long locateNodes exchange budget --- ..._bidi_locate_nodes_exchange_long_budget.rs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs new file mode 100644 index 000000000..5efe2a775 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs @@ -0,0 +1,111 @@ +use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; + +#[test] +fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { + 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 + .set_read_timeout(Some(Duration::from_secs(1))) + .expect("test read timeout must configure"); + + 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) + .expect("opening request must arrive"); + 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", + ) + .expect("opening response must be written"); + + let mut header = [0_u8; 2]; + stream + .read_exact(&mut header) + .expect("command frame header must arrive"); + assert_eq!(header[0], 0x81); + assert_ne!(header[1] & 0x80, 0); + let payload_length = usize::from(header[1] & 0x7f); + assert!(payload_length <= 125, "fixture command must use short frame encoding"); + let mut mask = [0_u8; 4]; + stream + .read_exact(&mut mask) + .expect("command mask must arrive"); + let mut payload = vec![0_u8; payload_length]; + stream + .read_exact(&mut payload) + .expect("command payload must arrive"); + + let response = RESPONSE_DOCUMENT.as_bytes(); + let response_length = u8::try_from(response.len()).expect("response must fit short frame"); + stream + .write_all(&[0x81, response_length]) + .expect("response header must be written"); + stream + .write_all(response) + .expect("response payload must be written"); + }); + + 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 key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY) + .expect("test client key must be valid"); + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_millis(500)) + .expect("opening request must be written") + .read_opening_response(Duration::from_millis(500)) + .expect("opening response must be valid"); + + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2) + .expect("test query must be valid"); + let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query) + .expect("test command must be valid"); + + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_secs(6), + ); + assert!(exchanged.is_ok(), "{exchanged:?}"); + assert!(server.join().is_ok()); +} From 2cb8e06875826a39246d528a03a5f1f8fa38b1a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:51:03 -0700 Subject: [PATCH 082/211] test(network): format long exchange budget regression --- ..._bidi_locate_nodes_exchange_long_budget.rs | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs index 5efe2a775..3b0c1f693 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs @@ -19,6 +19,37 @@ const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +fn read_client_text_frame(stream: &mut impl Read) { + let mut header = [0_u8; 2]; + stream + .read_exact(&mut header) + .expect("command frame header must arrive"); + assert_eq!(header[0], 0x81); + assert_ne!(header[1] & 0x80, 0); + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream + .read_exact(&mut extended) + .expect("16-bit command length must arrive"); + usize::from(u16::from_be_bytes(extended)) + } + 127 => panic!("test command must not require a 64-bit WebSocket length"), + _ => unreachable!("7-bit WebSocket payload marker"), + }; + + let mut mask = [0_u8; 4]; + stream + .read_exact(&mut mask) + .expect("command mask must arrive"); + let mut payload = vec![0_u8; payload_length]; + stream + .read_exact(&mut payload) + .expect("command payload must arrive"); +} + #[test] fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); @@ -45,22 +76,7 @@ fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { ) .expect("opening response must be written"); - let mut header = [0_u8; 2]; - stream - .read_exact(&mut header) - .expect("command frame header must arrive"); - assert_eq!(header[0], 0x81); - assert_ne!(header[1] & 0x80, 0); - let payload_length = usize::from(header[1] & 0x7f); - assert!(payload_length <= 125, "fixture command must use short frame encoding"); - let mut mask = [0_u8; 4]; - stream - .read_exact(&mut mask) - .expect("command mask must arrive"); - let mut payload = vec![0_u8; payload_length]; - stream - .read_exact(&mut payload) - .expect("command payload must arrive"); + read_client_text_frame(&mut stream); let response = RESPONSE_DOCUMENT.as_bytes(); let response_length = u8::try_from(response.len()).expect("response must fit short frame"); @@ -72,10 +88,9 @@ fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { .expect("response payload must be written"); }); - 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"); From 6cd64de5ea65e9d80ee258a364bcef913980bf10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:54:09 -0700 Subject: [PATCH 083/211] fix(network): cap locateNodes frame operations within exchange deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 00b69ba31..c9dfbf77f 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -11,8 +11,8 @@ use originweave_core::{ }; use crate::webdriver_bidi_websocket_handshake::{ - WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, - WebDriverBiDiWebSocketMaskKey, + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, }; /// Maximum number of valid RFC 6455 Ping/Pong control frames one `locateNodes` exchange will process. @@ -121,6 +121,14 @@ fn remaining_exchange_budget( } } +fn remaining_frame_operation_budget( + exchange_timeout: Duration, + elapsed: Duration, +) -> Result { + remaining_exchange_budget(exchange_timeout, elapsed) + .map(|remaining| remaining.min(MAX_WEBSOCKET_FRAME_TIMEOUT)) +} + fn next_pong_masking_key( next_key: &mut dyn FnMut() -> Option, ) -> Result { @@ -145,13 +153,16 @@ impl WebDriverBiDiWebSocketEstablished { /// fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, - /// and response read. Elapsed time is subtracted before every subsequent operation and the budget - /// is never reset. The underlying frame boundary independently caps each frame at its existing - /// size ceiling. In addition, at most [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid - /// Ping/Pong frames are processed before the exchange fails closed, so RFC 6455 control-frame - /// interleaving cannot create an unbounded iteration budget even when the wall-clock deadline has - /// not yet expired. Any failure consumes this transport state and yields no reusable WebSocket - /// stream, preventing a partially written/read protocol state from becoming later authority. + /// and response read. Elapsed time is subtracted before every operation and the budget is never + /// reset. Each individual frame operation is additionally capped at the established frame + /// timeout ceiling, so a longer end-to-end exchange budget remains valid without widening the + /// per-operation I/O bound. The underlying frame boundary independently caps each frame at its + /// existing size ceiling. In addition, at most + /// [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid Ping/Pong frames are processed before + /// the exchange fails closed, so RFC 6455 control-frame interleaving cannot create an unbounded + /// iteration budget even when the wall-clock deadline has not yet expired. Any failure consumes + /// this transport state and yields no reusable WebSocket stream, preventing a partially + /// written/read protocol state from becoming later authority. /// /// The final complete text payload passes the existing bounded UTF-8/document admission, /// complete WebDriver BiDi response parser, exact command-id correlation, and wire-derived node @@ -170,16 +181,18 @@ impl WebDriverBiDiWebSocketEstablished { WebDriverBiDiLocateNodesExchangeError, > { let started_at = Instant::now(); + let write_timeout = + remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; let mut established = map_established_frame_result(self.write_text_frame( command.as_json(), command_masking_key, - exchange_timeout, + write_timeout, ))?; let mut control_frame_count = 0_usize; loop { let remaining_timeout = - remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; + remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; let (next_established, frame) = established .read_frame(remaining_timeout) .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; @@ -201,7 +214,7 @@ impl WebDriverBiDiWebSocketEstablished { 0x9 => { let masking_key = next_pong_masking_key(next_pong_key)?; let remaining_timeout = - remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; + remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; established = map_established_frame_result(established.write_pong_frame( frame.payload(), masking_key, @@ -243,7 +256,7 @@ mod tests { use super::{ MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, - next_pong_masking_key, remaining_exchange_budget, + next_pong_masking_key, remaining_exchange_budget, remaining_frame_operation_budget, }; #[test] @@ -269,6 +282,22 @@ mod tests { ); } + #[test] + fn exchange_budget_caps_each_frame_operation_without_resetting_total_time() { + assert_eq!( + remaining_frame_operation_budget(Duration::from_secs(6), Duration::ZERO).ok(), + Some(MAX_WEBSOCKET_FRAME_TIMEOUT) + ); + assert_eq!( + remaining_frame_operation_budget(Duration::from_secs(6), Duration::from_secs(2)).ok(), + Some(Duration::from_secs(4)) + ); + assert!( + remaining_frame_operation_budget(Duration::from_secs(6), Duration::from_secs(6)) + .is_err() + ); + } + #[test] fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); From 587a8389e763035bfd86f59efd6e3edbbfb95e40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:56:49 -0700 Subject: [PATCH 084/211] fix(network): preserve zero-budget frame refusal semantics --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index c9dfbf77f..7162f9161 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -153,11 +153,11 @@ impl WebDriverBiDiWebSocketEstablished { /// fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, - /// and response read. Elapsed time is subtracted before every operation and the budget is never - /// reset. Each individual frame operation is additionally capped at the established frame - /// timeout ceiling, so a longer end-to-end exchange budget remains valid without widening the - /// per-operation I/O bound. The underlying frame boundary independently caps each frame at its - /// existing size ceiling. In addition, at most + /// and response read. Elapsed time is subtracted before every subsequent operation and the budget + /// is never reset. Each individual frame operation is additionally capped at the established + /// frame timeout ceiling, so a longer end-to-end exchange budget remains valid without widening + /// the per-operation I/O bound. The underlying frame boundary independently caps each frame at + /// its existing size ceiling. In addition, at most /// [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid Ping/Pong frames are processed before /// the exchange fails closed, so RFC 6455 control-frame interleaving cannot create an unbounded /// iteration budget even when the wall-clock deadline has not yet expired. Any failure consumes @@ -181,8 +181,7 @@ impl WebDriverBiDiWebSocketEstablished { WebDriverBiDiLocateNodesExchangeError, > { let started_at = Instant::now(); - let write_timeout = - remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; + let write_timeout = exchange_timeout.min(MAX_WEBSOCKET_FRAME_TIMEOUT); let mut established = map_established_frame_result(self.write_text_frame( command.as_json(), command_masking_key, From 63bd87a57e8bc0e431e8beac6e81452d5bad9095 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:59:31 -0700 Subject: [PATCH 085/211] test(network): satisfy strict lints for long exchange budget regression --- ..._bidi_locate_nodes_exchange_long_budget.rs | 179 +++++++++++------- 1 file changed, 107 insertions(+), 72 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs index 3b0c1f693..9bc5d190b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs @@ -1,5 +1,5 @@ use std::{ - io::{Read, Write}, + io::{self, Read, Write}, net::TcpListener, thread, time::Duration, @@ -19,101 +19,131 @@ const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; -fn read_client_text_frame(stream: &mut impl Read) { +fn read_client_text_frame(stream: &mut impl Read) -> io::Result<()> { let mut header = [0_u8; 2]; - stream - .read_exact(&mut header) - .expect("command frame header must arrive"); + stream.read_exact(&mut header)?; assert_eq!(header[0], 0x81); assert_ne!(header[1] & 0x80, 0); - let payload_length = match header[1] & 0x7f { - value @ 0..=125 => usize::from(value), - 126 => { - let mut extended = [0_u8; 2]; - stream - .read_exact(&mut extended) - .expect("16-bit command length must arrive"); - usize::from(u16::from_be_bytes(extended)) - } - 127 => panic!("test command must not require a 64-bit WebSocket length"), - _ => unreachable!("7-bit WebSocket payload marker"), + let payload_marker = header[1] & 0x7f; + let payload_length = if payload_marker <= 125 { + usize::from(payload_marker) + } else if payload_marker == 126 { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test command unexpectedly used a 64-bit WebSocket payload length", + )); }; let mut mask = [0_u8; 4]; - stream - .read_exact(&mut mask) - .expect("command mask must arrive"); + stream.read_exact(&mut mask)?; let mut payload = vec![0_u8; payload_length]; - stream - .read_exact(&mut payload) - .expect("command payload must arrive"); + stream.read_exact(&mut payload)?; + Ok(()) } #[test] fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { - let listener = TcpListener::bind(("127.0.0.1", 0)).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 - .set_read_timeout(Some(Duration::from_secs(1))) - .expect("test read timeout must configure"); + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let address = listener.local_addr(); + assert!(address.is_ok(), "{address:?}"); + let Ok(address) = address else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(1)))?; let mut opening = Vec::new(); let mut byte = [0_u8; 1]; while !opening.ends_with(b"\r\n\r\n") { - stream - .read_exact(&mut byte) - .expect("opening request must arrive"); + 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", - ) - .expect("opening response must be written"); + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; - read_client_text_frame(&mut stream); + read_client_text_frame(&mut stream)?; let response = RESPONSE_DOCUMENT.as_bytes(); - let response_length = u8::try_from(response.len()).expect("response must fit short frame"); - stream - .write_all(&[0x81, response_length]) - .expect("response header must be written"); - stream - .write_all(response) - .expect("response payload must be written"); + let response_length = u8::try_from(response.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response unexpectedly exceeded short-frame length", + ) + })?; + stream.write_all(&[0x81, response_length])?; + stream.write_all(response)?; + Ok(()) }); let endpoint = - WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")) - .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 key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY) - .expect("test client key must be valid"); - let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key) - .expect("test handshake plan must be valid") - .write_opening_request(Duration::from_millis(500)) - .expect("opening request must be written") - .read_opening_response(Duration::from_millis(500)) - .expect("opening response must be valid"); + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")); + assert!(endpoint.is_ok(), "{endpoint:?}"); + let Ok(endpoint) = endpoint else { + return; + }; + let correlated = endpoint.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + return; + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + return; + }; + let connection_plan = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(connection_plan.is_ok(), "{connection_plan:?}"); + let Ok(connection_plan) = connection_plan else { + return; + }; + let connection = connection_plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + return; + }; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let handshake = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(handshake.is_ok(), "{handshake:?}"); + let Ok(handshake) = handshake else { + return; + }; + let opening = handshake.write_opening_request(Duration::from_millis(500)); + assert!(opening.is_ok(), "{opening:?}"); + let Ok(opening) = opening else { + return; + }; + let established = opening.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; - let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2) - .expect("test query must be valid"); - let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query) - .expect("test command must be valid"); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2); + assert!(query.is_ok(), "{query:?}"); + let Ok(query) = query else { + return; + }; + let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query); + assert!(command.is_ok(), "{command:?}"); + let Ok(command) = command else { + return; + }; let exchanged = established.exchange_locate_nodes( command, @@ -122,5 +152,10 @@ fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { Duration::from_secs(6), ); assert!(exchanged.is_ok(), "{exchanged:?}"); - assert!(server.join().is_ok()); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(server_io) = server_result { + assert!(server_io.is_ok(), "{server_io:?}"); + } } From 17b8834b608cee6d5d5f680958470c5c84dcf9ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 22:00:40 -0700 Subject: [PATCH 086/211] style(network): apply canonical rustfmt to long-budget regression --- .../tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs index 9bc5d190b..84c09eed6 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs @@ -102,8 +102,7 @@ fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { let Ok(target) = target else { return; }; - let connection_plan = - WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + let connection_plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); assert!(connection_plan.is_ok(), "{connection_plan:?}"); let Ok(connection_plan) = connection_plan else { return; From 4c95e213b67107da7fb92ab1cc71b99f7435bbd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:05:40 -0700 Subject: [PATCH 087/211] 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 088/211] 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 089/211] 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 090/211] 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 091/211] 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 092/211] 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 093/211] 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 094/211] 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 095/211] 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 096/211] 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 097/211] 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 098/211] 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 099/211] 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 100/211] 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 101/211] 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 102/211] 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 103/211] 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 104/211] 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 105/211] 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 106/211] 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 9ffe549de2188a48e04563bb9bc92017a9b9d519 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:13:34 -0700 Subject: [PATCH 107/211] test(network): carry WebSocket nonce redaction into opening-write stack --- .../webdriver_bidi_websocket_handshake.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 64c1bba6e..be447fd90 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -13,6 +13,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REDACTED_CLIENT_KEY: &str = ""; fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); @@ -43,6 +44,57 @@ fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { connection } +#[test] +fn client_key_debug_redacts_websocket_nonce() { + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + + let debug = format!("{key:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); +} + +#[test] +fn handshake_plan_debug_redacts_websocket_nonce() { + 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 || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let debug = format!("{plan:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + #[test] fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { let listener = TcpListener::bind(("127.0.0.1", 0)); From 117aa45f0c0d95c7d9f314cfa7f450f1cd6fef89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:16:29 -0700 Subject: [PATCH 108/211] fix(network): redact WebSocket nonce debug in opening-write stack --- .../src/webdriver_bidi_websocket_handshake.rs | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index d8ca9bd98..948d80089 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -11,6 +11,7 @@ use originweave_core::VerifiedWebDriverBiDiSocketPeer; use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +const REDACTED_WEBSOCKET_CLIENT_NONCE: &str = ""; /// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. /// @@ -60,10 +61,20 @@ impl Error for WebDriverBiDiWebSocketHandshakeError {} /// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type /// validates only the canonical wire representation, including zero padding bits. It does not /// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce -/// for each connection attempt. -#[derive(Debug, Eq, PartialEq)] +/// for each connection attempt. Its [`fmt::Debug`] representation deliberately redacts the nonce so +/// diagnostic output cannot disclose handshake material. +#[derive(Eq, PartialEq)] pub struct WebDriverBiDiWebSocketClientKey(String); +impl fmt::Debug for WebDriverBiDiWebSocketClientKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("WebDriverBiDiWebSocketClientKey") + .field(&REDACTED_WEBSOCKET_CLIENT_NONCE) + .finish() + } +} + impl WebDriverBiDiWebSocketClientKey { /// Admit one canonical base64 client key representing exactly 16 bytes. pub fn new(value: &str) -> Result { @@ -87,18 +98,29 @@ impl WebDriverBiDiWebSocketClientKey { /// the fixed WebSocket version-13 request required for the admitted `/session/` resource /// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. /// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary -/// before any WebSocket bytes may be written. +/// before any WebSocket bytes may be written. Its [`fmt::Debug`] representation omits the serialized +/// request and redacts the client nonce because the request embeds that nonce in `Sec-WebSocket-Key`. /// /// 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_nonce", &REDACTED_WEBSOCKET_CLIENT_NONCE) + .field("request_byte_count", &self.request.len()) + .finish() + } +} + impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( From 1d873b1f74fa3facf4d5abdbbf443fd8ce998d18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:13:20 -0700 Subject: [PATCH 109/211] fix(network): adapt locateNodes exchange to validated WebSocket exports --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 7162f9161..d7c15f2ca 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -10,7 +10,7 @@ use originweave_core::{ WebDriverBiDiResponseDocumentAdmissionError, }; -use crate::webdriver_bidi_websocket_handshake::{ +use crate::{ MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, }; From b5fb68aeceb904b7951199c8048eeb8d99411a16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:17:02 -0700 Subject: [PATCH 110/211] test(network): preserve deterministic Pong write fault injection --- ...er_bidi_locate_nodes_exchange_transport_failure_tests.rs | 2 +- .../src/webdriver_bidi_websocket_validated.rs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index 14c5cad2a..3c0ed55b4 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -118,7 +118,7 @@ fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; let written = plan.write_opening_request(Duration::from_millis(500))?; let established = written.read_opening_response(Duration::from_millis(500))?; - let shutdown_stream = established.stream.try_clone()?; + let shutdown_stream = established.try_clone_stream_for_test()?; let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); let exchanged = established.exchange_locate_nodes( locate_nodes_command()?, diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index d32eab98d..bcbe2428f 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -166,6 +166,12 @@ impl WebDriverBiDiWebSocketEstablished { self.0.write_timeout() } + /// Clone the exact underlying stream for crate-internal fault-injection tests only. + #[cfg(test)] + pub(crate) fn try_clone_stream_for_test(&self) -> std::io::Result { + self.0.stream.try_clone() + } + /// Write one unfragmented, masked UTF-8 text frame on this verified stream. pub fn write_text_frame( self, From 02b43e971dc4e49c992488c365d4e955747faeed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:41:16 -0700 Subject: [PATCH 111/211] 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 112/211] 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 113/211] 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 3ba836a5af4779c6e9ff801493b82b605bd047d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:11:42 -0700 Subject: [PATCH 114/211] test(network): require live locateNodes authority binding --- ...ver_bidi_locate_nodes_current_authority.rs | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs new file mode 100644 index 000000000..3e622325f --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs @@ -0,0 +1,261 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserSessionId, BrowsingContextId, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type ServerHandle = thread::JoinHandle>>; +type EstablishedFixture = + Result<(SocketAddr, WebDriverBiDiWebSocketEstablished, ServerHandle), Box>; + +fn connect(endpoint: &str) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one masked final client text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture rejects 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn establish() -> EstablishedFixture { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_client_text_frame(&mut stream)?; + let response = RESPONSE_DOCUMENT.as_bytes(); + let response_length = u8::try_from(response.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "test response exceeds short frame") + })?; + if response_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeds short frame", + )); + } + stream.write_all(&[0x81, response_length])?; + stream.write_all(response)?; + Ok(command) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((local_addr, established, server)) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + origin: &'a Origin, +) -> Result< + ( + BrowserContextOriginEpochDispatchTarget<'a>, + BrowserSessionId, + BrowsingContextId, + ), + Box, +> { + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, origin)?; + Ok(( + BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + origin, + ), + epoch, + ), + session, + context, + )) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn live_websocket_locate_nodes_exchange_binds_wire_nodes_to_current_authority() +-> Result<(), Box> { + let (local_addr, established, server) = establish()?; + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let (target, _session, _context) = current_target(&mut registry, &origin)?; + let expected_epoch = target.expected_epoch(); + + let (established, handles) = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + semantic_observation_proof()?, + &mut registry, + target, + )?; + + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].origin(), &origin); + assert_eq!(handles[0].document_epoch(), expected_epoch); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + let command = server.join().map_err(|_| "test server panicked")??; + assert_eq!(command, locate_nodes_command()?.as_json().as_bytes()); + Ok(()) +} + +#[test] +fn live_exchange_fails_closed_when_document_epoch_changed_before_wire_node_binding() +-> Result<(), Box> { + let (_local_addr, established, server) = establish()?; + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let (target, session, context) = current_target(&mut registry, &origin)?; + let stale_epoch = target.expected_epoch(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin(session, context, &origin)?; + + let error = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + semantic_observation_proof()?, + &mut registry, + target, + ); + + assert!(matches!( + error, + Err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected, + current, + }, + ), + )) if expected == stale_epoch && current == current_epoch + )); + let command = server.join().map_err(|_| "test server panicked")??; + assert_eq!(command, locate_nodes_command()?.as_json().as_bytes()); + Ok(()) +} From b22a371fb8fe90d1c748a872957abdcd7a7ba04f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:14:38 -0700 Subject: [PATCH 115/211] test(network): format live authority regression --- .../webdriver_bidi_locate_nodes_current_authority.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs index 3e622325f..ba4f9653a 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs @@ -34,7 +34,9 @@ type ServerHandle = thread::JoinHandle>>; type EstablishedFixture = Result<(SocketAddr, WebDriverBiDiWebSocketEstablished, ServerHandle), Box>; -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()?; @@ -109,7 +111,10 @@ fn establish() -> EstablishedFixture { let command = read_client_text_frame(&mut stream)?; let response = RESPONSE_DOCUMENT.as_bytes(); let response_length = u8::try_from(response.len()).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "test response exceeds short frame") + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeds short frame", + ) })?; if response_length > 125 { return Err(io::Error::new( From 082a8e288742f1adeff00fbfcf7be8daa4137567 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:18:29 -0700 Subject: [PATCH 116/211] feat(network): bind live BiDi nodes to current authority --- .../webdriver_bidi_locate_nodes_exchange.rs | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index d7c15f2ca..049598fb0 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -5,9 +5,10 @@ use std::{ }; use originweave_core::{ - BoundedWebDriverBiDiResponseDocument, ValidatedWebDriverBiDiLocateNodesResult, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, - WebDriverBiDiResponseDocumentAdmissionError, + BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, ValidatedBrowserProtocolUse, + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{ @@ -241,6 +242,47 @@ impl WebDriverBiDiWebSocketEstablished { } } } + + /// Exchange `locateNodes` and bind the exact wire-derived nodes to current browser authority. + /// + /// This is the live transport composition boundary for semantic node observation. The bounded + /// command is exchanged on the already peer-verified WebSocket using [`Self::exchange_locate_nodes`]. + /// Only after exact wire parsing and command correlation succeed does the method revalidate the + /// caller's reviewed WebDriver BiDi `SemanticObservation` proof plus the exact current + /// session/context/origin/document epoch through + /// [`ValidatedWebDriverBiDiLocateNodesResult::bind_current_nodes`]. No raw node identifier can be + /// substituted between the wire response and authority binding. + /// + /// A binding failure consumes this transport result and returns no reusable stream or node + /// handle, so a navigation or authority change observed after command construction cannot be + /// converted into stale node authority. Success returns only current [`ObservedNodeHandle`] + /// values together with the same established peer-verified stream. It still does not authorize + /// typed input, execute an action, or prove a post-condition. + pub fn exchange_locate_nodes_and_bind_current_nodes( + self, + command: WebDriverBiDiLocateNodesCommand, + command_masking_key: WebDriverBiDiWebSocketMaskKey, + next_pong_key: &mut dyn FnMut() -> Option, + exchange_timeout: Duration, + validated: ValidatedBrowserProtocolUse, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + ) -> Result<(Self, Vec), WebDriverBiDiLocateNodesExchangeError> { + let (established, result) = self.exchange_locate_nodes( + command, + command_masking_key, + next_pong_key, + exchange_timeout, + )?; + let handles = result + .bind_current_nodes(validated, authority_registry, target) + .map_err(|error| { + WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), + ) + })?; + Ok((established, handles)) + } } #[cfg(test)] From 217e070ac387453785d84d4b498d54053be52f16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:22:49 -0700 Subject: [PATCH 117/211] refactor(network): bundle current BiDi authority input --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 049598fb0..d0e39727c 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -248,8 +248,8 @@ impl WebDriverBiDiWebSocketEstablished { /// This is the live transport composition boundary for semantic node observation. The bounded /// command is exchanged on the already peer-verified WebSocket using [`Self::exchange_locate_nodes`]. /// Only after exact wire parsing and command correlation succeed does the method revalidate the - /// caller's reviewed WebDriver BiDi `SemanticObservation` proof plus the exact current - /// session/context/origin/document epoch through + /// reviewed WebDriver BiDi `SemanticObservation` proof and exact current + /// session/context/origin/document epoch carried together in `authority` through /// [`ValidatedWebDriverBiDiLocateNodesResult::bind_current_nodes`]. No raw node identifier can be /// substituted between the wire response and authority binding. /// @@ -264,10 +264,13 @@ impl WebDriverBiDiWebSocketEstablished { command_masking_key: WebDriverBiDiWebSocketMaskKey, next_pong_key: &mut dyn FnMut() -> Option, exchange_timeout: Duration, - validated: ValidatedBrowserProtocolUse, + authority: ( + ValidatedBrowserProtocolUse, + BrowserContextOriginEpochDispatchTarget<'_>, + ), authority_registry: &mut BrowserAuthorityRegistry, - target: BrowserContextOriginEpochDispatchTarget<'_>, ) -> Result<(Self, Vec), WebDriverBiDiLocateNodesExchangeError> { + let (validated, target) = authority; let (established, result) = self.exchange_locate_nodes( command, command_masking_key, From 8ead5b9c6c8fb34299ab626d24a0a065285d2764 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:23:53 -0700 Subject: [PATCH 118/211] test(network): pass bundled live BiDi authority --- .../tests/webdriver_bidi_locate_nodes_current_authority.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs index ba4f9653a..2c91dd6bc 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs @@ -208,9 +208,8 @@ fn live_websocket_locate_nodes_exchange_binds_wire_nodes_to_current_authority() WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), &mut || None, Duration::from_millis(500), - semantic_observation_proof()?, + (semantic_observation_proof()?, target), &mut registry, - target, )?; assert_eq!(handles.len(), 1); @@ -244,9 +243,8 @@ fn live_exchange_fails_closed_when_document_epoch_changed_before_wire_node_bindi WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), &mut || None, Duration::from_millis(500), - semantic_observation_proof()?, + (semantic_observation_proof()?, target), &mut registry, - target, ); assert!(matches!( From 6a82023ad30d84004aacafdd8afec041f590f940 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 16:40:02 -0700 Subject: [PATCH 119/211] test(network): cover live bind exchange failures --- ...i_locate_nodes_binding_exchange_failure.rs | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs new file mode 100644 index 000000000..e0c0ac43a --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -0,0 +1,191 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one masked final client text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture rejects 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn establish_with_unexpected_binary_response() -> Result< + ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>>, + ), + Box, +> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_client_text_frame(&mut stream)?; + stream.write_all(&[0x82, 0x00])?; + Ok(command) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn live_binding_wrapper_fails_closed_when_wire_exchange_fails_before_binding() +-> Result<(), Box> { + let (established, server) = establish_with_unexpected_binary_response()?; + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + + let error = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + (semantic_observation_proof()?, target), + &mut registry, + ); + + assert!(matches!( + error, + Err(WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: true, + opcode: 0x2, + }) + )); + let command = server.join().map_err(|_| "test server panicked")??; + assert_eq!(command, locate_nodes_command()?.as_json().as_bytes()); + Ok(()) +} From 79fd48063e692b7d899ffb8347499791c3ecbfe4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 16:41:40 -0700 Subject: [PATCH 120/211] style(network): apply canonical exchange regression formatting --- ...river_bidi_locate_nodes_binding_exchange_failure.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index e0c0ac43a..84a968f37 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -180,10 +180,12 @@ fn live_binding_wrapper_fails_closed_when_wire_exchange_fails_before_binding() assert!(matches!( error, - Err(WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { - fin: true, - opcode: 0x2, - }) + Err( + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: true, + opcode: 0x2, + } + ) )); let command = server.join().map_err(|_| "test server panicked")??; assert_eq!(command, locate_nodes_command()?.as_json().as_bytes()); From 437237bdbad598213b25e284192c76112197e64b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 17:05:01 -0700 Subject: [PATCH 121/211] test(network): simplify BiDi failure fixture type --- ..._bidi_locate_nodes_binding_exchange_failure.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index 84a968f37..1b8e58d74 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -27,6 +27,12 @@ const ADAPTER_VERSION: &str = "originweave-bidi-v1"; const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; const BROWSER_REVISION: &str = "chromium-r1639810"; +type UnexpectedBinaryServer = thread::JoinHandle>>; +type EstablishedWithUnexpectedBinaryServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + UnexpectedBinaryServer, +); + fn connect( endpoint: &str, ) -> Result> { @@ -86,13 +92,8 @@ fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { Ok(payload) } -fn establish_with_unexpected_binary_response() -> Result< - ( - originweave_network::WebDriverBiDiWebSocketEstablished, - thread::JoinHandle>>, - ), - Box, -> { +fn establish_with_unexpected_binary_response( +) -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result> { From f31e129dcd5100654015d4788e632743f74848ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 17:07:08 -0700 Subject: [PATCH 122/211] test(network): apply canonical rustfmt to BiDi failure fixture --- .../webdriver_bidi_locate_nodes_binding_exchange_failure.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index 1b8e58d74..50615339b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -92,8 +92,8 @@ fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { Ok(payload) } -fn establish_with_unexpected_binary_response( -) -> Result> { +fn establish_with_unexpected_binary_response() +-> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result> { From 400e7a4e7396fd023b78d3f32bd0e0471645c860 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:10:21 -0700 Subject: [PATCH 123/211] test(network): reject reused BiDi frame masks --- ...bidi_locate_nodes_masking_key_freshness.rs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs new file mode 100644 index 000000000..c8fa26140 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs @@ -0,0 +1,181 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const PING_PAYLOAD: &[u8] = b"fresh-mask"; +const COMMAND_MASK: WebDriverBiDiWebSocketMaskKey = + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); +const PONG_MASK: WebDriverBiDiWebSocketMaskKey = + WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); +const REUSED_MASK_ERROR: &str = + "WebDriver BiDi locateNodes exchange refused a Pong masking key already used by this exchange"; + +type EstablishedServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_frame(stream: &mut TcpStream, expected_first_byte: u8) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != expected_first_byte || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client frame did not have the expected final opcode and masking bit", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture does not admit 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn write_ping(stream: &mut TcpStream) -> io::Result<()> { + let payload_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test Ping payload exceeded one-byte length", + ) + })?; + stream.write_all(&[0x89, payload_length])?; + stream.write_all(PING_PAYLOAD) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn establish_with_ping_sequence(read_first_pong: bool) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_frame(&mut stream, 0x81)?; + write_ping(&mut stream)?; + if read_first_pong { + read_masked_client_frame(&mut stream, 0x8a)?; + write_ping(&mut stream)?; + } + thread::sleep(Duration::from_millis(150)); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let client_key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, client_key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn join_server(server: thread::JoinHandle>) -> Result<(), Box> { + let result = server + .join() + .map_err(|_| io::Error::other("masking-key freshness test server panicked"))?; + Ok(result?) +} + +#[test] +fn locate_nodes_exchange_rejects_pong_mask_reused_from_command_frame() +-> Result<(), Box> { + let (established, server) = establish_with_ping_sequence(false)?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + COMMAND_MASK, + &mut || Some(COMMAND_MASK), + Duration::from_millis(500), + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("reusing the command masking key for Pong unexpectedly succeeded") + })?; + assert_eq!(error.to_string(), REUSED_MASK_ERROR); + join_server(server) +} + +#[test] +fn locate_nodes_exchange_rejects_pong_mask_reused_from_prior_pong() +-> Result<(), Box> { + let (established, server) = establish_with_ping_sequence(true)?; + let mut keys = [PONG_MASK, PONG_MASK].into_iter(); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + COMMAND_MASK, + &mut || keys.next(), + Duration::from_millis(500), + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("reusing a prior Pong masking key unexpectedly succeeded") + })?; + assert_eq!(error.to_string(), REUSED_MASK_ERROR); + join_server(server) +} From bdb3efef49368347b36baf8e2a0cb3c15c97533e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:12:25 -0700 Subject: [PATCH 124/211] test(network): canonicalize mask freshness regressions --- ...bdriver_bidi_locate_nodes_masking_key_freshness.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs index c8fa26140..6b1db5c1e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs @@ -109,7 +109,9 @@ fn locate_nodes_command() -> Result Result> { +fn establish_with_ping_sequence( + read_first_pong: bool, +) -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { @@ -144,8 +146,8 @@ fn join_server(server: thread::JoinHandle>) -> Result<(), Box Result<(), Box> { +fn locate_nodes_exchange_rejects_pong_mask_reused_from_command_frame() -> Result<(), Box> +{ let (established, server) = establish_with_ping_sequence(false)?; let exchanged = established.exchange_locate_nodes( locate_nodes_command()?, @@ -162,8 +164,7 @@ fn locate_nodes_exchange_rejects_pong_mask_reused_from_command_frame() } #[test] -fn locate_nodes_exchange_rejects_pong_mask_reused_from_prior_pong() --> Result<(), Box> { +fn locate_nodes_exchange_rejects_pong_mask_reused_from_prior_pong() -> Result<(), Box> { let (established, server) = establish_with_ping_sequence(true)?; let mut keys = [PONG_MASK, PONG_MASK].into_iter(); let exchanged = established.exchange_locate_nodes( From 4f03c5f54a8a62d9cb5baaf179b27c1c1a3070ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:16:26 -0700 Subject: [PATCH 125/211] fix(network): reject reused BiDi frame masks --- .../webdriver_bidi_locate_nodes_exchange.rs | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index d0e39727c..f17d7d623 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -29,8 +29,9 @@ pub const MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE: usize = 64; /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded /// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the /// admitted document must correlate to the exact consumed command before result nodes are returned. -/// Protocol-shape, resource-budget, exhausted-deadline, and missing caller entropy refusals have no -/// nested source because none masks an underlying I/O or parser failure. +/// Protocol-shape, resource-budget, exhausted-deadline, missing caller entropy, and exact client +/// masking-key reuse refusals have no nested source because none masks an underlying I/O or parser +/// failure. #[derive(Debug)] pub enum WebDriverBiDiLocateNodesExchangeError { /// Bounded WebSocket frame write or read failed. @@ -47,6 +48,8 @@ pub enum WebDriverBiDiLocateNodesExchangeError { }, /// A server Ping required a fresh client masking key, but the caller supplied none. PongMaskingKeyUnavailable, + /// A caller supplied a Pong masking key already used by a client frame in this exchange. + PongMaskingKeyReused, /// The returned frame was neither an admissible control frame nor one complete text response. UnexpectedResponseFrame { /// Whether the returned frame carried the RFC 6455 FIN bit. @@ -80,6 +83,9 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { Self::PongMaskingKeyUnavailable => formatter.write_str( "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key", ), + Self::PongMaskingKeyReused => formatter.write_str( + "WebDriver BiDi locateNodes exchange refused a Pong masking key already used by this exchange", + ), Self::UnexpectedResponseFrame { fin, opcode } => write!( formatter, "WebDriver BiDi locateNodes exchange requires control handling or one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" @@ -105,6 +111,7 @@ impl Error for WebDriverBiDiLocateNodesExchangeError { Self::ExchangeDeadlineExceeded { .. } | Self::ControlFrameLimitExceeded { .. } | Self::PongMaskingKeyUnavailable + | Self::PongMaskingKeyReused | Self::UnexpectedResponseFrame { .. } => None, } } @@ -148,10 +155,12 @@ impl WebDriverBiDiWebSocketEstablished { /// The command is serialized by the reviewed core boundary and written as one masked client /// text frame using `command_masking_key`. Valid server Ping frames are answered with a masked /// Pong carrying the exact Ping application data, while unsolicited valid Pong frames are - /// consumed without changing BiDi state. Each Ping obtains a fresh unpredictable client mask - /// from `next_pong_key`; exhausting that caller-owned entropy source fails closed and - /// consumes the transport rather than reusing a masking key. Close, binary, continuation, - /// fragmented data, and reserved shapes are not reinterpreted as a BiDi response. + /// consumed without changing BiDi state. Each Ping obtains a caller-supplied client mask from + /// `next_pong_key`; exhausting that caller-owned entropy source or repeating any key already used + /// by the command or a prior Pong fails closed before another client frame is emitted. The caller + /// remains responsible for generating each supplied key from a strong unpredictable entropy + /// source; exact non-reuse checks do not prove cryptographic unpredictability. Close, binary, + /// continuation, fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, /// and response read. Elapsed time is subtracted before every subsequent operation and the budget @@ -189,6 +198,11 @@ impl WebDriverBiDiWebSocketEstablished { write_timeout, ))?; let mut control_frame_count = 0_usize; + let mut used_client_masking_keys = [ + command_masking_key; + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1 + ]; + let mut used_client_masking_key_count = 1_usize; loop { let remaining_timeout = @@ -213,6 +227,13 @@ impl WebDriverBiDiWebSocketEstablished { match opcode { 0x9 => { let masking_key = next_pong_masking_key(next_pong_key)?; + if used_client_masking_keys[..used_client_masking_key_count] + .contains(&masking_key) + { + return Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyReused); + } + used_client_masking_keys[used_client_masking_key_count] = masking_key; + used_client_masking_key_count += 1; let remaining_timeout = remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; established = map_established_frame_result(established.write_pong_frame( @@ -394,6 +415,14 @@ mod tests { .contains("fresh caller-supplied Pong masking key") ); + let reused_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyReused; + assert!(reused_mask.source().is_none()); + assert!( + reused_mask + .to_string() + .contains("Pong masking key already used by this exchange") + ); + let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: false, opcode: 0x2, From b6f17ecb70abf8b9e69a87a6872be3ecc9a52f82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:20:07 -0700 Subject: [PATCH 126/211] style(network): apply canonical BiDi mask formatting --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index f17d7d623..67022b652 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -198,10 +198,8 @@ impl WebDriverBiDiWebSocketEstablished { write_timeout, ))?; let mut control_frame_count = 0_usize; - let mut used_client_masking_keys = [ - command_masking_key; - MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1 - ]; + let mut used_client_masking_keys = + [command_masking_key; MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1]; let mut used_client_masking_key_count = 1_usize; loop { From f7c1cec4533edff4c4173ebd74e972efb6e1da17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:36:11 -0700 Subject: [PATCH 127/211] 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 128/211] 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 129/211] 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 130/211] 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 d6d75c6e562563cf2ebe998429183cb6f8732cd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:59:32 -0700 Subject: [PATCH 131/211] test(network): classify zero BiDi exchange budget --- ..._nodes_exchange_transport_failure_tests.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index 3c0ed55b4..13403fed8 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -151,3 +151,57 @@ fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), assert!(error.source().is_some()); Ok(()) } + +#[test] +fn zero_exchange_timeout_fails_at_exchange_boundary_before_frame_write() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + let count = stream.read(&mut byte)?; + if count != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "zero-budget locateNodes exchange wrote a client frame", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::ZERO, + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("zero-budget locateNodes exchange unexpectedly succeeded") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + } if exchange_timeout.is_zero() + ), + "{error:?}" + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("zero-budget exchange test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + Ok(()) +} From c26030cf7f939d498fa436fd319371a7332eed6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 19:01:55 -0700 Subject: [PATCH 132/211] style(network): apply canonical zero-budget regression formatting --- ...river_bidi_locate_nodes_exchange_transport_failure_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index 13403fed8..972732d39 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -153,7 +153,8 @@ fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), } #[test] -fn zero_exchange_timeout_fails_at_exchange_boundary_before_frame_write() -> Result<(), Box> { +fn zero_exchange_timeout_fails_at_exchange_boundary_before_frame_write() +-> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { From 0007fc53f6d6d40fbc4db9906387ad411273d850 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 19:05:15 -0700 Subject: [PATCH 133/211] fix(network): enforce initial BiDi exchange deadline --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 67022b652..4d25d81dc 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -163,11 +163,11 @@ impl WebDriverBiDiWebSocketEstablished { /// continuation, fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, - /// and response read. Elapsed time is subtracted before every subsequent operation and the budget - /// is never reset. Each individual frame operation is additionally capped at the established - /// frame timeout ceiling, so a longer end-to-end exchange budget remains valid without widening - /// the per-operation I/O bound. The underlying frame boundary independently caps each frame at - /// its existing size ceiling. In addition, at most + /// and response read. Elapsed time is subtracted before every operation, including the initial + /// command write, and the budget is never reset. Each individual frame operation is additionally + /// capped at the established frame timeout ceiling, so a longer end-to-end exchange budget + /// remains valid without widening the per-operation I/O bound. The underlying frame boundary + /// independently caps each frame at its existing size ceiling. In addition, at most /// [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid Ping/Pong frames are processed before /// the exchange fails closed, so RFC 6455 control-frame interleaving cannot create an unbounded /// iteration budget even when the wall-clock deadline has not yet expired. Any failure consumes @@ -191,7 +191,8 @@ impl WebDriverBiDiWebSocketEstablished { WebDriverBiDiLocateNodesExchangeError, > { let started_at = Instant::now(); - let write_timeout = exchange_timeout.min(MAX_WEBSOCKET_FRAME_TIMEOUT); + let write_timeout = + remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; let mut established = map_established_frame_result(self.write_text_frame( command.as_json(), command_masking_key, From 225964d9b6aa3fd1d69cf14bf2a69dfd6cd84a99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 19:09:42 -0700 Subject: [PATCH 134/211] test(network): align zero-budget exchange classification --- .../tests/webdriver_bidi_websocket_locate_nodes_exchange.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index 7df73abdf..6a6436c16 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -342,7 +342,9 @@ fn exchange_preserves_frame_document_and_response_admission_boundaries() { let write_error = exchange_error(&[], Duration::ZERO, false); assert!(matches!( write_error, - WebDriverBiDiLocateNodesExchangeError::Frame(_) + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + } if exchange_timeout.is_zero() )); let read_error = exchange_error(&[], Duration::from_millis(500), true); From 151b9fd92afa162c4e773c68767c04aa8bda9735 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 19:15:50 -0700 Subject: [PATCH 135/211] test(network): cover initial BiDi command write failure --- ..._nodes_exchange_transport_failure_tests.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index 972732d39..1d84f1544 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -97,6 +97,63 @@ fn locate_nodes_command() -> Result Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + let count = stream.read(&mut byte)?; + if count != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "closed client write half still emitted a locateNodes command frame", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let shutdown_stream = established.try_clone_stream_for_test()?; + shutdown_stream.shutdown(Shutdown::Write)?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("initial write failure test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("locateNodes exchange unexpectedly survived a closed client write half") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { .. } + ) + ), + "{error:?}" + ); + assert!(error.source().is_some()); + Ok(()) +} + #[test] fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; From 49a8143c9af6cf5bb40ab22bbac283c1b1d03eaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:39:17 -0700 Subject: [PATCH 136/211] 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 137/211] 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 138/211] 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 dfff5083cde65b953ecf47cebccd22f494fd421d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:01:36 -0700 Subject: [PATCH 139/211] chore(network): retain current frame transport in locateNodes stack --- .../src/webdriver_bidi_websocket_validated.rs | 139 ++++++++++++++---- 1 file changed, 114 insertions(+), 25 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index a56cb0fad..e16acb59c 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,84 +169,97 @@ 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() } /// Clone the exact underlying stream for crate-internal fault-injection tests only. #[cfg(test)] pub(crate) fn try_clone_stream_for_test(&self) -> std::io::Result { - self.0.stream.try_clone() + self.raw.stream.try_clone() } /// 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)) } } @@ -223,3 +278,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 6d8d8922e92fea50ebb2844f8a2cea5548063fdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:02:37 -0700 Subject: [PATCH 140/211] test(network): retain WebSocket mask reuse regression in locateNodes stack --- ...driver_bidi_websocket_masking_key_reuse.rs | 137 ++++++++++++++++++ 1 file changed, 137 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..82f9472dc --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -0,0 +1,137 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REUSED_MASK_REASON: &str = + "client masking key was already used on this established WebSocket"; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text(stream: &mut TcpStream) -> io::Result { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client did not send one final masked text frame", + )); + } + let payload_length = usize::from(header[1] & 0x7f); + if payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test text payload unexpectedly used an extended length", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + String::from_utf8(payload).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn require_peer_closed_before_second_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "client emitted a second frame after reusing its masking key", + )), + Err(error) => Err(io::Error::new( + error.kind(), + format!("client did not close after refusing a reused masking key: {error}"), + )), + } +} + +#[test] +fn established_stream_rejects_client_mask_reuse_across_sequential_frames() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let first = read_masked_text(&mut stream)?; + require_peer_closed_before_second_frame(&mut stream)?; + Ok(first) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x21, 0x22, 0x23, 0x24]); + let established = + established.write_text_frame("first-frame", reused_mask, Duration::from_millis(500))?; + let error = + match established.write_text_frame("second-frame", reused_mask, Duration::from_millis(500)) + { + Ok(_) => { + return Err( + io::Error::other("RFC 6455 masking-key reuse unexpectedly succeeded").into(), + ); + } + Err(error) => error, + }; + assert!(matches!( + error, + WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_MASK_REASON + } + )); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket mask-reuse test server panicked"))??; + assert_eq!(received, "first-frame"); + Ok(()) +} From 93e5ec950f8c5c047340ce05fde6903aa77db991 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:15:02 -0700 Subject: [PATCH 141/211] 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 142/211] 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 9f0b9fb0d14dcc761d46eb0c3e626ab53ccc03c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:06:55 -0700 Subject: [PATCH 143/211] test(network): pin locateNodes coverage shape --- ..._webdriver_bidi_locate_nodes_coverage_shape.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py index 18eec0d6d..254843349 100644 --- a/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py +++ b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py @@ -6,6 +6,13 @@ coverage holes despite exercising the protocol paths. The callback is therefore a borrowed trait object at this boundary: its behavior remains stateful and caller owned without multiplying production coverage regions. + +Likewise, error conversion at the public binding wrapper must use a named production +function rather than an inline closure. The library is linked into both unit and +integration-test harnesses; an inline closure can therefore acquire a second uncovered +instantiation even when the real fail-closed binding path is exercised. Keeping that +conversion named makes exact coverage represent product behavior rather than linker +instantiation shape. """ from __future__ import annotations @@ -24,7 +31,7 @@ class WebDriverBiDiLocateNodesCoverageShapeTests(unittest.TestCase): - """Prevent callback monomorphization from invalidating exact coverage evidence.""" + """Prevent monomorphization artifacts from invalidating exact coverage evidence.""" def test_pong_entropy_callback_is_non_generic_at_exchange_boundary(self) -> None: source = SOURCE.read_text(encoding="utf-8") @@ -37,6 +44,12 @@ def test_pong_entropy_callback_is_non_generic_at_exchange_boundary(self) -> None source, ) + def test_node_binding_error_conversion_is_named_not_inline_closure(self) -> None: + source = SOURCE.read_text(encoding="utf-8") + self.assertIn("fn map_node_binding_error(", source) + self.assertIn(".map_err(map_node_binding_error)?;", source) + self.assertNotIn(".map_err(|error| {", source) + if __name__ == "__main__": unittest.main() From 8ee1712d98e472de314b64a2e1c86c2ea7fda1a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:10:34 -0700 Subject: [PATCH 144/211] fix(network): stabilize locateNodes binding coverage --- .../webdriver_bidi_locate_nodes_exchange.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 4d25d81dc..7e8118733 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -7,8 +7,9 @@ use std::{ use originweave_core::{ BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, ValidatedBrowserProtocolUse, - ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesCommand, - WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{ @@ -149,6 +150,14 @@ fn map_established_frame_result( result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) } +fn map_node_binding_error( + error: WebDriverBiDiLocateNodesAdmissionError, +) -> WebDriverBiDiLocateNodesExchangeError { + WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), + ) +} + impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// @@ -299,11 +308,7 @@ impl WebDriverBiDiWebSocketEstablished { )?; let handles = result .bind_current_nodes(validated, authority_registry, target) - .map_err(|error| { - WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( - WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), - ) - })?; + .map_err(map_node_binding_error)?; Ok((established, handles)) } } From cd3734e61fc7d6c2ea7f6e637fa500836a90420b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:15:22 -0700 Subject: [PATCH 145/211] test(network): require in-wrapper binding conversion --- ...driver_bidi_locate_nodes_coverage_shape.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py index 254843349..2b5b4d815 100644 --- a/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py +++ b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py @@ -7,12 +7,12 @@ borrowed trait object at this boundary: its behavior remains stateful and caller owned without multiplying production coverage regions. -Likewise, error conversion at the public binding wrapper must use a named production -function rather than an inline closure. The library is linked into both unit and -integration-test harnesses; an inline closure can therefore acquire a second uncovered -instantiation even when the real fail-closed binding path is exercised. Keeping that -conversion named makes exact coverage represent product behavior rather than linker -instantiation shape. +The final node-binding error conversion must also avoid generating a separate closure +or helper function. The library is linked into both unit and integration-test +harnesses, and either form can acquire an uncovered duplicate instantiation even when +the real fail-closed binding path is exercised. A direct match inside the already +exercised public wrapper keeps the typed error conversion at the causal boundary +without adding another production symbol for coverage to duplicate. """ from __future__ import annotations @@ -44,10 +44,11 @@ def test_pong_entropy_callback_is_non_generic_at_exchange_boundary(self) -> None source, ) - def test_node_binding_error_conversion_is_named_not_inline_closure(self) -> None: + def test_node_binding_error_conversion_stays_inside_exercised_wrapper(self) -> None: source = SOURCE.read_text(encoding="utf-8") - self.assertIn("fn map_node_binding_error(", source) - self.assertIn(".map_err(map_node_binding_error)?;", source) + self.assertIn("let handles = match result.bind_current_nodes(", source) + self.assertNotIn("fn map_node_binding_error(", source) + self.assertNotIn(".map_err(map_node_binding_error)?;", source) self.assertNotIn(".map_err(|error| {", source) From 6400d2ef4e5fa35cc8ad5392aa03129e78b9103a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:17:35 -0700 Subject: [PATCH 146/211] fix(network): keep binding conversion in exercised wrapper --- .../webdriver_bidi_locate_nodes_exchange.rs | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 7e8118733..6c52ae76e 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -7,9 +7,8 @@ use std::{ use originweave_core::{ BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, ValidatedBrowserProtocolUse, - ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesAdmissionError, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, - WebDriverBiDiResponseDocumentAdmissionError, + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{ @@ -150,14 +149,6 @@ fn map_established_frame_result( result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) } -fn map_node_binding_error( - error: WebDriverBiDiLocateNodesAdmissionError, -) -> WebDriverBiDiLocateNodesExchangeError { - WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( - WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), - ) -} - impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// @@ -306,9 +297,14 @@ impl WebDriverBiDiWebSocketEstablished { next_pong_key, exchange_timeout, )?; - let handles = result - .bind_current_nodes(validated, authority_registry, target) - .map_err(map_node_binding_error)?; + let handles = match result.bind_current_nodes(validated, authority_registry, target) { + Ok(handles) => handles, + Err(error) => { + return Err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), + )); + } + }; Ok((established, handles)) } } From 54db15ae3a42973099ab14c29d06e752289c59c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:19:49 -0700 Subject: [PATCH 147/211] test(network): reproduce WebSocket nonce debug disclosure --- .../webdriver_bidi_websocket_handshake.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 64c1bba6e..be447fd90 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -13,6 +13,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REDACTED_CLIENT_KEY: &str = ""; fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); @@ -43,6 +44,57 @@ fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { connection } +#[test] +fn client_key_debug_redacts_websocket_nonce() { + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + + let debug = format!("{key:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); +} + +#[test] +fn handshake_plan_debug_redacts_websocket_nonce() { + 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 || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let debug = format!("{plan:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + #[test] fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { let listener = TcpListener::bind(("127.0.0.1", 0)); From 3ade84e6e81bf94db96bf3411ddd394334b9cf98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:25:15 -0700 Subject: [PATCH 148/211] test(network): exercise binding wrapper in unit coverage crate --- ..._nodes_exchange_transport_failure_tests.rs | 104 +++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index 1d84f1544..57041b676 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -7,7 +7,10 @@ use std::{ }; use originweave_core::{ - WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, WebDriverBiDiWebSocketEndpoint, }; @@ -20,6 +23,11 @@ use crate::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; fn connect(endpoint: &str) -> Result> { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; @@ -97,6 +105,29 @@ fn locate_nodes_command() -> Result Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + #[test] fn locate_nodes_exchange_preserves_initial_command_write_failure() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; @@ -263,3 +294,74 @@ fn zero_exchange_timeout_fails_at_exchange_boundary_before_frame_write() assert!(server_result.is_ok(), "{server_result:?}"); Ok(()) } + +#[test] +fn zero_exchange_timeout_binding_wrapper_executes_in_unit_crate_before_frame_write() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + let count = stream.read(&mut byte)?; + if count != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "zero-budget binding wrapper wrote a locateNodes client frame", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + + let exchanged = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::ZERO, + (semantic_observation_proof()?, target), + &mut registry, + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("zero-budget locateNodes binding wrapper unexpectedly succeeded") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + } if exchange_timeout.is_zero() + ), + "{error:?}" + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("zero-budget binding-wrapper test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + Ok(()) +} From ba5b9be71c13b2ca0b109670ca4703feaed4404e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:28:48 -0700 Subject: [PATCH 149/211] fix(network): redact WebSocket nonce diagnostics --- .../src/webdriver_bidi_websocket_handshake.rs | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 3c826e626..d2826e425 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 REDACTED_WEBSOCKET_CLIENT_NONCE: &str = ""; /// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. /// @@ -77,10 +78,20 @@ impl Error for WebDriverBiDiWebSocketHandshakeError {} /// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type /// validates only the canonical wire representation, including zero padding bits. It does not /// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce -/// for each connection attempt. -#[derive(Debug, Eq, PartialEq)] +/// for each connection attempt. Its [`fmt::Debug`] representation deliberately redacts the nonce so +/// diagnostic output cannot disclose handshake material. +#[derive(Eq, PartialEq)] pub struct WebDriverBiDiWebSocketClientKey(String); +impl fmt::Debug for WebDriverBiDiWebSocketClientKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("WebDriverBiDiWebSocketClientKey") + .field(&REDACTED_WEBSOCKET_CLIENT_NONCE) + .finish() + } +} + impl WebDriverBiDiWebSocketClientKey { /// Admit one canonical base64 client key representing exactly 16 bytes. pub fn new(value: &str) -> Result { @@ -104,18 +115,29 @@ impl WebDriverBiDiWebSocketClientKey { /// the fixed WebSocket version-13 request required for the admitted `/session/` resource /// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. /// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary -/// before any WebSocket bytes may be written. +/// before any WebSocket bytes may be written. Its [`fmt::Debug`] representation omits the serialized +/// request and redacts the client nonce because the request embeds that nonce in `Sec-WebSocket-Key`. /// /// 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_nonce", &REDACTED_WEBSOCKET_CLIENT_NONCE) + .field("request_byte_count", &self.request.len()) + .finish() + } +} + impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( From 853808eb8db23d52277fc300e9a92a25fb437548 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:31:08 -0700 Subject: [PATCH 150/211] test(network): cover successful binding wrapper in unit crate --- ...cate_nodes_exchange_unit_coverage_tests.rs | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs new file mode 100644 index 000000000..5afbbe8ec --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs @@ -0,0 +1,177 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; + +use crate::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one masked final client text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture rejects 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_client_text_frame(&mut stream)?; + let response = RESPONSE_DOCUMENT.as_bytes(); + let response_length = u8::try_from(response.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "test response exceeds short frame") + })?; + if response_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeds short frame", + )); + } + stream.write_all(&[0x81, response_length])?; + stream.write_all(response) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)? + .connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + + let (_established, handles) = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + (semantic_observation_proof()?, target), + &mut registry, + )?; + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].origin(), &origin); + + server + .join() + .map_err(|_| io::Error::other("unit coverage test server panicked"))??; + Ok(()) +} From b6921622527e1ee39547e62b6e0dec49de841ea5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:31:31 -0700 Subject: [PATCH 151/211] test(network): register binding wrapper unit coverage --- crates/originweave-network/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index dba29c0c2..d8df2f977 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -19,6 +19,8 @@ mod webdriver_bidi_connection; mod webdriver_bidi_locate_nodes_exchange; #[cfg(test)] mod webdriver_bidi_locate_nodes_exchange_transport_failure_tests; +#[cfg(test)] +mod webdriver_bidi_locate_nodes_exchange_unit_coverage_tests; mod webdriver_bidi_websocket_control; #[cfg(test)] #[allow(clippy::expect_used)] From 18f2dff9b9564874f58ceee89186d4d612b9ca10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:35:38 -0700 Subject: [PATCH 152/211] test(network): apply canonical unit coverage rustfmt --- ...ver_bidi_locate_nodes_exchange_unit_coverage_tests.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs index 5afbbe8ec..7d4090db3 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs @@ -123,7 +123,10 @@ fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), B read_client_text_frame(&mut stream)?; let response = RESPONSE_DOCUMENT.as_bytes(); let response_length = u8::try_from(response.len()).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "test response exceeds short frame") + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeds short frame", + ) })?; if response_length > 125 { return Err(io::Error::new( @@ -139,8 +142,8 @@ fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), B let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; let correlated = admitted.correlate_session_id(SESSION_ID)?; let target = correlated.into_explicit_connect_target()?; - let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)? - .connect()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; let written = plan.write_opening_request(Duration::from_millis(500))?; From 5e0eab268482bade7356529281f740f42e716968 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:39:10 -0700 Subject: [PATCH 153/211] test(network): exercise locateNodes initial write rejection in integration --- ...i_locate_nodes_binding_exchange_failure.rs | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index 50615339b..6c0a73559 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -15,12 +15,14 @@ use originweave_core::{ }; use originweave_network::{ WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REUSED_MASK_REASON: &str = + "client masking key was already used on this established WebSocket"; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = OriginWeaveProtocolVersion::new(0, 1); const ADAPTER_VERSION: &str = "originweave-bidi-v1"; @@ -92,6 +94,22 @@ fn read_client_text_frame(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 a second frame after fail-closed locateNodes command rejection", + )), + Err(error) => Err(io::Error::new( + error.kind(), + format!("client did not close after fail-closed locateNodes rejection: {error}"), + )), + } +} + fn establish_with_unexpected_binary_response() -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; @@ -153,6 +171,59 @@ fn semantic_observation_proof() -> Result Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let first_frame = read_client_text_frame(&mut stream)?; + require_peer_closed_without_another_frame(&mut stream)?; + Ok(first_frame) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); + let established = established.write_text_frame( + "coverage-primer", + reused_mask, + Duration::from_millis(500), + )?; + + let error = established.exchange_locate_nodes( + locate_nodes_command()?, + reused_mask, + &mut || None, + Duration::from_millis(500), + ); + assert!(matches!( + error, + Err(WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_MASK_REASON, + } + )) + )); + + let first_frame = server.join().map_err(|_| "test server panicked")??; + assert_eq!(first_frame, b"coverage-primer"); + Ok(()) +} + #[test] fn live_binding_wrapper_fails_closed_when_wire_exchange_fails_before_binding() -> Result<(), Box> { From 8385467af36dcceb2a1408d4ed7c924ea60d3743 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:40:42 -0700 Subject: [PATCH 154/211] 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 d1150374b33c1437860402e82f6897343d2abf2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:40:51 -0700 Subject: [PATCH 155/211] test(network): apply canonical locateNodes integration rustfmt --- ...webdriver_bidi_locate_nodes_binding_exchange_failure.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index 6c0a73559..7ead71ae7 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -198,11 +198,8 @@ fn locate_nodes_command_mask_reuse_fails_before_second_wire_write() -> Result<() let written = plan.write_opening_request(Duration::from_millis(500))?; let established = written.read_opening_response(Duration::from_millis(500))?; let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); - let established = established.write_text_frame( - "coverage-primer", - reused_mask, - Duration::from_millis(500), - )?; + let established = + established.write_text_frame("coverage-primer", reused_mask, Duration::from_millis(500))?; let error = established.exchange_locate_nodes( locate_nodes_command()?, From 22bf20ec77be81858e627b9b1316af5e2fc92a82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:47:14 -0700 Subject: [PATCH 156/211] 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 157/211] 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 370594cb3b12db11eedaf5fd5914454c01f3021d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:05:06 -0700 Subject: [PATCH 158/211] test(network): require fragmented BiDi response reassembly --- ...bdriver_bidi_locate_nodes_fragmentation.rs | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs new file mode 100644 index 000000000..6a32c4f8b --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs @@ -0,0 +1,176 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; + +type EstablishedFragmentServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client command was not one final masked text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + let payload_length = u64::from_be_bytes(extended); + usize::try_from(payload_length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client command length did not fit usize", + ) + })? + } + marker => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected WebSocket length marker {marker}"), + )); + } + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn establish_with_fragmented_response() -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_text_frame(&mut stream)?; + + let response = RESPONSE_DOCUMENT.as_bytes(); + let split = response.len() / 2; + let first = &response[..split]; + let second = &response[split..]; + let first_length = u8::try_from(first.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "first response fragment exceeded one-byte test length", + ) + })?; + let second_length = u8::try_from(second.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "second response fragment exceeded one-byte test length", + ) + })?; + + let mut frames = Vec::with_capacity(response.len() + 4); + frames.extend_from_slice(&[0x01, first_length]); + frames.extend_from_slice(first); + frames.extend_from_slice(&[0x80, second_length]); + frames.extend_from_slice(second); + stream.write_all(&frames) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +#[test] +fn locate_nodes_exchange_reassembles_fragmented_text_response() -> Result<(), Box> { + let (established, server) = establish_with_fragmented_response()?; + let command = locate_nodes_command()?; + let mut no_pong_keys = || None; + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("fragmentation regression server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + assert!(exchanged.is_ok(), "{exchanged:?}"); + + let (_, result) = exchanged?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + Ok(()) +} From 87a9171a148ff684c4fff85d4c9fed265ea9a615 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:10:41 -0700 Subject: [PATCH 159/211] fix(network): reassemble bounded fragmented BiDi responses --- .../webdriver_bidi_locate_nodes_exchange.rs | 115 +++++++++++++----- 1 file changed, 86 insertions(+), 29 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 6c52ae76e..ec5358751 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -6,9 +6,10 @@ use std::{ use originweave_core::{ BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, - BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, ValidatedBrowserProtocolUse, - ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesCommand, - WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, + BrowserContextOriginEpochDispatchTarget, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, + ObservedNodeHandle, ValidatedBrowserProtocolUse, ValidatedWebDriverBiDiLocateNodesResult, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{ @@ -50,14 +51,14 @@ pub enum WebDriverBiDiLocateNodesExchangeError { PongMaskingKeyUnavailable, /// A caller supplied a Pong masking key already used by a client frame in this exchange. PongMaskingKeyReused, - /// The returned frame was neither an admissible control frame nor one complete text response. + /// The returned frame could not continue the one admissible text response message. UnexpectedResponseFrame { /// Whether the returned frame carried the RFC 6455 FIN bit. fin: bool, /// Exact returned RFC 6455 opcode. opcode: u8, }, - /// The exact response-frame payload failed bounded raw-document admission. + /// The exact response-message payload failed bounded raw-document admission. ResponseDocument(WebDriverBiDiResponseDocumentAdmissionError), /// The admitted response document failed parsing, exact correlation, or node admission. LocateNodesResponse(WebDriverBiDiLocateNodesResponseDocumentError), @@ -88,11 +89,11 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { ), Self::UnexpectedResponseFrame { fin, opcode } => write!( formatter, - "WebDriver BiDi locateNodes exchange requires control handling or one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" + "WebDriver BiDi locateNodes exchange requires control handling or one bounded text response message; received fin={fin}, opcode=0x{opcode:02x}" ), Self::ResponseDocument(error) => write!( formatter, - "WebDriver BiDi locateNodes response frame failed raw-document admission: {error}" + "WebDriver BiDi locateNodes response message failed raw-document admission: {error}" ), Self::LocateNodesResponse(error) => write!( formatter, @@ -149,6 +150,30 @@ fn map_established_frame_result( result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) } +fn append_response_fragment( + response_message: &mut Vec, + payload: &[u8], +) -> Result<(), WebDriverBiDiLocateNodesExchangeError> { + if payload.len() > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - response_message.len() { + return Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge, + )); + } + response_message.extend_from_slice(payload); + Ok(()) +} + +fn admit_response_payload( + command: &WebDriverBiDiLocateNodesCommand, + payload: &[u8], +) -> Result { + let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(payload) + .map_err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument)?; + command + .admit_response_document_nodes(document) + .map_err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse) +} + impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// @@ -159,22 +184,29 @@ impl WebDriverBiDiWebSocketEstablished { /// `next_pong_key`; exhausting that caller-owned entropy source or repeating any key already used /// by the command or a prior Pong fails closed before another client frame is emitted. The caller /// remains responsible for generating each supplied key from a strong unpredictable entropy - /// source; exact non-reuse checks do not prove cryptographic unpredictability. Close, binary, - /// continuation, fragmented data, and reserved shapes are not reinterpreted as a BiDi response. + /// source; exact non-reuse checks do not prove cryptographic unpredictability. + /// + /// RFC 6455 text-message fragmentation is reassembled only for one response message at a time. + /// A non-final text frame starts that message, continuation frames extend it in order, and a final + /// continuation completes it. Ping/Pong control frames remain admissible between fragments. The + /// total assembled response is capped by [`MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES`] before + /// allocation can grow beyond the existing pre-parser budget. Orphan continuations, a second data + /// message before completion, binary/Close/reserved shapes, and malformed frame sequences fail + /// closed and consume the transport state. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, - /// and response read. Elapsed time is subtracted before every operation, including the initial - /// command write, and the budget is never reset. Each individual frame operation is additionally - /// capped at the established frame timeout ceiling, so a longer end-to-end exchange budget - /// remains valid without widening the per-operation I/O bound. The underlying frame boundary - /// independently caps each frame at its existing size ceiling. In addition, at most - /// [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid Ping/Pong frames are processed before - /// the exchange fails closed, so RFC 6455 control-frame interleaving cannot create an unbounded - /// iteration budget even when the wall-clock deadline has not yet expired. Any failure consumes - /// this transport state and yields no reusable WebSocket stream, preventing a partially - /// written/read protocol state from becoming later authority. + /// response-fragment read, and response read. Elapsed time is subtracted before every operation, + /// including the initial command write, and the budget is never reset. Each individual frame + /// operation is additionally capped at the established frame timeout ceiling, so a longer + /// end-to-end exchange budget remains valid without widening the per-operation I/O bound. The + /// underlying frame boundary independently caps each frame at its existing size ceiling. In + /// addition, at most [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid Ping/Pong frames are + /// processed before the exchange fails closed, so RFC 6455 control-frame interleaving cannot + /// create an unbounded iteration budget even when the wall-clock deadline has not yet expired. + /// Any failure consumes this transport state and yields no reusable WebSocket stream, preventing + /// a partially written/read protocol state from becoming later authority. /// - /// The final complete text payload passes the existing bounded UTF-8/document admission, + /// The final complete text message passes the existing bounded UTF-8/document admission, /// complete WebDriver BiDi response parser, exact command-id correlation, and wire-derived node /// admission. Success returns the same exact peer-verified WebSocket stream plus untrusted /// normalized node evidence. It does not authenticate Chromium/ChromeDriver process provenance, @@ -202,6 +234,8 @@ impl WebDriverBiDiWebSocketEstablished { let mut used_client_masking_keys = [command_masking_key; MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1]; let mut used_client_masking_key_count = 1_usize; + let mut response_message = Vec::new(); + let mut assembling_text_response = false; loop { let remaining_timeout = @@ -242,15 +276,21 @@ impl WebDriverBiDiWebSocketEstablished { ))?; } 0xa => {} - 0x1 if frame.fin() => { - let document = - BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(frame.payload()) - .map_err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument)?; - let result = command - .admit_response_document_nodes(document) - .map_err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse)?; + 0x1 if !assembling_text_response && frame.fin() => { + let result = admit_response_payload(&command, frame.payload())?; return Ok((established, result)); } + 0x1 if !assembling_text_response => { + append_response_fragment(&mut response_message, frame.payload())?; + assembling_text_response = true; + } + 0x0 if assembling_text_response => { + append_response_fragment(&mut response_message, frame.payload())?; + if frame.fin() { + let result = admit_response_payload(&command, &response_message)?; + return Ok((established, result)); + } + } _ => { return Err( WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { @@ -314,14 +354,16 @@ mod tests { use std::{error::Error as _, time::Duration}; use originweave_core::{ - WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, + MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; use super::{ MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, - next_pong_masking_key, remaining_exchange_budget, remaining_frame_operation_budget, + append_response_fragment, next_pong_masking_key, remaining_exchange_budget, + remaining_frame_operation_budget, }; #[test] @@ -363,6 +405,21 @@ mod tests { ); } + #[test] + fn response_fragment_buffer_never_exceeds_document_budget() { + let mut response = vec![0_u8; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - 1]; + assert!(append_response_fragment(&mut response, b"x").is_ok()); + assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); + + assert!(matches!( + append_response_fragment(&mut response, b"y"), + Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge + )) + )); + assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); + } + #[test] fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); From 3e53784dd5f4a17bc15c281d1fbf86cdeb64b610 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:14:15 -0700 Subject: [PATCH 160/211] fix(network): preserve consuming BiDi command admission --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index ec5358751..c8a1086e2 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -164,7 +164,7 @@ fn append_response_fragment( } fn admit_response_payload( - command: &WebDriverBiDiLocateNodesCommand, + command: WebDriverBiDiLocateNodesCommand, payload: &[u8], ) -> Result { let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(payload) @@ -277,7 +277,7 @@ impl WebDriverBiDiWebSocketEstablished { } 0xa => {} 0x1 if !assembling_text_response && frame.fin() => { - let result = admit_response_payload(&command, frame.payload())?; + let result = admit_response_payload(command, frame.payload())?; return Ok((established, result)); } 0x1 if !assembling_text_response => { @@ -287,7 +287,7 @@ impl WebDriverBiDiWebSocketEstablished { 0x0 if assembling_text_response => { append_response_fragment(&mut response_message, frame.payload())?; if frame.fin() { - let result = admit_response_payload(&command, &response_message)?; + let result = admit_response_payload(command, &response_message)?; return Ok((established, result)); } } From 1a3d80a5357ec7ba369b9863cd17b2fb9410dcba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:18:00 -0700 Subject: [PATCH 161/211] test(network): keep fragmented BiDi response semantics current --- .../tests/webdriver_bidi_websocket_locate_nodes_exchange.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index 6a6436c16..9185029b8 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -296,9 +296,9 @@ fn exchange_deadline_is_not_reset_after_the_frame_write() { } #[test] -fn exchange_rejects_a_non_final_or_non_text_response_frame() { +fn exchange_rejects_binary_or_orphan_continuation_response_frames() { for (first_byte, expected_fin, expected_opcode) in - [(0x01_u8, false, 0x01_u8), (0x82_u8, true, 0x02_u8)] + [(0x82_u8, true, 0x02_u8), (0x80_u8, true, 0x00_u8)] { let response_frame = server_frame(first_byte, &[]); assert!(response_frame.is_ok(), "{response_frame:?}"); From 0e797772ed727c520048aae22ae2c93ab152f69e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:20:20 -0700 Subject: [PATCH 162/211] test(network): exercise Ping between BiDi response fragments --- ...bdriver_bidi_locate_nodes_fragmentation.rs | 145 +++++++++++++----- 1 file changed, 109 insertions(+), 36 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs index 6a32c4f8b..5e10a7c9e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs @@ -19,6 +19,7 @@ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const PING_PAYLOAD: &[u8] = b"ping-between-fragments"; type EstablishedFragmentServer = ( originweave_network::WebDriverBiDiWebSocketEstablished, @@ -52,14 +53,14 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result> { +fn read_masked_client_frame(stream: &mut TcpStream, expected_first_byte: u8) -> io::Result> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; let mut header = [0_u8; 2]; stream.read_exact(&mut header)?; - if header[0] != 0x81 || header[1] & 0x80 == 0 { + if header[0] != expected_first_byte || header[1] & 0x80 == 0 { return Err(io::Error::new( io::ErrorKind::InvalidData, - "client command was not one final masked text frame", + "client frame did not use the expected final masked opcode", )); } let payload_length = match header[1] & 0x7f { @@ -76,7 +77,7 @@ fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result> usize::try_from(payload_length).map_err(|_| { io::Error::new( io::ErrorKind::InvalidData, - "client command length did not fit usize", + "client frame length did not fit usize", ) })? } @@ -97,6 +98,10 @@ fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result> Ok(payload) } +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result> { + read_masked_client_frame(stream, 0x81) +} + fn locate_nodes_command() -> Result> { let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; Ok(WebDriverBiDiLocateNodesCommand::new( @@ -106,6 +111,33 @@ fn locate_nodes_command() -> Result io::Result<(Vec, Vec)> { + let response = RESPONSE_DOCUMENT.as_bytes(); + let split = response.len() / 2; + let first = &response[..split]; + let second = &response[split..]; + let first_length = u8::try_from(first.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "first response fragment exceeded one-byte test length", + ) + })?; + let second_length = u8::try_from(second.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "second response fragment exceeded one-byte test length", + ) + })?; + + let mut first_frame = Vec::with_capacity(first.len() + 2); + first_frame.extend_from_slice(&[0x01, first_length]); + first_frame.extend_from_slice(first); + let mut second_frame = Vec::with_capacity(second.len() + 2); + second_frame.extend_from_slice(&[0x80, second_length]); + second_frame.extend_from_slice(second); + Ok((first_frame, second_frame)) +} + fn establish_with_fragmented_response() -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; @@ -116,30 +148,45 @@ fn establish_with_fragmented_response() -> Result Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_text_frame(&mut stream)?; + let (first_frame, second_frame) = response_fragments()?; + stream.write_all(&first_frame)?; + let ping_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "test Ping payload exceeded 125 bytes") })?; + stream.write_all(&[0x89, ping_length])?; + stream.write_all(PING_PAYLOAD)?; - let mut frames = Vec::with_capacity(response.len() + 4); - frames.extend_from_slice(&[0x01, first_length]); - frames.extend_from_slice(first); - frames.extend_from_slice(&[0x80, second_length]); - frames.extend_from_slice(second); - stream.write_all(&frames) + let pong_payload = read_masked_client_frame(&mut stream, 0x8a)?; + if pong_payload != PING_PAYLOAD { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client Pong did not echo the interleaved Ping payload", + )); + } + stream.write_all(&second_frame) }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); @@ -150,6 +197,29 @@ fn establish_with_fragmented_response() -> Result, + server: thread::JoinHandle>, +) -> Result<(), Box> { + let server_result = server + .join() + .map_err(|_| io::Error::other("fragmentation regression server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + assert!(exchanged.is_ok(), "{exchanged:?}"); + + let (_, result) = exchanged?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + Ok(()) +} + #[test] fn locate_nodes_exchange_reassembles_fragmented_text_response() -> Result<(), Box> { let (established, server) = establish_with_fragmented_response()?; @@ -161,16 +231,19 @@ fn locate_nodes_exchange_reassembles_fragmented_text_response() -> Result<(), Bo &mut no_pong_keys, Duration::from_millis(500), ); + assert_successful_exchange(exchanged, server) +} - let server_result = server - .join() - .map_err(|_| io::Error::other("fragmentation regression server panicked"))?; - assert!(server_result.is_ok(), "{server_result:?}"); - assert!(exchanged.is_ok(), "{exchanged:?}"); - - let (_, result) = exchanged?; - assert_eq!(result.command_id(), 7); - assert_eq!(result.nodes().len(), 1); - assert_eq!(result.nodes()[0].shared_id(), "shared-1"); - Ok(()) +#[test] +fn locate_nodes_exchange_handles_ping_between_response_fragments() -> Result<(), Box> { + let (established, server) = establish_with_ping_between_fragments()?; + let command = locate_nodes_command()?; + let mut pong_keys = || Some(WebDriverBiDiWebSocketMaskKey::new([0x55, 0x66, 0x77, 0x88])); + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut pong_keys, + Duration::from_millis(500), + ); + assert_successful_exchange(exchanged, server) } From 8e061d68cb82731e54da43a336e29d960b663125 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:22:26 -0700 Subject: [PATCH 163/211] style(network): apply canonical fragmentation test formatting --- .../tests/webdriver_bidi_locate_nodes_fragmentation.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs index 5e10a7c9e..b1c98b61a 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs @@ -53,7 +53,10 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn read_masked_client_frame(stream: &mut TcpStream, expected_first_byte: u8) -> io::Result> { +fn read_masked_client_frame( + stream: &mut TcpStream, + expected_first_byte: u8, +) -> io::Result> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; let mut header = [0_u8; 2]; stream.read_exact(&mut header)?; @@ -174,7 +177,10 @@ fn establish_with_ping_between_fragments() -> Result Date: Wed, 26 Aug 2026 02:12:58 -0700 Subject: [PATCH 164/211] test(network): cover fragmented locateNodes boundaries --- ...i_locate_nodes_fragmentation_boundaries.rs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs new file mode 100644 index 000000000..fc9c9a7b1 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs @@ -0,0 +1,269 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiResponseDocumentAdmissionError, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; + +type EstablishedFrameServer = ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client frame payload length did not fit usize", + ) + })? + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn server_frame(fin: bool, opcode: u8, payload: &[u8]) -> io::Result> { + let first_byte = if fin { 0x80 | opcode } else { opcode }; + let mut frame = Vec::with_capacity(payload.len() + 10); + frame.push(first_byte); + + if payload.len() <= 125 { + frame.push(u8::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "short server frame length did not fit u8", + ) + })?); + } else if payload.len() <= usize::from(u16::MAX) { + frame.push(126); + frame.extend_from_slice( + &u16::try_from(payload.len()) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "medium server frame length did not fit u16", + ) + })? + .to_be_bytes(), + ); + } else { + frame.push(127); + frame.extend_from_slice( + &u64::try_from(payload.len()) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "large server frame length did not fit u64", + ) + })? + .to_be_bytes(), + ); + } + + frame.extend_from_slice(payload); + Ok(frame) +} + +fn establish_with_frames(frames: Vec>) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + for frame in frames { + stream.write_all(&frame)?; + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn join_server(server: thread::JoinHandle>) -> Result<(), Box> { + server + .join() + .map_err(|_| io::Error::other("fragmentation boundary server panicked"))??; + Ok(()) +} + +#[test] +fn three_fragment_response_reassembles() -> Result<(), Box> { + let response = RESPONSE_DOCUMENT.as_bytes(); + let first_end = response.len() / 3; + let second_end = first_end * 2; + let frames = vec![ + server_frame(false, 0x1, &response[..first_end])?, + server_frame(false, 0x0, &response[first_end..second_end])?, + server_frame(true, 0x0, &response[second_end..])?, + ]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + let (_, result) = exchange?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + Ok(()) +} + +#[test] +fn oversized_initial_fragment_fails_closed() -> Result<(), Box> { + let payload = vec![b'{'; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1]; + let frames = vec![server_frame(false, 0x1, &payload)?]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge + )) + )); + Ok(()) +} + +#[test] +fn fragmented_response_over_budget_fails_closed() -> Result<(), Box> { + let first_payload = vec![b'{'; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES]; + let frames = vec![ + server_frame(false, 0x1, &first_payload)?, + server_frame(true, 0x0, b"}")?, + ]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge + )) + )); + Ok(()) +} + +#[test] +fn fragmented_invalid_utf8_fails_closed() -> Result<(), Box> { + let frames = vec![ + server_frame(false, 0x1, b"{")?, + server_frame(true, 0x0, &[0xff, b'}'])?, + ]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8 + )) + )); + Ok(()) +} From 15914957feb2039192bd460fdc862bc554c60f5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:18:11 -0700 Subject: [PATCH 165/211] test(network): fail closed on impossible frame marker --- .../webdriver_bidi_locate_nodes_fragmentation_boundaries.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs index fc9c9a7b1..a167d526e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs @@ -72,6 +72,12 @@ fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { ) })? } + marker => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid client frame payload-length marker {marker}"), + )); + } }; let mut mask = [0_u8; 4]; From 4c1558d74bc503af32f90a9b7ac6e1bfacfac3d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:35:02 -0700 Subject: [PATCH 166/211] test(network): remove vacuous matches coverage branch --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index c8a1086e2..3b84a6c7f 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -411,12 +411,10 @@ mod tests { assert!(append_response_fragment(&mut response, b"x").is_ok()); assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); - assert!(matches!( - append_response_fragment(&mut response, b"y"), - Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( - WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge - )) - )); + assert_eq!( + format!("{:?}", append_response_fragment(&mut response, b"y")), + "Err(ResponseDocument(DocumentTooLarge))" + ); assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); } From f653c8e6da39690647d939f98f22be8b7135b12f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:36:05 -0700 Subject: [PATCH 167/211] test(network): cover fragmented locateNodes unit branches --- ...cate_nodes_exchange_unit_coverage_tests.rs | 130 ++++++++++++++---- 1 file changed, 102 insertions(+), 28 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs index 7d4090db3..d29336c92 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs @@ -78,6 +78,29 @@ fn read_client_text_frame(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 write_short_server_frame(stream: &mut TcpStream, first_byte: u8, payload: &[u8]) -> io::Result<()> { + let payload_length = u8::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "unit coverage server frame exceeds short-frame limit", + ) + })?; + if payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unit coverage server frame exceeds short-frame limit", + )); + } + stream.write_all(&[first_byte, payload_length])?; + stream.write_all(payload) +} + fn locate_nodes_command() -> Result> { let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; Ok(WebDriverBiDiLocateNodesCommand::new( @@ -110,6 +133,21 @@ fn semantic_observation_proof() -> Result Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + Ok(written.read_opening_response(Duration::from_millis(500))?) +} + #[test] fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; @@ -117,37 +155,12 @@ fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), B 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_client_text_frame(&mut stream)?; - let response = RESPONSE_DOCUMENT.as_bytes(); - let response_length = u8::try_from(response.len()).map_err(|_| { - io::Error::new( - io::ErrorKind::InvalidData, - "test response exceeds short frame", - ) - })?; - if response_length > 125 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "test response exceeds short frame", - )); - } - stream.write_all(&[0x81, response_length])?; - stream.write_all(response) + write_short_server_frame(&mut stream, 0x81, RESPONSE_DOCUMENT.as_bytes()) }); - let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); - let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; - let correlated = admitted.correlate_session_id(SESSION_ID)?; - let target = correlated.into_explicit_connect_target()?; - let connection = - WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; - let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; - let written = plan.write_opening_request(Duration::from_millis(500))?; - let established = written.read_opening_response(Duration::from_millis(500))?; + let established = establish_client(local_addr)?; let mut registry = BrowserAuthorityRegistry::new(); let origin = controlled_origin()?; @@ -178,3 +191,64 @@ fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), B .map_err(|_| io::Error::other("unit coverage test server panicked"))??; Ok(()) } + +#[test] +fn fragmented_response_executes_nonfinal_text_arm_in_library_unit_crate() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + read_client_text_frame(&mut stream)?; + let response = RESPONSE_DOCUMENT.as_bytes(); + write_short_server_frame(&mut stream, 0x01, &response[..1])?; + write_short_server_frame(&mut stream, 0x80, &response[1..]) + }); + + let established = establish_client(local_addr)?; + let (_established, result) = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x21, 0x22, 0x23, 0x24]), + &mut || None, + Duration::from_millis(500), + )?; + assert_eq!(result.nodes().len(), 1); + + server + .join() + .map_err(|_| io::Error::other("fragmented unit coverage test server panicked"))??; + Ok(()) +} + +#[test] +fn second_text_frame_during_fragmentation_executes_guard_denial_in_library_unit_crate( +) -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + read_client_text_frame(&mut stream)?; + write_short_server_frame(&mut stream, 0x01, b"{")?; + write_short_server_frame(&mut stream, 0x81, b"x") + }); + + let established = establish_client(local_addr)?; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x31, 0x32, 0x33, 0x34]), + &mut || None, + Duration::from_millis(500), + ); + assert_eq!( + format!("{exchange:?}"), + "Err(UnexpectedResponseFrame { fin: true, opcode: 1 })" + ); + + server + .join() + .map_err(|_| io::Error::other("second-text unit coverage test server panicked"))??; + Ok(()) +} From 3e71e63d6f89d55fa8095334a9eae1e312b2233d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:38:12 -0700 Subject: [PATCH 168/211] style(network): apply canonical rustfmt to BiDi coverage tests --- ...idi_locate_nodes_exchange_unit_coverage_tests.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs index d29336c92..bda5c2b98 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs @@ -84,7 +84,11 @@ fn write_opening_response(stream: &mut TcpStream) -> io::Result<()> { ) } -fn write_short_server_frame(stream: &mut TcpStream, first_byte: u8, payload: &[u8]) -> io::Result<()> { +fn write_short_server_frame( + stream: &mut TcpStream, + first_byte: u8, + payload: &[u8], +) -> io::Result<()> { let payload_length = u8::try_from(payload.len()).map_err(|_| { io::Error::new( io::ErrorKind::InvalidData, @@ -193,7 +197,8 @@ fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), B } #[test] -fn fragmented_response_executes_nonfinal_text_arm_in_library_unit_crate() -> Result<(), Box> { +fn fragmented_response_executes_nonfinal_text_arm_in_library_unit_crate() +-> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { @@ -222,8 +227,8 @@ fn fragmented_response_executes_nonfinal_text_arm_in_library_unit_crate() -> Res } #[test] -fn second_text_frame_during_fragmentation_executes_guard_denial_in_library_unit_crate( -) -> Result<(), Box> { +fn second_text_frame_during_fragmentation_executes_guard_denial_in_library_unit_crate() +-> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { From f537a4d939e832154feea09c0f7d30aa70920893 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:50:36 -0700 Subject: [PATCH 169/211] test(network): reject second text message during BiDi fragmentation --- ...idi_locate_nodes_fragmented_second_text.rs | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs new file mode 100644 index 000000000..1f201ae72 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs @@ -0,0 +1,139 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client frame payload length did not fit usize", + ) + })? + } + marker => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid client frame payload-length marker {marker}"), + )); + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +#[test] +fn second_text_message_during_fragmentation_fails_closed() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + stream.write_all(&[0x01, 0x01, b'{'])?; + stream.write_all(&[0x81, 0x01, b'x']) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x31, 0x32, 0x33, 0x34]), + &mut || None, + Duration::from_millis(500), + ); + + server + .join() + .map_err(|_| io::Error::other("fragmented second-text server panicked"))??; + assert_eq!( + format!("{exchange:?}"), + "Err(UnexpectedResponseFrame { fin: true, opcode: 1 })" + ); + assert!(matches!( + exchange, + Err(WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: true, + opcode: 0x1 + }) + )); + Ok(()) +} From 2f1ff590fc0c42e457f04c22379f2fe190cdce22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 03:03:30 -0700 Subject: [PATCH 170/211] style(network): apply canonical rustfmt to fragmentation regression --- ...bdriver_bidi_locate_nodes_fragmented_second_text.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs index 1f201ae72..15df8cad0 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs @@ -130,10 +130,12 @@ fn second_text_message_during_fragmentation_fails_closed() -> Result<(), Box Date: Wed, 26 Aug 2026 04:13:11 -0700 Subject: [PATCH 171/211] test(network): bound locateNodes response fragmentation --- ...i_locate_nodes_fragmentation_boundaries.rs | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs index a167d526e..f959dcc1b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs @@ -12,9 +12,10 @@ use originweave_core::{ WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -273,3 +274,33 @@ fn fragmented_invalid_utf8_fails_closed() -> Result<(), Box> { )); Ok(()) } + +#[test] +fn excessive_zero_length_fragments_fail_closed() -> Result<(), Box> { + let mut frames = + Vec::with_capacity(MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE + 1); + frames.push(server_frame(false, 0x1, b"")?); + for _ in 1..=MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE { + frames.push(server_frame(false, 0x0, b"")?); + } + + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_secs(2), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err( + WebDriverBiDiLocateNodesExchangeError::ResponseFragmentLimitExceeded { + maximum_fragments: MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, + } + ) + )); + Ok(()) +} From ce027d58cdbe522d5968e091ed05a02de74f3997 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:15:54 -0700 Subject: [PATCH 172/211] style(network): apply canonical rustfmt to fragmentation budget regression --- .../webdriver_bidi_locate_nodes_fragmentation_boundaries.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs index f959dcc1b..eedd13fd8 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs @@ -277,8 +277,7 @@ fn fragmented_invalid_utf8_fails_closed() -> Result<(), Box> { #[test] fn excessive_zero_length_fragments_fail_closed() -> Result<(), Box> { - let mut frames = - Vec::with_capacity(MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE + 1); + let mut frames = Vec::with_capacity(MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE + 1); frames.push(server_frame(false, 0x1, b"")?); for _ in 1..=MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE { frames.push(server_frame(false, 0x0, b"")?); From 9fa4e461f11a55ba31d09ab65ec764d53649b2d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:20:50 -0700 Subject: [PATCH 173/211] fix(network): bound locateNodes response fragment count --- .../webdriver_bidi_locate_nodes_exchange.rs | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 3b84a6c7f..d37cb9f25 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -25,6 +25,13 @@ use crate::{ /// independent wall-clock bound. pub const MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE: usize = 64; +/// Maximum number of data fragments accepted for one `locateNodes` response message. +/// +/// RFC 6455 permits a text message to be split into an arbitrary number of continuation frames, +/// including empty fragments. This OriginWeave product-safety budget prevents a peer from turning +/// bounded response bytes into unbounded frame-processing work before the end-to-end deadline. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE: usize = 256; + /// Fail-closed failures while exchanging one bounded WebDriver BiDi `locateNodes` command. /// /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded @@ -47,6 +54,11 @@ pub enum WebDriverBiDiLocateNodesExchangeError { /// Maximum number of control frames admitted for one exchange. maximum_control_frames: usize, }, + /// The peer exceeded the local resource budget for response-message data fragments. + ResponseFragmentLimitExceeded { + /// Maximum number of response-message data fragments admitted for one exchange. + maximum_fragments: usize, + }, /// A server Ping required a fresh client masking key, but the caller supplied none. PongMaskingKeyUnavailable, /// A caller supplied a Pong masking key already used by a client frame in this exchange. @@ -81,6 +93,10 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { formatter, "WebDriver BiDi locateNodes exchange exceeded the maximum {maximum_control_frames} interleaved control frames" ), + Self::ResponseFragmentLimitExceeded { maximum_fragments } => write!( + formatter, + "WebDriver BiDi locateNodes exchange exceeded the maximum {maximum_fragments} response-message data fragments" + ), Self::PongMaskingKeyUnavailable => formatter.write_str( "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key", ), @@ -111,6 +127,7 @@ impl Error for WebDriverBiDiLocateNodesExchangeError { Self::LocateNodesResponse(error) => Some(error), Self::ExchangeDeadlineExceeded { .. } | Self::ControlFrameLimitExceeded { .. } + | Self::ResponseFragmentLimitExceeded { .. } | Self::PongMaskingKeyUnavailable | Self::PongMaskingKeyReused | Self::UnexpectedResponseFrame { .. } => None, @@ -150,6 +167,20 @@ fn map_established_frame_result( result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) } +fn admit_response_fragment( + response_fragment_count: &mut usize, +) -> Result<(), WebDriverBiDiLocateNodesExchangeError> { + if *response_fragment_count == MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE { + return Err( + WebDriverBiDiLocateNodesExchangeError::ResponseFragmentLimitExceeded { + maximum_fragments: MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, + }, + ); + } + *response_fragment_count += 1; + Ok(()) +} + fn append_response_fragment( response_message: &mut Vec, payload: &[u8], @@ -190,9 +221,11 @@ impl WebDriverBiDiWebSocketEstablished { /// A non-final text frame starts that message, continuation frames extend it in order, and a final /// continuation completes it. Ping/Pong control frames remain admissible between fragments. The /// total assembled response is capped by [`MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES`] before - /// allocation can grow beyond the existing pre-parser budget. Orphan continuations, a second data - /// message before completion, binary/Close/reserved shapes, and malformed frame sequences fail - /// closed and consume the transport state. + /// allocation can grow beyond the existing pre-parser budget, and at most + /// [`MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE`] accepted data fragments may compose the + /// message, so empty continuation frames cannot create unbounded processing work. Orphan + /// continuations, a second data message before completion, binary/Close/reserved shapes, and + /// malformed frame sequences fail closed and consume the transport state. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, /// response-fragment read, and response read. Elapsed time is subtracted before every operation, @@ -234,6 +267,7 @@ impl WebDriverBiDiWebSocketEstablished { let mut used_client_masking_keys = [command_masking_key; MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1]; let mut used_client_masking_key_count = 1_usize; + let mut response_fragment_count = 0_usize; let mut response_message = Vec::new(); let mut assembling_text_response = false; @@ -277,14 +311,17 @@ impl WebDriverBiDiWebSocketEstablished { } 0xa => {} 0x1 if !assembling_text_response && frame.fin() => { + admit_response_fragment(&mut response_fragment_count)?; let result = admit_response_payload(command, frame.payload())?; return Ok((established, result)); } 0x1 if !assembling_text_response => { + admit_response_fragment(&mut response_fragment_count)?; append_response_fragment(&mut response_message, frame.payload())?; assembling_text_response = true; } 0x0 if assembling_text_response => { + admit_response_fragment(&mut response_fragment_count)?; append_response_fragment(&mut response_message, frame.payload())?; if frame.fin() { let result = admit_response_payload(command, &response_message)?; @@ -361,7 +398,8 @@ mod tests { use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; use super::{ - MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, append_response_fragment, next_pong_masking_key, remaining_exchange_budget, remaining_frame_operation_budget, }; @@ -462,6 +500,16 @@ mod tests { .contains("maximum 64 interleaved control frames") ); + let fragment_limit = WebDriverBiDiLocateNodesExchangeError::ResponseFragmentLimitExceeded { + maximum_fragments: MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, + }; + assert!(fragment_limit.source().is_none()); + assert!( + fragment_limit + .to_string() + .contains("maximum 256 response-message data fragments") + ); + let missing_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable; assert!(missing_mask.source().is_none()); assert!( From 3e504310bb4d07372b401f852c863197eaebdde4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:21:39 -0700 Subject: [PATCH 174/211] feat(network): export locateNodes response fragment budget --- crates/originweave-network/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 872073d98..3d2aac226 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -42,7 +42,8 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; pub use webdriver_bidi_locate_nodes_exchange::{ - MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, }; pub use webdriver_bidi_websocket_handshake::{ WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, From 078648b411c2fed874b77afdad3f31feb819c719 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:33:33 -0700 Subject: [PATCH 175/211] fix(network): remove unreachable first-fragment error edges --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index d37cb9f25..358e7ff56 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -311,12 +311,11 @@ impl WebDriverBiDiWebSocketEstablished { } 0xa => {} 0x1 if !assembling_text_response && frame.fin() => { - admit_response_fragment(&mut response_fragment_count)?; let result = admit_response_payload(command, frame.payload())?; return Ok((established, result)); } 0x1 if !assembling_text_response => { - admit_response_fragment(&mut response_fragment_count)?; + response_fragment_count = 1; append_response_fragment(&mut response_message, frame.payload())?; assembling_text_response = true; } From 02952da6f4c942fe990f7350dd0af9a98cbc1f99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:13:11 -0700 Subject: [PATCH 176/211] 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 177/211] 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 178/211] 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 179/211] 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 400ce7bec2bd649fe0566d5d86745dbbc5026770 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:36:58 -0700 Subject: [PATCH 180/211] fix(network): preserve redacted WebSocket mask-key boundary --- .../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 f10670cb287a564982fb69f4ed052f94ab20fb30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:37:21 -0700 Subject: [PATCH 181/211] fix(network): preserve redacted mask-key export in locateNodes stack --- 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 3d2aac226..59902999d 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -32,6 +32,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, @@ -55,5 +56,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 40335f184fc60cdfaf8e5bbaef70bed3c1998716 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:38:08 -0700 Subject: [PATCH 182/211] fix(network): preserve redacted mask-key transport in locateNodes stack --- .../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 e16acb59c..0b913b635 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) { @@ -219,17 +220,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) } @@ -240,7 +242,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)?; @@ -285,9 +287,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 f94b2ecf821815892d8870410023ed6fa3e7ddae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:38:36 -0700 Subject: [PATCH 183/211] test(network): preserve mask-key redaction regression in locateNodes stack --- .../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 b7e02876e57dfed0302a89cab9539cc272f9010b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 06:13:32 -0700 Subject: [PATCH 184/211] 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 185/211] 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 186/211] 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 187/211] 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 188/211] 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 189/211] 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 190/211] 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 191/211] 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 192/211] 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 193/211] 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 194/211] 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 195/211] 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(()) - } -} From b68e0f6f3b41cc147410fb6eef1050f286bacc6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 08:43:16 -0700 Subject: [PATCH 196/211] fix(network): preserve current frame-transport parent --- .../src/webdriver_bidi_websocket_handshake.rs | 12 ++- .../src/webdriver_bidi_websocket_validated.rs | 65 +++++------- ..._websocket_masking_key_history_capacity.rs | 99 +++++++++++++++++++ ...driver_bidi_websocket_masking_key_reuse.rs | 2 +- .../webdriver_bidi_websocket_pong_write.rs | 2 +- 5 files changed, 136 insertions(+), 44 deletions(-) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs 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( diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index 0b913b635..25f76a566 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 { @@ -217,11 +211,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, @@ -237,8 +232,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], @@ -286,11 +281,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!( @@ -300,17 +294,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()); } } diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs new file mode 100644 index 000000000..509d303a9 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs @@ -0,0 +1,99 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const FRAME_COUNT: u32 = 65_537; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_one_masked_single_byte_text_frame(stream: &mut TcpStream) -> io::Result<()> { + let mut frame = [0_u8; 7]; + stream.read_exact(&mut frame)?; + if frame[0] != 0x81 || frame[1] != 0x81 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client did not send one final masked single-byte text frame", + )); + } + if frame[6] ^ frame[2] != b'x' { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "masked text payload did not decode to the expected byte", + )); + } + Ok(()) +} + +#[test] +fn established_stream_does_not_gain_a_lifetime_frame_cap_from_reuse_detection() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + for _ in 0..FRAME_COUNT { + read_one_masked_single_byte_text_frame(&mut stream)?; + } + Ok(FRAME_COUNT) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let mut established = written.read_opening_response(Duration::from_millis(500))?; + + for ordinal in 0..FRAME_COUNT { + let key_ordinal = (ordinal % (FRAME_COUNT - 1)) + 1; + let masking_key = WebDriverBiDiWebSocketMaskKey::new(key_ordinal.to_be_bytes()); + established = established.write_text_frame("x", masking_key, Duration::from_millis(500))?; + } + drop(established); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket history-cap test server panicked"))??; + assert_eq!(received, FRAME_COUNT); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs 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, 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 9c165ccfb5d45464c16b2e9de7098f49f38210da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 08:47:37 -0700 Subject: [PATCH 197/211] fix(network): bound Pong entropy before deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 74 +++++++++++++++---- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 358e7ff56..14b615464 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -161,6 +161,15 @@ fn next_pong_masking_key( next_key().ok_or(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) } +fn next_pong_masking_key_before_deadline( + next_key: &mut dyn FnMut() -> Option, + exchange_timeout: Duration, + elapsed: Duration, +) -> Result { + remaining_frame_operation_budget(exchange_timeout, elapsed)?; + next_pong_masking_key(next_key) +} + fn map_established_frame_result( result: Result, ) -> Result { @@ -212,10 +221,12 @@ impl WebDriverBiDiWebSocketEstablished { /// text frame using `command_masking_key`. Valid server Ping frames are answered with a masked /// Pong carrying the exact Ping application data, while unsolicited valid Pong frames are /// consumed without changing BiDi state. Each Ping obtains a caller-supplied client mask from - /// `next_pong_key`; exhausting that caller-owned entropy source or repeating any key already used - /// by the command or a prior Pong fails closed before another client frame is emitted. The caller - /// remains responsible for generating each supplied key from a strong unpredictable entropy - /// source; exact non-reuse checks do not prove cryptographic unpredictability. + /// `next_pong_key` only after a positive remaining-budget check; exhausting that caller-owned + /// entropy source or repeating any key already used by the command or a prior Pong fails closed + /// before another client frame is emitted. Callback time is charged by a second deadline check + /// before the Pong write. The caller remains responsible for generating each supplied key from a + /// strong unpredictable entropy source; exact non-reuse checks do not prove cryptographic + /// unpredictability. /// /// RFC 6455 text-message fragmentation is reassembled only for one response message at a time. /// A non-final text frame starts that message, continuation frames extend it in order, and a final @@ -264,9 +275,7 @@ impl WebDriverBiDiWebSocketEstablished { write_timeout, ))?; let mut control_frame_count = 0_usize; - let mut used_client_masking_keys = - [command_masking_key; MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1]; - let mut used_client_masking_key_count = 1_usize; + let mut used_client_masking_keys = vec![command_masking_key]; let mut response_fragment_count = 0_usize; let mut response_message = Vec::new(); let mut assembling_text_response = false; @@ -293,14 +302,15 @@ impl WebDriverBiDiWebSocketEstablished { match opcode { 0x9 => { - let masking_key = next_pong_masking_key(next_pong_key)?; - if used_client_masking_keys[..used_client_masking_key_count] - .contains(&masking_key) - { + let masking_key = next_pong_masking_key_before_deadline( + next_pong_key, + exchange_timeout, + started_at.elapsed(), + )?; + if used_client_masking_keys.contains(&masking_key) { return Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyReused); } - used_client_masking_keys[used_client_masking_key_count] = masking_key; - used_client_masking_key_count += 1; + used_client_masking_keys.push(masking_key); let remaining_timeout = remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; established = map_established_frame_result(established.write_pong_frame( @@ -399,8 +409,8 @@ mod tests { use super::{ MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, - append_response_fragment, next_pong_masking_key, remaining_exchange_budget, - remaining_frame_operation_budget, + append_response_fragment, next_pong_masking_key, next_pong_masking_key_before_deadline, + remaining_exchange_budget, remaining_frame_operation_budget, }; #[test] @@ -468,6 +478,40 @@ mod tests { ); } + #[test] + fn pong_entropy_is_not_drawn_after_exchange_deadline() { + let expected = crate::WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); + let mut draw_count = 0_usize; + { + let mut next = || { + draw_count += 1; + Some(expected) + }; + assert!(matches!( + next_pong_masking_key_before_deadline( + &mut next, + Duration::from_millis(500), + Duration::from_millis(500), + ), + Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + }) if exchange_timeout == Duration::from_millis(500) + )); + } + assert_eq!(draw_count, 0); + + let mut available = || Some(expected); + assert_eq!( + next_pong_masking_key_before_deadline( + &mut available, + Duration::from_millis(500), + Duration::from_millis(100), + ) + .ok(), + Some(expected) + ); + } + #[test] fn exchange_errors_preserve_typed_sources_and_protocol_shape() { let frame = WebDriverBiDiLocateNodesExchangeError::Frame( From 73eb00fdfaccf8320096e20e54672e0104638d68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 09:06:01 -0700 Subject: [PATCH 198/211] test(network): admit non-adjacent mask collision --- ...bidi_locate_nodes_masking_key_freshness.rs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs index 6b1db5c1e..be61b0ff8 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs @@ -17,6 +17,8 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; const PING_PAYLOAD: &[u8] = b"fresh-mask"; const COMMAND_MASK: WebDriverBiDiWebSocketMaskKey = WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); @@ -100,6 +102,17 @@ fn write_ping(stream: &mut TcpStream) -> io::Result<()> { stream.write_all(PING_PAYLOAD) } +fn write_response(stream: &mut TcpStream) -> io::Result<()> { + let payload_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeded one-byte length", + ) + })?; + stream.write_all(&[0x81, payload_length])?; + stream.write_all(RESPONSE_DOCUMENT.as_bytes()) +} + fn locate_nodes_command() -> Result> { let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; Ok(WebDriverBiDiLocateNodesCommand::new( @@ -180,3 +193,45 @@ fn locate_nodes_exchange_rejects_pong_mask_reused_from_prior_pong() -> Result<() assert_eq!(error.to_string(), REUSED_MASK_ERROR); join_server(server) } + +#[test] +fn locate_nodes_exchange_allows_non_adjacent_random_mask_collision() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_frame(&mut stream, 0x81)?; + write_ping(&mut stream)?; + read_masked_client_frame(&mut stream, 0x8a)?; + write_ping(&mut stream)?; + read_masked_client_frame(&mut stream, 0x8a)?; + write_response(&mut stream) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let client_key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, client_key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let mut keys = [PONG_MASK, COMMAND_MASK].into_iter(); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + COMMAND_MASK, + &mut || keys.next(), + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("non-adjacent masking-key collision server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + let (_, result) = exchanged?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + Ok(()) +} From 160620cf4642584a51702bf24a6b1cd8645d82e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 09:08:12 -0700 Subject: [PATCH 199/211] fix(network): permit non-adjacent mask collisions --- .../webdriver_bidi_locate_nodes_exchange.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 14b615464..c64454215 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -37,7 +37,7 @@ pub const MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE: usize = 256; /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded /// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the /// admitted document must correlate to the exact consumed command before result nodes are returned. -/// Protocol-shape, resource-budget, exhausted-deadline, missing caller entropy, and exact client +/// Protocol-shape, resource-budget, exhausted-deadline, missing caller entropy, and adjacent client /// masking-key reuse refusals have no nested source because none masks an underlying I/O or parser /// failure. #[derive(Debug)] @@ -61,7 +61,7 @@ pub enum WebDriverBiDiLocateNodesExchangeError { }, /// A server Ping required a fresh client masking key, but the caller supplied none. PongMaskingKeyUnavailable, - /// A caller supplied a Pong masking key already used by a client frame in this exchange. + /// A caller supplied the same Pong masking key as the immediately preceding client frame. PongMaskingKeyReused, /// The returned frame could not continue the one admissible text response message. UnexpectedResponseFrame { @@ -222,10 +222,11 @@ impl WebDriverBiDiWebSocketEstablished { /// Pong carrying the exact Ping application data, while unsolicited valid Pong frames are /// consumed without changing BiDi state. Each Ping obtains a caller-supplied client mask from /// `next_pong_key` only after a positive remaining-budget check; exhausting that caller-owned - /// entropy source or repeating any key already used by the command or a prior Pong fails closed - /// before another client frame is emitted. Callback time is charged by a second deadline check - /// before the Pong write. The caller remains responsible for generating each supplied key from a - /// strong unpredictable entropy source; exact non-reuse checks do not prove cryptographic + /// entropy source or repeating the immediately preceding successful client-frame key fails + /// closed before another client frame is emitted. A later random collision after a different + /// client key remains admissible; the caller is responsible for deriving every key independently + /// from a strong unpredictable entropy source. Callback time is charged by a second deadline + /// check before the Pong write, and the adjacent-key guard does not claim to prove cryptographic /// unpredictability. /// /// RFC 6455 text-message fragmentation is reassembled only for one response message at a time. @@ -275,7 +276,7 @@ impl WebDriverBiDiWebSocketEstablished { write_timeout, ))?; let mut control_frame_count = 0_usize; - let mut used_client_masking_keys = vec![command_masking_key]; + let mut previous_client_masking_key = command_masking_key; let mut response_fragment_count = 0_usize; let mut response_message = Vec::new(); let mut assembling_text_response = false; @@ -307,10 +308,9 @@ impl WebDriverBiDiWebSocketEstablished { exchange_timeout, started_at.elapsed(), )?; - if used_client_masking_keys.contains(&masking_key) { + if previous_client_masking_key == masking_key { return Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyReused); } - used_client_masking_keys.push(masking_key); let remaining_timeout = remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; established = map_established_frame_result(established.write_pong_frame( @@ -318,6 +318,7 @@ impl WebDriverBiDiWebSocketEstablished { masking_key, remaining_timeout, ))?; + previous_client_masking_key = masking_key; } 0xa => {} 0x1 if !assembling_text_response && frame.fin() => { From cdfdc89f42d25a6d5a676f1c3684a12f7e5c4f2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 09:15:21 -0700 Subject: [PATCH 200/211] test(network): specify adjacent mask reuse diagnostic --- .../tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs index be61b0ff8..cb533c86e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs @@ -24,8 +24,7 @@ const COMMAND_MASK: WebDriverBiDiWebSocketMaskKey = WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); const PONG_MASK: WebDriverBiDiWebSocketMaskKey = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); -const REUSED_MASK_ERROR: &str = - "WebDriver BiDi locateNodes exchange refused a Pong masking key already used by this exchange"; +const REUSED_MASK_ERROR: &str = "WebDriver BiDi locateNodes exchange refused a Pong masking key matching the immediately preceding client frame"; type EstablishedServer = ( originweave_network::WebDriverBiDiWebSocketEstablished, From dec5092494fbcd9de0e7a397d014c3607364f3fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 09:16:54 -0700 Subject: [PATCH 201/211] fix(network): clarify adjacent mask reuse diagnostic --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index c64454215..975437192 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -101,7 +101,7 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key", ), Self::PongMaskingKeyReused => formatter.write_str( - "WebDriver BiDi locateNodes exchange refused a Pong masking key already used by this exchange", + "WebDriver BiDi locateNodes exchange refused a Pong masking key matching the immediately preceding client frame", ), Self::UnexpectedResponseFrame { fin, opcode } => write!( formatter, @@ -567,7 +567,7 @@ mod tests { assert!( reused_mask .to_string() - .contains("Pong masking key already used by this exchange") + .contains("Pong masking key matching the immediately preceding client frame") ); let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { From df321a8f8694579b5eab83ad84497b097cc30807 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 10:05:18 -0700 Subject: [PATCH 202/211] test(network): align mask reuse regression with adjacent-key contract --- .../webdriver_bidi_locate_nodes_binding_exchange_failure.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index 7ead71ae7..c98a22521 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -22,7 +22,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 ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = OriginWeaveProtocolVersion::new(0, 1); const ADAPTER_VERSION: &str = "originweave-bidi-v1"; From 863f9ab22f5f46f55f175b3ec4ae09da8444d0c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:44:16 +0900 Subject: [PATCH 203/211] test(network): restore locateNodes coverage --- CHANGELOG.md | 4 ++ .../webdriver_bidi_locate_nodes_exchange.rs | 38 ++++++++++--------- .../src/webdriver_bidi_websocket_handshake.rs | 11 ------ 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0167c1e..615211820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,10 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Restored exact locateNodes-stack coverage by exercising the deadline callback + without weakening its no-late-entropy assertion and removing an unreachable + private legacy handshake `Debug` implementation superseded by the redacting + wrapper. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 975437192..369d8f2df 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -481,25 +481,27 @@ mod tests { #[test] fn pong_entropy_is_not_drawn_after_exchange_deadline() { + use std::cell::Cell; + let expected = crate::WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); - let mut draw_count = 0_usize; - { - let mut next = || { - draw_count += 1; - Some(expected) - }; - assert!(matches!( - next_pong_masking_key_before_deadline( - &mut next, - Duration::from_millis(500), - Duration::from_millis(500), - ), - Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { - exchange_timeout - }) if exchange_timeout == Duration::from_millis(500) - )); - } - assert_eq!(draw_count, 0); + let draw_count = Cell::new(0_usize); + let mut next = || { + draw_count.set(draw_count.get() + 1); + Some(expected) + }; + assert_eq!(next(), Some(expected)); + draw_count.set(0); + assert!(matches!( + next_pong_masking_key_before_deadline( + &mut next, + Duration::from_millis(500), + Duration::from_millis(500), + ), + Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + }) if exchange_timeout == Duration::from_millis(500) + )); + assert_eq!(draw_count.get(), 0); let mut available = || Some(expected); assert_eq!( diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 5c349ba19..37c37e25a 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -150,17 +150,6 @@ pub struct WebDriverBiDiWebSocketHandshakePlan { request: Vec, } -impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("WebDriverBiDiWebSocketHandshakePlan") - .field("verified_peer", self.connection.verified_peer()) - .field("client_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 6f2c3f1924027cd47c3c5d835f30d058189ac9d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:00:57 +0900 Subject: [PATCH 204/211] test(network): remove uncovered deadline guard --- CHANGELOG.md | 3 ++- .../webdriver_bidi_locate_nodes_exchange.rs | 19 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 615211820..29f89dbb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed -- Restored exact locateNodes-stack coverage by exercising the deadline callback +- Restored exact locateNodes-stack coverage without introducing an uncovered + test-only pattern-guard branch by exercising the deadline callback without weakening its no-late-entropy assertion and removing an unreachable private legacy handshake `Debug` implementation superseded by the redacting wrapper. diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 369d8f2df..7f5b9875a 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -491,16 +491,15 @@ mod tests { }; assert_eq!(next(), Some(expected)); draw_count.set(0); - assert!(matches!( - next_pong_masking_key_before_deadline( - &mut next, - Duration::from_millis(500), - Duration::from_millis(500), - ), - Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { - exchange_timeout - }) if exchange_timeout == Duration::from_millis(500) - )); + let deadline_result = next_pong_masking_key_before_deadline( + &mut next, + Duration::from_millis(500), + Duration::from_millis(500), + ); + assert_eq!( + format!("{deadline_result:?}"), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); assert_eq!(draw_count.get(), 0); let mut available = || Some(expected); From ff92b8af34238be08da18cc58805106b787d83c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:12:17 +0900 Subject: [PATCH 205/211] test(network): remove timing-sensitive deadline case --- CHANGELOG.md | 3 ++ ...er_bidi_websocket_locate_nodes_exchange.rs | 32 ------------------- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f89dbb7..4a094d17f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ All notable changes to OriginWeave are documented in this file. The format follo without weakening its no-late-entropy assertion and removing an unreachable private legacy handshake `Debug` implementation superseded by the redacting wrapper. +- Removed a redundant 20-microsecond loopback deadline assertion whose error + variant depended on host scheduling; deterministic unit-clock tests retain + the shrinking end-to-end budget contract. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index 9185029b8..d04ff0eb0 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -263,38 +263,6 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r } } -#[test] -fn exchange_deadline_is_not_reset_after_the_frame_write() { - let response_frame = server_frame(0x81, RESPONSE_DOCUMENT.as_bytes()); - assert!(response_frame.is_ok(), "{response_frame:?}"); - let Ok(response_frame) = response_frame else { - return; - }; - let fixture = establish_with_server_frame(&response_frame); - assert!(fixture.is_ok(), "{fixture:?}"); - let Ok((_, established, server)) = fixture else { - return; - }; - - let error = established.exchange_locate_nodes( - locate_nodes_command(), - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - &mut || None, - Duration::from_micros(20), - ); - assert!(error.is_err(), "{error:?}"); - let Err(error) = error else { - unreachable!("asserted exhausted exchange deadline") - }; - assert_eq!( - error.to_string(), - "WebDriver BiDi locateNodes exchange exhausted its 20µs end-to-end deadline before the next operation" - ); - - let server_result = server.join(); - assert!(server_result.is_ok(), "{server_result:?}"); -} - #[test] fn exchange_rejects_binary_or_orphan_continuation_response_frames() { for (first_byte, expected_fin, expected_opcode) in From 65fb36afd9a978dbfa59ff45e3b2a5b0c45e3977 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:22:26 +0900 Subject: [PATCH 206/211] test(network): avoid socket-race coverage dependency --- CHANGELOG.md | 2 ++ .../src/webdriver_bidi_locate_nodes_exchange.rs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a094d17f..bb4e2c958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - Removed a redundant 20-microsecond loopback deadline assertion whose error variant depended on host scheduling; deterministic unit-clock tests retain the shrinking end-to-end budget contract. +- Kept the no-late-entropy deadline gate as one result chain so exact coverage + does not require recreating an expired-clock race through a real socket. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 7f5b9875a..9a24ab159 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -166,8 +166,8 @@ fn next_pong_masking_key_before_deadline( exchange_timeout: Duration, elapsed: Duration, ) -> Result { - remaining_frame_operation_budget(exchange_timeout, elapsed)?; - next_pong_masking_key(next_key) + remaining_frame_operation_budget(exchange_timeout, elapsed) + .and_then(|_| next_pong_masking_key(next_key)) } fn map_established_frame_result( From f8013956d9d6e97d58c70d6d897afdb26ddc295e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:36:48 +0900 Subject: [PATCH 207/211] fix(network): bound accumulated BiDi response size --- CHANGELOG.md | 3 +++ .../src/webdriver_bidi_locate_nodes_exchange.rs | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4e2c958..a812a25e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,9 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Made WebDriver BiDi response-fragment admission reject an already oversized + buffer without subtraction underflow or a production panic. + - Restored exact locateNodes-stack coverage without introducing an uncovered test-only pattern-guard branch by exercising the deadline callback without weakening its no-late-entropy assertion and removing an unreachable diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 9a24ab159..ac0c9f267 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -194,7 +194,9 @@ fn append_response_fragment( response_message: &mut Vec, payload: &[u8], ) -> Result<(), WebDriverBiDiLocateNodesExchangeError> { - if payload.len() > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - response_message.len() { + if response_message.len().saturating_add(payload.len()) + > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + { return Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge, )); @@ -466,6 +468,12 @@ mod tests { assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); } + #[test] + fn response_fragment_rejects_an_already_oversized_buffer_without_panicking() { + let mut response = vec![0_u8; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1]; + assert!(append_response_fragment(&mut response, &[]).is_err()); + } + #[test] fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); From e068efdc062b2652ffde662ccaa6be3a75730c14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:43:35 -0700 Subject: [PATCH 208/211] test(network): deterministically cover read deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 139 ++++++++++++++---- 1 file changed, 111 insertions(+), 28 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index ac0c9f267..1c3fa8b79 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -176,6 +176,15 @@ fn map_established_frame_result( result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) } +fn read_frame_with_exchange_budget( + exchange_timeout: Duration, + elapsed: Duration, + read_frame: impl FnOnce(Duration) -> Result, +) -> Result { + let remaining_timeout = remaining_frame_operation_budget(exchange_timeout, elapsed)?; + read_frame(remaining_timeout).map_err(WebDriverBiDiLocateNodesExchangeError::Frame) +} + fn admit_response_fragment( response_fragment_count: &mut usize, ) -> Result<(), WebDriverBiDiLocateNodesExchangeError> { @@ -284,11 +293,11 @@ impl WebDriverBiDiWebSocketEstablished { let mut assembling_text_response = false; loop { - let remaining_timeout = - remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; - let (next_established, frame) = established - .read_frame(remaining_timeout) - .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + let (next_established, frame) = read_frame_with_exchange_budget( + exchange_timeout, + started_at.elapsed(), + |remaining_timeout| established.read_frame(remaining_timeout), + )?; established = next_established; let opcode = frame.opcode(); @@ -373,28 +382,55 @@ impl WebDriverBiDiWebSocketEstablished { command_masking_key: WebDriverBiDiWebSocketMaskKey, next_pong_key: &mut dyn FnMut() -> Option, exchange_timeout: Duration, - authority: ( - ValidatedBrowserProtocolUse, - BrowserContextOriginEpochDispatchTarget<'_>, - ), - authority_registry: &mut BrowserAuthorityRegistry, - ) -> Result<(Self, Vec), WebDriverBiDiLocateNodesExchangeError> { - let (validated, target) = authority; - let (established, result) = self.exchange_locate_nodes( - command, - command_masking_key, - next_pong_key, - exchange_timeout, - )?; - let handles = match result.bind_current_nodes(validated, authority_registry, target) { - Ok(handles) => handles, - Err(error) => { - return Err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( - WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), - )); - } - }; - Ok((established, handles)) + authority: &mut BrowserAuthorityRegistry, + proof: ValidatedBrowserProtocolUse, + target: BrowserContextOriginEpochDispatchTarget, + ) -> Result< + (Self, Vec), + WebDriverBiDiLocateNodesAndBindError, + > { + let (established, result) = self + .exchange_locate_nodes( + command, + command_masking_key, + next_pong_key, + exchange_timeout, + ) + .map_err(WebDriverBiDiLocateNodesAndBindError::Exchange)?; + let nodes = result + .bind_current_nodes(authority, proof, target) + .map_err(WebDriverBiDiLocateNodesAndBindError::Authority)?; + Ok((established, nodes)) + } +} + +/// Failures while exchanging one `locateNodes` command and binding its exact returned nodes. +#[derive(Debug)] +pub enum WebDriverBiDiLocateNodesAndBindError { + /// The bounded WebSocket wire exchange failed; no node evidence reached authority binding. + Exchange(WebDriverBiDiLocateNodesExchangeError), + /// Wire exchange succeeded, but current browser authority no longer admits the observed nodes. + Authority(originweave_core::BrowserAuthorityError), +} + +impl fmt::Display for WebDriverBiDiLocateNodesAndBindError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Exchange(error) => write!(formatter, "WebDriver BiDi locateNodes exchange failed: {error}"), + Self::Authority(error) => write!( + formatter, + "WebDriver BiDi locateNodes current-authority binding failed: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesAndBindError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Exchange(error) => Some(error), + Self::Authority(error) => Some(error), + } } } @@ -413,7 +449,8 @@ mod tests { MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, append_response_fragment, next_pong_masking_key, next_pong_masking_key_before_deadline, - remaining_exchange_budget, remaining_frame_operation_budget, + read_frame_with_exchange_budget, remaining_exchange_budget, + remaining_frame_operation_budget, }; #[test] @@ -455,6 +492,52 @@ mod tests { ); } + #[test] + fn expired_exchange_budget_refuses_frame_read_before_io() { + use std::cell::Cell; + + let exchange_timeout = Duration::from_millis(500); + let read_count = Cell::new(0_usize); + let expired = read_frame_with_exchange_budget( + exchange_timeout, + exchange_timeout, + |_| { + read_count.set(read_count.get() + 1); + Ok::(Duration::ZERO) + }, + ); + assert_eq!( + format!("{expired:?}"), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); + assert_eq!(read_count.get(), 0); + + let available = read_frame_with_exchange_budget( + exchange_timeout, + Duration::from_millis(100), + |remaining_timeout| { + read_count.set(read_count.get() + 1); + Ok::(remaining_timeout) + }, + ); + assert_eq!(available.ok(), Some(Duration::from_millis(400))); + assert_eq!(read_count.get(), 1); + + let frame_error = read_frame_with_exchange_budget( + exchange_timeout, + Duration::from_millis(100), + |_| { + Err::( + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + ) + }, + ); + assert!(frame_error.is_err()); + } + #[test] fn response_fragment_buffer_never_exceeds_document_budget() { let mut response = vec![0_u8; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - 1]; From fc556f3a5b993ff0b8b6c103516192c2b0dc30b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:47:35 -0700 Subject: [PATCH 209/211] fix(network): preserve current bind contract --- .../webdriver_bidi_locate_nodes_exchange.rs | 71 ++++++------------- 1 file changed, 22 insertions(+), 49 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 1c3fa8b79..d7a757d13 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -382,55 +382,28 @@ impl WebDriverBiDiWebSocketEstablished { command_masking_key: WebDriverBiDiWebSocketMaskKey, next_pong_key: &mut dyn FnMut() -> Option, exchange_timeout: Duration, - authority: &mut BrowserAuthorityRegistry, - proof: ValidatedBrowserProtocolUse, - target: BrowserContextOriginEpochDispatchTarget, - ) -> Result< - (Self, Vec), - WebDriverBiDiLocateNodesAndBindError, - > { - let (established, result) = self - .exchange_locate_nodes( - command, - command_masking_key, - next_pong_key, - exchange_timeout, - ) - .map_err(WebDriverBiDiLocateNodesAndBindError::Exchange)?; - let nodes = result - .bind_current_nodes(authority, proof, target) - .map_err(WebDriverBiDiLocateNodesAndBindError::Authority)?; - Ok((established, nodes)) - } -} - -/// Failures while exchanging one `locateNodes` command and binding its exact returned nodes. -#[derive(Debug)] -pub enum WebDriverBiDiLocateNodesAndBindError { - /// The bounded WebSocket wire exchange failed; no node evidence reached authority binding. - Exchange(WebDriverBiDiLocateNodesExchangeError), - /// Wire exchange succeeded, but current browser authority no longer admits the observed nodes. - Authority(originweave_core::BrowserAuthorityError), -} - -impl fmt::Display for WebDriverBiDiLocateNodesAndBindError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Exchange(error) => write!(formatter, "WebDriver BiDi locateNodes exchange failed: {error}"), - Self::Authority(error) => write!( - formatter, - "WebDriver BiDi locateNodes current-authority binding failed: {error}" - ), - } - } -} - -impl Error for WebDriverBiDiLocateNodesAndBindError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Exchange(error) => Some(error), - Self::Authority(error) => Some(error), - } + authority: ( + ValidatedBrowserProtocolUse, + BrowserContextOriginEpochDispatchTarget<'_>, + ), + authority_registry: &mut BrowserAuthorityRegistry, + ) -> Result<(Self, Vec), WebDriverBiDiLocateNodesExchangeError> { + let (validated, target) = authority; + let (established, result) = self.exchange_locate_nodes( + command, + command_masking_key, + next_pong_key, + exchange_timeout, + )?; + let handles = match result.bind_current_nodes(validated, authority_registry, target) { + Ok(handles) => handles, + Err(error) => { + return Err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), + )); + } + }; + Ok((established, handles)) } } From a5ddb503ac08cd8cbca90d92d4aad72414942531 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:51:37 +0900 Subject: [PATCH 210/211] style(network): format deadline coverage --- .../webdriver_bidi_locate_nodes_exchange.rs | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index d7a757d13..26f5ce58a 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -471,14 +471,10 @@ mod tests { let exchange_timeout = Duration::from_millis(500); let read_count = Cell::new(0_usize); - let expired = read_frame_with_exchange_budget( - exchange_timeout, - exchange_timeout, - |_| { - read_count.set(read_count.get() + 1); - Ok::(Duration::ZERO) - }, - ); + let expired = read_frame_with_exchange_budget(exchange_timeout, exchange_timeout, |_| { + read_count.set(read_count.get() + 1); + Ok::(Duration::ZERO) + }); assert_eq!( format!("{expired:?}"), "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" @@ -496,18 +492,15 @@ mod tests { assert_eq!(available.ok(), Some(Duration::from_millis(400))); assert_eq!(read_count.get(), 1); - let frame_error = read_frame_with_exchange_budget( - exchange_timeout, - Duration::from_millis(100), - |_| { + let frame_error = + read_frame_with_exchange_budget(exchange_timeout, Duration::from_millis(100), |_| { Err::( WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { frame_timeout: Duration::ZERO, maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, }, ) - }, - ); + }); assert!(frame_error.is_err()); } From f427aa69151987d7e3369bd96d5739ea38d0f7ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:01:13 +0900 Subject: [PATCH 211/211] test(network): execute deadline guard reader --- .../webdriver_bidi_locate_nodes_exchange.rs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 26f5ce58a..95c5f9a78 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -471,27 +471,26 @@ mod tests { let exchange_timeout = Duration::from_millis(500); let read_count = Cell::new(0_usize); - let expired = read_frame_with_exchange_budget(exchange_timeout, exchange_timeout, |_| { + let read_frame = |remaining_timeout| { read_count.set(read_count.get() + 1); - Ok::(Duration::ZERO) - }); - assert_eq!( - format!("{expired:?}"), - "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" - ); - assert_eq!(read_count.get(), 0); - + Ok::(remaining_timeout) + }; let available = read_frame_with_exchange_budget( exchange_timeout, Duration::from_millis(100), - |remaining_timeout| { - read_count.set(read_count.get() + 1); - Ok::(remaining_timeout) - }, + read_frame, ); assert_eq!(available.ok(), Some(Duration::from_millis(400))); assert_eq!(read_count.get(), 1); + let expired = + read_frame_with_exchange_budget(exchange_timeout, exchange_timeout, read_frame); + assert_eq!( + format!("{expired:?}"), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); + assert_eq!(read_count.get(), 1); + let frame_error = read_frame_with_exchange_budget(exchange_timeout, Duration::from_millis(100), |_| { Err::(