diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b061a3fa..c1777c212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added - Bounded RFC 6455 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. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. - Explicit no-DNS WebDriver BiDi loopback connection targets that derive exact IPv4/IPv6 loopback `SocketAddr` metadata from a session-correlated endpoint, reject `localhost` as requiring separately trusted name resolution, preserve the TLS requirement and exact session id, perform no socket I/O, and grant no Agent authority. @@ -55,7 +56,9 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Kept the `session.status` frame-failure coverage contract focused on observable correlation state, avoiding assertion-internal uncovered branches without weakening preflight retirement or ambiguous-write retention checks. - Made the command-correlation release-record check run in the existing CI test suite, preserving its exact bounds and authority exclusions; carried the verified message-parent fixture repairs into the correlation stack. +- Carried the verified parent fixture and release-check repairs into the session-status sender without changing command or correlation behavior. - Aligned the bounded WebDriver BiDi error-envelope vocabulary with the current specification by admitting the defined `no such client window` response while retaining fail-closed rejection of unknown error codes. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 9115025a1..79084c139 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -7,9 +7,10 @@ //! `originweave-core` into one bounded exact TCP connection, binds and validates //! 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, and tracks bounded command-response -//! correlation without exposing generic JSON bodies or granting browser, TLS, -//! policy, secret, or Agent authority. +//! 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. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -18,6 +19,7 @@ mod connection; mod webdriver_bidi_command_correlation; mod webdriver_bidi_connection; mod webdriver_bidi_json_envelope; +mod webdriver_bidi_session_status_command; mod webdriver_bidi_websocket_frame; mod webdriver_bidi_websocket_handshake; mod webdriver_bidi_websocket_message; @@ -44,6 +46,9 @@ pub use webdriver_bidi_json_envelope::{ MAX_WEBDRIVER_BIDI_JS_UINT, MAX_WEBDRIVER_BIDI_JSON_DEPTH, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, }; +pub use webdriver_bidi_session_status_command::{ + WebDriverBiDiSessionStatusCommand, WebDriverBiDiSessionStatusCommandError, +}; 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 43d5e911e..67a6420c0 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,16 @@ 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 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. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct WebDriverBiDiCorrelatedResponse { command_id: u64, outcome: WebDriverBiDiCorrelatedResponseOutcome, + connection_generation: Option, } impl WebDriverBiDiCorrelatedResponse { @@ -76,6 +87,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 +113,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 +133,16 @@ 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. 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. #[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,7 +232,8 @@ 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 response handling belongs to its owning child slice. pub fn correlate_response_for( &mut self, envelope: &WebDriverBiDiJsonEnvelope, @@ -211,19 +265,19 @@ impl WebDriverBiDiCommandCorrelation { &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,11 +286,12 @@ 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, }) } } @@ -271,6 +326,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_session_status_command.rs b/crates/originweave-network/src/webdriver_bidi_session_status_command.rs new file mode 100644 index 000000000..092d06ec1 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_session_status_command.rs @@ -0,0 +1,242 @@ +use std::{error::Error, fmt, time::Duration}; + +use crate::{ + MAX_WEBDRIVER_BIDI_JS_UINT, MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiCommandCorrelation, + WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_STATUS_METHOD: &str = "session.status"; + +/// One bounded WebDriver BiDi `session.status` command. +/// +/// The command is deliberately concrete rather than a generic JSON or arbitrary-method escape +/// hatch. It carries only a WebDriver BiDi `js-uint` correlation identifier and always serializes +/// the standards-defined empty parameter map. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiSessionStatusCommand { + command_id: u64, +} + +impl WebDriverBiDiSessionStatusCommand { + /// Construct one `session.status` command with a JavaScript-safe correlation identifier. + pub fn new(command_id: u64) -> Result { + if command_id > MAX_WEBDRIVER_BIDI_JS_UINT { + return Err( + WebDriverBiDiSessionStatusCommandError::CommandIdOutOfRange { + command_id, + maximum_command_id: MAX_WEBDRIVER_BIDI_JS_UINT, + }, + ); + } + Ok(Self { command_id }) + } + + /// Return the exact local correlation identifier serialized for this command. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// 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 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, + correlation: &mut WebDriverBiDiCommandCorrelation, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { + return Err(WebDriverBiDiSessionStatusCommandError::FrameWrite { + source: WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + }); + } + correlation + .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) { + Ok(established) => Ok(established), + Err(source) => Err(map_frame_failure(correlation, self.command_id, source)), + } + } + + fn serialized(self) -> String { + format!( + "{{\"id\":{},\"method\":\"{SESSION_STATUS_METHOD}\",\"params\":{{}}}}", + self.command_id + ) + } +} + +fn map_frame_failure( + correlation: &mut WebDriverBiDiCommandCorrelation, + command_id: u64, + source: WebDriverBiDiWebSocketFrameError, +) -> WebDriverBiDiSessionStatusCommandError { + if matches!( + source, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + ) { + let _retirement = + correlation.retire_command_for(command_id, WebDriverBiDiCommandKind::SessionStatus); + } + WebDriverBiDiSessionStatusCommandError::FrameWrite { source } +} + +/// Fail-closed errors while constructing or sending one typed `session.status` command. +#[derive(Debug)] +pub enum WebDriverBiDiSessionStatusCommandError { + /// The requested command identifier is outside WebDriver BiDi's `js-uint` range. + CommandIdOutOfRange { + /// Rejected command identifier. + command_id: u64, + /// Largest JavaScript-safe identifier admitted by this boundary. + maximum_command_id: u64, + }, + /// The bounded local correlation registry rejected the command before network I/O. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// Frame preflight validation or a later write operation failed. + FrameWrite { + /// Exact typed bounded WebSocket frame validation/write failure. + source: WebDriverBiDiWebSocketFrameError, + }, +} + +impl fmt::Display for WebDriverBiDiSessionStatusCommandError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CommandIdOutOfRange { .. } => formatter + .write_str("WebDriver BiDi session.status command id is outside the js-uint range"), + Self::Correlation { .. } => formatter + .write_str("WebDriver BiDi session.status command correlation was rejected"), + Self::FrameWrite { .. } => { + formatter.write_str("WebDriver BiDi session.status command frame write failed") + } + } + } +} + +impl Error for WebDriverBiDiSessionStatusCommandError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::CommandIdOutOfRange { .. } => None, + Self::Correlation { source } => Some(source), + Self::FrameWrite { source } => Some(source), + } + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use super::*; + + #[test] + fn constructor_enforces_the_webdriver_bidi_js_uint_range() { + let accepted = WebDriverBiDiSessionStatusCommand::new(MAX_WEBDRIVER_BIDI_JS_UINT); + assert_eq!( + accepted.ok().map(|command| command.command_id()), + Some(MAX_WEBDRIVER_BIDI_JS_UINT) + ); + + let rejected = WebDriverBiDiSessionStatusCommand::new(MAX_WEBDRIVER_BIDI_JS_UINT + 1); + assert_eq!( + rejected.err().map(|error| error.to_string()).as_deref(), + Some("WebDriver BiDi session.status command id is outside the js-uint range") + ); + } + + #[test] + fn command_serialization_is_static_and_exact() { + let command = WebDriverBiDiSessionStatusCommand { command_id: 42 }; + assert_eq!(command.command_id(), 42); + assert_eq!( + command.serialized(), + r#"{"id":42,"method":"session.status","params":{}}"# + ); + } + + #[test] + fn command_errors_have_stable_messages_and_typed_sources() { + let range = WebDriverBiDiSessionStatusCommandError::CommandIdOutOfRange { + command_id: MAX_WEBDRIVER_BIDI_JS_UINT + 1, + maximum_command_id: MAX_WEBDRIVER_BIDI_JS_UINT, + }; + assert_eq!( + range.to_string(), + "WebDriver BiDi session.status command id is outside the js-uint range" + ); + assert!(range.source().is_none()); + + let correlation = WebDriverBiDiSessionStatusCommandError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandAlreadyOutstanding, + }; + assert_eq!( + correlation.to_string(), + "WebDriver BiDi session.status command correlation was rejected" + ); + assert!(correlation.source().is_some()); + + let frame = WebDriverBiDiSessionStatusCommandError::FrameWrite { + source: WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 0, + source: io::Error::other("test frame failure"), + }, + }; + assert_eq!( + frame.to_string(), + "WebDriver BiDi session.status command frame write failed" + ); + assert!(frame.source().is_some()); + } + + #[test] + fn only_frame_preflight_malformed_errors_retire_registered_correlation() { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + assert!( + correlation + .register_command_for(1, WebDriverBiDiCommandKind::SessionStatus) + .is_ok() + ); + let preflight = WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "test preflight rejection", + }; + map_frame_failure(&mut correlation, 1, preflight); + assert_eq!(correlation.outstanding_count(), 0); + + assert!( + correlation + .register_command_for(2, WebDriverBiDiCommandKind::SessionStatus) + .is_ok() + ); + let ambiguous = WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 1, + source: io::Error::other("test ambiguous write failure"), + }; + map_frame_failure(&mut correlation, 2, ambiguous); + assert_eq!(correlation.outstanding_count(), 1); + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_session_status_command.rs b/crates/originweave-network/tests/webdriver_bidi_session_status_command.rs new file mode 100644 index 000000000..75c1dbb25 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_session_status_command.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::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, + WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, + WebDriverBiDiSessionStatusCommand, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, +}; + +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":"ready"}}"#; + +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) +} + +#[test] +fn session_status_command_round_trips_over_the_verified_websocket_and_correlation_boundary() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(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, + format!( + "unexpected session.status command: {}", + String::from_utf8_lossy(&command) + ), + )); + } + stream.write_all(&[0x81, STATUS_RESPONSE.len() as u8])?; + stream.write_all(STATUS_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 command = WebDriverBiDiSessionStatusCommand::new(7)?; + let established = command.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + assert_eq!(correlation.outstanding_count(), 1); + + 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, + other => { + return Err(io::Error::other(format!( + "session.status response produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let envelope = WebDriverBiDiJsonEnvelope::parse(&text)?; + let completed = + correlation.correlate_response_for(&envelope, WebDriverBiDiCommandKind::SessionStatus)?; + assert_eq!(completed.command_id(), 7); + assert_eq!( + completed.outcome(), + WebDriverBiDiCorrelatedResponseOutcome::Success + ); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("session.status command test server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_session_status_command_failures.rs b/crates/originweave-network/tests/webdriver_bidi_session_status_command_failures.rs new file mode 100644 index 000000000..d19ae45dd --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_session_status_command_failures.rs @@ -0,0 +1,216 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + MAX_WEBDRIVER_BIDI_JS_UINT, MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiCommandCorrelation, + WebDriverBiDiCommandKind, WebDriverBiDiSessionStatusCommand, + WebDriverBiDiSessionStatusCommandError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +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 HandshakeOnlyServer = ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_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 frame 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_with_handshake_only_server() -> 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 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))?; + Ok((established, server)) +} + +#[test] +fn session_status_rejects_ids_above_the_webdriver_bidi_js_uint_range() { + let rejected = WebDriverBiDiSessionStatusCommand::new(MAX_WEBDRIVER_BIDI_JS_UINT + 1); + assert_eq!( + rejected.err().map(|error| error.to_string()).as_deref(), + Some("WebDriver BiDi session.status command id is outside the js-uint range") + ); +} + +#[test] +fn session_status_rejects_duplicate_correlation_before_any_frame_write() +-> Result<(), Box> { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(7, WebDriverBiDiCommandKind::SessionStatus)?; + let command = WebDriverBiDiSessionStatusCommand::new(7)?; + + let error = command + .send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("duplicate correlation unexpectedly sent a command"))?; + assert!(matches!( + error, + WebDriverBiDiSessionStatusCommandError::Correlation { .. } + )); + assert_eq!(correlation.outstanding_count(), 1); + + server + .join() + .map_err(|_| io::Error::other("duplicate-correlation test server panicked"))??; + Ok(()) +} + +#[test] +fn session_status_rejects_invalid_frame_timeout_before_correlation_registration() +-> Result<(), Box> { + for (command_id, frame_timeout) in [ + (11, Duration::ZERO), + (12, MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_millis(1)), + ] { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let command = WebDriverBiDiSessionStatusCommand::new(command_id)?; + + let error = command + .send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + frame_timeout, + ) + .err() + .ok_or_else(|| io::Error::other("invalid frame timeout unexpectedly sent a command"))?; + assert!(matches!( + error, + WebDriverBiDiSessionStatusCommandError::FrameWrite { .. } + )); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("invalid-timeout test server panicked"))??; + } + Ok(()) +} + +#[test] +fn session_status_reused_mask_key_rejection_does_not_leave_correlation_outstanding() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let seed = read_masked_text_frame(&mut stream)?; + if seed != b"{}" { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected seed frame before reused-key regression", + )); + } + 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)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let repeated_key = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); + let established = + established.write_text_frame("{}", repeated_key, Duration::from_millis(500))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let command = WebDriverBiDiSessionStatusCommand::new(13)?; + let error = command + .send( + established, + &mut correlation, + repeated_key, + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("reused masking key unexpectedly sent session.status"))?; + assert!(matches!( + error, + WebDriverBiDiSessionStatusCommandError::FrameWrite { .. } + )); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("reused-mask-key test server panicked"))??; + Ok(()) +} diff --git a/docs/doctoring.md b/docs/doctoring.md index 891ff5bb7..8b3b1f2fa 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -6,15 +6,17 @@ This document records external evidence that changes OriginWeave architecture, t ### Browser automation and interoperability -The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. +The live WebDriver BiDi Editor's Draft dated 3 September 2026 defines the current bidirectional remote-control protocol, events, commands, and user contexts. The 1 June 2026 W3C Working Draft remains the most recent dated published Working Draft referenced by this repository, but it is not treated as the current editor text. Because WebDriver BiDi remains a draft protocol, OriginWeave keeps it behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. -The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control, whitespace, or reviewed Unicode format characters. Requiring `sharedId` and rejecting control, whitespace, and format characters is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. The same-call QueryNodes admission boundary first obtains a non-cloneable SemanticObservation protocol-use proof and transfers that proof by ownership into `bind_current_nodes`, which refuses Navigation and TypedInput proofs before translating each admitted `sharedId` through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. That composition still performs no browser I/O and does not authorize typed input. +The same Editor's Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control, whitespace, or reviewed Unicode format characters. Requiring `sharedId` and rejecting control, whitespace, and format characters is a local fail-closed policy, not a claim that the Editor's Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. The same-call QueryNodes admission boundary first obtains a non-cloneable SemanticObservation protocol-use proof and transfers that proof by ownership into `bind_current_nodes`, which refuses Navigation and TypedInput proofs before translating each admitted `sharedId` through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. That composition still performs no browser I/O and does not authorize typed input. + +The current Editor's Draft defines `session.status` as a static command whose command type is the exact method `session.status` with `EmptyParams`; the result contains `ready` as a boolean and `message` as text. OriginWeave's first outbound command slice therefore serializes only that standards-defined method and empty parameter object. A successful WebSocket frame write is transport progress, not command completion: the command remains outstanding until a later admitted WebDriver BiDi response is parsed and correlated to the exact command id. Local validation failures that prove no command bytes could have been emitted may release only the corresponding just-registered correlation; partial or ambiguous write failures retain correlation because remote receipt cannot be disproved. Regression coverage verifies that distinction from the resulting correlation count rather than from an internal error wrapper. WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace, a control character, or a Unicode format character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control and reviewed format characters that would become protocol-text injection or bidirectional spoofing, and rejects whitespace-only names as non-selectors. UTS #39 Revision 32 is the current Unicode security-mechanisms standard and marks Default_Ignorable and bidirectional format characters as restricted in identifier profiles. UAX #9 defines the bidirectional format controls that can reorder displayed protocol text. UTR #36 Revision 15 remains a stabilized historical security-considerations report; its identifier recommendations are superseded by UTS #39 rather than cited as current normative profile rules. OriginWeave therefore rejects the reviewed format-character set in roles, shared identifiers, and registry external identifiers, and rejects those same characters inside accessible names while still allowing ordinary U+0020 spaces. -RFC 6455 carries the WebSocket opening handshake over HTTP/1.1, and RFC 9110 permits `obs-text` octets (`%x80-FF`) in field values while retaining ASCII field-name and delimiter syntax. RFC 6455 also specifies that unknown opening-handshake header fields are ignored. OriginWeave therefore treats unknown extension-field values as opaque compatibility data rather than requiring the entire opening response to be UTF-8, while keeping the authority-bearing `Upgrade`, `Connection`, and `Sec-WebSocket-Accept` checks fail closed: opaque replacement material cannot satisfy the reviewed ASCII token or exact accept-value contracts. Ignoring an unknown field never grants browser, network, secret, approval, or Agent authority. +RFC 6455 carries the WebSocket opening handshake over HTTP/1.1, and RFC 9110 permits `obs-text` octets (`%x80-FF`) in field values while retaining ASCII field-name and delimiter syntax. RFC 6455 also specifies that unknown opening-handshake header fields are ignored. OriginWeave therefore treats unknown extension-field values as opaque compatibility data rather than requiring the entire opening response to be UTF-8, while keeping the authority-bearing `Upgrade`, `Connection`, and `Sec-WebSocket-Accept` checks fail closed: opaque replacement material cannot satisfy the reviewed ASCII token or exact accept-value contracts. Ignoring an unknown field never grants browser, network, secret, approval, or Agent authority. RFC 6455 section 5.3 additionally requires every client-to-server frame to use a fresh unpredictable 32-bit masking key derived from strong entropy. OriginWeave's frame owner enforces that normative masking requirement and also rejects immediate reuse of the preceding key as a local fail-closed stuck-randomness defense; the adjacent-reuse rule is stronger local policy, not an RFC 6455 requirement. ### Browser origin equivalence @@ -106,6 +108,8 @@ The owning opening-response PR #242 already repaired that fixture at `17754d717b ### Opening-exchange fixture lifetime +PR #249 adopts correlation parent `b386f17c4826adabebda084bff2fba35aee94dd0` by ordinary merge into predecessor `017d6e816f5a86544a63821b3ceaba94d5f17f44`. The only merge conflict was adjacent CHANGELOG additions; both release records are retained. Before integration, the inherited release-record module collected zero native unittest checks and the required parent was absent from ancestry. Parent adoption carries the existing TestCase and peer-lifetime fixes without copying implementations; the session-status production, exports and Rust test blobs remain unchanged. The sender still retires only proven local preflight failures and retains correlation after ambiguous writes; no received-response provenance, browser post-condition or protected-main acceptance follows from this integration. + PR #248 adopts message parent `585791f3641fbe757c3bd9fd36d5316adcc78d63` by ordinary merge, replacing the obsolete base on the already merged #247 branch. Its correlation production and Rust test blobs remain identical to predecessor `7d6db16b2ead201fcec320854923f90d3ad0d8bc`. Review also reproduced a separate enforcement gap: native CI runs `python3 -m unittest discover`, but the correlation release-record contract was a free function and the exact loader collected zero tests. Converting that existing check to the repository's `unittest.TestCase` format preserves every assertion, collects one executable test and adds no dependency or workflow change. Earlier Python suite counts did not exercise that contract; they are not evidence that it was enforced. The bounded correlation state still does not authenticate the received connection or grant browser authority. PR #246 adopts frame-transport parent `97fab641ed9d76e6c515eadcef0629edfc8064a3` by ordinary merge. Its message-assembly and JSON-envelope implementation/test blobs remain identical to predecessor `b87191bcb6a95dfd7e0ed234e600639a1093c43a`, while the inherited opening-exchange fixtures match the corrected parent. Fifty pre-integration fixture-suite runs passed in this invocation, so no fresh failure rate or new reproduction is claimed; the missing parent ancestry and earlier recorded failures establish why the existing repair must propagate. The raw assembler remains a bounded protocol-data boundary, not received-connection provenance or browser authority. @@ -190,6 +194,8 @@ World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Application World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +World Wide Web Consortium. (2026, September 3). *WebDriver BiDi* [Editor's Draft]. https://w3c.github.io/webdriver-bidi/ + World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ 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