diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c8a3162f..7b061a3fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Deterministic WebDriver BiDi `browsingContext.locateNodes` command serialization that accepts only protocol-range `js-uint` identifiers and bounded injection-safe browsing-context identifiers, JSON-escapes reviewed query text, and emits the exact accessibility locator, finite node budget, and minimal serialization options without performing transport I/O or granting browser or Agent authority. - Fail-closed WebDriver BiDi `locateNodes` response correlation that consumes the exact serialized command, rejects out-of-range or mismatched response `id` values, and returns non-cloneable correlation evidence carrying only the matched command identifier and browsing context without parsing the response or granting browser/Agent authority. - Typed WebDriver BiDi response-envelope correlation that preserves success/error classification, rejects success responses with absent ids, treats nullable error ids as explicitly uncorrelatable, and prevents correlated error envelopes from becoming success correlation evidence without raw JSON parsing or browser/Agent authority grants. +- Bounded WebDriver BiDi outstanding-command correlation that retains at most 256 local ids with exact typed command-family provenance, consumes only matching success or correlatable error responses exactly once, leaves events, null-id errors, and kind mismatches unable to retire unrelated state, and performs no transport I/O or browser, policy, secret, or Agent authority grant. - Bounded raw WebDriver BiDi response-document admission before JSON parsing, with a 65,536-byte product safety budget, exact wire-text retention, JSON-whitespace-aware top-level object-boundary checks, and typed fail-closed errors; this coarse boundary deliberately does not claim JSON validity, response correlation, browser authenticity, or Agent authority. - One consuming bounded WebDriver BiDi response-document correlation boundary that parses the exact admitted JSON document and feeds only its typed response kind and protocol-range id into the existing exact command-correlation gate, preserving nested parser/correlation error sources without authenticating transport or granting browser/Agent authority. - Consuming WebDriver BiDi WebSocket endpoint/session correlation that validates one caller-supplied canonical session UUID and rejects exact session mismatches before later transport use; the correlated type preserves only bounded endpoint metadata and does not authenticate Chromium, ChromeDriver, the caller, or the socket peer. @@ -54,6 +55,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- 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. - 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 79dd97492..9115025a1 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -6,14 +6,16 @@ //! It also bridges a session-correlated WebDriver BiDi loopback target from //! `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, and -//! classifies complete local-end JSON envelopes without exposing generic JSON bodies -//! or granting browser, TLS, policy, secret, or Agent authority. +//! 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. #![forbid(unsafe_code)] #![deny(missing_docs)] mod connection; +mod webdriver_bidi_command_correlation; mod webdriver_bidi_connection; mod webdriver_bidi_json_envelope; mod webdriver_bidi_websocket_frame; @@ -28,10 +30,16 @@ pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, NetworkError, SocketConnectionEvidence, }; +pub use webdriver_bidi_command_correlation::{ + MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS, WebDriverBiDiCommandCorrelation, + WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, + WebDriverBiDiCorrelatedResponse, WebDriverBiDiCorrelatedResponseOutcome, +}; pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; +pub(crate) use webdriver_bidi_json_envelope::WebDriverBiDiJsonEnvelopeRouting; pub use webdriver_bidi_json_envelope::{ MAX_WEBDRIVER_BIDI_JS_UINT, MAX_WEBDRIVER_BIDI_JSON_DEPTH, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, diff --git a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs new file mode 100644 index 000000000..43d5e911e --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs @@ -0,0 +1,287 @@ +use std::{collections::BTreeMap, error::Error, fmt}; + +use crate::{ + MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeRouting, +}; + +/// Maximum number of local WebDriver BiDi commands retained as outstanding at once. +/// +/// WebDriver BiDi permits commands to complete out of order. OriginWeave therefore keeps a +/// bounded local correlation map instead of assuming response order, while this resource ceiling +/// prevents an unbounded remote-control session from growing local correlation state indefinitely. +pub const MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS: usize = 256; + +/// Exact WebDriver BiDi command family bound to one outstanding local correlation identifier. +/// +/// Command identifiers are local-end routing values rather than command-type provenance. Keeping +/// the reviewed command family beside each outstanding id prevents a success or protocol error for +/// one command from being consumed by a different typed response boundary that happens to receive +/// the same id. Additional command families are introduced by their owning typed command slices. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebDriverBiDiCommandKind { + /// WebDriver BiDi `session.status`. + SessionStatus, + /// WebDriver BiDi `session.end`. + SessionEnd, +} + +/// Outcome of a response after it has consumed the matching outstanding command identifier. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebDriverBiDiCorrelatedResponseOutcome { + /// The remote end returned a successful command response. + Success, + /// The remote end returned a protocol error for the command. + Error, +} + +/// 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. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiCorrelatedResponse { + command_id: u64, + outcome: WebDriverBiDiCorrelatedResponseOutcome, +} + +impl WebDriverBiDiCorrelatedResponse { + /// Return the local command identifier consumed by this response. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return whether the correlated response was successful or a protocol error. + #[must_use] + pub const fn outcome(&self) -> WebDriverBiDiCorrelatedResponseOutcome { + self.outcome + } +} + +/// Fail-closed command-correlation failures at the local WebDriver BiDi response boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebDriverBiDiCommandCorrelationError { + /// A caller attempted to register an identifier outside WebDriver BiDi's `js-uint` range. + CommandIdOutOfRange, + /// The identifier is already outstanding and cannot become ambiguous. + CommandAlreadyOutstanding, + /// The reviewed outstanding-command resource budget has been reached. + OutstandingCommandLimit, + /// No currently outstanding command matches the requested or returned identifier. + CommandNotOutstanding, + /// The typed consumer does not match the command family registered for this identifier. + CommandKindMismatch { + /// Command family required by the typed consumer. + expected: WebDriverBiDiCommandKind, + /// Command family actually registered for the outstanding identifier. + actual: WebDriverBiDiCommandKind, + }, + /// 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. + UncorrelatableErrorResponse, +} + +impl fmt::Display for WebDriverBiDiCommandCorrelationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::CommandIdOutOfRange => "WebDriver BiDi command id is outside the js-uint range", + Self::CommandAlreadyOutstanding => "WebDriver BiDi command id is already outstanding", + Self::OutstandingCommandLimit => "WebDriver BiDi outstanding-command limit reached", + Self::CommandNotOutstanding => "WebDriver BiDi command id is not outstanding", + Self::CommandKindMismatch { .. } => { + "WebDriver BiDi response command kind does not match the outstanding command" + } + Self::EventIsNotResponse => "WebDriver BiDi event cannot be correlated as a response", + Self::UncorrelatableErrorResponse => { + "WebDriver BiDi error response has no correlatable command id" + } + }; + formatter.write_str(message) + } +} + +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. +#[derive(Default)] +pub struct WebDriverBiDiCommandCorrelation { + outstanding: BTreeMap, +} + +impl fmt::Debug for WebDriverBiDiCommandCorrelation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiCommandCorrelation") + .field("outstanding_count", &self.outstanding.len()) + .finish() + } +} + +impl WebDriverBiDiCommandCorrelation { + /// Create empty correlation state. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Return the number of commands currently awaiting a correlatable response. + #[must_use] + pub fn outstanding_count(&self) -> usize { + self.outstanding.len() + } + + /// Register one local command id and its exact command family before its response can be accepted. + /// + /// Identifiers are unique only while outstanding. A completed or explicitly retired id may be + /// reused later, matching WebDriver BiDi's local-end correlation semantics. Reusing an id while + /// any command family is still outstanding fails before replacing its provenance. + pub fn register_command_for( + &mut self, + command_id: u64, + command_kind: WebDriverBiDiCommandKind, + ) -> Result<(), WebDriverBiDiCommandCorrelationError> { + if command_id > MAX_WEBDRIVER_BIDI_JS_UINT { + return Err(WebDriverBiDiCommandCorrelationError::CommandIdOutOfRange); + } + if self.outstanding.contains_key(&command_id) { + return Err(WebDriverBiDiCommandCorrelationError::CommandAlreadyOutstanding); + } + if self.outstanding.len() >= MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS { + return Err(WebDriverBiDiCommandCorrelationError::OutstandingCommandLimit); + } + let _previous = self.outstanding.insert(command_id, command_kind); + Ok(()) + } + + /// Explicitly retire one exact outstanding command without accepting a response for it. + /// + /// The expected command family must match the registered provenance. A mismatched caller cannot + /// retire another typed command merely by knowing or reusing its local correlation identifier. + pub fn retire_command_for( + &mut self, + command_id: u64, + expected_kind: WebDriverBiDiCommandKind, + ) -> Result<(), WebDriverBiDiCommandCorrelationError> { + self.require_command_kind(command_id, expected_kind)?; + let _removed = self.outstanding.remove(&command_id); + Ok(()) + } + + /// Correlate one parsed local-end envelope with an exact outstanding command family. + /// + /// 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. + 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, + ), + } + } + + fn require_command_kind( + &self, + command_id: u64, + expected_kind: WebDriverBiDiCommandKind, + ) -> Result<(), WebDriverBiDiCommandCorrelationError> { + let actual = self + .outstanding + .get(&command_id) + .copied() + .ok_or(WebDriverBiDiCommandCorrelationError::CommandNotOutstanding)?; + if actual != expected_kind { + return Err(WebDriverBiDiCommandCorrelationError::CommandKindMismatch { + expected: expected_kind, + actual, + }); + } + Ok(()) + } + + fn complete( + &mut self, + command_id: u64, + expected_kind: WebDriverBiDiCommandKind, + outcome: WebDriverBiDiCorrelatedResponseOutcome, + ) -> Result { + self.require_command_kind(command_id, expected_kind)?; + let _removed = self.outstanding.remove(&command_id); + Ok(WebDriverBiDiCorrelatedResponse { + command_id, + outcome, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind}; + + #[test] + fn correlation_errors_have_stable_nonempty_operator_messages() { + let cases = [ + ( + WebDriverBiDiCommandCorrelationError::CommandIdOutOfRange, + "WebDriver BiDi command id is outside the js-uint range", + ), + ( + WebDriverBiDiCommandCorrelationError::CommandAlreadyOutstanding, + "WebDriver BiDi command id is already outstanding", + ), + ( + WebDriverBiDiCommandCorrelationError::OutstandingCommandLimit, + "WebDriver BiDi outstanding-command limit reached", + ), + ( + WebDriverBiDiCommandCorrelationError::CommandNotOutstanding, + "WebDriver BiDi command id is not outstanding", + ), + ( + WebDriverBiDiCommandCorrelationError::CommandKindMismatch { + expected: WebDriverBiDiCommandKind::SessionEnd, + actual: WebDriverBiDiCommandKind::SessionStatus, + }, + "WebDriver BiDi response command kind does not match the outstanding command", + ), + ( + WebDriverBiDiCommandCorrelationError::EventIsNotResponse, + "WebDriver BiDi event cannot be correlated as a response", + ), + ( + WebDriverBiDiCommandCorrelationError::UncorrelatableErrorResponse, + "WebDriver BiDi error response has no correlatable command id", + ), + ]; + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + } + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs index e8d8e01d1..1d92d0ed8 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs @@ -57,6 +57,18 @@ pub enum WebDriverBiDiJsonEnvelopeKind { Event, } +/// Structurally valid command/event routing retained after common-envelope validation. +/// +/// Keeping success ids inside the success variant prevents an impossible `success` + missing-id +/// state from leaking into downstream command correlation. Error ids remain optional because the +/// WebDriver BiDi protocol explicitly permits `null` there, while events carry no command id. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WebDriverBiDiJsonEnvelopeRouting { + CommandSuccess { command_id: u64 }, + CommandError { command_id: Option }, + Event, +} + /// Credential-minimal classification of one complete WebDriver BiDi local-end JSON envelope. /// /// Result and parameter bodies are deliberately validated and discarded at this boundary. They @@ -64,8 +76,7 @@ pub enum WebDriverBiDiJsonEnvelopeKind { /// as generic JSON values that could become ambient browser or Agent authority. #[derive(Eq, PartialEq)] pub struct WebDriverBiDiJsonEnvelope { - kind: WebDriverBiDiJsonEnvelopeKind, - command_id: Option, + routing: WebDriverBiDiJsonEnvelopeRouting, method: Option, error_code: Option, } @@ -74,8 +85,8 @@ impl fmt::Debug for WebDriverBiDiJsonEnvelope { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("WebDriverBiDiJsonEnvelope") - .field("kind", &self.kind) - .field("command_id", &self.command_id) + .field("kind", &self.kind()) + .field("command_id", &self.command_id()) .field("has_method", &self.method.is_some()) .field("has_error_code", &self.error_code.is_some()) .finish() @@ -107,7 +118,15 @@ impl WebDriverBiDiJsonEnvelope { /// Return the classified local-end envelope kind. #[must_use] pub const fn kind(&self) -> WebDriverBiDiJsonEnvelopeKind { - self.kind + match self.routing { + WebDriverBiDiJsonEnvelopeRouting::CommandSuccess { .. } => { + WebDriverBiDiJsonEnvelopeKind::Success + } + WebDriverBiDiJsonEnvelopeRouting::CommandError { .. } => { + WebDriverBiDiJsonEnvelopeKind::Error + } + WebDriverBiDiJsonEnvelopeRouting::Event => WebDriverBiDiJsonEnvelopeKind::Event, + } } /// Return the command identifier for success and correlatable error responses. @@ -115,7 +134,15 @@ impl WebDriverBiDiJsonEnvelope { /// Events and error responses whose protocol `id` is `null` return `None`. #[must_use] pub const fn command_id(&self) -> Option { - self.command_id + match self.routing { + WebDriverBiDiJsonEnvelopeRouting::CommandSuccess { command_id } => Some(command_id), + WebDriverBiDiJsonEnvelopeRouting::CommandError { command_id } => command_id, + WebDriverBiDiJsonEnvelopeRouting::Event => None, + } + } + + pub(crate) const fn routing(&self) -> WebDriverBiDiJsonEnvelopeRouting { + self.routing } /// Borrow the event method when this is an event envelope. @@ -242,8 +269,7 @@ impl TopLevelFields { let command_id = required_js_uint(self.id, "id")?; require_object(self.result, "result")?; Ok(WebDriverBiDiJsonEnvelope { - kind: WebDriverBiDiJsonEnvelopeKind::Success, - command_id: Some(command_id), + routing: WebDriverBiDiJsonEnvelopeRouting::CommandSuccess { command_id }, method: None, error_code: None, }) @@ -260,8 +286,7 @@ impl TopLevelFields { return Err(invalid("error")); } Ok(WebDriverBiDiJsonEnvelope { - kind: WebDriverBiDiJsonEnvelopeKind::Error, - command_id, + routing: WebDriverBiDiJsonEnvelopeRouting::CommandError { command_id }, method: None, error_code: Some(error_code), }) @@ -277,8 +302,7 @@ impl TopLevelFields { return Err(invalid("method")); } Ok(WebDriverBiDiJsonEnvelope { - kind: WebDriverBiDiJsonEnvelopeKind::Event, - command_id: None, + routing: WebDriverBiDiJsonEnvelopeRouting::Event, method: Some(method), error_code: None, }) diff --git a/crates/originweave-network/tests/webdriver_bidi_command_correlation.rs b/crates/originweave-network/tests/webdriver_bidi_command_correlation.rs new file mode 100644 index 000000000..bb30ff988 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_command_correlation.rs @@ -0,0 +1,217 @@ +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_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS, + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, 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"; + +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 parse_over_loopback( + document: &'static [u8], +) -> Result> { + if document.len() > 125 { + return Err(io::Error::other("test JSON document exceeded one-byte frame length").into()); + } + 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)?; + stream.write_all(&[0x81, document.len() as u8])?; + stream.write_all(document) + }); + + 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 (_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!( + "validated text frame produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let envelope = WebDriverBiDiJsonEnvelope::parse(&text)?; + server + .join() + .map_err(|_| io::Error::other("command-correlation test server panicked"))??; + Ok(envelope) +} + +#[test] +fn responses_correlate_out_of_order_and_ids_can_be_reused_after_completion() +-> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(7, WebDriverBiDiCommandKind::SessionStatus)?; + correlation.register_command_for(8, WebDriverBiDiCommandKind::SessionStatus)?; + assert_eq!(correlation.outstanding_count(), 2); + + let success = parse_over_loopback(br#"{"type":"success","id":8,"result":{}}"#)?; + let completed = + correlation.correlate_response_for(&success, WebDriverBiDiCommandKind::SessionStatus)?; + assert_eq!(completed.command_id(), 8); + assert_eq!( + completed.outcome(), + WebDriverBiDiCorrelatedResponseOutcome::Success + ); + assert_eq!(correlation.outstanding_count(), 1); + + let error = parse_over_loopback( + br#"{"type":"error","id":7,"error":"invalid argument","message":"redacted by parser"}"#, + )?; + let completed = + correlation.correlate_response_for(&error, WebDriverBiDiCommandKind::SessionStatus)?; + assert_eq!(completed.command_id(), 7); + assert_eq!( + completed.outcome(), + WebDriverBiDiCorrelatedResponseOutcome::Error + ); + assert_eq!(correlation.outstanding_count(), 0); + + correlation.register_command_for(8, WebDriverBiDiCommandKind::SessionStatus)?; + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn correlation_fails_closed_without_consuming_unrelated_outstanding_commands() +-> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(7, WebDriverBiDiCommandKind::SessionStatus)?; + + let unknown = parse_over_loopback(br#"{"type":"success","id":8,"result":{}}"#)?; + assert_eq!( + correlation.correlate_response_for(&unknown, WebDriverBiDiCommandKind::SessionStatus), + Err(WebDriverBiDiCommandCorrelationError::CommandNotOutstanding) + ); + assert_eq!(correlation.outstanding_count(), 1); + + let event = parse_over_loopback(br#"{"type":"event","method":"log.entryAdded","params":{}}"#)?; + assert_eq!( + correlation.correlate_response_for(&event, WebDriverBiDiCommandKind::SessionStatus), + Err(WebDriverBiDiCommandCorrelationError::EventIsNotResponse) + ); + assert_eq!(correlation.outstanding_count(), 1); + + let uncorrelatable = parse_over_loopback( + br#"{"type":"error","id":null,"error":"invalid argument","message":"no command id"}"#, + )?; + assert_eq!( + correlation + .correlate_response_for(&uncorrelatable, WebDriverBiDiCommandKind::SessionStatus,), + Err(WebDriverBiDiCommandCorrelationError::UncorrelatableErrorResponse) + ); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn outstanding_command_budget_and_retirement_are_bounded() -> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + assert_eq!( + correlation.register_command_for( + MAX_WEBDRIVER_BIDI_JS_UINT + 1, + WebDriverBiDiCommandKind::SessionStatus, + ), + Err(WebDriverBiDiCommandCorrelationError::CommandIdOutOfRange) + ); + correlation.register_command_for(1, WebDriverBiDiCommandKind::SessionStatus)?; + assert_eq!( + correlation.register_command_for(1, WebDriverBiDiCommandKind::SessionEnd), + Err(WebDriverBiDiCommandCorrelationError::CommandAlreadyOutstanding) + ); + assert_eq!( + correlation.retire_command_for(1, WebDriverBiDiCommandKind::SessionEnd), + Err(WebDriverBiDiCommandCorrelationError::CommandKindMismatch { + expected: WebDriverBiDiCommandKind::SessionEnd, + actual: WebDriverBiDiCommandKind::SessionStatus, + }) + ); + assert_eq!(correlation.outstanding_count(), 1); + correlation.retire_command_for(1, WebDriverBiDiCommandKind::SessionStatus)?; + assert_eq!(correlation.outstanding_count(), 0); + assert_eq!( + correlation.retire_command_for(1, WebDriverBiDiCommandKind::SessionStatus), + Err(WebDriverBiDiCommandCorrelationError::CommandNotOutstanding) + ); + + for command_id in 0..MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS as u64 { + correlation.register_command_for(command_id, WebDriverBiDiCommandKind::SessionStatus)?; + } + assert_eq!( + correlation.outstanding_count(), + MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS + ); + assert_eq!( + correlation.register_command_for( + MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS as u64, + WebDriverBiDiCommandKind::SessionStatus, + ), + Err(WebDriverBiDiCommandCorrelationError::OutstandingCommandLimit) + ); + correlation.retire_command_for(0, WebDriverBiDiCommandKind::SessionStatus)?; + correlation.register_command_for( + MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS as u64, + WebDriverBiDiCommandKind::SessionStatus, + )?; + assert_eq!( + correlation.outstanding_count(), + MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS + ); + Ok(()) +} + +#[test] +fn correlation_debug_redacts_outstanding_command_identifiers() -> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(123_456_789, WebDriverBiDiCommandKind::SessionStatus)?; + + let debug = format!("{correlation:?}"); + assert!(debug.contains("outstanding_count")); + assert!(!debug.contains("123456789")); + assert!(!debug.contains("SessionStatus")); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs b/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs new file mode 100644 index 000000000..2321aa7fe --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_command_kind_correlation.rs @@ -0,0 +1,103 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, 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"; + +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 parse_success_over_loopback() -> Result> { + const DOCUMENT: &[u8] = br#"{"type":"success","id":42,"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)?; + stream.write_all(&[0x81, DOCUMENT.len() as u8])?; + stream.write_all(DOCUMENT) + }); + + 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 (_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!( + "validated text frame produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let envelope = WebDriverBiDiJsonEnvelope::parse(&text)?; + server + .join() + .map_err(|_| io::Error::other("command-kind correlation test server panicked"))??; + Ok(envelope) +} + +#[test] +fn response_cannot_consume_a_different_outstanding_command_kind() -> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(42, WebDriverBiDiCommandKind::SessionStatus)?; + let response = parse_success_over_loopback()?; + + assert_eq!( + correlation.correlate_response_for(&response, WebDriverBiDiCommandKind::SessionEnd), + Err(WebDriverBiDiCommandCorrelationError::CommandKindMismatch { + expected: WebDriverBiDiCommandKind::SessionEnd, + actual: WebDriverBiDiCommandKind::SessionStatus, + }) + ); + assert_eq!(correlation.outstanding_count(), 1); + + let completed = + correlation.correlate_response_for(&response, WebDriverBiDiCommandKind::SessionStatus)?; + assert_eq!(completed.command_id(), 42); + assert_eq!( + completed.outcome(), + WebDriverBiDiCorrelatedResponseOutcome::Success + ); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} diff --git a/docs/doctoring.md b/docs/doctoring.md index f31d37605..891ff5bb7 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -106,6 +106,8 @@ The owning opening-response PR #242 already repaired that fixture at `17754d717b ### Opening-exchange fixture lifetime +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. On 5 September 2026, a complete Rust run after integrating PR #242 head `55fef0c3fae1724eddada53e52c4a0311f509aa3` into #243 reproduced `WriteTimeoutCleanupFailed` with macOS `EINVAL` after 198 request bytes in `opening_response_rejects_a_mismatched_accept_value`. That fixture returned its invalid response and closed immediately, before the client could finish opening-write cleanup. The successful-handshake fixture's one-byte close probe could consume the first request byte rather than observe closure, and the request-only fixture also closed immediately after reading the request. All three paths therefore shared a premature peer-lifetime assumption; the previously repaired invalid-deadline fixture did not cover them. diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 0795642a4..c65fd0fcc 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -10,7 +10,7 @@ This addendum complements the main doctoring record. The main record already car The latest published W3C technical-report baseline reviewed here is the 3 September 2026 **Working Draft**, not a Recommendation. The live Editor’s Draft retrieved on 5 September 2026 also identifies itself as the 3 September 2026 draft and explicitly remains work in progress. OriginWeave therefore treats BiDi as a versioned browser-automation adapter rather than product-internal authority. Raw BiDi session/context/node identifiers do not become durable OriginWeave identities. -For the bounded `browsingContext.locateNodes` command-serialization boundary, the reviewed Editor’s Draft defines a command envelope with `id: js-uint`, defines `js-uint` as `0..9007199254740991`, and defines `browsingContext.locateNodes` parameters containing a browsing context, locator, optional positive `maxNodeCount`, optional `serializationOptions`, and optional `startNodes`. OriginWeave serializes only its separately reviewed accessibility-locator subset and fixed minimal serialization options; this deterministic JSON value is not transport authentication or browser/Agent authority. +For the bounded `browsingContext.locateNodes` command-serialization boundary, the reviewed specification defines a command envelope with `id: js-uint`, defines `js-uint` as `0..9007199254740991`, and defines `browsingContext.locateNodes` parameters containing a browsing context, locator, optional positive `maxNodeCount`, optional `serializationOptions`, and optional `startNodes`. OriginWeave serializes only its separately reviewed accessibility-locator subset and fixed minimal serialization options; this deterministic JSON value is not transport authentication or browser/Agent authority. WebDriver BiDi commands may execute concurrently and finish out of order. The 3 September 2026 Working Draft defines the command id as the local end’s correlation identifier; its local-end `CommandResponse` production requires `id: js-uint`, while `ErrorResponse.id` is `js-uint / null`. OriginWeave therefore represents a validated success response with a structurally present command id, retains nullable ids only for protocol errors, and fails closed unless a non-null protocol-range response id exactly matches the consumed command before later payload admission. Parsing success/error envelopes, handling nullable malformed-command errors, and authenticating the browser transport remain separate adapter boundaries. diff --git a/tests/test_webdriver_bidi_command_correlation_documentation_contract.py b/tests/test_webdriver_bidi_command_correlation_documentation_contract.py new file mode 100644 index 000000000..dc90c8b7d --- /dev/null +++ b/tests/test_webdriver_bidi_command_correlation_documentation_contract.py @@ -0,0 +1,37 @@ +"""Keep the bounded command-correlation release contract in native CI discovery.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +CHANGELOG = ROOT / "CHANGELOG.md" +SOURCE = ROOT / "crates/originweave-network/src/webdriver_bidi_command_correlation.rs" + + +class CommandCorrelationDocumentationTests(unittest.TestCase): + """Enforce the correlation-owned release record without constraining other entries.""" + + def test_command_correlation_release_record_matches_public_boundary(self) -> None: + """The release record must retain its resource, provenance and authority bounds.""" + changelog = CHANGELOG.read_text(encoding="utf-8") + source = SOURCE.read_text(encoding="utf-8") + + release_records = [ + line + for line in changelog.splitlines() + if line.startswith("- Bounded WebDriver BiDi outstanding-command correlation") + ] + self.assertEqual(len(release_records), 1) + release_record = release_records[0] + self.assertIn("at most 256 local ids", release_record) + self.assertIn("exact typed command-family provenance", release_record) + self.assertIn("events, null-id errors, and kind mismatches", release_record) + self.assertIn( + "performs no transport I/O or browser, policy, secret, or Agent authority grant", + release_record, + ) + + self.assertIn("MAX_WEBDRIVER_BIDI_OUTSTANDING_COMMANDS: usize = 256", source) + self.assertIn("CommandKindMismatch", source) + self.assertIn("UncorrelatableErrorResponse", source)