diff --git a/CHANGELOG.md b/CHANGELOG.md index c1777c212..c5b28b0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Regression checks now exercise fragmented browser replies, interleaved control messages, and rejected replies without losing a pending request. These checks do not establish browser readiness or release acceptance. +- The typed browser-status response stack now includes its verified command and opening-exchange prerequisites, including the release-record check that previously did not execute; parsing remains bounded and does not grant browser authority or prove operational readiness. - 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. - Typed outbound WebDriver BiDi `session.status` over the bounded client WebSocket stream: it serializes only the standards-defined method with empty params, preserves exact typed command-id correlation, rejects invalid frame deadlines before registration, retires only the just-registered id when a local masking-key preflight proves no command bytes were emitted, and keeps correlation outstanding after partial or ambiguous writes; frame-write success is not treated as command completion or 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. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 79084c139..f84912a65 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -8,9 +8,10 @@ //! the RFC 6455 opening exchange, provides bounded masked client writes and //! unmasked server-frame reads, assembles bounded WebDriver BiDi text messages, //! classifies complete local-end JSON envelopes, tracks bounded command-response -//! correlation, and sends one narrowly typed `session.status` command without -//! exposing generic JSON bodies or granting browser, TLS, policy, secret, or -//! Agent authority. +//! correlation, sends one narrowly typed `session.status` command, and admits its +//! required readiness result through one command-specific correlated parser without +//! exposing generic JSON bodies or granting browser, TLS, policy, secret, or Agent +//! authority. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -19,7 +20,9 @@ mod connection; mod webdriver_bidi_command_correlation; mod webdriver_bidi_connection; mod webdriver_bidi_json_envelope; +mod webdriver_bidi_received_message; mod webdriver_bidi_session_status_command; +mod webdriver_bidi_session_status_response; mod webdriver_bidi_websocket_frame; mod webdriver_bidi_websocket_handshake; mod webdriver_bidi_websocket_message; @@ -46,9 +49,17 @@ pub use webdriver_bidi_json_envelope::{ MAX_WEBDRIVER_BIDI_JS_UINT, MAX_WEBDRIVER_BIDI_JSON_DEPTH, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, }; +pub use webdriver_bidi_received_message::{ + WebDriverBiDiConnectionMessageRead, WebDriverBiDiConnectionMessageReadError, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiWebSocketMessageReader, +}; pub use webdriver_bidi_session_status_command::{ WebDriverBiDiSessionStatusCommand, WebDriverBiDiSessionStatusCommandError, }; +pub use webdriver_bidi_session_status_response::{ + MAX_WEBDRIVER_BIDI_SESSION_STATUS_MESSAGE_SIZE, WebDriverBiDiSessionStatusResponseError, + WebDriverBiDiSessionStatusResult, +}; pub use webdriver_bidi_websocket_frame::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrame, diff --git a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs index 67a6420c0..a3dd0d53b 100644 --- a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs +++ b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs @@ -45,9 +45,8 @@ pub enum WebDriverBiDiCorrelatedResponseOutcome { /// /// This value carries only the matched command identifier and success/error classification. A /// private process-local connection generation is retained when the command owner bound one before -/// I/O so a later response-provenance owner can compare transport evidence without accepting -/// caller-supplied provenance. It does not retain result bodies, error text, browser authority, -/// transport authority, or secrets. +/// I/O so later transport evidence can be compared without accepting caller-supplied provenance. +/// It does not retain result bodies, error text, browser authority, transport authority, or secrets. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct WebDriverBiDiCorrelatedResponse { command_id: u64, @@ -134,12 +133,13 @@ impl Error for WebDriverBiDiCommandCorrelationError {} /// /// Register an id together with its exact typed command family only after the caller has committed /// to that outbound command. Connection-owning command adapters may additionally bind the private -/// generation of the exact established transport before I/O. Generic success or correlatable error -/// responses consume the id exactly once through a matching typed consumer; a later slice that owns -/// received-connection evidence adds the connection-sensitive consuming boundary. This type -/// performs no I/O, retry, command serialization, browser authentication, or authority grant. Debug -/// output reports only the outstanding-count summary; command identifiers, families, and -/// generations remain private correlation state. +/// generation of the exact established transport before I/O. A success or correlatable error +/// response consumes the id exactly once only through a matching typed consumer. Events, null-id +/// errors, command-kind mismatches, missing connection provenance, and responses received on a +/// different verified connection leave outstanding state untouched. This type performs no I/O, +/// retry, command serialization, browser authentication, or authority grant. Debug output reports +/// only the outstanding-count summary; command identifiers, families, and generations remain +/// private correlation state. #[derive(Default)] pub struct WebDriverBiDiCommandCorrelation { outstanding: BTreeMap, @@ -233,32 +233,29 @@ impl WebDriverBiDiCommandCorrelation { /// Successful responses and error responses with ids consume exactly one matching command. /// Unknown ids and command-kind mismatches fail without consuming state. Events and null-id /// errors fail before touching the map. This generic path does not claim received-connection - /// provenance; connection-sensitive response handling belongs to its owning child slice. + /// provenance; connection-sensitive command owners must use their connection-bound path. pub fn correlate_response_for( &mut self, envelope: &WebDriverBiDiJsonEnvelope, expected_kind: WebDriverBiDiCommandKind, ) -> Result { - match envelope.routing() { - WebDriverBiDiJsonEnvelopeRouting::Event => { - Err(WebDriverBiDiCommandCorrelationError::EventIsNotResponse) - } - WebDriverBiDiJsonEnvelopeRouting::CommandError { command_id: None } => { - Err(WebDriverBiDiCommandCorrelationError::UncorrelatableErrorResponse) - } - WebDriverBiDiJsonEnvelopeRouting::CommandError { - command_id: Some(command_id), - } => self.complete( - command_id, - expected_kind, - WebDriverBiDiCorrelatedResponseOutcome::Error, - ), - WebDriverBiDiJsonEnvelopeRouting::CommandSuccess { command_id } => self.complete( - command_id, - expected_kind, - WebDriverBiDiCorrelatedResponseOutcome::Success, - ), - } + let (command_id, outcome) = response_route(envelope)?; + self.complete(command_id, expected_kind, outcome) + } + + pub(crate) fn correlate_response_for_connection( + &mut self, + envelope: &WebDriverBiDiJsonEnvelope, + expected_kind: WebDriverBiDiCommandKind, + received_connection_generation: WebDriverBiDiConnectionGeneration, + ) -> Result { + let (command_id, outcome) = response_route(envelope)?; + self.complete_on_connection( + command_id, + expected_kind, + outcome, + received_connection_generation, + ) } fn require_command_kind( @@ -294,6 +291,49 @@ impl WebDriverBiDiCommandCorrelation { connection_generation: outstanding.connection_generation, }) } + + fn complete_on_connection( + &mut self, + command_id: u64, + expected_kind: WebDriverBiDiCommandKind, + outcome: WebDriverBiDiCorrelatedResponseOutcome, + received_connection_generation: WebDriverBiDiConnectionGeneration, + ) -> Result { + let outstanding = self.require_command_kind(command_id, expected_kind)?; + let expected_connection_generation = outstanding.connection_generation.ok_or( + WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { command_id }, + )?; + if expected_connection_generation != received_connection_generation { + return Err( + WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { command_id }, + ); + } + let _removed = self.outstanding.remove(&command_id); + Ok(WebDriverBiDiCorrelatedResponse { + command_id, + outcome, + connection_generation: Some(expected_connection_generation), + }) + } +} + +fn response_route( + envelope: &WebDriverBiDiJsonEnvelope, +) -> Result<(u64, WebDriverBiDiCorrelatedResponseOutcome), WebDriverBiDiCommandCorrelationError> { + match envelope.routing() { + WebDriverBiDiJsonEnvelopeRouting::Event => { + Err(WebDriverBiDiCommandCorrelationError::EventIsNotResponse) + } + WebDriverBiDiJsonEnvelopeRouting::CommandError { command_id: None } => { + Err(WebDriverBiDiCommandCorrelationError::UncorrelatableErrorResponse) + } + WebDriverBiDiJsonEnvelopeRouting::CommandError { + command_id: Some(command_id), + } => Ok((command_id, WebDriverBiDiCorrelatedResponseOutcome::Error)), + WebDriverBiDiJsonEnvelopeRouting::CommandSuccess { command_id } => { + Ok((command_id, WebDriverBiDiCorrelatedResponseOutcome::Success)) + } + } } #[cfg(test)] diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs index 593fa41ae..94e6fd3de 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs @@ -9,10 +9,12 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, - WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiSessionStatusResponseError, + WebDriverBiDiSessionStatusResult, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -20,6 +22,7 @@ const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; const SUCCESS_MESSAGE: &[u8] = br#"{"type":"success","id":7,"result":{"ready":true,"slash":"\/","upper":"\uABCD"}}"#; +const EMPTY_STATUS_RESULT: &[u8] = br#"{"type":"success","id":7,"result":{}}"#; fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; @@ -38,9 +41,9 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn parse_over_loopback( +fn read_text_over_loopback( document: &'static [u8], -) -> Result, Box> { +) -> Result> { if document.len() > 125 { return Err(io::Error::other("unit JSON document exceeded one-byte frame length").into()); } @@ -65,24 +68,30 @@ fn parse_over_loopback( let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? .write_opening_request(Duration::from_millis(500))? .read_opening_response(Duration::from_millis(500))?; - let (_established, frame) = established.read_frame(Duration::from_millis(500))?; - let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); - let text = match assembler.push_frame(frame)? { - WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + let message = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, other => { return Err(io::Error::other(format!( - "validated text frame produced unexpected assembly state: {other:?}" + "validated text frame produced unexpected message state: {other:?}" )) .into()); } }; - let parsed = WebDriverBiDiJsonEnvelope::parse(&text); server .join() .map_err(|_| io::Error::other("JSON-envelope unit server panicked"))??; - Ok(parsed) + Ok(message) +} + +fn parse_over_loopback( + document: &'static [u8], +) -> Result, Box> { + let message = read_text_over_loopback(document)?; + Ok(WebDriverBiDiJsonEnvelope::parse(message.message())) } #[test] @@ -138,3 +147,18 @@ fn public_json_envelope_unit_build_covers_fail_closed_json_edges() -> Result<(), } Ok(()) } + +#[test] +fn public_session_status_empty_result_fails_closed_from_unit_build() -> Result<(), Box> { + let message = read_text_over_loopback(EMPTY_STATUS_RESULT)?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(7, WebDriverBiDiCommandKind::SessionStatus)?; + + let parsed = WebDriverBiDiSessionStatusResult::parse_and_correlate(&message, &mut correlation); + assert!(matches!( + parsed, + Err(WebDriverBiDiSessionStatusResponseError::MissingReady) + )); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} diff --git a/crates/originweave-network/src/webdriver_bidi_received_message.rs b/crates/originweave-network/src/webdriver_bidi_received_message.rs new file mode 100644 index 000000000..9b9422c93 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_received_message.rs @@ -0,0 +1,193 @@ +use std::{error::Error, fmt, time::Duration}; + +use crate::{ + WebDriverBiDiWebSocketControlMessage, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketMessageError, + WebDriverBiDiWebSocketTextMessage, + webdriver_bidi_connection::WebDriverBiDiConnectionGeneration, +}; + +/// One complete WebDriver BiDi text message bound to the exact verified connection that read it. +/// +/// The connection generation is minted by the TCP connection owner and cannot be supplied by +/// callers. The wrapper therefore provides correlation provenance without granting browser, page, +/// policy, process, profile, or Agent authority. Debug output never exposes the message payload. +pub struct WebDriverBiDiReceivedTextMessage { + message: WebDriverBiDiWebSocketTextMessage, + connection_generation: WebDriverBiDiConnectionGeneration, +} + +impl fmt::Debug for WebDriverBiDiReceivedTextMessage { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiReceivedTextMessage") + .field("payload_bytes", &self.message.as_str().len()) + .field("connection_bound", &true) + .finish() + } +} + +impl WebDriverBiDiReceivedTextMessage { + pub(crate) const fn message(&self) -> &WebDriverBiDiWebSocketTextMessage { + &self.message + } + + pub(crate) const fn connection_generation(&self) -> WebDriverBiDiConnectionGeneration { + self.connection_generation + } +} + +/// Stateful message reader that keeps RFC 6455 fragmentation on one verified BiDi connection. +/// +/// The reader owns both the live established connection and the message assembler. Every frame +/// admitted into a fragmented message is therefore read from that same non-cloneable connection; +/// callers cannot combine fragments from another socket and still obtain a connection-bound text +/// message. Interleaved control frames are surfaced without discarding partial message state. +pub struct WebDriverBiDiWebSocketMessageReader { + established: WebDriverBiDiWebSocketEstablished, + assembler: WebDriverBiDiWebSocketMessageAssembler, + connection_generation: WebDriverBiDiConnectionGeneration, +} + +impl fmt::Debug for WebDriverBiDiWebSocketMessageReader { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketMessageReader") + .field("connection_bound", &true) + .field("assembler", &self.assembler) + .finish() + } +} + +impl WebDriverBiDiWebSocketMessageReader { + /// Consume one established WebSocket into a connection-bound message reader. + #[must_use] + pub fn new(established: WebDriverBiDiWebSocketEstablished) -> Self { + let connection_generation = established.transport_evidence().connection_generation(); + Self { + established, + assembler: WebDriverBiDiWebSocketMessageAssembler::new(), + connection_generation, + } + } + + /// Read and admit exactly one frame while preserving connection-bound fragmentation state. + /// + /// `Pending` and `Control` outcomes return the same reader so the next frame cannot silently + /// switch sockets. A completed text message carries the private generation of this exact reader; + /// because a completed text leaves no partial fragments, its established transport is returned + /// directly for the next protocol stage rather than exposing a way to discard partial state. + pub fn read_next( + self, + frame_timeout: Duration, + ) -> Result { + let Self { + established, + mut assembler, + connection_generation, + } = self; + let (established, frame) = established + .read_frame(frame_timeout) + .map_err(|source| WebDriverBiDiConnectionMessageReadError::Frame { source })?; + let assembly = assembler + .push_frame(frame) + .map_err(|source| WebDriverBiDiConnectionMessageReadError::Message { source })?; + Ok(match assembly { + WebDriverBiDiWebSocketMessageAssembly::Pending => { + WebDriverBiDiConnectionMessageRead::Pending(Self { + established, + assembler, + connection_generation, + }) + } + WebDriverBiDiWebSocketMessageAssembly::Text(message) => { + WebDriverBiDiConnectionMessageRead::Text { + established, + message: WebDriverBiDiReceivedTextMessage { + message, + connection_generation, + }, + } + } + WebDriverBiDiWebSocketMessageAssembly::Control(message) => { + WebDriverBiDiConnectionMessageRead::Control { + reader: Self { + established, + assembler, + connection_generation, + }, + message, + } + } + }) + } +} + +/// Outcome from one connection-bound WebDriver BiDi message-reader step. +pub enum WebDriverBiDiConnectionMessageRead { + /// A fragmented text message remains incomplete; continue with this same reader. + Pending(WebDriverBiDiWebSocketMessageReader), + /// One complete text message was assembled entirely on this verified connection. + Text { + /// Established transport returned after the complete message left no partial fragments. + established: WebDriverBiDiWebSocketEstablished, + /// Complete text message carrying non-forgeable connection provenance. + message: WebDriverBiDiReceivedTextMessage, + }, + /// An interleaved RFC 6455 control message was observed without discarding partial text state. + Control { + /// Reader retaining the same established connection and partial-message state. + reader: WebDriverBiDiWebSocketMessageReader, + /// Validated control message observed on this exact connection. + message: WebDriverBiDiWebSocketControlMessage, + }, +} + +impl fmt::Debug for WebDriverBiDiConnectionMessageRead { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pending(_) => formatter.write_str("Pending(connection-bound reader)"), + Self::Text { message, .. } => formatter.debug_tuple("Text").field(message).finish(), + Self::Control { message, .. } => { + formatter.debug_tuple("Control").field(message).finish() + } + } + } +} + +/// Fail-closed frame or message error while reading one connection-bound BiDi message step. +#[derive(Debug)] +pub enum WebDriverBiDiConnectionMessageReadError { + /// The exact established connection failed while reading or validating one RFC 6455 frame. + Frame { + /// Underlying bounded frame failure. + source: WebDriverBiDiWebSocketFrameError, + }, + /// The connection-local message assembler rejected the frame sequence. + Message { + /// Underlying bounded message-assembly failure. + source: WebDriverBiDiWebSocketMessageError, + }, +} + +impl fmt::Display for WebDriverBiDiConnectionMessageReadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Frame { .. } => { + formatter.write_str("connection-bound WebDriver BiDi WebSocket frame read failed") + } + Self::Message { .. } => formatter + .write_str("connection-bound WebDriver BiDi WebSocket message assembly failed"), + } + } +} + +impl Error for WebDriverBiDiConnectionMessageReadError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Frame { source } => Some(source), + Self::Message { source } => Some(source), + } + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_session_status_response.rs b/crates/originweave-network/src/webdriver_bidi_session_status_response.rs new file mode 100644 index 000000000..d6a8f8605 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_session_status_response.rs @@ -0,0 +1,797 @@ +use std::{error::Error, fmt}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, + WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiReceivedTextMessage, +}; + +/// Maximum decoded byte length retained from WebDriver BiDi `session.status` implementation text. +/// +/// The protocol requires an implementation-defined status message but does not define a size +/// ceiling. OriginWeave therefore keeps this operator-facing field within a smaller reviewed bound +/// than the surrounding WebSocket message and never includes its contents in `Debug` output. +pub const MAX_WEBDRIVER_BIDI_SESSION_STATUS_MESSAGE_SIZE: usize = 4_096; + +/// Typed, correlated successful result of one WebDriver BiDi `session.status` command. +/// +/// This value retains only the exact correlated command id, the standards-defined readiness bit, +/// and one bounded implementation-defined status message. It carries no generic JSON value, +/// browser capability, secret, origin grant, or Agent authority. +#[derive(Eq, PartialEq)] +pub struct WebDriverBiDiSessionStatusResult { + command_id: u64, + ready: bool, + message: String, +} + +impl fmt::Debug for WebDriverBiDiSessionStatusResult { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiSessionStatusResult") + .field("command_id", &self.command_id) + .field("ready", &self.ready) + .field("message_len", &self.message.len()) + .finish() + } +} + +impl WebDriverBiDiSessionStatusResult { + /// Parse one bounded received message and consume its exact outstanding command on success. + /// + /// Common WebDriver BiDi envelope validation runs first. A successful envelope then undergoes + /// command-specific projection of `result.ready` and `result.message`; correlation is consumed + /// only after that result is valid, so malformed success bodies cannot silently retire an id. + /// A correlatable protocol-error response consumes its matching id and returns a typed remote + /// protocol failure retaining the protocol error code but not the implementation-defined remote + /// message or stacktrace. Both success and error responses must have arrived on the exact + /// connection generation that registered the command; replacement-connection responses leave + /// the original pending command untouched. Events, null-id errors, and unknown ids fail closed + /// through the existing correlation boundary. + pub fn parse_and_correlate( + message: &WebDriverBiDiReceivedTextMessage, + correlation: &mut WebDriverBiDiCommandCorrelation, + ) -> Result { + let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()) + .map_err(|source| WebDriverBiDiSessionStatusResponseError::Envelope { source })?; + + match envelope.kind() { + WebDriverBiDiJsonEnvelopeKind::Success => { + let projected = StatusProjection::parse(message.message().as_str())?; + let completed = correlation + .correlate_response_for_connection( + &envelope, + WebDriverBiDiCommandKind::SessionStatus, + message.connection_generation(), + ) + .map_err( + |source| WebDriverBiDiSessionStatusResponseError::Correlation { source }, + )?; + Ok(Self { + command_id: completed.command_id(), + ready: projected.ready, + message: projected.message, + }) + } + WebDriverBiDiJsonEnvelopeKind::Error => { + retain_validated_error_code(envelope.error_code()).and_then(|error_code| { + let completed = + correlation + .correlate_response_for_connection( + &envelope, + WebDriverBiDiCommandKind::SessionStatus, + message.connection_generation(), + ) + .map_err(|source| { + WebDriverBiDiSessionStatusResponseError::Correlation { source } + })?; + Err( + WebDriverBiDiSessionStatusResponseError::RemoteProtocolError { + command_id: completed.command_id(), + error_code, + }, + ) + }) + } + WebDriverBiDiJsonEnvelopeKind::Event => { + Err(WebDriverBiDiSessionStatusResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::EventIsNotResponse, + }) + } + } + } + + /// Return the exact local command identifier consumed by this result. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return whether the remote end reports readiness to create a new session. + #[must_use] + pub const fn ready(&self) -> bool { + self.ready + } + + /// Borrow the bounded implementation-defined readiness message. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +/// Fail-closed failures while admitting one typed WebDriver BiDi `session.status` response. +#[derive(Debug)] +pub enum WebDriverBiDiSessionStatusResponseError { + /// Common local-end JSON envelope validation failed. + Envelope { + /// Exact common-envelope validation failure. + source: WebDriverBiDiJsonEnvelopeError, + }, + /// The successful result object omits the required `ready` member. + MissingReady, + /// The successful result object's `ready` member is not a JSON boolean. + InvalidReady, + /// The successful result object omits the required `message` member. + MissingMessage, + /// The successful result object's `message` member is not JSON text. + InvalidMessage, + /// The successful result repeats one command-specific member and is ambiguous. + DuplicateResultMember { + /// Stable command-specific member name that was repeated. + member: &'static str, + }, + /// The decoded implementation-defined status message exceeds the reviewed bound. + MessageTooLarge { + /// Maximum decoded message length admitted in bytes. + maximum_bytes: usize, + }, + /// A validated success envelope could not be projected through the command-specific parser. + InvalidResultProjection, + /// Exact command-response correlation failed without consuming unrelated state. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// The remote end returned a correlatable WebDriver BiDi protocol error for this command. + RemoteProtocolError { + /// Exact local command identifier consumed by the protocol-error response. + command_id: u64, + /// Protocol error code retained from the already validated common envelope. + /// + /// The remote implementation-defined message and stacktrace are deliberately not retained. + error_code: String, + }, +} + +impl fmt::Display for WebDriverBiDiSessionStatusResponseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Envelope { .. } => { + formatter.write_str("WebDriver BiDi session.status envelope is invalid") + } + Self::MissingReady => { + formatter.write_str("WebDriver BiDi session.status result is missing ready") + } + Self::InvalidReady => { + formatter.write_str("WebDriver BiDi session.status result ready is invalid") + } + Self::MissingMessage => { + formatter.write_str("WebDriver BiDi session.status result is missing message") + } + Self::InvalidMessage => { + formatter.write_str("WebDriver BiDi session.status result message is invalid") + } + Self::DuplicateResultMember { .. } => formatter + .write_str("WebDriver BiDi session.status result contains a duplicate member"), + Self::MessageTooLarge { .. } => formatter + .write_str("WebDriver BiDi session.status result message exceeds the size bound"), + Self::InvalidResultProjection => { + formatter.write_str("WebDriver BiDi session.status result projection is invalid") + } + Self::Correlation { .. } => { + formatter.write_str("WebDriver BiDi session.status response correlation failed") + } + Self::RemoteProtocolError { .. } => { + formatter.write_str("WebDriver BiDi session.status returned a protocol error") + } + } + } +} + +impl Error for WebDriverBiDiSessionStatusResponseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Envelope { source } => Some(source), + Self::Correlation { source } => Some(source), + Self::MissingReady + | Self::InvalidReady + | Self::MissingMessage + | Self::InvalidMessage + | Self::DuplicateResultMember { .. } + | Self::MessageTooLarge { .. } + | Self::InvalidResultProjection + | Self::RemoteProtocolError { .. } => None, + } + } +} + +fn retain_validated_error_code( + error_code: Option<&str>, +) -> Result { + error_code + .map(str::to_owned) + .ok_or(WebDriverBiDiSessionStatusResponseError::Envelope { + source: WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "error" }, + }) +} + +struct StatusProjection { + ready: bool, + message: String, +} + +impl StatusProjection { + fn parse(text: &str) -> Result { + let mut cursor = ProjectionCursor::new(text); + cursor.skip_whitespace(); + if !cursor.consume_byte(b'{') { + return Err(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection); + } + cursor.skip_whitespace(); + if cursor.consume_byte(b'}') { + return Err(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection); + } + + loop { + cursor.skip_whitespace(); + let key = cursor + .parse_string() + .ok_or(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection)?; + cursor.skip_whitespace(); + if !cursor.consume_byte(b':') { + return Err(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection); + } + cursor.skip_whitespace(); + if key == "result" { + return cursor.parse_result_object(); + } + if !cursor.skip_value() { + return Err(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection); + } + cursor.skip_whitespace(); + if cursor.consume_byte(b'}') { + return Err(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection); + } + if !cursor.consume_byte(b',') { + return Err(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection); + } + } + } +} + +struct ProjectionCursor<'a> { + input: &'a str, + index: usize, +} + +impl<'a> ProjectionCursor<'a> { + const fn new(input: &'a str) -> Self { + Self { input, index: 0 } + } + + fn current_byte(&self) -> Option { + self.input.as_bytes().get(self.index).copied() + } + + fn consume_byte(&mut self, expected: u8) -> bool { + if self.current_byte() == Some(expected) { + self.index += 1; + true + } else { + false + } + } + + fn skip_whitespace(&mut self) { + while matches!(self.current_byte(), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.index += 1; + } + } + + fn parse_result_object( + &mut self, + ) -> Result { + if !self.consume_byte(b'{') { + return Err(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection); + } + self.skip_whitespace(); + let mut ready = None; + let mut message = None; + if self.consume_byte(b'}') { + return Err(WebDriverBiDiSessionStatusResponseError::MissingReady); + } + + loop { + self.skip_whitespace(); + let key = self + .parse_string() + .ok_or(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection)?; + self.skip_whitespace(); + if !self.consume_byte(b':') { + return Err(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection); + } + self.skip_whitespace(); + match key.as_str() { + "ready" => { + if ready.is_some() { + return Err( + WebDriverBiDiSessionStatusResponseError::DuplicateResultMember { + member: "ready", + }, + ); + } + ready = Some(self.parse_ready()?); + } + "message" => { + if message.is_some() { + return Err( + WebDriverBiDiSessionStatusResponseError::DuplicateResultMember { + member: "message", + }, + ); + } + let parsed = self + .parse_string() + .ok_or(WebDriverBiDiSessionStatusResponseError::InvalidMessage)?; + if parsed.len() > MAX_WEBDRIVER_BIDI_SESSION_STATUS_MESSAGE_SIZE { + return Err(WebDriverBiDiSessionStatusResponseError::MessageTooLarge { + maximum_bytes: MAX_WEBDRIVER_BIDI_SESSION_STATUS_MESSAGE_SIZE, + }); + } + message = Some(parsed); + } + _ => { + if !self.skip_value() { + return Err( + WebDriverBiDiSessionStatusResponseError::InvalidResultProjection, + ); + } + } + } + self.skip_whitespace(); + if self.consume_byte(b'}') { + break; + } + if !self.consume_byte(b',') { + return Err(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection); + } + } + + Ok(StatusProjection { + ready: ready.ok_or(WebDriverBiDiSessionStatusResponseError::MissingReady)?, + message: message.ok_or(WebDriverBiDiSessionStatusResponseError::MissingMessage)?, + }) + } + + fn parse_ready(&mut self) -> Result { + if self.consume_literal(b"true") { + Ok(true) + } else if self.consume_literal(b"false") { + Ok(false) + } else { + if !self.skip_value() { + return Err(WebDriverBiDiSessionStatusResponseError::InvalidResultProjection); + } + Err(WebDriverBiDiSessionStatusResponseError::InvalidReady) + } + } + + fn consume_literal(&mut self, literal: &[u8]) -> bool { + let end = self.index.saturating_add(literal.len()); + if self.input.as_bytes().get(self.index..end) == Some(literal) { + self.index = end; + true + } else { + false + } + } + + fn skip_value(&mut self) -> bool { + self.skip_whitespace(); + match self.current_byte() { + Some(b'"') => self.parse_string().is_some(), + Some(b'{') => self.skip_object(), + Some(b'[') => self.skip_array(), + Some(b't') => self.consume_literal(b"true"), + Some(b'f') => self.consume_literal(b"false"), + Some(b'n') => self.consume_literal(b"null"), + Some(b'-' | b'0'..=b'9') => self.skip_number(), + _ => false, + } + } + + fn skip_object(&mut self) -> bool { + if !self.consume_byte(b'{') { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b'}') { + return true; + } + loop { + self.skip_whitespace(); + if self.parse_string().is_none() { + return false; + } + self.skip_whitespace(); + if !self.consume_byte(b':') { + return false; + } + if !self.skip_value() { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b'}') { + return true; + } + if !self.consume_byte(b',') { + return false; + } + } + } + + fn skip_array(&mut self) -> bool { + if !self.consume_byte(b'[') { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b']') { + return true; + } + loop { + if !self.skip_value() { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b']') { + return true; + } + if !self.consume_byte(b',') { + return false; + } + } + } + + fn skip_number(&mut self) -> bool { + let start = self.index; + while matches!( + self.current_byte(), + Some(b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9') + ) { + self.index += 1; + } + self.index > start + } + + fn parse_string(&mut self) -> Option { + if !self.consume_byte(b'"') { + return None; + } + let mut output = String::new(); + loop { + let byte = self.current_byte()?; + match byte { + b'"' => { + self.index += 1; + return Some(output); + } + b'\\' => { + self.index += 1; + if !self.parse_escape(&mut output) { + return None; + } + } + 0x00..=0x1f => return None, + _ if byte.is_ascii() => { + output.push(char::from(byte)); + self.index += 1; + } + _ => { + let width = byte.leading_ones() as usize; + let end = self.index + width; + output.push_str(&self.input[self.index..end]); + self.index = end; + } + } + } + } + + fn parse_escape(&mut self, output: &mut String) -> bool { + let Some(escape) = self.current_byte() else { + return false; + }; + self.index += 1; + match escape { + b'"' => output.push('"'), + b'\\' => output.push('\\'), + b'/' => output.push('/'), + b'b' => output.push('\u{0008}'), + b'f' => output.push('\u{000c}'), + b'n' => output.push('\n'), + b'r' => output.push('\r'), + b't' => output.push('\t'), + b'u' => return self.parse_unicode_escape(output), + _ => return false, + } + true + } + + fn parse_unicode_escape(&mut self, output: &mut String) -> bool { + let Some(first) = self.parse_hex_u16() else { + return false; + }; + if (0xd800..=0xdbff).contains(&first) { + if !self.consume_byte(b'\\') || !self.consume_byte(b'u') { + return false; + } + let Some(second) = self.parse_hex_u16() else { + return false; + }; + if !(0xdc00..=0xdfff).contains(&second) { + return false; + } + output.push_str(&String::from_utf16_lossy(&[first, second])); + true + } else if (0xdc00..=0xdfff).contains(&first) { + false + } else { + output.push_str(&String::from_utf16_lossy(&[first])); + true + } + } + + fn parse_hex_u16(&mut self) -> Option { + let mut value = 0_u16; + for _ in 0..4 { + let byte = self.current_byte()?; + let digit = match byte { + b'0'..=b'9' => u16::from(byte - b'0'), + b'a'..=b'f' => u16::from(byte - b'a' + 10), + b'A'..=b'F' => u16::from(byte - b'A' + 10), + _ => return None, + }; + value = (value << 4) | digit; + self.index += 1; + } + Some(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projection_accepts_required_fields_unknown_metadata_and_escaped_keys() { + let projected = StatusProjection::parse( + r#"{"meta":[null,true,false,1,-2.5e+3,{"nested":"value"}],"re\u0073ult":{"message":"re\u0061dy \ud83d\ude80","extra":{},"ready":false}}"#, + ); + assert!(projected.is_ok()); + let projected = projected.ok(); + assert_eq!(projected.as_ref().map(|value| value.ready), Some(false)); + assert_eq!( + projected.as_ref().map(|value| value.message.as_str()), + Some("ready 🚀") + ); + + let spaced = StatusProjection::parse( + "\n\t { \r\n \"result\" : { \"ready\" : true , \"message\" : \"ok\" } }", + ); + assert!(spaced.is_ok()); + } + + #[test] + fn projection_rejects_missing_invalid_duplicate_and_oversized_required_fields() { + let cases = [ + (r#"{"result":{"message":"x"}}"#.to_owned(), "missing ready"), + ( + r#"{"result":{"ready":0,"message":"x"}}"#.to_owned(), + "invalid ready", + ), + (r#"{"result":{"ready":true}}"#.to_owned(), "missing message"), + ( + r#"{"result":{"ready":true,"message":false}}"#.to_owned(), + "invalid message", + ), + ( + r#"{"result":{"ready":true,"ready":false,"message":"x"}}"#.to_owned(), + "duplicate ready", + ), + ( + r#"{"result":{"ready":true,"message":"x","message":"y"}}"#.to_owned(), + "duplicate message", + ), + ( + format!( + "{{\"result\":{{\"ready\":true,\"message\":\"{}\"}}}}", + "x".repeat(MAX_WEBDRIVER_BIDI_SESSION_STATUS_MESSAGE_SIZE + 1) + ), + "oversized message", + ), + ]; + + for (document, label) in cases { + assert!(StatusProjection::parse(&document).is_err(), "{label}"); + } + } + + #[test] + fn projection_cursor_rejects_malformed_private_inputs_without_panicking() { + let malformed = [ + "", + "[]", + "{}", + r#"{"x":}"#, + r#"{"x":1}"#, + r#"{"x" 1}"#, + r#"{"x":1 ?}"#, + r#"{?}"#, + r#"{"result":[]}"#, + r#"{"result":{?}}"#, + r#"{"result":{"ready" true,"message":"x"}}"#, + r#"{"result":{"ready":true "message":"x"}}"#, + r#"{"result":{"ready":?,"message":"x"}}"#, + r#"{"result":{"ready":true,"message":"x","extra":?}}"#, + r#"{"result":{"ready":true,"message":"\uD800"}}"#, + r#"{"result":{"ready":true,"message":"\q"}}"#, + ]; + for document in malformed { + assert!(StatusProjection::parse(document).is_err()); + } + } + + #[test] + fn projection_cursor_defensive_helpers_cover_hostile_dispatch_edges() { + let mut object = ProjectionCursor::new("[]"); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new("{}"); + assert!(object.skip_object()); + let mut object = ProjectionCursor::new("{?}"); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x" 1}"#); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x":?}"#); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x":1 ?}"#); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x":1,"y":2}"#); + assert!(object.skip_object()); + + let mut array = ProjectionCursor::new("{}"); + assert!(!array.skip_array()); + let mut array = ProjectionCursor::new("[]"); + assert!(array.skip_array()); + let mut array = ProjectionCursor::new("[?]"); + assert!(!array.skip_array()); + let mut array = ProjectionCursor::new("[1 ?]"); + assert!(!array.skip_array()); + + for document in [r#""x""#, "{}", "[]", "true", "false", "null", "-2.5e+3"] { + let mut value = ProjectionCursor::new(document); + assert!(value.skip_value(), "{document}"); + } + let mut value = ProjectionCursor::new("?"); + assert!(!value.skip_value()); + + let mut number = ProjectionCursor::new("x"); + assert!(!number.skip_number()); + let mut number = ProjectionCursor::new("+1"); + assert!(number.skip_number()); + + let mut string = ProjectionCursor::new("x"); + assert!(string.parse_string().is_none()); + let mut string = ProjectionCursor::new("\"unterminated"); + assert!(string.parse_string().is_none()); + let mut string = ProjectionCursor::new("\"\u{0001}\""); + assert!(string.parse_string().is_none()); + let mut string = ProjectionCursor::new("\"é\""); + assert_eq!(string.parse_string().as_deref(), Some("é")); + + let mut output = String::new(); + let mut escape = ProjectionCursor::new(""); + assert!(!escape.parse_escape(&mut output)); + for sequence in ["\"", "\\", "/", "b", "f", "n", "r", "t"] { + let mut output = String::new(); + let mut escape = ProjectionCursor::new(sequence); + assert!(escape.parse_escape(&mut output), "{sequence:?}"); + } + let mut output = String::new(); + let mut escape = ProjectionCursor::new("q"); + assert!(!escape.parse_escape(&mut output)); + let mut output = String::new(); + let mut escape = ProjectionCursor::new("u0061"); + assert!(escape.parse_escape(&mut output)); + assert_eq!(output, "a"); + let mut output = String::new(); + let mut escape = ProjectionCursor::new("uD83D\\uDE80"); + assert!(escape.parse_escape(&mut output)); + assert_eq!(output, "🚀"); + + for sequence in [ + "u", + "uZZZZ", + "uD800x", + "uD800\\x", + "uD800\\u", + "uD800\\u0041", + "uDC00", + ] { + let mut output = String::new(); + let mut escape = ProjectionCursor::new(sequence); + assert!(!escape.parse_escape(&mut output), "{sequence}"); + } + + let mut hex = ProjectionCursor::new("09aF"); + assert_eq!(hex.parse_hex_u16(), Some(0x09af)); + let mut hex = ProjectionCursor::new("0"); + assert!(hex.parse_hex_u16().is_none()); + let mut hex = ProjectionCursor::new("00G0"); + assert!(hex.parse_hex_u16().is_none()); + } + + #[test] + fn response_errors_have_stable_redacted_messages_and_sources() { + let envelope = WebDriverBiDiSessionStatusResponseError::Envelope { + source: WebDriverBiDiJsonEnvelopeError::InvalidJson, + }; + assert!(envelope.source().is_some()); + assert_eq!( + envelope.to_string(), + "WebDriver BiDi session.status envelope is invalid" + ); + + let correlation = WebDriverBiDiSessionStatusResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandNotOutstanding, + }; + assert!(correlation.source().is_some()); + assert_eq!( + correlation.to_string(), + "WebDriver BiDi session.status response correlation failed" + ); + + let retained_error_code = retain_validated_error_code(Some("unknown error")); + assert!(matches!( + retained_error_code.as_deref(), + Ok("unknown error") + )); + + let missing_error_code = retain_validated_error_code(None); + assert!(matches!( + missing_error_code, + Err(WebDriverBiDiSessionStatusResponseError::Envelope { + source: WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "error" }, + }) + )); + + let leaf_errors = [ + WebDriverBiDiSessionStatusResponseError::MissingReady, + WebDriverBiDiSessionStatusResponseError::InvalidReady, + WebDriverBiDiSessionStatusResponseError::MissingMessage, + WebDriverBiDiSessionStatusResponseError::InvalidMessage, + WebDriverBiDiSessionStatusResponseError::DuplicateResultMember { member: "ready" }, + WebDriverBiDiSessionStatusResponseError::MessageTooLarge { + maximum_bytes: MAX_WEBDRIVER_BIDI_SESSION_STATUS_MESSAGE_SIZE, + }, + WebDriverBiDiSessionStatusResponseError::InvalidResultProjection, + WebDriverBiDiSessionStatusResponseError::RemoteProtocolError { + command_id: 7, + error_code: "unknown error".to_owned(), + }, + ]; + for error in leaf_errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_received_message.rs b/crates/originweave-network/tests/webdriver_bidi_received_message.rs new file mode 100644 index 000000000..edf752c06 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_received_message.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::{ + WebDriverBiDiConnectionMessageRead, WebDriverBiDiConnectionMessageReadError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = 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_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 established_for_frames( + frames: Vec>, +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + for frame in frames { + stream.write_all(&frame)?; + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +#[test] +fn fragmented_text_and_interleaved_control_remain_on_one_reader() -> Result<(), Box> { + let established = established_for_frames(vec![ + vec![0x01, 0x03, b'a', b'b', b'c'], + vec![0x89, 0x00], + vec![0x80, 0x03, b'd', b'e', b'f'], + ])?; + let reader = WebDriverBiDiWebSocketMessageReader::new(established); + assert!(format!("{reader:?}").contains("connection_bound")); + + let first = reader.read_next(Duration::from_millis(500))?; + assert!(format!("{first:?}").starts_with("Pending")); + let reader = match first { + WebDriverBiDiConnectionMessageRead::Pending(reader) => reader, + _ => return Err(io::Error::other("first fragment did not remain pending").into()), + }; + + let control = reader.read_next(Duration::from_millis(500))?; + assert!(format!("{control:?}").starts_with("Control")); + let reader = match control { + WebDriverBiDiConnectionMessageRead::Control { reader, message } => { + assert_eq!(message.payload(), b""); + reader + } + _ => return Err(io::Error::other("interleaved Ping was not surfaced").into()), + }; + + let completed = reader.read_next(Duration::from_millis(500))?; + let debug = format!("{completed:?}"); + assert!(debug.starts_with("Text")); + assert!(debug.contains("payload_bytes")); + match completed { + WebDriverBiDiConnectionMessageRead::Text { + established, + message: _, + } => drop(established), + _ => return Err(io::Error::other("continuation did not complete text message").into()), + } + Ok(()) +} + +#[test] +fn frame_and_message_failures_remain_typed_and_sourced() -> Result<(), Box> { + let malformed = established_for_frames(vec![vec![0x81, 0x80]])?; + let frame_error = WebDriverBiDiWebSocketMessageReader::new(malformed) + .read_next(Duration::from_millis(500)) + .err() + .ok_or_else(|| io::Error::other("masked server frame was accepted"))?; + assert!(matches!( + frame_error, + WebDriverBiDiConnectionMessageReadError::Frame { .. } + )); + assert_eq!( + frame_error.to_string(), + "connection-bound WebDriver BiDi WebSocket frame read failed" + ); + assert!(frame_error.source().is_some()); + + let binary = established_for_frames(vec![vec![0x82, 0x00]])?; + let message_error = WebDriverBiDiWebSocketMessageReader::new(binary) + .read_next(Duration::from_millis(500)) + .err() + .ok_or_else(|| io::Error::other("binary BiDi message was accepted"))?; + assert!(matches!( + message_error, + WebDriverBiDiConnectionMessageReadError::Message { .. } + )); + assert_eq!( + message_error.to_string(), + "connection-bound WebDriver BiDi WebSocket message assembly failed" + ); + assert!(message_error.source().is_some()); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_session_status_response.rs b/crates/originweave-network/tests/webdriver_bidi_session_status_response.rs new file mode 100644 index 000000000..6e733163c --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_session_status_response.rs @@ -0,0 +1,226 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiSessionStatusCommand, + WebDriverBiDiSessionStatusResponseError, WebDriverBiDiSessionStatusResult, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const STATUS_RESPONSE: &[u8] = + br#"{"type":"success","id":7,"result":{"ready":true,"message":"capacity available"}}"#; +const STATUS_RESPONSE_MISSING_READY: &[u8] = + br#"{"type":"success","id":7,"result":{"message":"capacity available"}}"#; +const STATUS_RESPONSE_EMPTY_RESULT: &[u8] = br#"{"type":"success","id":7,"result":{}}"#; + +#[test] +fn unbound_command_cannot_consume_a_connection_bound_reply() -> Result<(), Box> { + use originweave_network::{WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind}; + + let (message, mut original) = send_status_and_read_response(STATUS_RESPONSE)?; + let mut unbound = WebDriverBiDiCommandCorrelation::new(); + unbound.register_command_for(7, WebDriverBiDiCommandKind::SessionStatus)?; + assert!(matches!( + WebDriverBiDiSessionStatusResult::parse_and_correlate(&message, &mut unbound), + Err(WebDriverBiDiSessionStatusResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { + command_id: 7, + }, + }) + )); + assert_eq!(unbound.outstanding_count(), 1); + assert_eq!(original.outstanding_count(), 1); + let result = WebDriverBiDiSessionStatusResult::parse_and_correlate(&message, &mut original)?; + assert_eq!(result.command_id(), 7); + assert_eq!(original.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn event_and_null_id_error_preserve_the_sent_status_command() -> Result<(), Box> { + use originweave_network::WebDriverBiDiCommandCorrelationError; + + for (document, expected) in [ + ( + br#"{"type":"event","method":"log.entryAdded","params":{}}"#.as_slice(), + WebDriverBiDiCommandCorrelationError::EventIsNotResponse, + ), + ( + br#"{"type":"error","id":null,"error":"unknown error","message":"remote"}"#.as_slice(), + WebDriverBiDiCommandCorrelationError::UncorrelatableErrorResponse, + ), + ] { + let (message, mut correlation) = send_status_and_read_response(document)?; + let result = + WebDriverBiDiSessionStatusResult::parse_and_correlate(&message, &mut correlation); + assert!(matches!( + result, + Err(WebDriverBiDiSessionStatusResponseError::Correlation { source }) if source == expected + )); + assert_eq!(correlation.outstanding_count(), 1); + } + Ok(()) +} + +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_frame(stream: &mut TcpStream) -> io::Result> { + 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 length = usize::from(header[1] & 0x7f); + if length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test command unexpectedly required extended framing", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn send_status_and_read_response( + response: &'static [u8], +) -> Result< + ( + WebDriverBiDiReceivedTextMessage, + WebDriverBiDiCommandCorrelation, + ), + 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(OPENING_RESPONSE)?; + let command = read_masked_text_frame(&mut stream)?; + if command != br#"{"id":7,"method":"session.status","params":{}}"# { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected session.status command", + )); + } + stream.write_all(&[0x81, response.len() as u8])?; + stream.write_all(response) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = WebDriverBiDiSessionStatusCommand::new(7)?.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + + let message = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err(io::Error::other(format!( + "session.status response produced unexpected message state: {other:?}" + )) + .into()); + } + }; + + server + .join() + .map_err(|_| io::Error::other("session.status response test server panicked"))??; + Ok((message, correlation)) +} + +#[test] +fn session_status_success_result_is_typed_correlated_and_message_redacted_in_debug() +-> Result<(), Box> { + let (message, mut correlation) = send_status_and_read_response(STATUS_RESPONSE)?; + let result = WebDriverBiDiSessionStatusResult::parse_and_correlate(&message, &mut correlation)?; + + assert_eq!(result.command_id(), 7); + assert!(result.ready()); + assert_eq!(result.message(), "capacity available"); + assert_eq!(correlation.outstanding_count(), 0); + + let debug = format!("{result:?}"); + assert!(debug.contains("message_len")); + assert!(!debug.contains("capacity available")); + Ok(()) +} + +#[test] +fn malformed_status_result_does_not_consume_the_outstanding_command() -> Result<(), Box> +{ + let (message, mut correlation) = send_status_and_read_response(STATUS_RESPONSE_MISSING_READY)?; + let parsed = WebDriverBiDiSessionStatusResult::parse_and_correlate(&message, &mut correlation); + + assert!(matches!( + parsed, + Err(WebDriverBiDiSessionStatusResponseError::MissingReady) + )); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn empty_status_result_fails_before_consuming_the_outstanding_command() -> Result<(), Box> +{ + let (message, mut correlation) = send_status_and_read_response(STATUS_RESPONSE_EMPTY_RESULT)?; + let parsed = WebDriverBiDiSessionStatusResult::parse_and_correlate(&message, &mut correlation); + + assert!(matches!( + parsed, + Err(WebDriverBiDiSessionStatusResponseError::MissingReady) + )); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_session_status_response_connection_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_session_status_response_connection_provenance.rs new file mode 100644 index 000000000..0be86d79a --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_session_status_response_connection_provenance.rs @@ -0,0 +1,168 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiSessionStatusCommand, WebDriverBiDiSessionStatusResponseError, + WebDriverBiDiSessionStatusResult, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const STATUS_RESPONSE: &[u8] = + br#"{"type":"success","id":7,"result":{"ready":true,"message":"capacity available"}}"#; + +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_frame(stream: &mut TcpStream) -> io::Result> { + 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 length = usize::from(header[1] & 0x7f); + if length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "session.status fixture unexpectedly required extended framing", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn establish( + local_addr: std::net::SocketAddr, +) -> Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +fn receive_foreign_status_response( + listener: TcpListener, +) -> Result> { + 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(OPENING_RESPONSE)?; + stream.write_all(&[0x81, STATUS_RESPONSE.len() as u8])?; + stream.write_all(STATUS_RESPONSE) + }); + + let established = establish(local_addr)?; + let message = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err(io::Error::other(format!( + "replacement connection produced unexpected message state: {other:?}" + )) + .into()); + } + }; + server + .join() + .map_err(|_| io::Error::other("replacement-connection server panicked"))??; + Ok(message) +} + +#[test] +fn session_status_response_from_same_session_replacement_connection_cannot_consume_original_pending_command() +-> Result<(), Box> { + let original_listener = TcpListener::bind(("127.0.0.1", 0))?; + let original_addr = original_listener.local_addr()?; + let original_server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = original_listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let command = read_masked_text_frame(&mut stream)?; + if command != br#"{"id":7,"method":"session.status","params":{}}"# { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected session.status command on original connection", + )); + } + Ok(()) + }); + + let original = establish(original_addr)?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let _original = WebDriverBiDiSessionStatusCommand::new(7)?.send( + original, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + original_server + .join() + .map_err(|_| io::Error::other("original-connection server panicked"))??; + assert_eq!(correlation.outstanding_count(), 1); + + let replacement_listener = TcpListener::bind(("127.0.0.1", 0))?; + let replacement_response = receive_foreign_status_response(replacement_listener)?; + let parsed = WebDriverBiDiSessionStatusResult::parse_and_correlate( + &replacement_response, + &mut correlation, + ); + + assert!(matches!( + parsed, + Err(WebDriverBiDiSessionStatusResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { + command_id: 7, + }, + }) + )); + assert_eq!( + correlation.outstanding_count(), + 1, + "foreign-connection rejection must leave the original command pending" + ); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_session_status_response_hostile.rs b/crates/originweave-network/tests/webdriver_bidi_session_status_response_hostile.rs new file mode 100644 index 000000000..2e5d69e42 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_session_status_response_hostile.rs @@ -0,0 +1,296 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + MAX_WEBDRIVER_BIDI_SESSION_STATUS_MESSAGE_SIZE, WebDriverBiDiCommandCorrelation, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiSessionStatusCommand, WebDriverBiDiSessionStatusResponseError, + WebDriverBiDiSessionStatusResult, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; + +type StatusRead = ( + WebDriverBiDiReceivedTextMessage, + WebDriverBiDiCommandCorrelation, +); + +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_frame(stream: &mut TcpStream) -> io::Result> { + 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 length = usize::from(header[1] & 0x7f); + if length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test command unexpectedly required extended framing", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn write_unmasked_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + match payload.len() { + 0..=125 => { + let length = u8::try_from(payload.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "short frame length overflow") + })?; + stream.write_all(&[0x81, length])?; + } + 126..=65_535 => { + let length = u16::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "extended frame length overflow", + ) + })?; + stream.write_all(&[0x81, 126])?; + stream.write_all(&length.to_be_bytes())?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "hostile response exceeds the reviewed test-frame budget", + )); + } + } + stream.write_all(payload) +} + +fn send_status_and_read_response(response: 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(OPENING_RESPONSE)?; + let command = read_masked_text_frame(&mut stream)?; + if command != br#"{"id":7,"method":"session.status","params":{}}"# { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected session.status command", + )); + } + write_unmasked_text_frame(&mut stream, &response) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = WebDriverBiDiSessionStatusCommand::new(7)?.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + + let message = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err(io::Error::other(format!( + "session.status response produced unexpected message state: {other:?}" + )) + .into()); + } + }; + + server + .join() + .map_err(|_| io::Error::other("session.status response test server panicked"))??; + Ok((message, correlation)) +} + +fn parse_response( + response: Vec, +) -> Result< + ( + Result, + WebDriverBiDiCommandCorrelation, + ), + Box, +> { + let (message, mut correlation) = send_status_and_read_response(response)?; + let parsed = WebDriverBiDiSessionStatusResult::parse_and_correlate(&message, &mut correlation); + Ok((parsed, correlation)) +} + +#[test] +fn status_projection_accepts_extensible_json_and_unicode_through_real_transport() +-> Result<(), Box> { + let response = br#"{"type":"success","id":7,"meta":[null,true,false,1,-2.5e+3,{"nested":"value"}],"re\u0073ult":{"message":"re\u0061dy \ud83d\ude80","extra":{},"ready":false}}"# + .to_vec(); + let (parsed, correlation) = parse_response(response)?; + let result = parsed?; + + assert_eq!(result.command_id(), 7); + assert!(!result.ready()); + assert_eq!(result.message(), "ready 🚀"); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn status_projection_accepts_all_string_escapes_and_unknown_value_shapes() +-> Result<(), Box> { + let response = r#"{"type":"success","id":7,"before":{"k":"v","sequence":2},"result":{"unknown_string":"x","unknown_true":true,"unknown_false":false,"unknown_null":null,"unknown_number":-12.5e+2,"unknown_empty_array":[],"unknown_array":[{},[],"x"],"unknown_empty_object":{},"unknown_object":{"k":"v","sequence":2},"ready":true,"message":"quote:\" slash:\/ backslash:\\ back:\b form:\f newline:\n return:\r tab:\t bmp:\u00AF raw:é"},"after":[1]}"# + .as_bytes() + .to_vec(); + let (parsed, correlation) = parse_response(response)?; + let result = parsed?; + + assert_eq!(result.command_id(), 7); + assert!(result.ready()); + assert_eq!( + result.message(), + "quote:\" slash:/ backslash:\\ back:\u{0008} form:\u{000c} newline:\n return:\r tab:\t bmp:¯ raw:é" + ); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn malformed_success_bodies_fail_closed_without_consuming_correlation() -> Result<(), Box> +{ + let oversized = format!( + "{{\"type\":\"success\",\"id\":7,\"result\":{{\"ready\":true,\"message\":\"{}\"}}}}", + "x".repeat(MAX_WEBDRIVER_BIDI_SESSION_STATUS_MESSAGE_SIZE + 1) + ) + .into_bytes(); + let cases = [ + br#"{"type":"success","id":7,"result":{}}"#.to_vec(), + br#"{"type":"success","id":7,"result":{"message":"x"}}"#.to_vec(), + br#"{"type":"success","id":7,"result":{"ready":0,"message":"x"}}"#.to_vec(), + br#"{"type":"success","id":7,"result":{"ready":"true","message":"x"}}"#.to_vec(), + br#"{"type":"success","id":7,"result":{"ready":null,"message":"x"}}"#.to_vec(), + br#"{"type":"success","id":7,"result":{"ready":[],"message":"x"}}"#.to_vec(), + br#"{"type":"success","id":7,"result":{"ready":{},"message":"x"}}"#.to_vec(), + br#"{"type":"success","id":7,"result":{"ready":true}}"#.to_vec(), + br#"{"type":"success","id":7,"result":{"ready":true,"message":false}}"#.to_vec(), + br#"{"type":"success","id":7,"result":{"ready":true,"ready":false,"message":"x"}}"# + .to_vec(), + br#"{"type":"success","id":7,"result":{"ready":true,"message":"x","message":"y"}}"# + .to_vec(), + oversized, + ]; + + for response in cases { + let (parsed, correlation) = parse_response(response)?; + assert!(parsed.is_err()); + assert_eq!(correlation.outstanding_count(), 1); + } + Ok(()) +} + +#[test] +fn envelope_correlation_and_remote_error_failures_preserve_exact_command_semantics() +-> Result<(), Box> { + let (invalid_envelope, correlation) = parse_response( + br#"{"type":"success","id":7,"result":{"ready":true,"message":"x"}"#.to_vec(), + )?; + assert!(matches!( + invalid_envelope, + Err(WebDriverBiDiSessionStatusResponseError::Envelope { .. }) + )); + assert_eq!(correlation.outstanding_count(), 1); + + let (unknown_id, correlation) = parse_response( + br#"{"type":"success","id":8,"result":{"ready":true,"message":"x"}}"#.to_vec(), + )?; + assert!(matches!( + unknown_id, + Err(WebDriverBiDiSessionStatusResponseError::Correlation { .. }) + )); + assert_eq!(correlation.outstanding_count(), 1); + + let (event, correlation) = + parse_response(br#"{"type":"event","method":"log.entryAdded","params":{}}"#.to_vec())?; + assert!(matches!( + event, + Err(WebDriverBiDiSessionStatusResponseError::Correlation { .. }) + )); + assert_eq!(correlation.outstanding_count(), 1); + + let (unknown_error, correlation) = parse_response( + br#"{"type":"error","id":8,"error":"unknown error","message":"remote refused status"}"# + .to_vec(), + )?; + assert!(matches!( + unknown_error, + Err(WebDriverBiDiSessionStatusResponseError::Correlation { .. }) + )); + assert_eq!(correlation.outstanding_count(), 1); + + let (null_error, correlation) = parse_response( + br#"{"type":"error","id":null,"error":"unknown error","message":"remote refused status"}"# + .to_vec(), + )?; + assert!(matches!( + null_error, + Err(WebDriverBiDiSessionStatusResponseError::Correlation { .. }) + )); + assert_eq!(correlation.outstanding_count(), 1); + + let (remote_error, correlation) = parse_response( + br#"{"type":"error","id":7,"error":"unknown error","message":"remote refused status","stacktrace":""}"# + .to_vec(), + )?; + assert!(matches!( + remote_error, + Err(WebDriverBiDiSessionStatusResponseError::RemoteProtocolError { + command_id: 7, + ref error_code, + }) if error_code == "unknown error" + )); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index ba3c31624..73476ff0f 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -340,6 +340,10 @@ Do not retry deterministic failures blindly. If multiple distinct fixes fail, re Documentation contracts intentionally validate only durable properties such as required files, links, status vocabularies and authority assertions. Do not create brittle tests that freeze wording without preventing a real documentation defect. +### Active browser-status response checks + +The active response stack exercises fragmented text with an interleaved Ping, malformed frame and message errors, and payload-redacted diagnostics. Public loopback checks reject events, unattributable errors, and replies to requests lacking connection provenance while preserving the pending request; the original request can still accept its matching reply after rejection by an unbound registry. Generic and connection-bound correlation share routing validation, while result validation and connection checks still precede completion. These local checks are not protected-main, real-browser acceptance, or release evidence. + ## 17. Exit criteria for a production capability A capability may be documented as Implemented only when: diff --git a/docs/doctoring.md b/docs/doctoring.md index 8b3b1f2fa..b72c975ec 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -118,6 +118,12 @@ On 5 September 2026, a complete Rust run after integrating PR #242 head `55fef0c The test-only `serve_opening_exchange` helper reuses the bounded request reader, reads the complete request before sending the configured response, and retains the accepted stream until the client explicitly releases it after its assertions. Successful, mismatched-accept, and request-only tests all use that helper. No sleep, retry-based acceptance, production timeout change, ignored cleanup error, dependency, or coverage exclusion is introduced. Existing real-socket assertions remain the regression checks, including the requirement that an invalid accept value reaches `AcceptMismatch` rather than an earlier fixture-induced transport failure. Descendant stacks must adopt the owner fix and rerun their own gates; the reproduced failure and this fixture repair do not imply protected-main or browser-runtime delivery. +### Status-response parent integration evidence + +The #250 integration preserves all five child-owned production and Rust-test blobs from `0eab23d5e388c5c8b984c0021a58316680c9ba8b` and ordinarily adopts #249 `84b9407978ae0f6c115f01170b6069c601b21104`. The pre-integration branch lacked that parent, and native unittest discovery collected zero correlation release checks instead of one. Parent adoption brings the canonical synchronized opening-exchange fixture and the discoverable release TestCase into this response stack without copying either implementation. Command-specific projection still validates the common envelope, required readiness/message fields and bounded status text before consuming the exact typed outstanding correlation; status text is untrusted and is omitted from Debug output. This does not add a connection-bound received-message capability, browser policy authority, runtime process/profile teardown proof or real Chromium acceptance. The connection-provenance repair remains separately owned by its later stack. Local integrated tests and exact-head hosted gates must be evaluated independently; predecessor results do not transfer. + +On 6 September 2026, that historical no-connection-provenance state was superseded for the current #250 branch by an executed real-socket regression at `6b102c1d860629d1b3e1a49cb0c33d94ca825adb`: CI `34044758402` / Rust job `101517723552` showed that a same-session replacement WebSocket could consume the original pending `session.status` command. Parent #249 `e77150f4de6534887098fb9de7e02ecea7fbb59c` now registers `SessionStatus` with the established connection generation before frame I/O. #250 adopts that parent by an ordinary two-parent non-force merge and its command-specific response parser accepts only `WebDriverBiDiReceivedTextMessage`, correlating both success and protocol-error envelopes against the received connection generation. The regression requires typed `ResponseConnectionMismatch { command_id: 7 }` and preserves the original outstanding command after rejection. Malformed success projection and invalid protocol-error shape still fail before correlation consumption. This is transport/correlation provenance only: it does not grant browser policy authority, prove Chromium post-conditions or runtime process/profile teardown, or establish protected-main/release acceptance; exact-current hosted gates remain independently required. + ## References Amazon Web Services. (n.d.). *Set up the Amazon EKS Pod Identity Agent*. Retrieved August 6, 2026, from https://docs.aws.amazon.com/eks/latest/userguide/pod-id-agent-setup.html @@ -200,4 +206,4 @@ World Wide Web Consortium. (2026, August 5). *Accessible name and description co Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 -Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 +Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 \ No newline at end of file