From 74fdedb6ee441a4055ee335bc5a5b96dee852661 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:41:58 +0900 Subject: [PATCH 01/19] repair(network): reconstruct navigation subscription on live parent --- crates/originweave-network/src/lib.rs | 32 +- ..._bidi_navigation_committed_subscription.rs | 223 ++++++ ...igation_committed_subscription_response.rs | 756 ++++++++++++++++++ ..._bidi_navigation_committed_subscription.rs | 218 +++++ ...igation_committed_subscription_failures.rs | 223 ++++++ ...ommitted_subscription_response_failures.rs | 212 +++++ 6 files changed, 1654 insertions(+), 10 deletions(-) create mode 100644 crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs create mode 100644 crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs create mode 100644 crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs create mode 100644 crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs create mode 100644 crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 314ad0256..39663383c 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -9,16 +9,17 @@ //! unmasked server-frame reads, assembles bounded WebDriver BiDi text messages, //! classifies complete local-end JSON envelopes, tracks bounded command-response //! correlation, transports a narrowly typed pointer click, admits its typed -//! correlated protocol response, admits a bounded navigation-committed -//! post-condition observation for one exact registered context and URL, rotates -//! the matched context's document epoch only from an exact caller-captured -//! pre-action epoch, derives and binds the committed HTTP(S) URL's canonical -//! origin to that newly advanced document, sends narrowly typed `session.status` -//! and `session.end` commands, admits typed correlated status and end responses, -//! observes bounded peer Close or clean-EOF transport cessation, and keeps -//! protocol/transport evidence separate from explicit operational teardown -//! observations without exposing generic JSON bodies or granting browser, TLS, -//! policy, secret, process, profile, or Agent authority. +//! correlated protocol response, sends a context-bound subscription for +//! committed-navigation events, retains its typed bounded correlated subscription +//! identifier, admits a bounded navigation-committed post-condition observation +//! for one exact registered context and URL, rotates the matched context's document +//! epoch only from an exact caller-captured pre-action epoch, derives and binds the +//! committed HTTP(S) URL's canonical origin to that newly advanced document, sends +//! narrowly typed `session.status` and `session.end` commands, admits typed +//! correlated status and end responses, observes bounded peer Close or clean-EOF +//! transport cessation, and keeps protocol/transport evidence separate from +//! explicit operational teardown observations without exposing generic JSON bodies +//! or granting browser, TLS, policy, secret, process, profile, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -28,6 +29,8 @@ mod webdriver_bidi_command_correlation; mod webdriver_bidi_connection; mod webdriver_bidi_json_envelope; mod webdriver_bidi_navigation_committed_postcondition; +mod webdriver_bidi_navigation_committed_subscription; +mod webdriver_bidi_navigation_committed_subscription_response; mod webdriver_bidi_navigation_document_advance; mod webdriver_bidi_navigation_document_origin; mod webdriver_bidi_pointer_click_response; @@ -69,6 +72,15 @@ pub use webdriver_bidi_navigation_committed_postcondition::{ WebDriverBiDiNavigationCommittedObservationError, WebDriverBiDiNavigationCommittedProjectionError, }; +pub use webdriver_bidi_navigation_committed_subscription::{ + WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionCommandError, +}; +pub use webdriver_bidi_navigation_committed_subscription_response::{ + MAX_WEBDRIVER_BIDI_SUBSCRIPTION_IDENTIFIER_BYTES, + WebDriverBiDiNavigationCommittedSubscriptionResponseError, + WebDriverBiDiNavigationCommittedSubscriptionResult, +}; pub use webdriver_bidi_navigation_document_advance::{ WebDriverBiDiNavigationCommittedDocumentAdvance, WebDriverBiDiNavigationCommittedDocumentAdvanceError, diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs new file mode 100644 index 000000000..fae6b9bd0 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs @@ -0,0 +1,223 @@ +use std::{error::Error, fmt, time::Duration}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, +}; + +use crate::{ + MAX_WEBDRIVER_BIDI_JS_UINT, WEBDRIVER_BIDI_NAVIGATION_COMMITTED_METHOD, + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_SUBSCRIBE_METHOD: &str = "session.subscribe"; + +/// One context-scoped subscription for the committed-navigation WebDriver BiDi event. +/// +/// This command is deliberately narrower than the protocol's generic `session.subscribe` surface: +/// it can request only `browsingContext.navigationCommitted`, for one external context that already +/// maps to the exact supplied OriginWeave session/context pair. It does not expose arbitrary event +/// names, global subscriptions, user-context subscriptions, generic JSON, or arbitrary method +/// dispatch. Successful construction or transport does not authenticate Chromium, authorize a +/// navigation, grant destination or policy authority, or make later event data reusable Agent +/// authority. +pub struct WebDriverBiDiNavigationCommittedSubscriptionCommand { + command_id: u64, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_context: String, +} + +impl WebDriverBiDiNavigationCommittedSubscriptionCommand { + /// Construct one bounded context-scoped committed-navigation subscription command. + /// + /// The external protocol identifier must already name the exact registered OriginWeave + /// session/context pair. No registry state is created as a side effect of untrusted adapter text. + pub fn new( + command_id: u64, + registry: &BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_context: &str, + ) -> Result { + if command_id > MAX_WEBDRIVER_BIDI_JS_UINT { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionCommandError::CommandIdOutOfRange { + command_id, + maximum_command_id: MAX_WEBDRIVER_BIDI_JS_UINT, + }, + ); + } + require_registered_context( + registry, + browser_session, + browsing_context, + external_context, + )?; + Ok(Self { + command_id, + browser_session, + browsing_context, + external_context: external_context.to_owned(), + }) + } + + /// Return the exact local correlation identifier serialized by this command. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the exact registered OriginWeave browser session bound during construction. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the exact registered OriginWeave browsing context bound during construction. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } + + /// Borrow the exact external WebDriver BiDi context identifier serialized by this command. + #[must_use] + pub fn external_context(&self) -> &str { + &self.external_context + } + + /// Revalidate, register, and write this exact subscription on an established verified BiDi stream. + /// + /// Context binding is revalidated immediately before command correlation and network I/O so a + /// command retained across registry retirement cannot subscribe a stale or replacement context. + /// Correlation registration then occurs before the first possible remote side effect. A binding + /// or correlation failure therefore writes nothing. After successful registration, a frame-write + /// failure consumes the transport and intentionally leaves the identifier outstanding because a + /// partial or fully emitted frame has ambiguous remote effect. + pub fn send( + self, + registry: &BrowserAuthorityRegistry, + established: WebDriverBiDiWebSocketEstablished, + correlation: &mut WebDriverBiDiCommandCorrelation, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result< + WebDriverBiDiWebSocketEstablished, + WebDriverBiDiNavigationCommittedSubscriptionCommandError, + > { + require_registered_context( + registry, + self.browser_session, + self.browsing_context, + &self.external_context, + )?; + correlation + .register_command(self.command_id) + .map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionCommandError::Correlation { source } + })?; + let message = self.serialized(); + established + .write_text_frame(&message, masking_key, frame_timeout) + .map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionCommandError::FrameWrite { source } + }) + } + + fn serialized(&self) -> String { + let mut message = format!( + "{{\"id\":{},\"method\":\"{SESSION_SUBSCRIBE_METHOD}\",\"params\":{{\"events\":[\"{WEBDRIVER_BIDI_NAVIGATION_COMMITTED_METHOD}\"],\"contexts\":[", + self.command_id + ); + push_json_string(&mut message, &self.external_context); + message.push_str("]}}"); + message + } +} + +fn require_registered_context( + registry: &BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_context: &str, +) -> Result<(), WebDriverBiDiNavigationCommittedSubscriptionCommandError> { + registry + .require_registered_context_external_identifier( + browser_session, + browsing_context, + external_context, + ) + .map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionCommandError::ContextBinding { source } + }) +} + +fn push_json_string(target: &mut String, value: &str) { + target.push('"'); + for character in value.chars() { + match character { + '"' => target.push_str("\\\""), + '\\' => target.push_str("\\\\"), + _ => target.push(character), + } + } + target.push('"'); +} + +/// Fail-closed failures while constructing or sending one typed committed-navigation subscription. +#[derive(Debug)] +pub enum WebDriverBiDiNavigationCommittedSubscriptionCommandError { + /// 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 external protocol context does not name the exact registered OriginWeave context. + ContextBinding { + /// Exact typed browser-registry authority failure. + source: BrowserRegistryError, + }, + /// The bounded local correlation registry rejected the command before network I/O. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// Writing the already-registered command frame failed and the transport is not reusable. + FrameWrite { + /// Exact typed bounded WebSocket frame-write failure. + source: WebDriverBiDiWebSocketFrameError, + }, +} + +impl fmt::Display for WebDriverBiDiNavigationCommittedSubscriptionCommandError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CommandIdOutOfRange { .. } => formatter.write_str( + "WebDriver BiDi navigation subscription command id is outside the js-uint range", + ), + Self::ContextBinding { .. } => formatter.write_str( + "WebDriver BiDi navigation subscription context does not match registered authority", + ), + Self::Correlation { .. } => formatter.write_str( + "WebDriver BiDi navigation subscription command correlation was rejected", + ), + Self::FrameWrite { .. } => formatter.write_str( + "WebDriver BiDi navigation subscription command frame write failed", + ), + } + } +} + +impl Error for WebDriverBiDiNavigationCommittedSubscriptionCommandError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::CommandIdOutOfRange { .. } => None, + Self::ContextBinding { source } => Some(source), + Self::Correlation { source } => Some(source), + Self::FrameWrite { source } => Some(source), + } + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs new file mode 100644 index 000000000..961ea8ec7 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs @@ -0,0 +1,756 @@ +use std::{error::Error, fmt}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, + WebDriverBiDiWebSocketTextMessage, +}; + +/// Maximum decoded UTF-8 bytes retained from a WebDriver BiDi `session.Subscription` identifier. +/// +/// WebDriver BiDi defines the identifier as opaque text without a protocol size ceiling. OriginWeave +/// therefore applies a reviewed local retention bound while preserving the identifier byte-for-byte +/// for later typed subscription lifecycle work. The value is never included in `Debug` output. +pub const MAX_WEBDRIVER_BIDI_SUBSCRIPTION_IDENTIFIER_BYTES: usize = 4_096; + +/// Typed, correlated successful result of one context-scoped WebDriver BiDi `session.subscribe`. +/// +/// This value retains only the exact correlated command id and the bounded opaque subscription +/// identifier returned by the remote end. It does not expose a generic JSON result, grant event, +/// browser, policy, origin, secret, or Agent authority, or prove that any subscribed event has fired. +#[derive(Eq, PartialEq)] +pub struct WebDriverBiDiNavigationCommittedSubscriptionResult { + command_id: u64, + subscription_id: String, +} + +impl fmt::Debug for WebDriverBiDiNavigationCommittedSubscriptionResult { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiNavigationCommittedSubscriptionResult") + .field("command_id", &self.command_id) + .field("subscription_id_len", &self.subscription_id.len()) + .finish() + } +} + +impl WebDriverBiDiNavigationCommittedSubscriptionResult { + /// Parse one bounded local-end message and consume its exact outstanding command on success. + /// + /// Common WebDriver BiDi envelope validation runs first. A successful envelope then undergoes + /// command-specific projection of the required `result.subscription` text before correlation is + /// consumed, so malformed or ambiguous success bodies cannot silently retire a command id. A + /// correlatable protocol-error response consumes its matching id and returns a typed remote + /// failure retaining only the protocol error code. Events, null-id errors, malformed envelopes, + /// and unknown ids fail closed without consuming unrelated outstanding correlation state. + pub fn parse_and_correlate( + message: &WebDriverBiDiWebSocketTextMessage, + correlation: &mut WebDriverBiDiCommandCorrelation, + ) -> Result { + let envelope = WebDriverBiDiJsonEnvelope::parse(message).map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Envelope { source } + })?; + + match envelope.kind() { + WebDriverBiDiJsonEnvelopeKind::Success => { + let projected = SubscriptionProjection::parse(message.as_str())?; + let completed = correlation + .correlate_response(&envelope) + .map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source, + } + })?; + Ok(Self { + command_id: completed.command_id(), + subscription_id: projected.subscription_id, + }) + } + WebDriverBiDiJsonEnvelopeKind::Error => { + retain_validated_error_code(envelope.error_code()).and_then(|error_code| { + let completed = correlation.correlate_response(&envelope).map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source, + } + })?; + Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::RemoteProtocolError { + command_id: completed.command_id(), + error_code, + }, + ) + }) + } + WebDriverBiDiJsonEnvelopeKind::Event => Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::EventIsNotResponse, + }, + ), + } + } + + /// Return the exact local command identifier consumed by this result. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Borrow the bounded opaque subscription identifier returned by the remote end. + #[must_use] + pub fn subscription_id(&self) -> &str { + &self.subscription_id + } +} + +/// Fail-closed failures while admitting one typed WebDriver BiDi `session.subscribe` response. +#[derive(Debug, Eq, PartialEq)] +pub enum WebDriverBiDiNavigationCommittedSubscriptionResponseError { + /// Common local-end JSON envelope validation failed. + Envelope { + /// Exact common-envelope validation failure. + source: WebDriverBiDiJsonEnvelopeError, + }, + /// The successful result object omits the required `subscription` member. + MissingSubscription, + /// The successful result object's `subscription` member is not JSON text. + InvalidSubscription, + /// The successful result repeats the `subscription` member and is ambiguous. + DuplicateSubscription, + /// The decoded subscription identifier exceeds the reviewed local retention bound. + SubscriptionTooLarge { + /// Maximum decoded identifier length admitted in bytes. + maximum_bytes: usize, + }, + /// A validated success envelope could not be projected through the command-specific parser. + InvalidResultProjection, + /// Exact command-response correlation failed without consuming unrelated state. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// The remote end returned a correlatable WebDriver BiDi protocol error for this command. + RemoteProtocolError { + /// Exact local command identifier consumed by the protocol-error response. + command_id: u64, + /// Protocol error code retained from the already validated common envelope. + error_code: String, + }, +} + +impl fmt::Display for WebDriverBiDiNavigationCommittedSubscriptionResponseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Envelope { .. } => { + formatter.write_str("WebDriver BiDi session.subscribe envelope is invalid") + } + Self::MissingSubscription => formatter + .write_str("WebDriver BiDi session.subscribe result is missing subscription"), + Self::InvalidSubscription => formatter + .write_str("WebDriver BiDi session.subscribe result subscription is invalid"), + Self::DuplicateSubscription => formatter.write_str( + "WebDriver BiDi session.subscribe result contains duplicate subscription", + ), + Self::SubscriptionTooLarge { .. } => formatter.write_str( + "WebDriver BiDi session.subscribe result subscription exceeds the size bound", + ), + Self::InvalidResultProjection => { + formatter.write_str("WebDriver BiDi session.subscribe result projection is invalid") + } + Self::Correlation { .. } => { + formatter.write_str("WebDriver BiDi session.subscribe response correlation failed") + } + Self::RemoteProtocolError { .. } => { + formatter.write_str("WebDriver BiDi session.subscribe returned a protocol error") + } + } + } +} + +impl Error for WebDriverBiDiNavigationCommittedSubscriptionResponseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Envelope { source } => Some(source), + Self::Correlation { source } => Some(source), + Self::MissingSubscription + | Self::InvalidSubscription + | Self::DuplicateSubscription + | Self::SubscriptionTooLarge { .. } + | Self::InvalidResultProjection + | Self::RemoteProtocolError { .. } => None, + } + } +} + +fn retain_validated_error_code( + error_code: Option<&str>, +) -> Result { + error_code.map(str::to_owned).ok_or( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Envelope { + source: WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "error" }, + }, + ) +} + +struct SubscriptionProjection { + subscription_id: String, +} + +impl SubscriptionProjection { + fn parse( + text: &str, + ) -> Result { + let mut cursor = ProjectionCursor::new(text); + cursor.skip_whitespace(); + if !cursor.consume_byte(b'{') { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + ); + } + cursor.skip_whitespace(); + if cursor.consume_byte(b'}') { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + ); + } + + loop { + cursor.skip_whitespace(); + let key = cursor.parse_string().ok_or( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + )?; + cursor.skip_whitespace(); + if !cursor.consume_byte(b':') { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + ); + } + cursor.skip_whitespace(); + if key == "result" { + return cursor.parse_result_object(); + } + if !cursor.skip_value() { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + ); + } + cursor.skip_whitespace(); + if cursor.consume_byte(b'}') { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + ); + } + if !cursor.consume_byte(b',') { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + ); + } + } + } +} + +struct ProjectionCursor<'a> { + input: &'a str, + index: usize, +} + +impl<'a> ProjectionCursor<'a> { + const fn new(input: &'a str) -> Self { + Self { input, index: 0 } + } + + fn current_byte(&self) -> Option { + self.input.as_bytes().get(self.index).copied() + } + + fn consume_byte(&mut self, expected: u8) -> bool { + if self.current_byte() == Some(expected) { + self.index += 1; + true + } else { + false + } + } + + fn skip_whitespace(&mut self) { + while matches!(self.current_byte(), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.index += 1; + } + } + + fn parse_result_object( + &mut self, + ) -> Result + { + if !self.consume_byte(b'{') { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + ); + } + self.skip_whitespace(); + let mut subscription_id = None; + if self.consume_byte(b'}') { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::MissingSubscription, + ); + } + + loop { + self.skip_whitespace(); + let key = self.parse_string().ok_or( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + )?; + self.skip_whitespace(); + if !self.consume_byte(b':') { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + ); + } + self.skip_whitespace(); + if key == "subscription" { + if subscription_id.is_some() { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::DuplicateSubscription, + ); + } + let parsed = self.parse_string().ok_or( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidSubscription, + )?; + if parsed.len() > MAX_WEBDRIVER_BIDI_SUBSCRIPTION_IDENTIFIER_BYTES { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::SubscriptionTooLarge { + maximum_bytes: MAX_WEBDRIVER_BIDI_SUBSCRIPTION_IDENTIFIER_BYTES, + }, + ); + } + subscription_id = Some(parsed); + } else if !self.skip_value() { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + ); + } + self.skip_whitespace(); + if self.consume_byte(b'}') { + break; + } + if !self.consume_byte(b',') { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + ); + } + } + + Ok(SubscriptionProjection { + subscription_id: subscription_id.ok_or( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::MissingSubscription, + )?, + }) + } + + fn consume_literal(&mut self, literal: &[u8]) -> bool { + let end = self.index.saturating_add(literal.len()); + if self.input.as_bytes().get(self.index..end) == Some(literal) { + self.index = end; + true + } else { + false + } + } + + fn skip_value(&mut self) -> bool { + self.skip_whitespace(); + match self.current_byte() { + Some(b'"') => self.parse_string().is_some(), + Some(b'{') => self.skip_object(), + Some(b'[') => self.skip_array(), + Some(b't') => self.consume_literal(b"true"), + Some(b'f') => self.consume_literal(b"false"), + Some(b'n') => self.consume_literal(b"null"), + Some(b'-' | b'0'..=b'9') => self.skip_number(), + _ => false, + } + } + + fn skip_object(&mut self) -> bool { + if !self.consume_byte(b'{') { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b'}') { + return true; + } + loop { + self.skip_whitespace(); + if self.parse_string().is_none() { + return false; + } + self.skip_whitespace(); + if !self.consume_byte(b':') { + return false; + } + if !self.skip_value() { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b'}') { + return true; + } + if !self.consume_byte(b',') { + return false; + } + } + } + + fn skip_array(&mut self) -> bool { + if !self.consume_byte(b'[') { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b']') { + return true; + } + loop { + if !self.skip_value() { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b']') { + return true; + } + if !self.consume_byte(b',') { + return false; + } + } + } + + fn skip_number(&mut self) -> bool { + let start = self.index; + while matches!( + self.current_byte(), + Some(b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9') + ) { + self.index += 1; + } + self.index > start + } + + fn parse_string(&mut self) -> Option { + if !self.consume_byte(b'"') { + return None; + } + let mut output = String::new(); + loop { + let byte = self.current_byte()?; + match byte { + b'"' => { + self.index += 1; + return Some(output); + } + b'\\' => { + self.index += 1; + if !self.parse_escape(&mut output) { + return None; + } + } + 0x00..=0x1f => return None, + _ if byte.is_ascii() => { + output.push(char::from(byte)); + self.index += 1; + } + _ => { + let width = byte.leading_ones() as usize; + let end = self.index + width; + output.push_str(&self.input[self.index..end]); + self.index = end; + } + } + } + } + + fn parse_escape(&mut self, output: &mut String) -> bool { + let Some(escape) = self.current_byte() else { + return false; + }; + self.index += 1; + match escape { + b'"' => output.push('"'), + b'\\' => output.push('\\'), + b'/' => output.push('/'), + b'b' => output.push('\u{0008}'), + b'f' => output.push('\u{000c}'), + b'n' => output.push('\n'), + b'r' => output.push('\r'), + b't' => output.push('\t'), + b'u' => return self.parse_unicode_escape(output), + _ => return false, + } + true + } + + fn parse_unicode_escape(&mut self, output: &mut String) -> bool { + let Some(first) = self.parse_hex_u16() else { + return false; + }; + if (0xd800..=0xdbff).contains(&first) { + if !self.consume_byte(b'\\') || !self.consume_byte(b'u') { + return false; + } + let Some(second) = self.parse_hex_u16() else { + return false; + }; + if !(0xdc00..=0xdfff).contains(&second) { + return false; + } + output.push_str(&String::from_utf16_lossy(&[first, second])); + true + } else if (0xdc00..=0xdfff).contains(&first) { + false + } else { + output.push_str(&String::from_utf16_lossy(&[first])); + true + } + } + + fn parse_hex_u16(&mut self) -> Option { + let mut value = 0_u16; + for _ in 0..4 { + let byte = self.current_byte()?; + let digit = match byte { + b'0'..=b'9' => u16::from(byte - b'0'), + b'a'..=b'f' => u16::from(byte - b'a' + 10), + b'A'..=b'F' => u16::from(byte - b'A' + 10), + _ => return None, + }; + value = (value << 4) | digit; + self.index += 1; + } + Some(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projection_accepts_subscription_unknown_metadata_and_escaped_keys() { + let projected = SubscriptionProjection::parse( + r#"{"meta":[null,true,false,1,-2.5e+3,{"nested":"value"}],"re\u0073ult":{"extra":{},"sub\u0073cription":"sub-\ud83d\ude80"}}"#, + ); + assert!(projected.is_ok()); + assert_eq!( + projected.ok().map(|value| value.subscription_id), + Some("sub-🚀".to_owned()) + ); + + let direct_utf8 = SubscriptionProjection::parse( + "\n\t { \r\n \"result\" : { \"subscription\" : \"구독-a\" } }", + ); + assert_eq!( + direct_utf8.ok().map(|value| value.subscription_id), + Some("구독-a".to_owned()) + ); + } + + #[test] + fn projection_rejects_missing_invalid_duplicate_and_oversized_subscription() { + let cases = [ + (r#"{"result":{}}"#.to_owned(), "missing"), + ( + r#"{"result":{"extra":1}}"#.to_owned(), + "missing after metadata", + ), + (r#"{"result":{"subscription":false}}"#.to_owned(), "invalid"), + ( + r#"{"result":{"subscription":"a","subscription":"b"}}"#.to_owned(), + "duplicate", + ), + ( + format!( + "{{\"result\":{{\"subscription\":\"{}\"}}}}", + "x".repeat(MAX_WEBDRIVER_BIDI_SUBSCRIPTION_IDENTIFIER_BYTES + 1) + ), + "oversized", + ), + (r#"{"x":1}"#.to_owned(), "missing result"), + ]; + + for (document, label) in cases { + assert!(SubscriptionProjection::parse(&document).is_err(), "{label}"); + } + } + + #[test] + fn projection_cursor_rejects_malformed_private_inputs_without_panicking() { + let malformed = [ + "", + "[]", + "{}", + r#"{"x":}"#, + r#"{"x" 1}"#, + r#"{"x":1 ?}"#, + r#"{?}"#, + r#"{"result":[]}"#, + r#"{"result":{?}}"#, + r#"{"result":{"subscription" "x"}}"#, + r#"{"result":{"subscription":"x" "extra":1}}"#, + r#"{"result":{"subscription":"x","extra":?}}"#, + r#"{"result":{"subscription":"\uD800"}}"#, + r#"{"result":{"subscription":"\q"}}"#, + ]; + for document in malformed { + assert!( + SubscriptionProjection::parse(document).is_err(), + "{document}" + ); + } + } + + #[test] + fn projection_cursor_defensive_helpers_cover_hostile_dispatch_edges() { + let mut object = ProjectionCursor::new("[]"); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new("{}"); + assert!(object.skip_object()); + let mut object = ProjectionCursor::new("{?}"); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x" 1}"#); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x":?}"#); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x":1 ?}"#); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x":1,"y":2}"#); + assert!(object.skip_object()); + + let mut array = ProjectionCursor::new("{}"); + assert!(!array.skip_array()); + let mut array = ProjectionCursor::new("[]"); + assert!(array.skip_array()); + let mut array = ProjectionCursor::new("[?]"); + assert!(!array.skip_array()); + let mut array = ProjectionCursor::new("[1 ?]"); + assert!(!array.skip_array()); + + for document in [r#""x""#, "{}", "[]", "true", "false", "null", "-2.5e+3"] { + let mut value = ProjectionCursor::new(document); + assert!(value.skip_value(), "{document}"); + } + let mut value = ProjectionCursor::new("?"); + assert!(!value.skip_value()); + let mut literal = ProjectionCursor::new("tru?"); + assert!(!literal.consume_literal(b"true")); + + let mut number = ProjectionCursor::new("x"); + assert!(!number.skip_number()); + let mut number = ProjectionCursor::new("+1"); + assert!(number.skip_number()); + + let mut string = ProjectionCursor::new("x"); + assert!(string.parse_string().is_none()); + let mut string = ProjectionCursor::new("\"unterminated"); + assert!(string.parse_string().is_none()); + let mut string = ProjectionCursor::new("\"\u{0001}\""); + assert!(string.parse_string().is_none()); + let mut string = ProjectionCursor::new("\"é\""); + assert_eq!(string.parse_string().as_deref(), Some("é")); + + let mut output = String::new(); + let mut escape = ProjectionCursor::new(""); + assert!(!escape.parse_escape(&mut output)); + for sequence in ["\"", "\\", "/", "b", "f", "n", "r", "t"] { + let mut output = String::new(); + let mut escape = ProjectionCursor::new(sequence); + assert!(escape.parse_escape(&mut output), "{sequence:?}"); + } + let mut output = String::new(); + let mut escape = ProjectionCursor::new("q"); + assert!(!escape.parse_escape(&mut output)); + + for sequence in ["0000", "aBcD", "Ff09"] { + let mut hex = ProjectionCursor::new(sequence); + assert!(hex.parse_hex_u16().is_some()); + } + let mut hex = ProjectionCursor::new("xyz1"); + assert!(hex.parse_hex_u16().is_none()); + let mut hex = ProjectionCursor::new("0"); + assert!(hex.parse_hex_u16().is_none()); + + let unicode_cases = [ + ("0041", true), + ("d83d\\ude80", true), + ("d83d", false), + ("d83d\\x", false), + ("d83d\\u0", false), + ("d83d\\u0041", false), + ("dc00", false), + ("zzzz", false), + ]; + for (sequence, expected) in unicode_cases { + let mut output = String::new(); + let mut unicode = ProjectionCursor::new(sequence); + assert_eq!(unicode.parse_unicode_escape(&mut output), expected); + } + } + + #[test] + fn response_errors_have_stable_messages_and_typed_sources() { + let envelope = WebDriverBiDiNavigationCommittedSubscriptionResponseError::Envelope { + source: WebDriverBiDiJsonEnvelopeError::InvalidJson, + }; + assert_eq!( + envelope.to_string(), + "WebDriver BiDi session.subscribe envelope is invalid" + ); + assert!(envelope.source().is_some()); + + let correlation = WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandNotOutstanding, + }; + assert_eq!( + correlation.to_string(), + "WebDriver BiDi session.subscribe response correlation failed" + ); + assert!(correlation.source().is_some()); + + let source_free = [ + WebDriverBiDiNavigationCommittedSubscriptionResponseError::MissingSubscription, + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidSubscription, + WebDriverBiDiNavigationCommittedSubscriptionResponseError::DuplicateSubscription, + WebDriverBiDiNavigationCommittedSubscriptionResponseError::SubscriptionTooLarge { + maximum_bytes: MAX_WEBDRIVER_BIDI_SUBSCRIPTION_IDENTIFIER_BYTES, + }, + WebDriverBiDiNavigationCommittedSubscriptionResponseError::InvalidResultProjection, + WebDriverBiDiNavigationCommittedSubscriptionResponseError::RemoteProtocolError { + command_id: 7, + error_code: "invalid argument".to_owned(), + }, + ]; + for error in source_free { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + } + + #[test] + fn result_debug_redacts_opaque_subscription_identifier() { + let result = WebDriverBiDiNavigationCommittedSubscriptionResult { + command_id: 7, + subscription_id: "sensitive-subscription".to_owned(), + }; + let debug = format!("{result:?}"); + assert!(debug.contains("command_id")); + assert!(debug.contains("subscription_id_len")); + assert!(!debug.contains("sensitive-subscription")); + assert_eq!(result.command_id(), 7); + assert_eq!(result.subscription_id(), "sensitive-subscription"); + } + + #[test] + fn retain_error_code_fails_closed_when_common_invariant_is_absent() { + assert_eq!( + retain_validated_error_code(Some("invalid argument")).as_deref(), + Ok("invalid argument") + ); + assert!(retain_validated_error_code(None).is_err()); + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs new file mode 100644 index 000000000..4b5a7ad39 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs @@ -0,0 +1,218 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; +use originweave_network::{ + MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiCommandCorrelation, + WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CONTEXT_ID: &str = "context-\"a\\b"; +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 SUBSCRIBE_RESPONSE: &[u8] = + r#"{"type":"success","id":7,"result":{"subscription":"subscription-a","vendorNote":"café"}}"# + .as_bytes(); + +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 = match header[1] & 0x7f { + length @ 0..=125 => usize::from(length), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test command unexpectedly required 64-bit 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 navigation_committed_subscription_round_trips_on_the_registered_context() +-> 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.subscribe","params":{"events":["browsingContext.navigationCommitted"],"contexts":["context-\"a\\b"]}}"# + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "unexpected session.subscribe command: {}", + String::from_utf8_lossy(&command) + ), + )); + } + stream.write_all(&[0x81, SUBSCRIBE_RESPONSE.len() as u8])?; + stream.write_all(SUBSCRIBE_RESPONSE) + }); + + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + + 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 = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )?; + assert_eq!(command.command_id(), 7); + assert_eq!(command.browser_session(), session); + assert_eq!(command.browsing_context(), context); + assert_eq!(command.external_context(), CONTEXT_ID); + let established = command.send( + ®istry, + 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.subscribe response produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let result = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &text, + &mut correlation, + )?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.subscription_id(), "subscription-a"); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("session.subscribe command test server panicked"))??; + Ok(()) +} + +#[test] +fn subscription_constructor_rejects_out_of_range_command_id_without_source() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + + let result = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + MAX_WEBDRIVER_BIDI_JS_UINT + 1, + ®istry, + session, + context, + CONTEXT_ID, + ); + let error = match result { + Ok(_) => { + return Err(io::Error::other( + "out-of-range session.subscribe command id was unexpectedly accepted", + ) + .into()); + } + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi navigation subscription command id is outside the js-uint range" + ); + assert!(error.source().is_none()); + Ok(()) +} + +#[test] +fn subscription_constructor_rejects_mismatched_registered_context() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + + let result = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + ®istry, + session, + context, + "different-context", + ); + let error = match result { + Ok(_) => { + return Err(io::Error::other( + "mismatched session.subscribe context was unexpectedly accepted", + ) + .into()); + } + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi navigation subscription context does not match registered authority" + ); + assert!(error.source().is_some()); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs new file mode 100644 index 000000000..2d85a62d8 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs @@ -0,0 +1,223 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiNavigationCommittedSubscriptionCommand, + 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"; + +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 require_no_client_command(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "subscription command was written despite local rejection", + )), + Err(source) + if matches!( + source.kind(), + io::ErrorKind::ConnectionReset | io::ErrorKind::ConnectionAborted + ) => + { + Ok(()) + } + Err(source) => Err(source), + } +} + +fn spawn_no_command_server(listener: TcpListener) -> thread::JoinHandle> { + thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + require_no_client_command(&mut stream) + }) +} + +fn establish_websocket( + local_addr: SocketAddr, +) -> Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +#[test] +fn retired_context_is_rejected_before_correlation_or_command_write() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = spawn_no_command_server(listener); + + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "context-a")?; + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + ®istry, + session, + context, + "context-a", + )?; + let established = establish_websocket(local_addr)?; + registry.remove_context(context)?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let result = command.send( + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ); + let error = match result { + Ok(_) => { + return Err(io::Error::other("retired context unexpectedly sent subscription").into()); + } + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi navigation subscription context does not match registered authority" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("retired-context test server panicked"))??; + Ok(()) +} + +#[test] +fn duplicate_command_id_is_rejected_before_command_write() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = spawn_no_command_server(listener); + + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "context-a")?; + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + ®istry, + session, + context, + "context-a", + )?; + let established = establish_websocket(local_addr)?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command(7)?; + let result = command.send( + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ); + let error = match result { + Ok(_) => { + return Err( + io::Error::other("duplicate command id unexpectedly sent subscription").into(), + ); + } + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi navigation subscription command correlation was rejected" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + + server + .join() + .map_err(|_| io::Error::other("duplicate-command test server panicked"))??; + Ok(()) +} + +#[test] +fn invalid_frame_timeout_consumes_transport_and_retains_correlation() -> Result<(), Box> +{ + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = spawn_no_command_server(listener); + + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "context-a")?; + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + ®istry, + session, + context, + "context-a", + )?; + let established = establish_websocket(local_addr)?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let result = command.send( + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::ZERO, + ); + let error = match result { + Ok(_) => { + return Err( + io::Error::other("zero frame timeout unexpectedly sent subscription").into(), + ); + } + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi navigation subscription command frame write failed" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + + server + .join() + .map_err(|_| io::Error::other("frame-write test server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs new file mode 100644 index 000000000..82dea5dfe --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs @@ -0,0 +1,212 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiNavigationCommittedSubscriptionResponseError, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiWebSocketTextMessage, +}; + +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 MALFORMED_SUCCESS_RESPONSE: &[u8] = br#"{"type":"success","id":7,"result":"#; +const MISSING_SUBSCRIPTION_RESPONSE: &[u8] = br#"{"type":"success","id":7,"result":{"extra":1}}"#; +const UNKNOWN_SUCCESS_RESPONSE: &[u8] = + br#"{"type":"success","id":8,"result":{"subscription":"subscription-b"}}"#; +const MATCHED_ERROR_RESPONSE: &[u8] = + br#"{"type":"error","id":7,"error":"invalid argument","message":"denied"}"#; +const UNKNOWN_ERROR_RESPONSE: &[u8] = + br#"{"type":"error","id":8,"error":"invalid argument","message":"denied"}"#; +const NAVIGATION_EVENT: &[u8] = + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{}}"#; + +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 write_unmasked_text_frame(stream: &mut TcpStream, document: &[u8]) -> io::Result<()> { + if document.len() <= 125 { + let length = u8::try_from(document.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "short frame length exceeds u8") + })?; + stream.write_all(&[0x81, length])?; + } else { + let length = u16::try_from(document.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test JSON document exceeds two-byte frame length", + ) + })?; + stream.write_all(&[0x81, 126])?; + stream.write_all(&length.to_be_bytes())?; + } + stream.write_all(document) +} + +fn read_text_over_loopback( + document: &'static [u8], +) -> 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)?; + write_unmasked_text_frame(&mut stream, 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!( + "subscription response produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + + server + .join() + .map_err(|_| io::Error::other("subscription response test server panicked"))??; + Ok(text) +} + +#[test] +fn malformed_and_invalid_success_responses_preserve_outstanding_correlation() +-> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command(7)?; + + let malformed = read_text_over_loopback(MALFORMED_SUCCESS_RESPONSE)?; + assert_eq!( + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &malformed, + &mut correlation, + ), + Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Envelope { + source: WebDriverBiDiJsonEnvelopeError::InvalidJson, + } + ) + ); + assert_eq!(correlation.outstanding_count(), 1); + + let missing = read_text_over_loopback(MISSING_SUBSCRIPTION_RESPONSE)?; + assert_eq!( + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &missing, + &mut correlation, + ), + Err(WebDriverBiDiNavigationCommittedSubscriptionResponseError::MissingSubscription) + ); + assert_eq!(correlation.outstanding_count(), 1); + + let unknown = read_text_over_loopback(UNKNOWN_SUCCESS_RESPONSE)?; + assert_eq!( + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &unknown, + &mut correlation, + ), + Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandNotOutstanding, + } + ) + ); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn protocol_error_consumes_only_its_exact_outstanding_command() -> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command(7)?; + + let unknown = read_text_over_loopback(UNKNOWN_ERROR_RESPONSE)?; + assert_eq!( + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &unknown, + &mut correlation, + ), + Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandNotOutstanding, + } + ) + ); + assert_eq!(correlation.outstanding_count(), 1); + + let matched = read_text_over_loopback(MATCHED_ERROR_RESPONSE)?; + assert_eq!( + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &matched, + &mut correlation, + ), + Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::RemoteProtocolError { + command_id: 7, + error_code: "invalid argument".to_owned(), + } + ) + ); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn event_response_is_rejected_without_consuming_outstanding_command() -> Result<(), Box> +{ + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command(7)?; + let event = read_text_over_loopback(NAVIGATION_EVENT)?; + + assert_eq!( + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &event, + &mut correlation, + ), + Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::EventIsNotResponse, + } + ) + ); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} From 0a6b26a2180b4e845bbcef8f804fd0fd2e78a04a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:54:24 +0900 Subject: [PATCH 02/19] style(network): apply pinned rustfmt Signed-off-by: Seongho Bae --- .../src/webdriver_bidi_pointer_click_response.rs | 5 ++--- .../src/webdriver_bidi_pointer_click_transport.rs | 4 ++-- .../src/webdriver_bidi_session_status_response.rs | 8 +++++--- .../tests/webdriver_bidi_pointer_click_send_failures.rs | 8 ++++---- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_pointer_click_response.rs b/crates/originweave-network/src/webdriver_bidi_pointer_click_response.rs index f251af400..980388968 100644 --- a/crates/originweave-network/src/webdriver_bidi_pointer_click_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_pointer_click_response.rs @@ -2,9 +2,8 @@ use std::{error::Error, fmt}; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, - WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiWebSocketTextMessage, }; /// Typed protocol acknowledgment for one correlated WebDriver BiDi `input.performActions` diff --git a/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs b/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs index 429e76127..1c74717af 100644 --- a/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs +++ b/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs @@ -4,8 +4,8 @@ use originweave_core::WebDriverBiDiPointerClickCommand; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiCommandKind, WebDriverBiDiWebSocketEstablished, - WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiCommandKind, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, }; /// Fail-closed errors while transporting one already validated pointer-click command. diff --git a/crates/originweave-network/src/webdriver_bidi_session_status_response.rs b/crates/originweave-network/src/webdriver_bidi_session_status_response.rs index e08a7efbb..b1e5596ba 100644 --- a/crates/originweave-network/src/webdriver_bidi_session_status_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_session_status_response.rs @@ -71,9 +71,11 @@ impl WebDriverBiDiSessionStatusResult { retain_validated_error_code(envelope.error_code()).and_then(|error_code| { let completed = correlation .correlate_response_for(&envelope, WebDriverBiDiCommandKind::SessionStatus) - .map_err(|source| { - WebDriverBiDiSessionStatusResponseError::Correlation { source } - })?; + .map_err( + |source| WebDriverBiDiSessionStatusResponseError::Correlation { + source, + }, + )?; Err( WebDriverBiDiSessionStatusResponseError::RemoteProtocolError { command_id: completed.command_id(), diff --git a/crates/originweave-network/tests/webdriver_bidi_pointer_click_send_failures.rs b/crates/originweave-network/tests/webdriver_bidi_pointer_click_send_failures.rs index 9d4a0ee38..c2d9cffcc 100644 --- a/crates/originweave-network/tests/webdriver_bidi_pointer_click_send_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_pointer_click_send_failures.rs @@ -12,10 +12,10 @@ use originweave_core::{ }; use originweave_network::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiCommandKind, WebDriverBiDiPointerClickSendError, - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, send_webdriver_bidi_pointer_click, + WebDriverBiDiCommandKind, WebDriverBiDiPointerClickSendError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + send_webdriver_bidi_pointer_click, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; From 117f6414e8a6db46eb2b32f4ebae85cf2a208371 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:13:28 +0900 Subject: [PATCH 03/19] fix(network): reject subscription deadlines before registration Reuse frame validation before reserving correlation. Prove invalid deadlines emit no command and preserve unrelated pending work. Keep post-registration frame-failure retention. Verify 11 focused tests, 142 Python contracts, full Rust gates and numeric 100% coverage. Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + ..._bidi_navigation_committed_subscription.rs | 15 ++- .../src/webdriver_bidi_websocket_frame.rs | 5 +- ...igation_committed_subscription_failures.rs | 127 +++++++++++++----- docs/doctoring.md | 4 + 5 files changed, 115 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cb79914e..d5042e6c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Reject invalid navigation-subscription deadlines before reserving a pending request, preserving existing requests and leaving the rejected identifier reusable without sending subscription bytes. - Integrated current origin-binding prerequisites into context-scoped navigation subscriptions, preserving typed command isolation, response bounds, and the original subscription tests while restoring the inherited executable release contract. - Integrated current document-advance prerequisites into committed-navigation origin binding, preserving URL validation before mutation and stale-epoch rejection while restoring the inherited executable release contract. - Integrated current navigation-observation prerequisites into document-epoch advancement, preserving stale-epoch and retired-context rejection and the Proposed architecture decision without granting a new origin or action authority. diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs index 9e9a77449..2628a5b2a 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs @@ -4,6 +4,7 @@ use originweave_core::{ BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, }; +use crate::webdriver_bidi_websocket_frame::validate_frame_timeout; use crate::{ MAX_WEBDRIVER_BIDI_JS_UINT, WEBDRIVER_BIDI_NAVIGATION_COMMITTED_METHOD, WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, @@ -91,10 +92,11 @@ impl WebDriverBiDiNavigationCommittedSubscriptionCommand { /// /// Context binding is revalidated immediately before command correlation and network I/O so a /// command retained across registry retirement cannot subscribe a stale or replacement context. - /// Correlation registration then occurs before the first possible remote side effect. A binding - /// or correlation failure therefore writes nothing. After successful registration, a frame-write - /// failure consumes the transport and intentionally leaves the identifier outstanding because a - /// partial or fully emitted frame has ambiguous remote effect. + /// Invalid frame deadlines fail before correlation registration. Registration then occurs before + /// the first possible remote side effect. A binding, deadline, or correlation failure therefore + /// writes nothing. After successful registration, frame failures consume the transport and + /// conservatively leave the identifier outstanding, including ambiguous partial or full emission + /// and frame-owner preflight failures. pub fn send( self, registry: &BrowserAuthorityRegistry, @@ -112,6 +114,9 @@ impl WebDriverBiDiNavigationCommittedSubscriptionCommand { self.browsing_context, &self.external_context, )?; + validate_frame_timeout(frame_timeout).map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionCommandError::FrameWrite { source } + })?; correlation .register_command_for( self.command_id, @@ -188,7 +193,7 @@ pub enum WebDriverBiDiNavigationCommittedSubscriptionCommandError { /// Exact typed correlation failure. source: WebDriverBiDiCommandCorrelationError, }, - /// Writing the already-registered command frame failed and the transport is not reusable. + /// Preparing or writing the command frame failed and the transport is not reusable. FrameWrite { /// Exact typed bounded WebSocket frame-write failure. source: WebDriverBiDiWebSocketFrameError, diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs b/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs index 4a680daed..e931dd4b8 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs @@ -469,7 +469,10 @@ impl Error for WebDriverBiDiWebSocketFrameError { } } -fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWebSocketFrameError> { +/// Reject invalid frame deadlines before a caller reserves command or transport state. +pub(crate) fn validate_frame_timeout( + frame_timeout: Duration, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { return Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { frame_timeout, diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs index aae170a4f..50684e084 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs @@ -8,10 +8,11 @@ use std::{ use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; use originweave_network::{ - WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiNavigationCommittedSubscriptionCommand, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -175,12 +176,76 @@ fn duplicate_command_id_is_rejected_before_command_write() -> Result<(), Box Result<(), Box> -{ +fn invalid_frame_timeout_preserves_only_preexisting_correlation() -> Result<(), Box> { + for frame_timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), + ] { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = spawn_no_command_server(listener); + + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "context-a")?; + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + ®istry, + session, + context, + "context-a", + )?; + let established = establish_websocket(local_addr)?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + let result = command.send( + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + frame_timeout, + ); + let error = match result { + Ok(_) => { + return Err(io::Error::other( + "invalid frame timeout unexpectedly sent subscription", + ) + .into()); + } + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi navigation subscription command frame write failed" + ); + assert!(error.source().is_some()); + server + .join() + .map_err(|_| io::Error::other("frame-write test server panicked"))??; + assert_eq!(correlation.outstanding_count(), 1); + correlation.retire_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + correlation + .register_command_for(7, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; + } + Ok(()) +} + +#[test] +fn post_registration_frame_failure_keeps_the_outstanding_command() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; - let server = spawn_no_command_server(listener); - + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let mut pong = [0_u8; 6]; + stream.read_exact(&mut pong)?; + if pong != [0x8a, 0x80, 1, 2, 3, 4] { + return Err(io::Error::other("expected the initial masked empty Pong")); + } + require_no_client_command(&mut stream) + }); let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session(SESSION_ID)?; let context = registry.register_context(session, "context-a")?; @@ -191,33 +256,33 @@ fn invalid_frame_timeout_consumes_transport_and_retains_correlation() -> Result< context, "context-a", )?; - let established = establish_websocket(local_addr)?; - + let masking_key = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let established = establish_websocket(local_addr)?.write_pong_frame( + &[], + masking_key, + Duration::from_millis(500), + )?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); - let result = command.send( - ®istry, - established, - &mut correlation, - WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), - Duration::ZERO, - ); - let error = match result { - Ok(_) => { - return Err( - io::Error::other("zero frame timeout unexpectedly sent subscription").into(), - ); - } - Err(error) => error, - }; - assert_eq!( - error.to_string(), - "WebDriver BiDi navigation subscription command frame write failed" - ); - assert!(error.source().is_some()); - assert_eq!(correlation.outstanding_count(), 1); - + let error = command + .send( + ®istry, + established, + &mut correlation, + masking_key, + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("repeated masking key unexpectedly sent a subscription"))?; + assert!(matches!( + error + .source() + .and_then(|source| source.downcast_ref::()), + Some(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) + )); server .join() - .map_err(|_| io::Error::other("frame-write test server panicked"))??; + .map_err(|_| io::Error::other("frame-rejection test server panicked"))??; + assert_eq!(correlation.outstanding_count(), 1); + correlation.retire_command_for(7, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; Ok(()) } diff --git a/docs/doctoring.md b/docs/doctoring.md index ae9f5cc8f..ea0ba3852 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -6,6 +6,10 @@ This document records external evidence that changes OriginWeave architecture, t ### Browser automation and interoperability +Fresh deadline-repair verification passed 11 focused subscription loopback tests and all 142 Python contracts, plus the complete locked Rust workspace checks/tests, formatting, all-feature Clippy, warning-denying rustdoc, compileall, and diff checks. Numeric production coverage is 100% for 1,221 functions, 12,781 lines, 16,404 regions, and 1,418 branches; the unstable branch-measurement warning remains. The 1,220-function result below belongs to the earlier parent-adoption tree, not this subsequent source change. + +The subsequent subscription deadline repair reuses the frame owner's existing local timeout validator before command registration. A loopback regression on parent-adoption head `3c0484174eeda0703492ba76b530be125e3e99dd` observed no subscription bytes but two outstanding commands where only the pre-existing command should remain. Zero and over-limit deadlines now leave that command untouched and the rejected identifier reusable. This narrow change does not release correlation after post-registration frame failures: even known frame-owner preflight failures remain conservatively outstanding. A separate loopback regression retains that boundary using adjacent masking-key rejection after a successful empty Pong. No deadline bound, wire format, response parser, connection authority, or external API changes. + Fresh verification of that current-parent subscription composition passed 16 focused loopback tests, all 142 Python contracts, format, locked workspace check/tests, all-feature Clippy, warning-denying rustdoc, compileall, and diff checks. Production coverage is numerically 100% for 1,220 functions, 12,774 lines, 16,397 regions, and 1,418 branches. The `--branch option is unstable` measurement warning remains; hosted acceptance, protected-main integration, and real-browser completion still need their own evidence. The committed-navigation subscription child adopts origin-binding parent `934eb7d37568b439c442ffe1d1f6a9c8f8ed58a0` while preserving the two subscription production modules, three loopback test files, and Proposed ADR text from contributor head `01038ba71fb276426cc67f90a91a3c431e194db5`. Its existing `NavigationCommittedSubscription` command family remains distinct; malformed success data and wrong command kinds cannot consume unrelated correlation. The response parser remains protocol-correlation evidence, not connection authentication or proof that an event occurred. Current-parent adoption does not change the existing conservative retention of correlation after frame-write failure, including a rejected deadline. Native discovery reproduced zero inherited release-contract tests before adoption and one passing test after reusing the parent fix. From beceedb5612263987a3a6883a1d2f0dc0320fe6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:38:56 +0900 Subject: [PATCH 04/19] test(network): retire no-write subscription correlation --- ...iver_bidi_navigation_committed_subscription_failures.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs index 50684e084..e7decd258 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs @@ -232,7 +232,7 @@ fn invalid_frame_timeout_preserves_only_preexisting_correlation() -> Result<(), } #[test] -fn post_registration_frame_failure_keeps_the_outstanding_command() -> Result<(), Box> { +fn local_masking_key_rejection_retires_only_the_new_subscription() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { @@ -263,6 +263,7 @@ fn post_registration_frame_failure_keeps_the_outstanding_command() -> Result<(), Duration::from_millis(500), )?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; let error = command .send( ®istry, @@ -283,6 +284,8 @@ fn post_registration_frame_failure_keeps_the_outstanding_command() -> Result<(), .join() .map_err(|_| io::Error::other("frame-rejection test server panicked"))??; assert_eq!(correlation.outstanding_count(), 1); - correlation.retire_command_for(7, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; + correlation.retire_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + correlation + .register_command_for(7, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; Ok(()) } From 99ea577fcedff00632f5195223123b1e86647ecc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:39:26 +0900 Subject: [PATCH 05/19] fix(network): retire locally rejected subscription id --- ..._bidi_navigation_committed_subscription.rs | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs index 2628a5b2a..21b80e418 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs @@ -93,10 +93,10 @@ impl WebDriverBiDiNavigationCommittedSubscriptionCommand { /// Context binding is revalidated immediately before command correlation and network I/O so a /// command retained across registry retirement cannot subscribe a stale or replacement context. /// Invalid frame deadlines fail before correlation registration. Registration then occurs before - /// the first possible remote side effect. A binding, deadline, or correlation failure therefore - /// writes nothing. After successful registration, frame failures consume the transport and - /// conservatively leave the identifier outstanding, including ambiguous partial or full emission - /// and frame-owner preflight failures. + /// the first possible remote side effect. A frame-owner preflight rejection that proves no write + /// began retires this exact subscription again; currently that covers adjacent client masking-key + /// reuse. Once frame emission can have begun, later failures conservatively leave the identifier + /// outstanding because partial or full emission is ambiguous. pub fn send( self, registry: &BrowserAuthorityRegistry, @@ -126,11 +126,10 @@ impl WebDriverBiDiNavigationCommittedSubscriptionCommand { WebDriverBiDiNavigationCommittedSubscriptionCommandError::Correlation { source } })?; let message = self.serialized(); - established - .write_text_frame(&message, masking_key, frame_timeout) - .map_err(|source| { - WebDriverBiDiNavigationCommittedSubscriptionCommandError::FrameWrite { source } - }) + 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 { @@ -144,6 +143,23 @@ impl WebDriverBiDiNavigationCommittedSubscriptionCommand { } } +fn map_frame_failure( + correlation: &mut WebDriverBiDiCommandCorrelation, + command_id: u64, + source: WebDriverBiDiWebSocketFrameError, +) -> WebDriverBiDiNavigationCommittedSubscriptionCommandError { + if matches!( + source, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + ) { + let _retirement = correlation.retire_command_for( + command_id, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + ); + } + WebDriverBiDiNavigationCommittedSubscriptionCommandError::FrameWrite { source } +} + fn require_registered_context( registry: &BrowserAuthorityRegistry, browser_session: BrowserSessionId, From 479bfd298aa81cd3f8a6d279036b0ad4882fb6ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:40:31 +0900 Subject: [PATCH 06/19] test(network): cover ambiguous subscription frame failure --- ..._bidi_navigation_committed_subscription.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs index 21b80e418..14c59e4dd 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs @@ -245,3 +245,39 @@ impl Error for WebDriverBiDiNavigationCommittedSubscriptionCommandError { } } } + +#[cfg(test)] +mod tests { + use std::io; + + use super::*; + + #[test] + fn only_provably_local_frame_failures_retire_subscription_correlation() { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation + .register_command_for( + 1, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + ) + .unwrap(); + let preflight = WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "test preflight rejection", + }; + map_frame_failure(&mut correlation, 1, preflight); + assert_eq!(correlation.outstanding_count(), 0); + + correlation + .register_command_for( + 2, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + ) + .unwrap(); + 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); + } +} From 129c76a26597cb6b3732e9f36239f30d1406d39b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:41:00 +0900 Subject: [PATCH 07/19] test(network): avoid prohibited unwrap in correlation regression --- ..._bidi_navigation_committed_subscription.rs | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs index 14c59e4dd..2b549677f 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs @@ -255,24 +255,28 @@ mod tests { #[test] fn only_provably_local_frame_failures_retire_subscription_correlation() { let mut correlation = WebDriverBiDiCommandCorrelation::new(); - correlation - .register_command_for( - 1, - WebDriverBiDiCommandKind::NavigationCommittedSubscription, - ) - .unwrap(); + assert!( + correlation + .register_command_for( + 1, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + ) + .is_ok() + ); let preflight = WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "test preflight rejection", }; map_frame_failure(&mut correlation, 1, preflight); assert_eq!(correlation.outstanding_count(), 0); - correlation - .register_command_for( - 2, - WebDriverBiDiCommandKind::NavigationCommittedSubscription, - ) - .unwrap(); + assert!( + correlation + .register_command_for( + 2, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + ) + .is_ok() + ); let ambiguous = WebDriverBiDiWebSocketFrameError::FrameWriteFailed { bytes_written: 1, source: io::Error::other("test ambiguous write failure"), From ef140a921313f213d535d35c7572ef9897053d91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 07:03:10 +0900 Subject: [PATCH 08/19] test(docs): bind navigation subscription doctoring to source --- ...igation_subscription_doctoring_contract.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/test_navigation_subscription_doctoring_contract.py diff --git a/tests/test_navigation_subscription_doctoring_contract.py b/tests/test_navigation_subscription_doctoring_contract.py new file mode 100644 index 000000000..407fa2df2 --- /dev/null +++ b/tests/test_navigation_subscription_doctoring_contract.py @@ -0,0 +1,63 @@ +"""Regression contracts for committed-navigation subscription doctoring.""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE = ROOT / "crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs" +DOCTORING = ROOT / "docs/doctoring.md" +ADR = ROOT / "docs/adr/0103-semantic-observation-and-stale-node-identity.md" + + +class NavigationSubscriptionDoctoringContractTests(unittest.TestCase): + """Keep durable browser-protocol evidence aligned with the implemented correlation boundary.""" + + def test_doctoring_matches_provably_local_subscription_failure_retirement(self) -> None: + """Doctoring must not retain correlation for a no-write failure that source can prove locally.""" + source = SOURCE.read_text(encoding="utf-8") + doctoring = DOCTORING.read_text(encoding="utf-8") + + self.assertLess( + source.index("validate_frame_timeout(frame_timeout)"), + source.index(".register_command_for("), + ) + self.assertIn("WebDriverBiDiWebSocketFrameError::MalformedFrame", source) + self.assertIn("correlation.retire_command_for(", source) + + self.assertNotIn( + "even known frame-owner preflight failures remain conservatively outstanding", + doctoring, + ) + self.assertNotIn( + "including a rejected deadline", + doctoring, + ) + self.assertIn( + "Provably local no-write failures retire only the exact typed subscription correlation", + doctoring, + ) + self.assertIn( + "ambiguous frame-write failures retain correlation", + doctoring, + ) + + def test_webdriver_bidi_reference_tracks_current_published_working_draft(self) -> None: + """ADR and aggregate doctoring must cite the same current published WebDriver BiDi draft.""" + adr = ADR.read_text(encoding="utf-8") + doctoring = DOCTORING.read_text(encoding="utf-8") + current_url = "https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/" + + self.assertIn("September 3, 2026 Working Draft", adr) + self.assertIn(current_url, adr) + self.assertIn("3 September 2026 W3C Working Draft", doctoring) + self.assertIn(current_url, doctoring) + self.assertNotIn( + "The 1 June 2026 W3C Working Draft remains the most recent dated published Working Draft", + doctoring, + ) + + +if __name__ == "__main__": + unittest.main() From 35e01a695e7b388e4aee56b04e606e15fb10872b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 07:03:52 +0900 Subject: [PATCH 09/19] docs(adr): refresh WebDriver BiDi published draft --- docs/adr/0103-semantic-observation-and-stale-node-identity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0103-semantic-observation-and-stale-node-identity.md b/docs/adr/0103-semantic-observation-and-stale-node-identity.md index e334ecc52..bbe1251ea 100644 --- a/docs/adr/0103-semantic-observation-and-stale-node-identity.md +++ b/docs/adr/0103-semantic-observation-and-stale-node-identity.md @@ -21,7 +21,7 @@ Agentic browsing needs compact, stable observations without treating one browser All page-derived observations are untrusted data. Observation sources can inform planning but cannot grant action capability or approval. A semantic node identity is meaningful only within its browser session/context and document epoch. Network or structured-data observations may corroborate semantics but do not override policy. -WebDriver BiDi `browsingContext.navigationCommitted` is treated as protocol lifecycle evidence, not as action authority. The August 18, 2026 Working Draft defines the event from the navigable plus its navigation status and emits `NavigationInfo` carrying browsing-context, navigation, timestamp, and URL data. OriginWeave may use an accepted event to invalidate the previous document epoch only after the event has matched the already governed session/context and expected navigation state; the protocol event alone cannot bind a new origin, prove click causality, authorize a side effect, or revive stale node authority. +WebDriver BiDi `browsingContext.navigationCommitted` is treated as protocol lifecycle evidence, not as action authority. The September 3, 2026 Working Draft defines the event from the navigable plus its navigation status and emits `NavigationInfo` carrying browsing-context, navigation, timestamp, and URL data. OriginWeave may use an accepted event to invalidate the previous document epoch only after the event has matched the already governed session/context and expected navigation state; the protocol event alone cannot bind a new origin, prove click causality, authorize a side effect, or revive stale node authority. The context-scoped `session.subscribe` command uses its own correlation command family. Its success or protocol-error response may consume only an outstanding committed-navigation subscription identifier; an identifier registered for another BiDi command remains outstanding and fails closed on a type mismatch. @@ -92,7 +92,7 @@ Chrome DevTools Protocol. (2026). *DOMSnapshot domain*. Chromium. Retrieved Augu Chrome DevTools Protocol. (2026). *WebMCP domain*. Chromium. Retrieved August 9, 2026, from https://chromedevtools.github.io/devtools-protocol/tot/WebMCP/ -World Wide Web Consortium. (2026, August 18). *WebDriver BiDi* [Working Draft]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260818/ +World Wide Web Consortium. (2026, September 3). *WebDriver BiDi* [Working Draft]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/ ## Related documents From f2e27bb6f12a67c4170744e7396e0b5b2ed76c0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 07:05:16 +0900 Subject: [PATCH 10/19] docs(doctoring): align subscription evidence with source --- docs/doctoring.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/doctoring.md b/docs/doctoring.md index ea0ba3852..0b5ac87e6 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,13 +8,13 @@ This document records external evidence that changes OriginWeave architecture, t Fresh deadline-repair verification passed 11 focused subscription loopback tests and all 142 Python contracts, plus the complete locked Rust workspace checks/tests, formatting, all-feature Clippy, warning-denying rustdoc, compileall, and diff checks. Numeric production coverage is 100% for 1,221 functions, 12,781 lines, 16,404 regions, and 1,418 branches; the unstable branch-measurement warning remains. The 1,220-function result below belongs to the earlier parent-adoption tree, not this subsequent source change. -The subsequent subscription deadline repair reuses the frame owner's existing local timeout validator before command registration. A loopback regression on parent-adoption head `3c0484174eeda0703492ba76b530be125e3e99dd` observed no subscription bytes but two outstanding commands where only the pre-existing command should remain. Zero and over-limit deadlines now leave that command untouched and the rejected identifier reusable. This narrow change does not release correlation after post-registration frame failures: even known frame-owner preflight failures remain conservatively outstanding. A separate loopback regression retains that boundary using adjacent masking-key rejection after a successful empty Pong. No deadline bound, wire format, response parser, connection authority, or external API changes. +The subsequent subscription deadline repair reuses the frame owner's existing local timeout validator before command registration. A loopback regression on parent-adoption head `3c0484174eeda0703492ba76b530be125e3e99dd` observed no subscription bytes but two outstanding commands where only the pre-existing command should remain. Zero and over-limit deadlines now leave that command untouched and the rejected identifier reusable. Provably local no-write failures retire only the exact typed subscription correlation; adjacent masking-key reuse is one such `MalformedFrame` preflight rejection. A separate loopback regression preserves unrelated correlation, proves the rejected subscription identifier reusable, and observes no subscription command bytes. Failures after frame emission may have begun remain ambiguous; ambiguous frame-write failures retain correlation. No deadline bound, wire format, response parser, connection authority, or external API changes. Fresh verification of that current-parent subscription composition passed 16 focused loopback tests, all 142 Python contracts, format, locked workspace check/tests, all-feature Clippy, warning-denying rustdoc, compileall, and diff checks. Production coverage is numerically 100% for 1,220 functions, 12,774 lines, 16,397 regions, and 1,418 branches. The `--branch option is unstable` measurement warning remains; hosted acceptance, protected-main integration, and real-browser completion still need their own evidence. -The committed-navigation subscription child adopts origin-binding parent `934eb7d37568b439c442ffe1d1f6a9c8f8ed58a0` while preserving the two subscription production modules, three loopback test files, and Proposed ADR text from contributor head `01038ba71fb276426cc67f90a91a3c431e194db5`. Its existing `NavigationCommittedSubscription` command family remains distinct; malformed success data and wrong command kinds cannot consume unrelated correlation. The response parser remains protocol-correlation evidence, not connection authentication or proof that an event occurred. Current-parent adoption does not change the existing conservative retention of correlation after frame-write failure, including a rejected deadline. Native discovery reproduced zero inherited release-contract tests before adoption and one passing test after reusing the parent fix. +The committed-navigation subscription child adopts origin-binding parent `934eb7d37568b439c442ffe1d1f6a9c8f8ed58a0` while preserving the two subscription production modules, three loopback test files, and Proposed ADR text from contributor head `01038ba71fb276426cc67f90a91a3c431e194db5`. Its existing `NavigationCommittedSubscription` command family remains distinct; malformed success data and wrong command kinds cannot consume unrelated correlation. The response parser remains protocol-correlation evidence, not connection authentication or proof that an event occurred. Invalid frame deadlines are rejected before correlation registration; after registration, a `MalformedFrame` that proves no write began retires only the exact typed subscription identifier, while ambiguous frame-write failures retain correlation. Native discovery reproduced zero inherited release-contract tests before adoption and one passing test after reusing the parent fix. -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 3 September 2026 W3C Working Draft is the current published WebDriver BiDi technical report. The W3C technical-report cover page and publication history identify that dated draft as the latest published version; the Editor's Draft remains a separate living document. 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 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. @@ -164,7 +164,7 @@ Chromium Authors. (n.d.). *Proxy support in Chrome* [Source documentation]. Chro Chromium Authors. (2026). *URL canonicalizer unit tests* [Source code]. Chromium. https://chromium.googlesource.com/chromium/src/+/446d05d21720f0b3505ec21057b3e9f909784262/url/url_canon_unittest.cc -Cooper, D., Santesson, S., Farrell, S., Boeyen, S., Housley, R., & Polk, W. (2008). *Internet X.509 public key infrastructure certificate and certificate revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. https://doi.org/10.17487/RFC5280 +Cooper, D., Santesson, S., Farrell, S., Boeyen, R., Housley, R., & Polk, W. (2008). *Internet X.509 public key infrastructure certificate and certificate revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. https://doi.org/10.17487/RFC5280 Cotton, M., Vegoda, L., Bonica, R., & Haberman, B. (2013). *Special-purpose IP address registries* (RFC 6890). Internet Engineering Task Force. https://doi.org/10.17487/RFC6890 @@ -226,7 +226,7 @@ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.o World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/2023/REC-wai-aria-1.2-20230606/ -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* [Working Draft]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/ World Wide Web Consortium. (2026, September 3). *WebDriver BiDi* [Editor's Draft]. https://w3c.github.io/webdriver-bidi/ From ff49dd8f623ae0d02229b2fa46e54c0197e18c48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 07:07:00 +0900 Subject: [PATCH 11/19] docs(changelog): record no-write correlation retirement --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5042e6c9..071ca7d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed - Reject invalid navigation-subscription deadlines before reserving a pending request, preserving existing requests and leaving the rejected identifier reusable without sending subscription bytes. +- Retire only the exact committed-navigation subscription correlation when frame preparation fails locally as `MalformedFrame` before any command bytes can be emitted, while preserving unrelated requests and retaining correlation after ambiguous frame-write failures. - Integrated current origin-binding prerequisites into context-scoped navigation subscriptions, preserving typed command isolation, response bounds, and the original subscription tests while restoring the inherited executable release contract. - Integrated current document-advance prerequisites into committed-navigation origin binding, preserving URL validation before mutation and stale-epoch rejection while restoring the inherited executable release contract. - Integrated current navigation-observation prerequisites into document-epoch advancement, preserving stale-epoch and retired-context rejection and the Proposed architecture decision without granting a new origin or action authority. @@ -51,7 +52,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. +- 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 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. - Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, rejecting control and whitespace so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. - Same-call `locateNodes` result admission that revalidates the exact current session, browsing context, canonical origin, and document epoch, rejects an over-budget or non-node result, and translates each admitted `sharedId` through the authority registry into an `ObservedNodeHandle` without performing browser I/O. From 6d43541498aafec56703bc3ed5ce5b5f9cfe89e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 07:08:23 +0900 Subject: [PATCH 12/19] docs(changelog): restore authority wording --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 071ca7d43..2a9a73010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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 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. - Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, rejecting control and whitespace so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. - Same-call `locateNodes` result admission that revalidates the exact current session, browsing context, canonical origin, and document epoch, rejects an over-budget or non-node result, and translates each admitted `sharedId` through the authority registry into an `ObservedNodeHandle` without performing browser I/O. From c8cbfa41a00c43e1613ab2a8406a6b42ff910a8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:06:09 +0900 Subject: [PATCH 13/19] style(network): apply canonical rustfmt diagnostic Signed-off-by: Seongho Bae --- ...webdriver_bidi_navigation_committed_subscription.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs index 2b549677f..d5e872b94 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs @@ -257,10 +257,7 @@ mod tests { let mut correlation = WebDriverBiDiCommandCorrelation::new(); assert!( correlation - .register_command_for( - 1, - WebDriverBiDiCommandKind::NavigationCommittedSubscription, - ) + .register_command_for(1, WebDriverBiDiCommandKind::NavigationCommittedSubscription,) .is_ok() ); let preflight = WebDriverBiDiWebSocketFrameError::MalformedFrame { @@ -271,10 +268,7 @@ mod tests { assert!( correlation - .register_command_for( - 2, - WebDriverBiDiCommandKind::NavigationCommittedSubscription, - ) + .register_command_for(2, WebDriverBiDiCommandKind::NavigationCommittedSubscription,) .is_ok() ); let ambiguous = WebDriverBiDiWebSocketFrameError::FrameWriteFailed { From 3734a8749124739102cd386e3b2576e5fd56afd6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:09:28 +0900 Subject: [PATCH 14/19] test: reject pointer reply from replacement connection (cherry picked from commit 8193fcd50125d9e9a43b4755e0f7626801b74374) Signed-off-by: Seongho Bae --- ...er_click_response_connection_provenance.rs | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_pointer_click_response_connection_provenance.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_pointer_click_response_connection_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_pointer_click_response_connection_provenance.rs new file mode 100644 index 000000000..814f4f9d9 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_pointer_click_response_connection_provenance.rs @@ -0,0 +1,188 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiPointerClickCommand, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiPointerClickResult, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketTextMessage, + send_webdriver_bidi_pointer_click, +}; + +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 CLICK_SUCCESS_RESPONSE: &[u8] = + br#"{"type":"success","id":42,"result":{"vendorExtension":{"observed":false}}}"#; + +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 marker = header[1] & 0x7f; + let length = match marker { + 0..=125 => usize::from(marker), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + let length = u64::from_be_bytes(extended); + usize::try_from(length).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "pointer frame length exceeds usize") + })? + } + _ => unreachable!(), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn establish(local_addr: SocketAddr) -> Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +fn receive_replacement_response( + listener: TcpListener, +) -> Result> { + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + stream.write_all(&[0x81, CLICK_SUCCESS_RESPONSE.len() as u8])?; + stream.write_all(CLICK_SUCCESS_RESPONSE) + }); + + let established = establish(local_addr)?; + 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!( + "replacement pointer connection produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + server + .join() + .map_err(|_| io::Error::other("replacement pointer server panicked"))??; + Ok(text) +} + +#[test] +fn pointer_response_from_same_session_replacement_connection_cannot_consume_original_pending_command( +) -> Result<(), Box> { + let original_listener = TcpListener::bind(("127.0.0.1", 0))?; + let original_addr = original_listener.local_addr()?; + let expected = WebDriverBiDiPointerClickCommand::new( + 42, + "context-a", + &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, + )?; + let expected_json = expected.as_json().as_bytes().to_vec(); + let original_server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = original_listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let command = read_masked_text_frame(&mut stream)?; + if command != expected_json { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected pointer command on original connection", + )); + } + Ok(()) + }); + + let original = establish(original_addr)?; + let command = WebDriverBiDiPointerClickCommand::new( + 42, + "context-a", + &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, + )?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let _original = send_webdriver_bidi_pointer_click( + &command, + original, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + original_server + .join() + .map_err(|_| io::Error::other("original pointer server panicked"))??; + assert_eq!(correlation.outstanding_count(), 1); + + let replacement_listener = TcpListener::bind(("127.0.0.1", 0))?; + let replacement_response = receive_replacement_response(replacement_listener)?; + let parsed = WebDriverBiDiPointerClickResult::parse_and_correlate( + &replacement_response, + &mut correlation, + ); + + assert!( + parsed.is_err(), + "same-session replacement connection unexpectedly consumed the original pointer command" + ); + assert_eq!( + correlation.outstanding_count(), + 1, + "foreign-connection rejection must leave the original pointer command pending" + ); + Ok(()) +} From 121578a43adda12221a9ca9ab8ada0a4fd03efef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:10:00 +0900 Subject: [PATCH 15/19] docs: record subscription receipt parent adoption Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + ...i-received-response-connection-provenance.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4599e7ec..fad2eb9fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Carried replacement-connection click-reply rejection into the navigation-subscription stack while preserving deadline rejection, unrelated pending requests and conservative handling of uncertain writes. - Reject invalid navigation-subscription deadlines before reserving a pending request, preserving existing requests and leaving the rejected identifier reusable without sending subscription bytes. - Retire only the exact committed-navigation subscription correlation when frame preparation fails locally as `MalformedFrame` before any command bytes can be emitted, while preserving unrelated requests and retaining correlation after ambiguous frame-write failures. - Integrated current origin-binding prerequisites into context-scoped navigation subscriptions, preserving typed command isolation, response bounds, and the original subscription tests while restoring the inherited executable release contract. diff --git a/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md index b38dded4f..3dab7f151 100644 --- a/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md +++ b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md @@ -140,6 +140,23 @@ measurements. The resulting origin is registry-local evidence derived from the accepted observation; it does not authenticate the observation, authorize a destination, prove click causality or establish released browser behavior. +### Subscription owner adopts the pointer receipt repair + +On #277 `973e34bc24ae9bdd96a50764f2be6c8603eff66c`, canonical replay +`3734a874` reproduced a replacement connection consuming the original pending +pointer command (zero passed, one failed). Ordinary merge `408d6257` adopts #261 +`ba100fbc39e1ac4f10ee4faade38418551bb8298`, preserving the repaired pointer +receipt and navigation fixture. The two subscription production modules, three +integration suites, doctoring publication-status correction and Proposed ADR 0103 +remain byte-identical to the child predecessor. Both release histories survive. + +All twenty-three focused subscription, pointer-response and navigation tests pass +locally. Full exact-head checks and coverage remain separate measurements. This +adoption does not retrofit received-connection provenance into the distinct +subscription response API or establish event provenance, browser ownership, action +causality or a released browser workflow. The existing deadline-before-registration, +proven-no-byte retirement and ambiguous-write retention contracts remain intact. + ## References Fette, I., & Melnikov, A. (2011). *The WebSocket Protocol* (RFC 6455). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc6455 From d8bfcc220db584cb8ca1fbaddec229734f4bff29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:28:40 +0900 Subject: [PATCH 16/19] test(network): reproduce subscription connection substitution Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- ...cription_response_connection_provenance.rs | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs new file mode 100644 index 000000000..1511d1082 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs @@ -0,0 +1,207 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionResponseError, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const SUBSCRIBE_SUCCESS_RESPONSE: &[u8] = + br#"{"type":"success","id":42,"result":{"subscription":"subscription-a"}}"#; +const SUBSCRIBE_ERROR_RESPONSE: &[u8] = + br#"{"type":"error","id":42,"error":"invalid argument","message":"blocked","stacktrace":"remote"}"#; + +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 marker = header[1] & 0x7f; + let length = match marker { + 0..=125 => usize::from(marker), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + let length = u64::from_be_bytes(extended); + usize::try_from(length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "subscription frame length exceeds usize", + ) + })? + } + _ => unreachable!(), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn establish(local_addr: SocketAddr) -> Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +fn read_response( + established: WebDriverBiDiWebSocketEstablished, +) -> Result> { + let text = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err(io::Error::other(format!( + "replacement subscription connection produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + Ok(text) +} + +fn assert_replacement_rejected(foreign_response: &'static [u8]) -> Result<(), Box> { + let original_listener = TcpListener::bind(("127.0.0.1", 0))?; + let original_addr = original_listener.local_addr()?; + let expected_json = br#"{"id":42,"method":"session.subscribe","params":{"events":["browsingContext.navigationCommitted"],"contexts":["context-a"]}}"#.to_vec(); + let original_server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = original_listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let command = read_masked_text_frame(&mut stream)?; + if command != expected_json { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected subscription command on original connection", + )); + } + let (mut replacement, _) = original_listener.accept()?; + read_opening_request(&mut replacement)?; + replacement.write_all(OPENING_RESPONSE)?; + replacement.write_all(&[0x81, foreign_response.len() as u8])?; + replacement.write_all(foreign_response)?; + stream.write_all(&[0x81, SUBSCRIBE_SUCCESS_RESPONSE.len() as u8])?; + stream.write_all(SUBSCRIBE_SUCCESS_RESPONSE) + }); + + let original = establish(original_addr)?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "context-a")?; + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 42, + ®istry, + session, + context, + "context-a", + )?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(43, WebDriverBiDiCommandKind::SessionStatus)?; + let original = command.send( + ®istry, + original, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + assert_eq!(correlation.outstanding_count(), 2); + + let replacement_response = read_response(establish(original_addr)?)?; + let parsed = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + replacement_response.message(), + &mut correlation, + ); + + let original_response = read_response(original)?; + original_server + .join() + .map_err(|_| io::Error::other("original subscription server panicked"))??; + assert!( + matches!( + parsed, + Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { + command_id: 42 + } + } + ) + ), + "replacement response must fail for exact connection mismatch: {parsed:?}" + ); + assert_eq!(correlation.outstanding_count(), 2); + let accepted = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + original_response.message(), + &mut correlation, + )?; + assert_eq!(accepted.command_id(), 42); + assert_eq!(accepted.subscription_id(), "subscription-a"); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn replacement_success_cannot_consume_original_subscription_command() -> Result<(), Box> +{ + assert_replacement_rejected(SUBSCRIBE_SUCCESS_RESPONSE) +} + +#[test] +fn replacement_error_cannot_consume_original_subscription_command() -> Result<(), Box> { + assert_replacement_rejected(SUBSCRIBE_ERROR_RESPONSE) +} From 122ca13939d1ce1e199cba94540945919bddc1c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:29:08 +0900 Subject: [PATCH 17/19] test(network): exercise existing raw subscription boundary Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- ...cription_response_connection_provenance.rs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs index 1511d1082..169d7f29b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs +++ b/crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs @@ -9,13 +9,13 @@ use std::{ use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; use originweave_network::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, - WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiCommandKind, WebDriverBiDiNavigationCommittedSubscriptionCommand, WebDriverBiDiNavigationCommittedSubscriptionResponseError, - WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiReceivedTextMessage, - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiWebSocketTextMessage, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -100,11 +100,10 @@ fn establish(local_addr: SocketAddr) -> Result Result> { - let text = match WebDriverBiDiWebSocketMessageReader::new(established) - .read_next(Duration::from_millis(500))? - { - WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, +) -> Result> { + let (_, frame) = established.read_frame(Duration::from_millis(500))?; + let text = match WebDriverBiDiWebSocketMessageAssembler::new().push_frame(frame)? { + WebDriverBiDiWebSocketMessageAssembly::Text(message) => message, other => { return Err(io::Error::other(format!( "replacement subscription connection produced unexpected assembly state: {other:?}" @@ -163,7 +162,7 @@ fn assert_replacement_rejected(foreign_response: &'static [u8]) -> Result<(), Bo let replacement_response = read_response(establish(original_addr)?)?; let parsed = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( - replacement_response.message(), + &replacement_response, &mut correlation, ); @@ -186,7 +185,7 @@ fn assert_replacement_rejected(foreign_response: &'static [u8]) -> Result<(), Bo ); assert_eq!(correlation.outstanding_count(), 2); let accepted = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( - original_response.message(), + &original_response, &mut correlation, )?; assert_eq!(accepted.command_id(), 42); From 8b1508c8472f7cec46cb492d13db762eef7d7ebd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:30:59 +0900 Subject: [PATCH 18/19] fix(network): bind subscription responses to their sending connection Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- ..._bidi_navigation_committed_subscription.rs | 3 +- ...igation_committed_subscription_response.rs | 19 ++- ..._bidi_navigation_committed_subscription.rs | 13 +- ...ommitted_subscription_response_failures.rs | 124 +++++++++++++++--- ...cription_response_connection_provenance.rs | 21 +-- 5 files changed, 139 insertions(+), 41 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs index d5e872b94..3d02690fd 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs @@ -118,9 +118,10 @@ impl WebDriverBiDiNavigationCommittedSubscriptionCommand { WebDriverBiDiNavigationCommittedSubscriptionCommandError::FrameWrite { source } })?; correlation - .register_command_for( + .register_command_for_connection( self.command_id, WebDriverBiDiCommandKind::NavigationCommittedSubscription, + established.transport_evidence().connection_generation(), ) .map_err(|source| { WebDriverBiDiNavigationCommittedSubscriptionCommandError::Correlation { source } diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs index 9a2f66394..c4e41f01b 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs @@ -3,7 +3,7 @@ use std::{error::Error, fmt}; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, - WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiReceivedTextMessage, }; /// Maximum decoded UTF-8 bytes retained from a WebDriver BiDi `session.Subscription` identifier. @@ -35,7 +35,10 @@ impl fmt::Debug for WebDriverBiDiNavigationCommittedSubscriptionResult { } impl WebDriverBiDiNavigationCommittedSubscriptionResult { - /// Parse one bounded local-end message and consume its exact outstanding command on success. + /// Parse one sealed receiving-connection receipt and consume its exact outstanding command. + /// + /// Successes and protocol errors require the sender-registered connection generation. Raw + /// messages, missing sender provenance, and replacement connections cannot retire the command. /// /// Common WebDriver BiDi envelope validation runs first. A successful envelope then undergoes /// command-specific projection of the required `result.subscription` text before correlation is @@ -44,20 +47,21 @@ impl WebDriverBiDiNavigationCommittedSubscriptionResult { /// failure retaining only the protocol error code. Events, null-id errors, malformed envelopes, /// and unknown ids fail closed without consuming unrelated outstanding correlation state. pub fn parse_and_correlate( - message: &WebDriverBiDiWebSocketTextMessage, + message: &WebDriverBiDiReceivedTextMessage, correlation: &mut WebDriverBiDiCommandCorrelation, ) -> Result { - let envelope = WebDriverBiDiJsonEnvelope::parse(message).map_err(|source| { + let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()).map_err(|source| { WebDriverBiDiNavigationCommittedSubscriptionResponseError::Envelope { source } })?; match envelope.kind() { WebDriverBiDiJsonEnvelopeKind::Success => { - let projected = SubscriptionProjection::parse(message.as_str())?; + let projected = SubscriptionProjection::parse(message.message().as_str())?; let completed = correlation - .correlate_response_for( + .correlate_response_for_connection( &envelope, WebDriverBiDiCommandKind::NavigationCommittedSubscription, + message.connection_generation(), ) .map_err(|source| { WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { @@ -72,9 +76,10 @@ impl WebDriverBiDiNavigationCommittedSubscriptionResult { WebDriverBiDiJsonEnvelopeKind::Error => { retain_validated_error_code(envelope.error_code()).and_then(|error_code| { let completed = correlation - .correlate_response_for( + .correlate_response_for_connection( &envelope, WebDriverBiDiCommandKind::NavigationCommittedSubscription, + message.connection_generation(), ) .map_err(|source| { WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs index 4b5a7ad39..075fbbe31 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs @@ -9,11 +9,10 @@ use std::{ use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; use originweave_network::{ MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiCommandCorrelation, - WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiNavigationCommittedSubscriptionCommand, WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, - WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -131,10 +130,10 @@ fn navigation_committed_subscription_round_trips_on_the_registered_context() )?; 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, + let text = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, other => { return Err(io::Error::other(format!( "session.subscribe response produced unexpected assembly state: {other:?}" diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs index e171f76bf..2fe4b3806 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs @@ -6,15 +6,16 @@ use std::{ time::Duration, }; -use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; use originweave_network::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiCommandKind, WebDriverBiDiJsonEnvelopeError, + WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, WebDriverBiDiJsonEnvelopeError, + WebDriverBiDiNavigationCommittedSubscriptionCommand, WebDriverBiDiNavigationCommittedSubscriptionResponseError, - WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -69,15 +70,59 @@ fn write_unmasked_text_frame(stream: &mut TcpStream, document: &[u8]) -> io::Res stream.write_all(document) } +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 = match header[1] & 0x7f { + length @ 0..=125 => usize::from(length), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test command unexpectedly required 64-bit 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 read_text_over_loopback( document: &'static [u8], -) -> Result> { +) -> Result> { + read_response_over_loopback(document, None) +} + +fn read_response_over_loopback( + document: &'static [u8], + correlation: Option<&mut WebDriverBiDiCommandCorrelation>, +) -> Result> { + let send_command = correlation.is_some(); 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)?; + if send_command { + assert_eq!(read_masked_text_frame(&mut stream)?, br#"{"id":7,"method":"session.subscribe","params":{"events":["browsingContext.navigationCommitted"],"contexts":["context-a"]}}"#); + } write_unmasked_text_frame(&mut stream, document) }); @@ -91,11 +136,31 @@ fn read_text_over_loopback( let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? .write_opening_request(Duration::from_millis(500))? .read_opening_response(Duration::from_millis(500))?; - let (_established, frame) = established.read_frame(Duration::from_millis(500))?; - - let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); - let text = match assembler.push_frame(frame)? { - WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + let established = if let Some(correlation) = correlation { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "context-a")?; + WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + ®istry, + session, + context, + "context-a", + )? + .send( + ®istry, + established, + correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )? + } else { + established + }; + let text = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, other => { return Err(io::Error::other(format!( "subscription response produced unexpected assembly state: {other:?}" @@ -160,8 +225,7 @@ fn malformed_and_invalid_success_responses_preserve_outstanding_correlation() #[test] fn protocol_error_consumes_only_its_exact_outstanding_command() -> Result<(), Box> { let mut correlation = WebDriverBiDiCommandCorrelation::new(); - correlation - .register_command_for(7, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; + correlation.register_command_for(43, WebDriverBiDiCommandKind::SessionStatus)?; let unknown = read_text_over_loopback(UNKNOWN_ERROR_RESPONSE)?; assert_eq!( @@ -177,7 +241,8 @@ fn protocol_error_consumes_only_its_exact_outstanding_command() -> Result<(), Bo ); assert_eq!(correlation.outstanding_count(), 1); - let matched = read_text_over_loopback(MATCHED_ERROR_RESPONSE)?; + let matched = read_response_over_loopback(MATCHED_ERROR_RESPONSE, Some(&mut correlation))?; + assert_eq!(correlation.outstanding_count(), 2); assert_eq!( WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( &matched, @@ -190,7 +255,7 @@ fn protocol_error_consumes_only_its_exact_outstanding_command() -> Result<(), Bo } ) ); - assert_eq!(correlation.outstanding_count(), 0); + assert_eq!(correlation.outstanding_count(), 1); Ok(()) } @@ -240,3 +305,30 @@ fn event_response_is_rejected_without_consuming_outstanding_command() -> Result< assert_eq!(correlation.outstanding_count(), 1); Ok(()) } + +#[test] +fn manually_registered_subscription_cannot_supply_sender_provenance() -> Result<(), Box> +{ + for document in [MATCHED_SUCCESS_RESPONSE, MATCHED_ERROR_RESPONSE] { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation + .register_command_for(7, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; + let message = read_text_over_loopback(document)?; + assert_eq!( + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &message, + &mut correlation + ), + Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source: + WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { + command_id: 7 + }, + } + ) + ); + assert_eq!(correlation.outstanding_count(), 1); + } + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs index 169d7f29b..5ac97dce5 100644 --- a/crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs +++ b/crates/originweave-network/tests/webdriver_bidi_subscription_response_connection_provenance.rs @@ -9,13 +9,13 @@ use std::{ use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; use originweave_network::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiCommandKind, WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiNavigationCommittedSubscriptionCommand, WebDriverBiDiNavigationCommittedSubscriptionResponseError, - WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, - WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -100,10 +100,11 @@ fn establish(local_addr: SocketAddr) -> Result Result> { - let (_, frame) = established.read_frame(Duration::from_millis(500))?; - let text = match WebDriverBiDiWebSocketMessageAssembler::new().push_frame(frame)? { - WebDriverBiDiWebSocketMessageAssembly::Text(message) => message, +) -> Result> { + let text = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, other => { return Err(io::Error::other(format!( "replacement subscription connection produced unexpected assembly state: {other:?}" From 46ae62aa31e35c702cd61c16322d05c7a9c35da1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:32:53 +0900 Subject: [PATCH 19/19] docs(network): record subscription provenance repair and limits Commit-Message-Assisted-by: Codex (via Codex) Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + docs/doctoring.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fad2eb9fe..db4fe2fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Reject navigation-subscription replies received on replacement connections while keeping the original request available for its own reply; a successful subscription still does not prove that a navigation occurred. - Carried replacement-connection click-reply rejection into the navigation-subscription stack while preserving deadline rejection, unrelated pending requests and conservative handling of uncertain writes. - Reject invalid navigation-subscription deadlines before reserving a pending request, preserving existing requests and leaving the rejected identifier reusable without sending subscription bytes. - Retire only the exact committed-navigation subscription correlation when frame preparation fails locally as `MalformedFrame` before any command bytes can be emitted, while preserving unrelated requests and retaining correlation after ambiguous frame-write failures. diff --git a/docs/doctoring.md b/docs/doctoring.md index 0b5ac87e6..703e1c8eb 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -6,6 +6,8 @@ This document records external evidence that changes OriginWeave architecture, t ### Browser automation and interoperability +The subscription response repair reuses the existing sender-owned connection generation and sealed receiving-message capability. On regression head `122ca139`, two real connections to the same listener and session reproduced replacement success and protocol-error responses consuming the original subscription. Source repair `8b1508c8` rejects both with an exact connection mismatch, preserves two outstanding requests, and accepts the original connection's response while leaving the unrelated request outstanding. Manually registered commands lack sender provenance and cannot bypass that check. Required subscription projection still precedes correlation consumption; invalid deadlines precede registration, proven no-write failures retire only their exact request, and ambiguous writes retain correlation. The 14 focused subscription tests pass. This closes response-connection substitution only: outbound registry-to-endpoint session binding, authenticated later navigation events, protected-main asset preservation, hosted checks and browser-runtime acceptance remain separate work. + Fresh deadline-repair verification passed 11 focused subscription loopback tests and all 142 Python contracts, plus the complete locked Rust workspace checks/tests, formatting, all-feature Clippy, warning-denying rustdoc, compileall, and diff checks. Numeric production coverage is 100% for 1,221 functions, 12,781 lines, 16,404 regions, and 1,418 branches; the unstable branch-measurement warning remains. The 1,220-function result below belongs to the earlier parent-adoption tree, not this subsequent source change. The subsequent subscription deadline repair reuses the frame owner's existing local timeout validator before command registration. A loopback regression on parent-adoption head `3c0484174eeda0703492ba76b530be125e3e99dd` observed no subscription bytes but two outstanding commands where only the pre-existing command should remain. Zero and over-limit deadlines now leave that command untouched and the rejected identifier reusable. Provably local no-write failures retire only the exact typed subscription correlation; adjacent masking-key reuse is one such `MalformedFrame` preflight rejection. A separate loopback regression preserves unrelated correlation, proves the rejected subscription identifier reusable, and observes no subscription command bytes. Failures after frame emission may have begun remain ambiguous; ambiguous frame-write failures retain correlation. No deadline bound, wire format, response parser, connection authority, or external API changes.