diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5058d9b..6127f0374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,11 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Session-ending replies from a replacement connection can no longer complete the original pending request. The original reply remains usable, and a protocol acknowledgment still does not prove browser shutdown or cleanup. +- The session-ending command stack now retains the status-reply protections from its current parent. A reply from a replacement connection is rejected while the original pending status request remains recoverable; sending the end command still does not prove that the browser session ended. - Typed outbound WebDriver BiDi `session.end` over the bounded client WebSocket stream: it serializes only the standards-defined method with empty params, rejects invalid frame deadlines before correlation registration, retires only the just-registered id when frame preflight proves no command bytes were emitted, preserves exact command-kind correlation across ambiguous writes, and does not treat frame-write success as proof that the browser session ended. - Typed `session.end` response admission that consumes only the exact outstanding command-kind correlation after complete envelope validation, preserves remote protocol errors as failures, and does not claim browser-process exit or resource cleanup from a protocol acknowledgment. +- 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. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 470aba3eb..b2cc56cb1 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -19,6 +19,7 @@ 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_end_command; mod webdriver_bidi_session_end_response; mod webdriver_bidi_session_status_command; @@ -49,6 +50,10 @@ 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_end_command::{ WebDriverBiDiSessionEndCommand, WebDriverBiDiSessionEndCommandError, }; diff --git a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs index 43d5e911e..a3dd0d53b 100644 --- a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs +++ b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs @@ -2,6 +2,7 @@ use std::{collections::BTreeMap, error::Error, fmt}; use crate::{ MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeRouting, + webdriver_bidi_connection::WebDriverBiDiConnectionGeneration, }; /// Maximum number of local WebDriver BiDi commands retained as outstanding at once. @@ -25,6 +26,12 @@ pub enum WebDriverBiDiCommandKind { SessionEnd, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct OutstandingCommand { + kind: WebDriverBiDiCommandKind, + connection_generation: Option, +} + /// Outcome of a response after it has consumed the matching outstanding command identifier. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WebDriverBiDiCorrelatedResponseOutcome { @@ -36,12 +43,15 @@ pub enum WebDriverBiDiCorrelatedResponseOutcome { /// Credential-free evidence that one parsed response consumed one outstanding local command. /// -/// This value carries only the matched command identifier and success/error classification. It -/// does not retain result bodies, error text, browser authority, transport authority, or secrets. +/// 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 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, outcome: WebDriverBiDiCorrelatedResponseOutcome, + connection_generation: Option, } impl WebDriverBiDiCorrelatedResponse { @@ -76,6 +86,16 @@ pub enum WebDriverBiDiCommandCorrelationError { /// Command family actually registered for the outstanding identifier. actual: WebDriverBiDiCommandKind, }, + /// A connection-bound consumer found an outstanding command with no connection provenance. + CommandConnectionProvenanceMissing { + /// Exact outstanding local command identifier. + command_id: u64, + }, + /// The response was received on a different verified connection from the outstanding command. + ResponseConnectionMismatch { + /// Exact outstanding local command identifier left untouched after rejection. + command_id: u64, + }, /// An event is not a command response and cannot consume correlation state. EventIsNotResponse, /// A protocol error with a `null` id cannot be attributed to one outstanding command. @@ -92,6 +112,12 @@ impl fmt::Display for WebDriverBiDiCommandCorrelationError { Self::CommandKindMismatch { .. } => { "WebDriver BiDi response command kind does not match the outstanding command" } + Self::CommandConnectionProvenanceMissing { .. } => { + "WebDriver BiDi outstanding command lacks connection provenance" + } + Self::ResponseConnectionMismatch { .. } => { + "WebDriver BiDi response arrived on a different connection" + } Self::EventIsNotResponse => "WebDriver BiDi event cannot be correlated as a response", Self::UncorrelatableErrorResponse => { "WebDriver BiDi error response has no correlatable command id" @@ -106,14 +132,17 @@ impl Error for WebDriverBiDiCommandCorrelationError {} /// Bounded local WebDriver BiDi command-response correlation state. /// /// Register an id together with its exact typed command family only after the caller has committed -/// to that outbound command. A success or correlatable error response consumes the id exactly once -/// only through a matching typed consumer. Events, null-id errors, and command-kind mismatches 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 and command families remain private correlation state. +/// to that outbound command. Connection-owning command adapters may additionally bind the private +/// 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, + outstanding: BTreeMap, } impl fmt::Debug for WebDriverBiDiCommandCorrelation { @@ -147,6 +176,24 @@ impl WebDriverBiDiCommandCorrelation { &mut self, command_id: u64, command_kind: WebDriverBiDiCommandKind, + ) -> Result<(), WebDriverBiDiCommandCorrelationError> { + self.register(command_id, command_kind, None) + } + + pub(crate) fn register_command_for_connection( + &mut self, + command_id: u64, + command_kind: WebDriverBiDiCommandKind, + connection_generation: WebDriverBiDiConnectionGeneration, + ) -> Result<(), WebDriverBiDiCommandCorrelationError> { + self.register(command_id, command_kind, Some(connection_generation)) + } + + fn register( + &mut self, + command_id: u64, + command_kind: WebDriverBiDiCommandKind, + connection_generation: Option, ) -> Result<(), WebDriverBiDiCommandCorrelationError> { if command_id > MAX_WEBDRIVER_BIDI_JS_UINT { return Err(WebDriverBiDiCommandCorrelationError::CommandIdOutOfRange); @@ -157,7 +204,13 @@ impl WebDriverBiDiCommandCorrelation { if self.outstanding.len() >= MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS { return Err(WebDriverBiDiCommandCorrelationError::OutstandingCommandLimit); } - let _previous = self.outstanding.insert(command_id, command_kind); + let _previous = self.outstanding.insert( + command_id, + OutstandingCommand { + kind: command_kind, + connection_generation, + }, + ); Ok(()) } @@ -179,51 +232,49 @@ 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. + /// errors fail before touching the map. This generic path does not claim received-connection + /// 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( &self, command_id: u64, expected_kind: WebDriverBiDiCommandKind, - ) -> Result<(), WebDriverBiDiCommandCorrelationError> { + ) -> Result { let actual = self .outstanding .get(&command_id) .copied() .ok_or(WebDriverBiDiCommandCorrelationError::CommandNotOutstanding)?; - if actual != expected_kind { + if actual.kind != expected_kind { return Err(WebDriverBiDiCommandCorrelationError::CommandKindMismatch { expected: expected_kind, - actual, + actual: actual.kind, }); } - Ok(()) + Ok(actual) } fn complete( @@ -232,15 +283,59 @@ impl WebDriverBiDiCommandCorrelation { expected_kind: WebDriverBiDiCommandKind, outcome: WebDriverBiDiCorrelatedResponseOutcome, ) -> Result { - self.require_command_kind(command_id, expected_kind)?; + let outstanding = self.require_command_kind(command_id, expected_kind)?; let _removed = self.outstanding.remove(&command_id); Ok(WebDriverBiDiCorrelatedResponse { command_id, outcome, + 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)] mod tests { use super::{WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind}; @@ -271,6 +366,16 @@ mod tests { }, "WebDriver BiDi response command kind does not match the outstanding command", ), + ( + WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { + command_id: 7, + }, + "WebDriver BiDi outstanding command lacks connection provenance", + ), + ( + WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { command_id: 7 }, + "WebDriver BiDi response arrived on a different connection", + ), ( WebDriverBiDiCommandCorrelationError::EventIsNotResponse, "WebDriver BiDi event cannot be correlated as a response", diff --git a/crates/originweave-network/src/webdriver_bidi_connection.rs b/crates/originweave-network/src/webdriver_bidi_connection.rs index 5d39bb5e3..f1701a923 100644 --- a/crates/originweave-network/src/webdriver_bidi_connection.rs +++ b/crates/originweave-network/src/webdriver_bidi_connection.rs @@ -1,6 +1,7 @@ use std::{ io, net::{SocketAddr, TcpStream}, + sync::atomic::{AtomicU64, Ordering}, time::Duration, }; @@ -12,9 +13,31 @@ mod error; pub use error::WebDriverBiDiTcpConnectionError; +#[cfg(test)] +mod generation_exhaustion_tests; #[cfg(test)] mod tests; +static NEXT_CONNECTION_GENERATION: AtomicU64 = AtomicU64::new(1); + +/// Process-local identity of one verified WebDriver BiDi transport generation. +/// +/// The value is minted only by the connection owner, is never accepted from callers, and exists +/// solely to prevent evidence from distinct sockets being combined across later protocol stages. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct WebDriverBiDiConnectionGeneration(u64); + +fn allocate_connection_generation( + counter: &AtomicU64, +) -> Result { + counter + .try_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .map(WebDriverBiDiConnectionGeneration) + .map_err(|_| WebDriverBiDiTcpConnectionError::ConnectionGenerationExhausted) +} + fn is_retryable_connect_error(kind: io::ErrorKind) -> bool { matches!( kind, @@ -32,7 +55,9 @@ fn is_retryable_connect_error(kind: io::ErrorKind) -> bool { /// produced by `originweave-core`. It applies the same bounded per-attempt timeout and retry /// ceilings as the general direct-network connector, opens only the exact [`SocketAddr`] carried by /// that target, and does not expose the stream until the operating system's observed peer has been -/// verified by the consumed target. +/// verified by the consumed target. Each verified stream also receives one process-local monotonic +/// connection generation that later transport stages can retain as non-forgeable correlation +/// provenance; the generation is not public authority and is never accepted from callers. /// /// This boundary performs no DNS lookup, proxy or PAC routing, Chromium/ChromeDriver process /// authentication, TLS negotiation, WebSocket upgrade, BiDi framing, browser policy decision, or @@ -83,6 +108,14 @@ impl WebDriverBiDiTcpConnectionPlan { fn connect_with( self, connector: &dyn WebDriverBiDiSocketConnector, + ) -> Result { + self.connect_with_generation_counter(connector, &NEXT_CONNECTION_GENERATION) + } + + fn connect_with_generation_counter( + self, + connector: &dyn WebDriverBiDiSocketConnector, + generation_counter: &AtomicU64, ) -> Result { let socket_address = self.target.socket_addr(); let connect_timeout = self.connect_timeout; @@ -107,11 +140,13 @@ impl WebDriverBiDiTcpConnectionPlan { attempt_number, source, })?; + let connection_generation = allocate_connection_generation(generation_counter)?; return Ok(WebDriverBiDiTcpConnection { stream, verified_peer, attempt_number, connect_timeout, + connection_generation, }); } Err(source) @@ -170,13 +205,16 @@ impl WebDriverBiDiSocketConnector for SystemWebDriverBiDiConnector { /// /// This wrapper proves only exact transport-destination equality for one bounded connection. The /// caller must still establish any required TLS channel, complete a WebSocket handshake, bind the -/// transport to the expected browser process/session, and pass separate action-policy checks. +/// transport to the expected browser process/session, and pass separate action-policy checks. A +/// private process-local connection generation follows this exact stream so later evidence cannot +/// be mixed with another connection that happens to use the same session or command identifier. #[derive(Debug)] pub struct WebDriverBiDiTcpConnection { stream: TcpStream, verified_peer: VerifiedWebDriverBiDiSocketPeer, attempt_number: u8, connect_timeout: Duration, + connection_generation: WebDriverBiDiConnectionGeneration, } impl WebDriverBiDiTcpConnection { @@ -207,15 +245,17 @@ impl WebDriverBiDiTcpConnection { /// Consume the wrapper into the original verified stream and credential-free transport evidence. /// /// This handoff does not clone the socket or create reusable connection authority. The returned - /// evidence records only the already-verified peer plus bounded connection-attempt metadata; it - /// does not authenticate a browser process, establish TLS, complete WebSocket framing, or grant - /// browser or Agent authority. + /// evidence records the already-verified peer, bounded connection-attempt metadata, and one + /// private process-local connection generation for downstream provenance matching. It does not + /// authenticate a browser process, establish TLS, complete WebSocket framing, or grant browser + /// or Agent authority. #[must_use] pub fn into_parts(self) -> (TcpStream, WebDriverBiDiTcpConnectionEvidence) { let evidence = WebDriverBiDiTcpConnectionEvidence { verified_peer: self.verified_peer, attempt_number: self.attempt_number, connect_timeout: self.connect_timeout, + connection_generation: self.connection_generation, }; (self.stream, evidence) } @@ -224,13 +264,15 @@ impl WebDriverBiDiTcpConnection { /// Credential-free evidence retained when a verified WebDriver BiDi TCP stream is consumed. /// /// This value records exact peer/session/TLS-requirement metadata inherited from the consumed -/// no-DNS target together with the successful bounded attempt and per-attempt timeout. It is -/// transport evidence only and grants no process, TLS, WebSocket, browser-action, or Agent authority. +/// no-DNS target together with the successful bounded attempt, per-attempt timeout, and a private +/// process-local connection generation. It is transport evidence only and grants no process, TLS, +/// WebSocket, browser-action, or Agent authority. #[derive(Debug)] pub struct WebDriverBiDiTcpConnectionEvidence { verified_peer: VerifiedWebDriverBiDiSocketPeer, attempt_number: u8, connect_timeout: Duration, + connection_generation: WebDriverBiDiConnectionGeneration, } impl WebDriverBiDiTcpConnectionEvidence { @@ -251,4 +293,8 @@ impl WebDriverBiDiTcpConnectionEvidence { pub const fn connect_timeout(&self) -> Duration { self.connect_timeout } + + pub(crate) const fn connection_generation(&self) -> WebDriverBiDiConnectionGeneration { + self.connection_generation + } } diff --git a/crates/originweave-network/src/webdriver_bidi_connection/error.rs b/crates/originweave-network/src/webdriver_bidi_connection/error.rs index 226cd1d2b..75e574acf 100644 --- a/crates/originweave-network/src/webdriver_bidi_connection/error.rs +++ b/crates/originweave-network/src/webdriver_bidi_connection/error.rs @@ -19,6 +19,8 @@ pub enum WebDriverBiDiTcpConnectionError { /// The largest accepted attempt count. maximum_attempts: u8, }, + /// The process-local connection-generation space was exhausted before a distinct identity could be minted. + ConnectionGenerationExhausted, /// The final bounded connection attempt timed out. ConnectionTimedOut { /// Exact approved socket address submitted to the operating system. @@ -66,7 +68,9 @@ impl WebDriverBiDiTcpConnectionError { | Self::ConnectionFailed { attempt_count, .. } => Some(*attempt_count), Self::PeerInspectionFailed { attempt_number, .. } | Self::PeerMismatch { attempt_number, .. } => Some(*attempt_number), - Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + Self::InvalidConnectTimeout { .. } + | Self::InvalidAttemptCount { .. } + | Self::ConnectionGenerationExhausted => None, } } } @@ -88,6 +92,9 @@ impl fmt::Display for WebDriverBiDiTcpConnectionError { formatter, "WebDriver BiDi connection attempt count {attempt_count} is outside 1..={maximum_attempts}", ), + Self::ConnectionGenerationExhausted => { + formatter.write_str("WebDriver BiDi connection generation space is exhausted") + } Self::ConnectionTimedOut { socket_address, attempt_count, @@ -128,7 +135,9 @@ impl std::error::Error for WebDriverBiDiTcpConnectionError { | Self::ConnectionFailed { source, .. } | Self::PeerInspectionFailed { source, .. } => Some(source), Self::PeerMismatch { source, .. } => Some(source), - Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + Self::InvalidConnectTimeout { .. } + | Self::InvalidAttemptCount { .. } + | Self::ConnectionGenerationExhausted => None, } } } diff --git a/crates/originweave-network/src/webdriver_bidi_connection/generation_exhaustion_tests.rs b/crates/originweave-network/src/webdriver_bidi_connection/generation_exhaustion_tests.rs new file mode 100644 index 000000000..071216ad5 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection/generation_exhaustion_tests.rs @@ -0,0 +1,91 @@ +#![allow(clippy::expect_used)] + +use std::{ + cell::{Cell, RefCell}, + io, + net::{SocketAddr, TcpListener, TcpStream}, + sync::atomic::AtomicU64, + time::Duration, +}; + +use originweave_core::{WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketEndpoint}; + +use super::{ + WebDriverBiDiSocketConnector, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +struct VerifiedConnector { + stream: RefCell>, + connect_calls: Cell, + peer_calls: Cell, +} + +impl VerifiedConnector { + fn new(stream: TcpStream) -> Self { + Self { + stream: RefCell::new(Some(stream)), + connect_calls: Cell::new(0), + peer_calls: Cell::new(0), + } + } +} + +impl WebDriverBiDiSocketConnector for VerifiedConnector { + fn connect_timeout( + &self, + _socket_address: &SocketAddr, + _timeout: Duration, + ) -> io::Result { + self.connect_calls.set(self.connect_calls.get() + 1); + self.stream + .borrow_mut() + .take() + .ok_or_else(|| io::Error::other("test stream already consumed")) + } + + fn peer_addr(&self, _stream: &TcpStream) -> io::Result { + self.peer_calls.set(self.peer_calls.get() + 1); + Ok(SocketAddr::from(([127, 0, 0, 1], 9515))) + } +} + +fn loopback_stream() -> TcpStream { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback listener"); + let address = listener + .local_addr() + .expect("read loopback listener address"); + let client = TcpStream::connect(address).expect("connect loopback client"); + let (server, _) = listener.accept().expect("accept loopback client"); + drop(server); + client +} + +fn connect_target() -> WebDriverBiDiWebSocketConnectTarget { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + WebDriverBiDiWebSocketEndpoint::new(&endpoint) + .expect("admit endpoint") + .correlate_session_id(SESSION_ID) + .expect("correlate endpoint") + .into_explicit_connect_target() + .expect("derive explicit connect target") +} + +#[test] +fn verified_connection_fails_closed_when_generation_space_is_exhausted() { + let connector = VerifiedConnector::new(loopback_stream()); + let exhausted_counter = AtomicU64::new(u64::MAX); + let error = + WebDriverBiDiTcpConnectionPlan::new(connect_target(), Duration::from_millis(250), 1) + .expect("valid plan") + .connect_with_generation_counter(&connector, &exhausted_counter) + .expect_err("generation exhaustion must fail after exact peer verification"); + + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionGenerationExhausted + )); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); +} diff --git a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs index e2d287ca1..63564283f 100644 --- a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs @@ -6,14 +6,16 @@ use std::{ error::Error, io, net::{SocketAddr, TcpListener, TcpStream}, + sync::atomic::AtomicU64, time::Duration, }; use originweave_core::{WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketEndpoint}; use super::{ - WebDriverBiDiSocketConnector, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, - is_retryable_connect_error, + WebDriverBiDiConnectionGeneration, WebDriverBiDiSocketConnector, + WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, + allocate_connection_generation, is_retryable_connect_error, }; use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; @@ -115,6 +117,25 @@ fn plan(maximum_attempts: u8) -> WebDriverBiDiTcpConnectionPlan { .expect("valid test plan") } +#[test] +fn connection_generation_allocator_is_monotonic_and_fails_before_reuse() { + let counter = AtomicU64::new(41); + assert_eq!( + allocate_connection_generation(&counter).ok(), + Some(WebDriverBiDiConnectionGeneration(41)) + ); + assert_eq!( + allocate_connection_generation(&counter).ok(), + Some(WebDriverBiDiConnectionGeneration(42)) + ); + + let exhausted = AtomicU64::new(u64::MAX); + assert!(matches!( + allocate_connection_generation(&exhausted), + Err(WebDriverBiDiTcpConnectionError::ConnectionGenerationExhausted) + )); +} + #[test] fn validates_timeout_and_attempt_bounds_before_io() { let zero_timeout = @@ -316,6 +337,7 @@ fn error_display_source_and_attempt_contracts_cover_every_variant() { attempt_count: 0, maximum_attempts: MAX_CONNECTION_ATTEMPTS, }, + WebDriverBiDiTcpConnectionError::ConnectionGenerationExhausted, WebDriverBiDiTcpConnectionError::ConnectionTimedOut { socket_address: socket_address(), attempt_count: 2, @@ -341,21 +363,24 @@ fn error_display_source_and_attempt_contracts_cover_every_variant() { let messages: Vec = errors.iter().map(ToString::to_string).collect(); assert!(messages[0].contains("outside 1ns")); assert!(messages[1].contains("attempt count 0")); - assert!(messages[2].contains("timed out after 2 attempts")); - assert!(messages[3].contains("failed after 3 attempts")); - assert!(messages[4].contains("peer inspection failed")); - assert!(messages[5].contains("did not match the approved target")); + assert!(messages[2].contains("generation space is exhausted")); + assert!(messages[3].contains("timed out after 2 attempts")); + assert!(messages[4].contains("failed after 3 attempts")); + assert!(messages[5].contains("peer inspection failed")); + assert!(messages[6].contains("did not match the approved target")); assert_eq!(errors[0].attempt_count(), None); assert_eq!(errors[1].attempt_count(), None); - assert_eq!(errors[2].attempt_count(), Some(2)); - assert_eq!(errors[3].attempt_count(), Some(3)); - assert_eq!(errors[4].attempt_count(), Some(1)); + assert_eq!(errors[2].attempt_count(), None); + assert_eq!(errors[3].attempt_count(), Some(2)); + assert_eq!(errors[4].attempt_count(), Some(3)); assert_eq!(errors[5].attempt_count(), Some(1)); + assert_eq!(errors[6].attempt_count(), Some(1)); assert!(errors[0].source().is_none()); assert!(errors[1].source().is_none()); - assert!(errors[2].source().is_some()); + assert!(errors[2].source().is_none()); assert!(errors[3].source().is_some()); assert!(errors[4].source().is_some()); assert!(errors[5].source().is_some()); + assert!(errors[6].source().is_some()); } 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 1e97bff2f..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,12 +9,12 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use crate::{ - WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiJsonEnvelope, - WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, - WebDriverBiDiSessionStatusResponseError, WebDriverBiDiSessionStatusResult, - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, - WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiSessionStatusResponseError, + WebDriverBiDiSessionStatusResult, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -43,7 +43,7 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { fn read_text_over_loopback( document: &'static [u8], -) -> Result> { +) -> Result> { if document.len() > 125 { return Err(io::Error::other("unit JSON document exceeded one-byte frame length").into()); } @@ -68,14 +68,14 @@ fn read_text_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()); } @@ -84,14 +84,14 @@ fn read_text_over_loopback( server .join() .map_err(|_| io::Error::other("JSON-envelope unit server panicked"))??; - Ok(text) + Ok(message) } fn parse_over_loopback( document: &'static [u8], ) -> Result, Box> { - let text = read_text_over_loopback(document)?; - Ok(WebDriverBiDiJsonEnvelope::parse(&text)) + let message = read_text_over_loopback(document)?; + Ok(WebDriverBiDiJsonEnvelope::parse(message.message())) } #[test] @@ -150,11 +150,11 @@ fn public_json_envelope_unit_build_covers_fail_closed_json_edges() -> Result<(), #[test] fn public_session_status_empty_result_fails_closed_from_unit_build() -> Result<(), Box> { - let text = read_text_over_loopback(EMPTY_STATUS_RESULT)?; + 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(&text, &mut correlation); + let parsed = WebDriverBiDiSessionStatusResult::parse_and_correlate(&message, &mut correlation); assert!(matches!( parsed, Err(WebDriverBiDiSessionStatusResponseError::MissingReady) 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_end_command.rs b/crates/originweave-network/src/webdriver_bidi_session_end_command.rs index 29fbe05aa..e9d3f7174 100644 --- a/crates/originweave-network/src/webdriver_bidi_session_end_command.rs +++ b/crates/originweave-network/src/webdriver_bidi_session_end_command.rs @@ -41,7 +41,8 @@ impl WebDriverBiDiSessionEndCommand { /// Register and write this exact command on an already established verified BiDi stream. /// /// Locally invalid frame deadlines fail before correlation registration and before any remote - /// side effect. Correlation then registers the command before the first possible frame write. + /// side effect. Correlation then binds the command to this connection before the first possible + /// frame write. Only a reply received on this same connection can complete that registration. /// A frame-owner preflight rejection that proves no write began retires this exact command /// again. Once frame emission can have begun, a later failure leaves the identifier outstanding /// because partial or full emission is ambiguous. A successful write also leaves the identifier @@ -62,7 +63,11 @@ impl WebDriverBiDiSessionEndCommand { }); } correlation - .register_command_for(self.command_id, WebDriverBiDiCommandKind::SessionEnd) + .register_command_for_connection( + self.command_id, + WebDriverBiDiCommandKind::SessionEnd, + established.transport_evidence().connection_generation(), + ) .map_err(|source| WebDriverBiDiSessionEndCommandError::Correlation { source })?; let message = self.serialized(); match established.write_text_frame(&message, masking_key, frame_timeout) { diff --git a/crates/originweave-network/src/webdriver_bidi_session_end_response.rs b/crates/originweave-network/src/webdriver_bidi_session_end_response.rs index 9ab083230..cd284f134 100644 --- a/crates/originweave-network/src/webdriver_bidi_session_end_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_session_end_response.rs @@ -3,7 +3,7 @@ use std::{error::Error, fmt}; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, - WebDriverBiDiJsonEnvelopeError, WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiReceivedTextMessage, }; /// Typed protocol acknowledgment for one correlated WebDriver BiDi `session.end` command. @@ -26,15 +26,21 @@ impl WebDriverBiDiSessionEndResult { /// can be consumed. Successful responses retain only the matched command id. A correlatable /// protocol-error response consumes its matching id and returns a typed remote failure, while /// events, null-id errors, malformed envelopes, unknown ids, and command-kind mismatches fail - /// closed without consuming unrelated outstanding state. + /// closed without consuming unrelated outstanding state. Only a sealed reply from the same + /// connection that registered the command can consume it; a replacement connection cannot + /// complete the request even when its session and command identifiers match. pub fn parse_and_correlate( - message: &WebDriverBiDiWebSocketTextMessage, + message: &WebDriverBiDiReceivedTextMessage, correlation: &mut WebDriverBiDiCommandCorrelation, ) -> Result { - let envelope = WebDriverBiDiJsonEnvelope::parse(message) + let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()) .map_err(|source| WebDriverBiDiSessionEndResponseError::Envelope { source })?; let completed = correlation - .correlate_response_for(&envelope, WebDriverBiDiCommandKind::SessionEnd) + .correlate_response_for_connection( + &envelope, + WebDriverBiDiCommandKind::SessionEnd, + message.connection_generation(), + ) .map_err(|source| WebDriverBiDiSessionEndResponseError::Correlation { source })?; match completed.outcome() { diff --git a/crates/originweave-network/src/webdriver_bidi_session_status_command.rs b/crates/originweave-network/src/webdriver_bidi_session_status_command.rs index e6341c416..092d06ec1 100644 --- a/crates/originweave-network/src/webdriver_bidi_session_status_command.rs +++ b/crates/originweave-network/src/webdriver_bidi_session_status_command.rs @@ -42,12 +42,14 @@ impl WebDriverBiDiSessionStatusCommand { /// Register and write this exact command on an already established verified BiDi stream. /// /// Locally invalid frame deadlines fail before correlation registration and before any remote - /// side effect. Correlation then registers the command before the first possible frame write. - /// A frame-owner preflight rejection that proves no write began retires this exact command - /// again; currently that covers adjacent client masking-key reuse. Once frame emission can have - /// begun, a later failure leaves the identifier outstanding because partial or full emission is - /// ambiguous. Callers must treat that failed stream/correlation pairing as unusable or - /// explicitly tear down its session state. + /// side effect. Correlation then registers the command together with the established + /// connection generation before the first possible frame write, so a response received on a + /// same-session replacement connection cannot consume this pending command. A frame-owner + /// preflight rejection that proves no write began retires this exact command again; currently + /// that covers adjacent client masking-key reuse. Once frame emission can have begun, a later + /// failure leaves the identifier outstanding because partial or full emission is ambiguous. + /// Callers must treat that failed stream/correlation pairing as unusable or explicitly tear down + /// its session state. pub fn send( self, established: WebDriverBiDiWebSocketEstablished, @@ -64,7 +66,11 @@ impl WebDriverBiDiSessionStatusCommand { }); } correlation - .register_command_for(self.command_id, WebDriverBiDiCommandKind::SessionStatus) + .register_command_for_connection( + self.command_id, + WebDriverBiDiCommandKind::SessionStatus, + established.transport_evidence().connection_generation(), + ) .map_err(|source| WebDriverBiDiSessionStatusCommandError::Correlation { source })?; let message = self.serialized(); match established.write_text_frame(&message, masking_key, frame_timeout) { diff --git a/crates/originweave-network/src/webdriver_bidi_session_status_response.rs b/crates/originweave-network/src/webdriver_bidi_session_status_response.rs index b1e5596ba..d6a8f8605 100644 --- a/crates/originweave-network/src/webdriver_bidi_session_status_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_session_status_response.rs @@ -3,7 +3,7 @@ use std::{error::Error, fmt}; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, - WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiReceivedTextMessage, }; /// Maximum decoded byte length retained from WebDriver BiDi `session.status` implementation text. @@ -37,27 +37,33 @@ impl fmt::Debug for WebDriverBiDiSessionStatusResult { } impl WebDriverBiDiSessionStatusResult { - /// Parse one bounded local-end message and consume its exact outstanding command on success. + /// 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. Events, null-id errors, and unknown ids fail closed through the - /// existing correlation boundary. + /// 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: &WebDriverBiDiWebSocketTextMessage, + message: &WebDriverBiDiReceivedTextMessage, correlation: &mut WebDriverBiDiCommandCorrelation, ) -> Result { - let envelope = WebDriverBiDiJsonEnvelope::parse(message) + let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()) .map_err(|source| WebDriverBiDiSessionStatusResponseError::Envelope { source })?; match envelope.kind() { WebDriverBiDiJsonEnvelopeKind::Success => { - let projected = StatusProjection::parse(message.as_str())?; + let projected = StatusProjection::parse(message.message().as_str())?; let completed = correlation - .correlate_response_for(&envelope, WebDriverBiDiCommandKind::SessionStatus) + .correlate_response_for_connection( + &envelope, + WebDriverBiDiCommandKind::SessionStatus, + message.connection_generation(), + ) .map_err( |source| WebDriverBiDiSessionStatusResponseError::Correlation { source }, )?; @@ -69,13 +75,16 @@ impl WebDriverBiDiSessionStatusResult { } WebDriverBiDiJsonEnvelopeKind::Error => { retain_validated_error_code(envelope.error_code()).and_then(|error_code| { - let completed = correlation - .correlate_response_for(&envelope, WebDriverBiDiCommandKind::SessionStatus) - .map_err( - |source| WebDriverBiDiSessionStatusResponseError::Correlation { - source, - }, - )?; + 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(), 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_end_response.rs b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs index 042f5137b..610526556 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs @@ -8,12 +8,12 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - WebDriverBiDiCommandCorrelation, WebDriverBiDiSessionEndCommand, - WebDriverBiDiSessionEndResponseError, WebDriverBiDiSessionEndResult, - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiSessionEndCommand, WebDriverBiDiSessionEndResponseError, + WebDriverBiDiSessionEndResult, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, - WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -27,6 +27,72 @@ const END_UNKNOWN_ID_RESPONSE: &[u8] = br#"{"type":"success","id":8,"result":{"vendorExtension":true}}"#; const END_MALFORMED_RESPONSE: &[u8] = br#"{"type":"success","id":7}"#; +#[test] +fn unbound_end_command_cannot_consume_a_connection_bound_reply() -> Result<(), Box> { + use originweave_network::WebDriverBiDiCommandKind; + + let (message, mut original) = send_end_and_read_response(END_SUCCESS_RESPONSE)?; + let mut unbound = WebDriverBiDiCommandCorrelation::new(); + unbound.register_command_for(7, WebDriverBiDiCommandKind::SessionEnd)?; + assert!(matches!( + WebDriverBiDiSessionEndResult::parse_and_correlate(&message, &mut unbound), + Err(WebDriverBiDiSessionEndResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { + command_id: 7, + }, + }) + )); + assert_eq!(unbound.outstanding_count(), 1); + let result = WebDriverBiDiSessionEndResult::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_end_command() -> Result<(), Box> { + 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_end_and_read_response(document)?; + assert!(matches!( + WebDriverBiDiSessionEndResult::parse_and_correlate(&message, &mut correlation), + Err(WebDriverBiDiSessionEndResponseError::Correlation { source }) if source == expected + )); + assert_eq!(correlation.outstanding_count(), 1); + } + Ok(()) +} + +#[test] +fn replacement_end_replies_preserve_original_pending_request_and_recovery() +-> Result<(), Box> { + for response in [END_SUCCESS_RESPONSE, END_REMOTE_ERROR_RESPONSE] { + let (original, mut pending) = send_end_and_read_response(END_SUCCESS_RESPONSE)?; + let (replacement, _) = send_end_and_read_response(response)?; + assert!(matches!( + WebDriverBiDiSessionEndResult::parse_and_correlate(&replacement, &mut pending), + Err(WebDriverBiDiSessionEndResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { + command_id: 7 + } + }) + )); + assert_eq!(pending.outstanding_count(), 1); + let result = WebDriverBiDiSessionEndResult::parse_and_correlate(&original, &mut pending)?; + assert_eq!(result.command_id(), 7); + assert_eq!(pending.outstanding_count(), 0); + } + Ok(()) +} + fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; let mut request = Vec::new(); @@ -74,7 +140,7 @@ fn send_end_and_read_response( response: &'static [u8], ) -> Result< ( - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiCommandCorrelation, ), Box, @@ -115,10 +181,10 @@ fn send_end_and_read_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 text = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, other => { return Err(io::Error::other(format!( "session.end response produced unexpected assembly state: {other:?}" diff --git a/crates/originweave-network/tests/webdriver_bidi_session_status_response.rs b/crates/originweave-network/tests/webdriver_bidi_session_status_response.rs index 347dc5fec..6779da6c1 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_status_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_status_response.rs @@ -8,12 +8,12 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - WebDriverBiDiCommandCorrelation, WebDriverBiDiSessionStatusCommand, + WebDriverBiDiCommandCorrelation, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiSessionStatusCommand, WebDriverBiDiSessionStatusResponseError, WebDriverBiDiSessionStatusResult, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, - WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -25,6 +25,55 @@ 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(); @@ -72,7 +121,7 @@ fn send_status_and_read_response( response: &'static [u8], ) -> Result< ( - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiCommandCorrelation, ), Box, @@ -113,13 +162,13 @@ fn send_status_and_read_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!( - "session.status response produced unexpected assembly state: {other:?}" + "session.status response produced unexpected message state: {other:?}" )) .into()); } @@ -128,14 +177,14 @@ fn send_status_and_read_response( server .join() .map_err(|_| io::Error::other("session.status response test server panicked"))??; - Ok((text, correlation)) + Ok((message, correlation)) } #[test] fn session_status_success_result_is_typed_correlated_and_message_redacted_in_debug() -> Result<(), Box> { - let (text, mut correlation) = send_status_and_read_response(STATUS_RESPONSE)?; - let result = WebDriverBiDiSessionStatusResult::parse_and_correlate(&text, &mut correlation)?; + 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()); @@ -151,8 +200,8 @@ fn session_status_success_result_is_typed_correlated_and_message_redacted_in_deb #[test] fn malformed_status_result_does_not_consume_the_outstanding_command() -> Result<(), Box> { - let (text, mut correlation) = send_status_and_read_response(STATUS_RESPONSE_MISSING_READY)?; - let parsed = WebDriverBiDiSessionStatusResult::parse_and_correlate(&text, &mut correlation); + 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, @@ -165,8 +214,8 @@ fn malformed_status_result_does_not_consume_the_outstanding_command() -> Result< #[test] fn empty_status_result_fails_before_consuming_the_outstanding_command() -> Result<(), Box> { - let (text, mut correlation) = send_status_and_read_response(STATUS_RESPONSE_EMPTY_RESULT)?; - let parsed = WebDriverBiDiSessionStatusResult::parse_and_correlate(&text, &mut correlation); + 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, @@ -175,3 +224,24 @@ fn empty_status_result_fails_before_consuming_the_outstanding_command() -> Resul assert_eq!(correlation.outstanding_count(), 1); Ok(()) } + +#[test] +fn replacement_status_reply_preserves_original_pending_request_and_recovery() +-> Result<(), Box> { + let (original, mut pending) = send_status_and_read_response(STATUS_RESPONSE)?; + let (replacement, _replacement_pending) = send_status_and_read_response(STATUS_RESPONSE)?; + + assert!(matches!( + WebDriverBiDiSessionStatusResult::parse_and_correlate(&replacement, &mut pending), + Err(WebDriverBiDiSessionStatusResponseError::Correlation { + source: originweave_network::WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { + command_id: 7, + }, + }) + )); + assert_eq!(pending.outstanding_count(), 1); + let completed = WebDriverBiDiSessionStatusResult::parse_and_correlate(&original, &mut pending)?; + assert_eq!(completed.command_id(), 7); + assert_eq!(pending.outstanding_count(), 0); + 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 index e588fa6e5..2e5d69e42 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_status_response_hostile.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_status_response_hostile.rs @@ -9,11 +9,11 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ MAX_WEBDRIVER_BIDI_SESSION_STATUS_MESSAGE_SIZE, WebDriverBiDiCommandCorrelation, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiReceivedTextMessage, WebDriverBiDiSessionStatusCommand, WebDriverBiDiSessionStatusResponseError, WebDriverBiDiSessionStatusResult, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, - WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -21,7 +21,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"; type StatusRead = ( - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiCommandCorrelation, ); @@ -132,13 +132,13 @@ fn send_status_and_read_response(response: Vec) -> Result text, + 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 assembly state: {other:?}" + "session.status response produced unexpected message state: {other:?}" )) .into()); } @@ -147,7 +147,7 @@ fn send_status_and_read_response(response: Vec) -> Result, > { - let (text, mut correlation) = send_status_and_read_response(response)?; - let parsed = WebDriverBiDiSessionStatusResult::parse_and_correlate(&text, &mut correlation); + let (message, mut correlation) = send_status_and_read_response(response)?; + let parsed = WebDriverBiDiSessionStatusResult::parse_and_correlate(&message, &mut correlation); Ok((parsed, correlation)) } 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 d5ca44070..42a289dd1 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -122,6 +122,8 @@ The test-only `serve_opening_exchange` helper reuses the bounded request reader, 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. + ### Session-end command parent integration evidence The #251 integration adopts #250 `ec433b844a121f8554c062f92267991af9cacb6f` by ordinary merge and retains both release records. Native discovery on predecessor `86e8ad76838f2a64aa7e0cd56ba1f931c8d0c3dc` collected zero command-correlation release tests; the current parent supplies the existing discoverable TestCase and synchronized opening-exchange fixtures. The session-end sender, its public exports and both child-owned Rust integration tests remain unchanged. The sender still registers only the exact typed command, retires correlation only after proven preflight rejection, and leaves ambiguous writes outstanding. A successful frame write does not prove session termination, browser-process exit, profile deletion, authenticated connection provenance or real Chromium acceptance. Local verification and current-head hosted checks remain separate prerequisites; earlier stack results do not transfer. @@ -130,6 +132,18 @@ The #251 integration adopts #250 `ec433b844a121f8554c062f92267991af9cacb6f` by o PR #252 ordinarily adopts current command parent `f02af6d0dd01708d495cc08dec785675f3d58898` while preserving the response implementation, public exports and four real-loopback response tests from `2015259529ada99af836989079cc85a15779a2d8`. The pre-integration native loader again collected zero correlation release-record checks; adopting the existing parent TestCase makes that contract executable without a new framework or copied owner fix. Both sides of the changelog-only conflict are retained. The response boundary still validates the entire bounded envelope before consuming exact typed correlation, retains remote errors as failures and leaves malformed or mismatched responses unable to consume another command. The acknowledgment remains unbound to received-connection provenance in this layer and does not prove process exit, profile removal or operational teardown. Later connection-bound evidence belongs to its own owner stack; local quality results, hosted checks and protected delivery remain separate. +### Current session-end sender adoption of status-reply provenance + +On 7 September 2026, the session-end sender stack reproduced the inherited status-reply gap at `edec535b8d8b3c843f9f9baf8562f31ff5c7af23`: two real loopback connections using the same session and command id allowed the replacement reply to complete the original pending status request. The ordinary merge of #250 `bbdc6ace7a5932adf24836700f806850e6b230bc` preserves the session-end sender and both original sender integration-test files byte-for-byte while inheriting sealed status receipts and connection-aware correlation. The regression now requires the exact connection-mismatch error, unchanged pending count, and subsequent completion using the original received reply. The server fixtures have already finished; this proves retained reply/correlation recovery, not liveness of an open original stream or same-endpoint replacement coverage. + +The shared routing validation, result projection order, and parent connection safeguards are unchanged. This supersedes the earlier no-received-provenance description for the status path only: the session-end sender still makes no received-acknowledgment, process-exit, profile-cleanup, browser-policy or release-acceptance claim. End-response provenance belongs to its separate consumer. Local tests and exact-head hosted checks must be evaluated independently after this adoption. + +### Current session-end response connection binding + +On 7 September 2026, regression `6aaf7f3f` reproduced replacement-reply acceptance on #252. Ordinary adoption of #251 `924ad97551750d4a901ded38b89488cc5438e54f` retained the failure, confirming that parent status safeguards alone did not repair the end-response consumer. The end sender now uses the existing connection-bound registration before writing, and the response parser accepts the existing sealed received-text type and checks that connection through shared correlation before consuming pending state. The original sender preflight retirement and ambiguous-write behavior, result shape, command-id accessor and remote-error classification remain unchanged. No new authority accessor or duplicate reader is introduced. + +Real loopback tests require exact connection-mismatch rejection for replacement success and error replies, unchanged original pending state, and subsequent completion using the original received reply. They also reject unbound registrations, events and null-id errors without consuming the pending command. These fixtures use separate listener endpoints and retain receipts after their server threads finish; they do not prove same-endpoint replacement rejection or liveness of an open original connection. This supersedes the earlier unbound-response description for the current #252 branch only. A correlated acknowledgment still does not prove browser-process exit, profile deletion, resource cleanup, browser policy authority, protected integration or release acceptance. Exact-head local and hosted verification remain separate. + ## 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