diff --git a/CHANGELOG.md b/CHANGELOG.md index 8afeb8cae..db4fe2fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ 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. +- 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. - Carried replacement-connection click-reply rejection into navigation origin binding, preserving invalid-URL and stale-document rejection before changes to the registered origin. - 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. - Carried replacement-connection click-reply rejection into document advancement while preserving rejection of stale or retired contexts; a successful reply still does not authenticate a later navigation. @@ -29,6 +34,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Typed WebDriver BiDi navigation-committed observation that admits only the exact event, registered browsing context, bounded navigation metadata, and caller-declared URL while granting no document-epoch, origin, action-cause, or Agent authority. - Consuming WebDriver BiDi navigation document advance that revalidates the exact pre-action epoch before rotating registry authority, clears stale origin and node bindings, and cannot reuse one accepted event or bind the new origin. - Canonical origin binding for an accepted WebDriver BiDi navigation that validates the serialized URL before mutation, advances the exact expected document epoch, and binds only the resulting HTTP(S) origin without granting destination or action authority. +- Context-scoped WebDriver BiDi `session.subscribe` exchange for committed-navigation events with exact registered-context revalidation, bounded transport, typed command-family correlation, and bounded opaque subscription identity retention. - Typed outbound WebDriver BiDi `session.end` over the bounded client WebSocket stream: it serializes only the standards-defined method with empty params, rejects invalid frame deadlines before correlation registration, retires only the just-registered id when frame preflight proves no command bytes were emitted, preserves exact command-kind correlation across ambiguous writes, and does not treat frame-write success as proof that the browser session ended. - Typed `session.end` response admission that consumes only the exact outstanding command-kind correlation after complete envelope validation, preserves remote protocol errors as failures, and does not claim browser-process exit or resource cleanup from a protocol acknowledgment. - Fail-closed `session.end` teardown assessment that binds only the typed observation produced by consuming the exact transport, keeps browser-process-exit and task-profile-removal evidence unavailable until their runtime owners exist, and therefore cannot report operational completion from caller-supplied booleans. @@ -84,6 +90,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Bound the committed-navigation `session.subscribe` command and both success and protocol-error responses to a distinct correlation command family, so a response for another outstanding BiDi command cannot retire the subscription identifier. - Carried current response prerequisites and the executable release-record check into the teardown-assessment stack; caller-supplied cleanup claims remain unverified and cannot establish operational acceptance. - Carried verified command prerequisites and the executable release-record check into session-end response validation without changing response admission or treating an acknowledgment as proof of resource cleanup. - Carried the verified status-response prerequisites into the session-end sender, preserving its command behavior and making the inherited release-record check execute in the existing test suite. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 0df47a1d2..7060c9e64 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -10,7 +10,8 @@ //! binds received fragmented text to one exact verified connection, 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 +//! response, sends a context-bound committed-navigation subscription and retains +//! its typed bounded correlated identifier, admits a bounded navigation observation for //! one exact registered context and URL, rotates that 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 the new document, sends narrowly typed @@ -29,6 +30,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; @@ -72,6 +75,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_command_correlation.rs b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs index 6d279fdba..55712f050 100644 --- a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs +++ b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs @@ -26,6 +26,8 @@ pub enum WebDriverBiDiCommandKind { SessionEnd, /// WebDriver BiDi `input.performActions` pointer click. PointerClick, + /// Context-scoped WebDriver BiDi `session.subscribe` for committed navigation. + NavigationCommittedSubscription, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] 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..3d02690fd --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs @@ -0,0 +1,282 @@ +use std::{error::Error, fmt, time::Duration}; + +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, + WebDriverBiDiCommandKind, 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. + /// Invalid frame deadlines fail before correlation registration. Registration then occurs before + /// 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, + 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, + )?; + validate_frame_timeout(frame_timeout).map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionCommandError::FrameWrite { source } + })?; + correlation + .register_command_for_connection( + self.command_id, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + established.transport_evidence().connection_generation(), + ) + .map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionCommandError::Correlation { source } + })?; + let message = self.serialized(); + match established.write_text_frame(&message, masking_key, frame_timeout) { + Ok(established) => Ok(established), + Err(source) => Err(map_frame_failure(correlation, self.command_id, source)), + } + } + + fn serialized(&self) -> String { + 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 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, + 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, + }, + /// Preparing or writing the 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), + } + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use super::*; + + #[test] + fn only_provably_local_frame_failures_retire_subscription_correlation() { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + 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); + + 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"), + }; + map_frame_failure(&mut correlation, 2, ambiguous); + assert_eq!(correlation.outstanding_count(), 1); + } +} 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..c4e41f01b --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs @@ -0,0 +1,769 @@ +use std::{error::Error, fmt}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, + WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiReceivedTextMessage, +}; + +/// 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 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 + /// 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: &WebDriverBiDiReceivedTextMessage, + correlation: &mut WebDriverBiDiCommandCorrelation, + ) -> Result { + let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()).map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Envelope { source } + })?; + + match envelope.kind() { + WebDriverBiDiJsonEnvelopeKind::Success => { + let projected = SubscriptionProjection::parse(message.message().as_str())?; + let completed = correlation + .correlate_response_for_connection( + &envelope, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + message.connection_generation(), + ) + .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_for_connection( + &envelope, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + message.connection_generation(), + ) + .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/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.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs new file mode 100644 index 000000000..075fbbe31 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs @@ -0,0 +1,217 @@ +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, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, +}; + +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 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:?}" + )) + .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..e7decd258 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs @@ -0,0 +1,291 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; +use originweave_network::{ + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, + WebDriverBiDiNavigationCommittedSubscriptionCommand, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrameError, 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_for(7, WebDriverBiDiCommandKind::SessionStatus)?; + 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_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 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<()> { + 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")?; + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + ®istry, + session, + context, + "context-a", + )?; + 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(); + correlation.register_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + 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-rejection test server panicked"))??; + assert_eq!(correlation.outstanding_count(), 1); + correlation.retire_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + correlation + .register_command_for(7, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; + 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..2fe4b3806 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs @@ -0,0 +1,334 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, WebDriverBiDiJsonEnvelopeError, + WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionResponseError, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const MALFORMED_SUCCESS_RESPONSE: &[u8] = br#"{"type":"success","id":7,"result":"#; +const MISSING_SUBSCRIPTION_RESPONSE: &[u8] = br#"{"type":"success","id":7,"result":{"extra":1}}"#; +const MATCHED_SUCCESS_RESPONSE: &[u8] = + br#"{"type":"success","id":7,"result":{"subscription":"subscription-a"}}"#; +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_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> { + 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) + }); + + 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 = 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:?}" + )) + .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_for(7, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; + + 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_for(43, WebDriverBiDiCommandKind::SessionStatus)?; + + 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_response_over_loopback(MATCHED_ERROR_RESPONSE, Some(&mut correlation))?; + assert_eq!(correlation.outstanding_count(), 2); + 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(), 1); + Ok(()) +} + +#[test] +fn subscription_response_cannot_consume_another_command_kind() -> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(7, WebDriverBiDiCommandKind::SessionStatus)?; + let response = read_text_over_loopback(MATCHED_SUCCESS_RESPONSE)?; + + assert_eq!( + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &response, + &mut correlation, + ), + Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandKindMismatch { + expected: WebDriverBiDiCommandKind::NavigationCommittedSubscription, + actual: WebDriverBiDiCommandKind::SessionStatus, + }, + } + ) + ); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn event_response_is_rejected_without_consuming_outstanding_command() -> Result<(), Box> +{ + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation + .register_command_for(7, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; + 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(()) +} + +#[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 new file mode 100644 index 000000000..5ac97dce5 --- /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, + &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, + &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) +} 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 5b8036204..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,9 @@ 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. ## Options considered @@ -90,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 diff --git a/docs/doctoring.md b/docs/doctoring.md index c09a216d1..703e1c8eb 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -6,7 +6,17 @@ This document records external evidence that changes OriginWeave architecture, t ### Browser automation and interoperability -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 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. + +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. 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 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. @@ -156,7 +166,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 @@ -218,7 +228,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/ 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 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()