diff --git a/CHANGELOG.md b/CHANGELOG.md index 4542111c9..425580bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,22 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Integrated the current navigation-subscription safeguards while preserving active-subscription admission, replay rejection and stale-document checks. A response from a replacement connection still cannot complete an earlier session shutdown; this source integration is not real-browser or release acceptance. + ### Changed +- Reject replacement-connection click replies while retaining increasing request numbers, original subscription ownership and same-connection shutdown checks. + +- Reject a navigation subscription aimed at a different browser session before sending it, without creating or replacing browser state. +- Keep connection-failure checks focused on the requested action by completing fixture setup before the simulated peer disconnects. + +- Keep subscription shutdown on its original connection and reject replies from another connection without losing the pending request. Beginning shutdown ends local event admission; a failed shutdown requires a new subscription before admission resumes. + +- Prevent an earlier browser reply from completing a later request that reuses its number. Typed browser requests now use increasing numbers on each connection, and low-level protocol traffic uses a separate connection. + +- Keep navigation subscriptions and accepted navigation events attached to their original browser state, so a replacement state with matching local identifiers cannot send a request or change another document. +- Reject navigation-subscription replies and events received on a different connection, even when their session and request details match. Rejected messages leave the original request and document unchanged, so the original connection can still complete its work. +- Prevent an unsent navigation subscription from borrowing another request's successful response, including when separate sessions reuse the same local numbers. Re-registering a completed request number without sending a new request cannot recreate its consumed subscription. - Carried replacement-connection subscription-reply rejection into unsubscribe preparation, preserving opaque identifiers and existing teardown checks without claiming that pending events have drained. - 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. diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs index 3af93cb07..309a011c8 100644 --- a/crates/originweave-core/src/browser_authority_registry.rs +++ b/crates/originweave-core/src/browser_authority_registry.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use crate::browser_registry::BrowserAuthorityRegistry as RawBrowserAuthorityRegistry; use crate::{ BrowserRegistryError, BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, @@ -15,14 +17,24 @@ use crate::{ /// before atomically minting handles. pub struct BrowserAuthorityRegistry { inner: RawBrowserAuthorityRegistry, + registry_identity: Arc<()>, } +/// Opaque process-local identity of one browser-authority registry allocation. +/// +/// Cloning preserves the same registry identity without keeping its mutable state alive. This +/// witness contains no wire identifier, address, durable identity, or grant of browser authority. +/// It must be captured by a trusted admission boundary and revalidated with current context state. +#[derive(Clone)] +pub struct BrowserRegistryIdentity(Arc<()>); + impl BrowserAuthorityRegistry { /// Create an empty registry with the reviewed default per-namespace identifier capacity. #[must_use] pub fn new() -> Self { Self { inner: RawBrowserAuthorityRegistry::new(), + registry_identity: Arc::new(()), } } @@ -34,9 +46,33 @@ impl BrowserAuthorityRegistry { pub fn with_identifier_limit(maximum_identifier: u64) -> Self { Self { inner: RawBrowserAuthorityRegistry::with_identifier_limit(maximum_identifier), + registry_identity: Arc::new(()), } } + /// Capture this registry's opaque identity for later exact-owner revalidation. + /// + /// The identity survives moves of this registry but cannot match a replacement registry, + /// including one whose local session and context identifiers have identical numeric values. + #[must_use] + pub fn registry_identity(&self) -> BrowserRegistryIdentity { + BrowserRegistryIdentity(Arc::clone(&self.registry_identity)) + } + + /// Reject a witness issued by any other registry before consulting registry-local identifiers. + /// + /// Success proves only registry ownership; callers must still validate live session, context, + /// document epoch, origin and operation-specific authority at their actual use boundary. + pub fn require_identity( + &self, + expected_identity: &BrowserRegistryIdentity, + ) -> Result<(), BrowserRegistryError> { + if !Arc::ptr_eq(&self.registry_identity, &expected_identity.0) { + return Err(BrowserRegistryError::RegistryInstanceMismatch); + } + Ok(()) + } + /// Register one opaque external browser-session identifier. pub fn register_session( &mut self, @@ -45,6 +81,20 @@ impl BrowserAuthorityRegistry { self.inner.register_session(external_identifier) } + /// Require external protocol session text to name this exact currently registered session. + /// + /// This read-only check reuses the canonical mapping and never creates authority from transport + /// text. Unknown, retired and mismatched sessions fail closed. Success does not authenticate a + /// browser process or authorize an action; callers must also revalidate registry ownership. + pub fn require_registered_session_external_identifier( + &self, + browser_session: BrowserSessionId, + external_identifier: &str, + ) -> Result<(), BrowserRegistryError> { + self.inner + .require_session_external_identifier(browser_session, external_identifier) + } + /// Register one opaque external browsing-context identifier inside a known browser session. pub fn register_context( &mut self, diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 26a05249f..9ef319f24 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -108,6 +108,19 @@ impl BrowserAuthorityRegistry { }) } + /// Check the existing canonical external-session mapping without creating authority. + pub(crate) fn require_session_external_identifier( + &self, + browser_session: BrowserSessionId, + external_identifier: &str, + ) -> Result<(), BrowserRegistryError> { + validate_external_identifier(external_identifier)?; + if self.session_by_external.get(external_identifier).copied() != Some(browser_session) { + return Err(BrowserRegistryError::SessionExternalIdentifierMismatch); + } + Ok(()) + } + /// Register one opaque external browsing-context identifier inside a known browser session. /// /// A newly registered context starts at document epoch one. The same external context text in @@ -427,10 +440,14 @@ impl Default for BrowserAuthorityRegistry { /// A fail-closed error produced while translating external browser identifiers into local authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { + /// A retained authority witness belongs to a different registry instance. + RegistryInstanceMismatch, /// An external identifier was empty, contained control, whitespace, or Unicode format text, or exceeded the reviewed byte bound. InvalidExternalIdentifier, /// The supplied OriginWeave browser session is not registered in this registry. UnknownBrowserSession, + /// The transport-level session identifier does not name the supplied registered session. + SessionExternalIdentifierMismatch, /// The supplied OriginWeave browsing context is not registered in this registry. UnknownBrowsingContext, /// The browsing context belongs to another browser session. @@ -457,12 +474,18 @@ pub enum BrowserRegistryError { impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::RegistryInstanceMismatch => { + formatter.write_str("browser authority belongs to another registry instance") + } Self::InvalidExternalIdentifier => formatter.write_str( "external browser identifier must contain 1 to 512 UTF-8 bytes without control, whitespace, or Unicode format characters", ), Self::UnknownBrowserSession => { formatter.write_str("browser session is not registered in this authority registry") } + Self::SessionExternalIdentifierMismatch => formatter.write_str( + "browser session external identifier does not match the registered session", + ), Self::UnknownBrowsingContext => { formatter.write_str("browsing context is not registered in this authority registry") } @@ -836,13 +859,30 @@ mod tests { #[test] fn browser_registry_errors_have_non_sensitive_deterministic_text() { + let original_registry = crate::BrowserAuthorityRegistry::new(); + let retained_identity = original_registry.registry_identity().clone(); + assert!( + original_registry + .require_identity(&retained_identity) + .is_ok() + ); + drop(original_registry); + let replacement_registry = crate::BrowserAuthorityRegistry::with_identifier_limit(8); + let replacement_errors: Vec<_> = replacement_registry + .require_identity(&retained_identity) + .err() + .into_iter() + .collect(); + assert_eq!(replacement_errors.len(), 1); let expected_values = values(BrowserSessionId::new(1)); let actual_values = values(BrowserSessionId::new(2)); assert_eq!(expected_values.len(), 1); assert_eq!(actual_values.len(), 1); let errors = [ + replacement_errors[0], BrowserRegistryError::InvalidExternalIdentifier, BrowserRegistryError::UnknownBrowserSession, + BrowserRegistryError::SessionExternalIdentifierMismatch, BrowserRegistryError::UnknownBrowsingContext, BrowserRegistryError::ContextSessionMismatch { expected: expected_values[0], diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 3fa97bffa..211b75348 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -41,7 +41,7 @@ mod webdriver_bidi_result; mod webdriver_bidi_websocket_connect_target; mod webdriver_bidi_websocket_endpoint; -pub use browser_authority_registry::BrowserAuthorityRegistry; +pub use browser_authority_registry::{BrowserAuthorityRegistry, BrowserRegistryIdentity}; pub use browser_protocol::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, diff --git a/crates/originweave-core/tests/browser_registry_session_provenance.rs b/crates/originweave-core/tests/browser_registry_session_provenance.rs new file mode 100644 index 000000000..ca1128476 --- /dev/null +++ b/crates/originweave-core/tests/browser_registry_session_provenance.rs @@ -0,0 +1,46 @@ +use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId}; + +#[test] +fn session_mapping_is_read_only_exact_and_revoked_on_retirement() -> Result<(), BrowserRegistryError> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let first = registry.register_session("session-a")?; + let second = registry.register_session("session-b")?; + registry.require_registered_session_external_identifier(first, "session-a")?; + for (session, external) in [ + (first, "session-b"), + (second, "session-a"), + (first, "unknown"), + ( + BrowserSessionId::new(99) + .map_err(|_| BrowserRegistryError::InternalAuthorityInvariant)?, + "session-a", + ), + ] { + assert_eq!( + registry.require_registered_session_external_identifier(session, external), + Err(BrowserRegistryError::SessionExternalIdentifierMismatch) + ); + } + assert_eq!( + registry.require_registered_session_external_identifier(first, "\n"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + registry.remove_session(first)?; + assert_eq!( + registry.require_registered_session_external_identifier(first, "session-a"), + Err(BrowserRegistryError::SessionExternalIdentifierMismatch) + ); + let replacement = registry.register_session("session-a")?; + assert_ne!(first, replacement); + registry.require_registered_session_external_identifier(replacement, "session-a")?; + assert_eq!( + registry.require_registered_session_external_identifier(first, "session-a"), + Err(BrowserRegistryError::SessionExternalIdentifierMismatch) + ); + assert_eq!( + BrowserRegistryError::SessionExternalIdentifierMismatch.to_string(), + "browser session external identifier does not match the registered session" + ); + Ok(()) +} diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index b70e7f9dd..7feedb504 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -11,7 +11,9 @@ //! complete local-end JSON envelopes, tracks bounded command-response correlation, //! transports a narrowly typed pointer click, admits its typed correlated protocol //! response, sends a context-bound committed-navigation subscription and retains -//! its typed bounded correlated identifier, explicitly unsubscribes that exact +//! its typed bounded correlated identifier, binds navigation-event admission to +//! that active command/receipt lifecycle with bounded fail-closed navigation replay +//! prevention, explicitly unsubscribes that exact //! retained identifier, admits its typed correlated unsubscribe response, 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 @@ -33,6 +35,7 @@ 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_admission; mod webdriver_bidi_navigation_committed_subscription_response; mod webdriver_bidi_navigation_committed_unsubscribe; mod webdriver_bidi_navigation_committed_unsubscribe_response; @@ -83,6 +86,14 @@ pub use webdriver_bidi_navigation_committed_subscription::{ WebDriverBiDiNavigationCommittedSubscriptionCommand, WebDriverBiDiNavigationCommittedSubscriptionCommandError, }; +pub use webdriver_bidi_navigation_committed_subscription_admission::{ + MAX_WEBDRIVER_BIDI_NAVIGATION_COMMITTED_ADMISSIONS, + WebDriverBiDiNavigationCommittedSubscribedObservation, + WebDriverBiDiNavigationCommittedSubscriptionAdmission, + WebDriverBiDiNavigationCommittedSubscriptionAdmissionError, + WebDriverBiDiNavigationCommittedSubscriptionBinding, + WebDriverBiDiNavigationCommittedSubscriptionEventError, +}; pub use webdriver_bidi_navigation_committed_subscription_response::{ MAX_WEBDRIVER_BIDI_SUBSCRIPTION_IDENTIFIER_BYTES, WebDriverBiDiNavigationCommittedSubscriptionResponseError, diff --git a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs index b70a8feba..98326c47a 100644 --- a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs +++ b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, error::Error, fmt}; +use std::{collections::BTreeMap, error::Error, fmt, sync::Arc}; use crate::{ MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeRouting, @@ -32,10 +32,11 @@ pub enum WebDriverBiDiCommandKind { NavigationCommittedUnsubscribe, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] struct OutstandingCommand { kind: WebDriverBiDiCommandKind, connection_generation: Option, + subscription_intent: Option>, } /// Outcome of a response after it has consumed the matching outstanding command identifier. @@ -97,6 +98,11 @@ pub enum WebDriverBiDiCommandCorrelationError { /// Exact outstanding local command identifier. command_id: u64, }, + /// No subscription sender bound its private command-instance identity to this command. + CommandSubscriptionProvenanceMissing { + /// Exact outstanding local command identifier left untouched after rejection. + command_id: u64, + }, /// The response was received on a different verified connection from the outstanding command. ResponseConnectionMismatch { /// Exact outstanding local command identifier left untouched after rejection. @@ -121,6 +127,9 @@ impl fmt::Display for WebDriverBiDiCommandCorrelationError { Self::CommandConnectionProvenanceMissing { .. } => { "WebDriver BiDi outstanding command lacks connection provenance" } + Self::CommandSubscriptionProvenanceMissing { .. } => { + "WebDriver BiDi outstanding subscription lacks sent-context provenance" + } Self::ResponseConnectionMismatch { .. } => { "WebDriver BiDi response arrived on a different connection" } @@ -139,13 +148,15 @@ impl Error for WebDriverBiDiCommandCorrelationError {} /// /// Register an id together with its exact typed command family only after the caller has committed /// to that outbound command. Connection-owning command adapters may additionally bind the private -/// generation of the exact established transport before I/O. A success or correlatable error -/// response consumes the id exactly once only through a matching typed consumer. Events, null-id -/// errors, command-kind mismatches, missing connection provenance, and responses received on a -/// different verified connection leave outstanding state untouched. This type performs no I/O, -/// retry, command serialization, browser authentication, or authority grant. Debug output reports -/// only the outstanding-count summary; command identifiers, families, and generations remain -/// private correlation state. +/// generation of the exact established transport before I/O. Subscription senders retain both a +/// private command-instance identity and the exact verified connection generation; generic +/// registration cannot supply either. A success or correlatable error response consumes the id +/// exactly once only through a matching typed consumer. Events, null-id errors, command-kind +/// mismatches, missing connection provenance, and responses received on a different verified +/// connection leave outstanding state untouched. This type performs no I/O, retry, command +/// serialization, browser authentication, or authority grant. Debug output reports only the +/// outstanding-count summary; command identifiers, families, and generations remain private +/// correlation state. #[derive(Default)] pub struct WebDriverBiDiCommandCorrelation { outstanding: BTreeMap, @@ -177,13 +188,15 @@ impl WebDriverBiDiCommandCorrelation { /// /// Identifiers are unique only while outstanding. A completed or explicitly retired id may be /// reused later, matching WebDriver BiDi's local-end correlation semantics. Reusing an id while - /// any command family is still outstanding fails before replacing its provenance. + /// any command family is still outstanding fails before replacing its provenance. This generic + /// table does not authorize dispatch: the typed connection owner independently requires strictly + /// increasing identifiers across all typed command families for its complete lifetime. pub fn register_command_for( &mut self, command_id: u64, command_kind: WebDriverBiDiCommandKind, ) -> Result<(), WebDriverBiDiCommandCorrelationError> { - self.register(command_id, command_kind, None) + self.register(command_id, command_kind, None, None) } pub(crate) fn register_command_for_connection( @@ -192,7 +205,45 @@ impl WebDriverBiDiCommandCorrelation { command_kind: WebDriverBiDiCommandKind, connection_generation: WebDriverBiDiConnectionGeneration, ) -> Result<(), WebDriverBiDiCommandCorrelationError> { - self.register(command_id, command_kind, Some(connection_generation)) + self.register(command_id, command_kind, Some(connection_generation), None) + } + + pub(crate) fn register_subscription_command_for_connection( + &mut self, + command_id: u64, + connection_generation: WebDriverBiDiConnectionGeneration, + subscription_intent: Arc<()>, + ) -> Result<(), WebDriverBiDiCommandCorrelationError> { + self.register( + command_id, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + Some(connection_generation), + Some(subscription_intent), + ) + } + + pub(crate) fn complete_subscription_command_on_connection( + &mut self, + command_id: u64, + received_connection_generation: WebDriverBiDiConnectionGeneration, + ) -> Result<(Arc<()>, WebDriverBiDiConnectionGeneration), WebDriverBiDiCommandCorrelationError> + { + let outstanding = self.require_command_kind( + command_id, + WebDriverBiDiCommandKind::NavigationCommittedSubscription, + )?; + let subscription_intent = outstanding.subscription_intent.ok_or( + WebDriverBiDiCommandCorrelationError::CommandSubscriptionProvenanceMissing { + command_id, + }, + )?; + let expected_connection_generation = require_connection_generation( + outstanding.connection_generation, + command_id, + received_connection_generation, + )?; + let _removed = self.outstanding.remove(&command_id); + Ok((subscription_intent, expected_connection_generation)) } fn register( @@ -200,6 +251,7 @@ impl WebDriverBiDiCommandCorrelation { command_id: u64, command_kind: WebDriverBiDiCommandKind, connection_generation: Option, + subscription_intent: Option>, ) -> Result<(), WebDriverBiDiCommandCorrelationError> { if command_id > MAX_WEBDRIVER_BIDI_JS_UINT { return Err(WebDriverBiDiCommandCorrelationError::CommandIdOutOfRange); @@ -215,6 +267,7 @@ impl WebDriverBiDiCommandCorrelation { OutstandingCommand { kind: command_kind, connection_generation, + subscription_intent, }, ); Ok(()) @@ -306,7 +359,7 @@ impl WebDriverBiDiCommandCorrelation { let actual = self .outstanding .get(&command_id) - .copied() + .cloned() .ok_or(WebDriverBiDiCommandCorrelationError::CommandNotOutstanding)?; if actual.kind != expected_kind { return Err(WebDriverBiDiCommandCorrelationError::CommandKindMismatch { @@ -340,14 +393,11 @@ impl WebDriverBiDiCommandCorrelation { received_connection_generation: WebDriverBiDiConnectionGeneration, ) -> Result { let outstanding = self.require_command_kind(command_id, expected_kind)?; - let expected_connection_generation = outstanding.connection_generation.ok_or( - WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { command_id }, + let expected_connection_generation = require_connection_generation( + outstanding.connection_generation, + command_id, + received_connection_generation, )?; - if expected_connection_generation != received_connection_generation { - return Err( - WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { command_id }, - ); - } let _removed = self.outstanding.remove(&command_id); Ok(WebDriverBiDiCorrelatedResponse { command_id, @@ -357,6 +407,22 @@ impl WebDriverBiDiCommandCorrelation { } } +fn require_connection_generation( + expected: Option, + command_id: u64, + received: WebDriverBiDiConnectionGeneration, +) -> Result { + let expected = expected.ok_or( + WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { command_id }, + )?; + if expected != received { + return Err( + WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { command_id }, + ); + } + Ok(expected) +} + #[cfg(test)] mod tests { use super::{WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind}; @@ -393,6 +459,12 @@ mod tests { }, "WebDriver BiDi outstanding command lacks connection provenance", ), + ( + WebDriverBiDiCommandCorrelationError::CommandSubscriptionProvenanceMissing { + command_id: 7, + }, + "WebDriver BiDi outstanding subscription lacks sent-context provenance", + ), ( WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { command_id: 7 }, "WebDriver BiDi response arrived on a different connection", diff --git a/crates/originweave-network/src/webdriver_bidi_connection.rs b/crates/originweave-network/src/webdriver_bidi_connection.rs index f1701a923..b80aebc9b 100644 --- a/crates/originweave-network/src/webdriver_bidi_connection.rs +++ b/crates/originweave-network/src/webdriver_bidi_connection.rs @@ -208,6 +208,13 @@ impl WebDriverBiDiSocketConnector for SystemWebDriverBiDiConnector { /// transport to the expected browser process/session, and pass separate action-policy checks. A /// private process-local connection generation follows this exact stream so later evidence cannot /// be mixed with another connection that happens to use the same session or command identifier. +/// Raw socket aliases cannot coexist with this upgradeable connection owner: +/// +/// ```compile_fail +/// fn retain_alias(connection: &originweave_network::WebDriverBiDiTcpConnection) { +/// let _alias = connection.stream().try_clone(); +/// } +/// ``` #[derive(Debug)] pub struct WebDriverBiDiTcpConnection { stream: TcpStream, @@ -218,12 +225,6 @@ pub struct WebDriverBiDiTcpConnection { } impl WebDriverBiDiTcpConnection { - /// Borrow the verified TCP stream. - #[must_use] - pub const fn stream(&self) -> &TcpStream { - &self.stream - } - /// Borrow the session-correlated exact peer evidence consumed by this connection. #[must_use] pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { diff --git a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs index 63564283f..00dee93a6 100644 --- a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs @@ -25,6 +25,54 @@ fn socket_address() -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], 9515)) } +#[test] +fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() +-> Result<(), Box> { + use crate::{ + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningWriteError, + }; + use std::{net::Shutdown, sync::mpsc, thread}; + + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let (release_server, await_release) = mpsc::sync_channel(0); + let server = thread::spawn(move || -> io::Result<()> { + let accepted = listener.accept()?; + await_release + .recv_timeout(Duration::from_secs(2)) + .map_err(io::Error::other)?; + drop(accepted); + Ok(()) + }); + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + connection.stream.shutdown(Shutdown::Both)?; + let key = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ==")?; + let write = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_secs(1)); + let failed_closed_without_writing = match write { + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, .. + }) => true, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + source, + }) => source.kind() == io::ErrorKind::InvalidInput, + _ => false, + }; + release_server.send(())?; + server + .join() + .map_err(|_| io::Error::other("revoked stream server panicked"))??; + assert!(failed_closed_without_writing); + Ok(()) +} + enum ConnectOutcome { Success(TcpStream), Error(io::ErrorKind), @@ -185,7 +233,7 @@ fn verified_peer_is_required_before_stream_exposure() { .connect_with(&connector) .expect("verified connection"); - assert!(connection.stream().peer_addr().is_ok()); + assert!(connection.stream.peer_addr().is_ok()); assert_eq!(connection.verified_peer().socket_addr(), socket_address()); assert!(connection.verified_peer().requires_tls()); assert_eq!(connection.verified_peer().session_id(), SESSION_ID); diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs index 3d02690fd..866bfa296 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs @@ -1,14 +1,16 @@ -use std::{error::Error, fmt, time::Duration}; +use std::{error::Error, fmt, sync::Arc, time::Duration}; use originweave_core::{ - BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, + BrowserAuthorityRegistry, BrowserRegistryError, BrowserRegistryIdentity, 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, + WebDriverBiDiCommandKind, WebDriverBiDiNavigationCommittedSubscriptionBinding, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, }; @@ -28,6 +30,8 @@ pub struct WebDriverBiDiNavigationCommittedSubscriptionCommand { browser_session: BrowserSessionId, browsing_context: BrowsingContextId, external_context: String, + subscription_intent: Arc<()>, + registry_identity: BrowserRegistryIdentity, } impl WebDriverBiDiNavigationCommittedSubscriptionCommand { @@ -61,6 +65,8 @@ impl WebDriverBiDiNavigationCommittedSubscriptionCommand { browser_session, browsing_context, external_context: external_context.to_owned(), + subscription_intent: Arc::new(()), + registry_identity: registry.registry_identity(), }) } @@ -88,15 +94,35 @@ impl WebDriverBiDiNavigationCommittedSubscriptionCommand { &self.external_context } + /// Capture the exact command-side binding required to admit the correlated subscription later. + /// + /// The binding is intentionally captured before this single-use command is consumed by + /// [`Self::send`]. It contains no remote subscription identifier and grants no event authority by + /// itself; the exact correlated `session.subscribe` result must be bound to it separately. + #[must_use] + pub fn admission_binding(&self) -> WebDriverBiDiNavigationCommittedSubscriptionBinding { + WebDriverBiDiNavigationCommittedSubscriptionBinding::new( + self.command_id, + self.browser_session, + self.browsing_context, + &self.external_context, + Arc::clone(&self.subscription_intent), + self.registry_identity.clone(), + ) + } + /// 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 + /// The original registry identity and context binding are revalidated before correlation and 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. + /// The verified transport's protocol session must also match the registry's canonical external + /// session mapping; this comparison does not authenticate the browser process. + /// Invalid frame deadlines fail before correlation registration. Registration then binds both the + /// private command-instance identity and this established connection's process-local generation + /// 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, @@ -108,6 +134,11 @@ impl WebDriverBiDiNavigationCommittedSubscriptionCommand { WebDriverBiDiWebSocketEstablished, WebDriverBiDiNavigationCommittedSubscriptionCommandError, > { + registry + .require_identity(&self.registry_identity) + .map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionCommandError::ContextBinding { source } + })?; require_registered_context( registry, self.browser_session, @@ -117,17 +148,30 @@ impl WebDriverBiDiNavigationCommittedSubscriptionCommand { validate_frame_timeout(frame_timeout).map_err(|source| { WebDriverBiDiNavigationCommittedSubscriptionCommandError::FrameWrite { source } })?; + registry + .require_registered_session_external_identifier( + self.browser_session, + established + .transport_evidence() + .verified_peer() + .session_id(), + ) + .map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionCommandError::ContextBinding { source } + })?; + let connection_generation = established.transport_evidence().connection_generation(); correlation - .register_command_for_connection( + .register_subscription_command_for_connection( self.command_id, - WebDriverBiDiCommandKind::NavigationCommittedSubscription, - established.transport_evidence().connection_generation(), + connection_generation, + Arc::clone(&self.subscription_intent), ) .map_err(|source| { WebDriverBiDiNavigationCommittedSubscriptionCommandError::Correlation { source } })?; let message = self.serialized(); - match established.write_text_frame(&message, masking_key, frame_timeout) { + match established.write_command_frame(self.command_id, &message, masking_key, frame_timeout) + { Ok(established) => Ok(established), Err(source) => Err(map_frame_failure(correlation, self.command_id, source)), } @@ -200,7 +244,7 @@ pub enum WebDriverBiDiNavigationCommittedSubscriptionCommandError { /// Largest JavaScript-safe identifier admitted by this boundary. maximum_command_id: u64, }, - /// The external protocol context does not name the exact registered OriginWeave context. + /// The protocol session or context does not match the exact registered OriginWeave authority. ContextBinding { /// Exact typed browser-registry authority failure. source: BrowserRegistryError, diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_admission.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_admission.rs new file mode 100644 index 000000000..cdfe8f19a --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_admission.rs @@ -0,0 +1,443 @@ +use std::{error::Error, fmt, sync::Arc}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserRegistryIdentity, BrowserSessionId, + BrowsingContextId, +}; + +use crate::{ + WebDriverBiDiNavigationCommittedObservation, WebDriverBiDiNavigationCommittedObservationError, + WebDriverBiDiNavigationCommittedSubscriptionResult, + WebDriverBiDiNavigationCommittedUnsubscribeCommand, + WebDriverBiDiNavigationCommittedUnsubscribeCommandError, WebDriverBiDiReceivedTextMessage, +}; + +/// Maximum distinct committed-navigation identifiers retained by one active subscription admission. +/// +/// Exhaustion fails closed instead of evicting old identifiers because eviction would permit an old +/// protocol event to become fresh state-changing evidence again. Callers can explicitly unsubscribe +/// and establish a new typed subscription when this reviewed per-subscription resource bound is met. +pub const MAX_WEBDRIVER_BIDI_NAVIGATION_COMMITTED_ADMISSIONS: usize = 256; + +/// Immutable command-side binding retained before a committed-navigation subscription is sent. +/// +/// The binding carries only the exact local command identifier and the already-registered +/// OriginWeave session/context association used to serialize that command. The external BiDi +/// context identifier is retained privately for immediate registry revalidation and is not exposed +/// as durable OriginWeave authority. A private allocation identity binds this value to the exact +/// command instance; matching caller-supplied numbers cannot recreate that identity. +/// A separate core-issued witness preserves the original registry instance across later use. +pub struct WebDriverBiDiNavigationCommittedSubscriptionBinding { + command_id: u64, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_context: String, + subscription_intent: Arc<()>, + registry_identity: BrowserRegistryIdentity, +} + +impl fmt::Debug for WebDriverBiDiNavigationCommittedSubscriptionBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiNavigationCommittedSubscriptionBinding") + .field("command_id", &self.command_id) + .field("browser_session", &self.browser_session.value()) + .field("browsing_context", &self.browsing_context.value()) + .field("external_context_bytes", &self.external_context.len()) + .finish() + } +} + +impl WebDriverBiDiNavigationCommittedSubscriptionBinding { + pub(crate) fn new( + command_id: u64, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_context: &str, + subscription_intent: Arc<()>, + registry_identity: BrowserRegistryIdentity, + ) -> Self { + Self { + command_id, + browser_session, + browsing_context, + external_context: external_context.to_owned(), + subscription_intent, + registry_identity, + } + } + + /// Return the exact local command identifier this binding was captured from. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the exact registered OriginWeave browser session bound to the subscription command. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the exact registered OriginWeave browsing context bound to the subscription command. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } +} + +/// Active local admission capability for one exact committed-navigation BiDi subscription. +/// +/// Construction requires both the correlated remote subscription receipt and the immutable binding +/// captured from the exact command that requested it. The command identifiers and private command +/// allocation identity must match, and the original external context mapping must still resolve to +/// the exact OriginWeave session/context in the original registry. Event admission requires the message to +/// have been assembled on the same verified connection generation that carried the subscription +/// command and receipt. Holding this value is therefore narrower than holding an opaque protocol +/// subscription string. It grants only admission of the matching committed-navigation event through +/// the existing bounded parser; it grants no navigation, destination, origin, policy, secret, node, +/// or Agent authority. Each admitted non-null WebDriver BiDi navigation identifier is retained until +/// unsubscribe so a replayed remote event cannot mint a second state-changing observation from the +/// same navigation. +pub struct WebDriverBiDiNavigationCommittedSubscriptionAdmission { + subscription: WebDriverBiDiNavigationCommittedSubscriptionResult, + binding: WebDriverBiDiNavigationCommittedSubscriptionBinding, + admitted_navigation_ids: Vec, +} + +impl fmt::Debug for WebDriverBiDiNavigationCommittedSubscriptionAdmission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiNavigationCommittedSubscriptionAdmission") + .field("command_id", &self.binding.command_id) + .field("browser_session", &self.binding.browser_session.value()) + .field("browsing_context", &self.binding.browsing_context.value()) + .field( + "subscription_id_bytes", + &self.subscription.subscription_id().len(), + ) + .field("connection_bound", &true) + .field( + "admitted_navigation_count", + &self.admitted_navigation_ids.len(), + ) + .finish() + } +} + +impl WebDriverBiDiNavigationCommittedSubscriptionAdmission { + /// Bind one correlated subscription receipt to the exact command-side session/context intent. + /// + /// A response correlated to a different command instance cannot be rebound to this capability, + /// even when its numeric command, session and context identifiers match. The original external + /// BiDi context is revalidated before the capability exists, so a retired or replaced registry + /// mapping fails closed without creating active event-admission state. Connection provenance is + /// retained inside the correlated receipt and cannot be supplied by this caller. + pub fn new( + subscription: WebDriverBiDiNavigationCommittedSubscriptionResult, + binding: WebDriverBiDiNavigationCommittedSubscriptionBinding, + registry: &BrowserAuthorityRegistry, + ) -> Result { + if subscription.command_id() != binding.command_id { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionAdmissionError::CommandIdMismatch { + subscription_command_id: subscription.command_id(), + binding_command_id: binding.command_id, + }, + ); + } + if !Arc::ptr_eq( + &subscription.subscription_intent, + &binding.subscription_intent, + ) { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionAdmissionError::CommandIntentMismatch, + ); + } + require_current_binding(registry, &binding).map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionAdmissionError::ContextBinding { source } + })?; + Ok(Self { + subscription, + binding, + admitted_navigation_ids: Vec::new(), + }) + } + + /// Return the exact registered OriginWeave browser session admitted by this capability. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.binding.browser_session + } + + /// Return the exact registered OriginWeave browsing context admitted by this capability. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.binding.browsing_context + } + + /// Admit one exact committed-navigation event while this subscription capability remains active. + /// + /// The event must first have been assembled by the connection-bound reader on the same private + /// connection generation that carried the exact typed subscription command and receipt. A message + /// from another verified connection fails before registry revalidation or event parsing. The + /// original command-side external-context mapping is then revalidated immediately before parsing + /// the event. The event must independently carry that same registered context and the exact + /// declared URL. State-changing admission additionally requires the WebDriver BiDi navigation + /// identifier to be present and unique within this active subscription. The specification defines + /// non-null navigation identifiers as unique identifiers for ongoing navigations; retaining them + /// prevents replay of an already-admitted event. The history is resource bounded and fails closed + /// at capacity rather than evicting evidence that would make an older replay admissible again. + /// The returned subscribed observation is the only navigation observation type accepted by the + /// state-changing document-advance boundary. + pub fn admit( + &mut self, + received: &WebDriverBiDiReceivedTextMessage, + registry: &BrowserAuthorityRegistry, + expected_url: &str, + ) -> Result< + WebDriverBiDiNavigationCommittedSubscribedObservation, + WebDriverBiDiNavigationCommittedSubscriptionEventError, + > { + if self.subscription.connection_generation != received.connection_generation() { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionEventError::EventConnectionMismatch, + ); + } + require_current_binding(registry, &self.binding).map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionEventError::ContextBinding { source } + })?; + let observation = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + received.message(), + registry, + self.binding.browser_session, + self.binding.browsing_context, + expected_url, + ) + .map_err(|source| { + WebDriverBiDiNavigationCommittedSubscriptionEventError::Observation { source } + })?; + let navigation_id = observation.navigation_id().ok_or( + WebDriverBiDiNavigationCommittedSubscriptionEventError::MissingNavigationIdentity, + )?; + if self + .admitted_navigation_ids + .iter() + .any(|admitted| admitted == navigation_id) + { + return Err(WebDriverBiDiNavigationCommittedSubscriptionEventError::ReplayedNavigation); + } + if self.admitted_navigation_ids.len() >= MAX_WEBDRIVER_BIDI_NAVIGATION_COMMITTED_ADMISSIONS + { + return Err( + WebDriverBiDiNavigationCommittedSubscriptionEventError::ReplayHistoryExhausted { + maximum_events: MAX_WEBDRIVER_BIDI_NAVIGATION_COMMITTED_ADMISSIONS, + }, + ); + } + self.admitted_navigation_ids.push(navigation_id.to_owned()); + Ok(WebDriverBiDiNavigationCommittedSubscribedObservation( + observation, + self.binding.registry_identity.clone(), + )) + } + + /// Consume active event admission and construct teardown for this exact subscription receipt. + /// + /// Consumption deliberately ends local event admission before the unsubscribe command can be + /// emitted. If later transport or remote teardown fails, callers must explicitly establish a new + /// typed subscription before admitting more events; ambiguous teardown never restores authority. + /// Teardown retains the subscription connection identity and accepts its acknowledgment only + /// from that same connection. Previously admitted observations are not retroactively revoked. + pub fn into_unsubscribe( + self, + command_id: u64, + ) -> Result< + WebDriverBiDiNavigationCommittedUnsubscribeCommand, + WebDriverBiDiNavigationCommittedUnsubscribeCommandError, + > { + WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(command_id, self.subscription) + } +} + +fn require_current_binding( + registry: &BrowserAuthorityRegistry, + binding: &WebDriverBiDiNavigationCommittedSubscriptionBinding, +) -> Result<(), BrowserRegistryError> { + registry.require_identity(&binding.registry_identity)?; + registry.require_registered_context_external_identifier( + binding.browser_session, + binding.browsing_context, + &binding.external_context, + ) +} + +/// One committed-navigation observation admitted through an active exact subscription capability. +/// +/// Unlike the lower-level protocol observation, this value proves that local admission was bound to +/// the exact typed `session.subscribe` command/receipt pair and the same verified transport +/// generation for the registered context at the time the event was admitted. It retains the original +/// registry witness for revalidation at the eventual document-mutation boundary. It does not prove +/// action causality or grant destination, origin, policy, node, secret, process, profile, or reusable +/// Agent authority. +pub struct WebDriverBiDiNavigationCommittedSubscribedObservation( + WebDriverBiDiNavigationCommittedObservation, + BrowserRegistryIdentity, +); + +impl fmt::Debug for WebDriverBiDiNavigationCommittedSubscribedObservation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("WebDriverBiDiNavigationCommittedSubscribedObservation") + .field(&self.0) + .finish() + } +} + +impl WebDriverBiDiNavigationCommittedSubscribedObservation { + pub(crate) fn require_registry( + &self, + registry: &BrowserAuthorityRegistry, + ) -> Result<(), BrowserRegistryError> { + registry.require_identity(&self.1) + } + + /// Return the exact OriginWeave browser session whose active subscription admitted the event. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.0.browser_session() + } + + /// Return the exact OriginWeave browsing context whose active subscription admitted the event. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.0.browsing_context() + } + + /// Borrow the optional opaque WebDriver BiDi navigation identifier. + #[must_use] + pub fn navigation_id(&self) -> Option<&str> { + self.0.navigation_id() + } + + /// Return the admitted WebDriver BiDi monotonic event timestamp. + #[must_use] + pub const fn timestamp(&self) -> u64 { + self.0.timestamp() + } + + /// Borrow the exact bounded serialized URL admitted through the active subscription. + #[must_use] + pub fn url(&self) -> &str { + self.0.url() + } +} + +/// Fail-closed failures while binding a correlated subscription receipt to command-side authority. +#[derive(Debug)] +pub enum WebDriverBiDiNavigationCommittedSubscriptionAdmissionError { + /// The supplied binding was captured from a different command instance than the actual sender. + CommandIntentMismatch, + /// The correlated response belongs to a different local command than the supplied binding. + CommandIdMismatch { + /// Exact command identifier carried by the correlated subscription receipt. + subscription_command_id: u64, + /// Exact command identifier captured from the intended subscription command. + binding_command_id: u64, + }, + /// The original external context no longer maps to the exact registered session/context pair. + ContextBinding { + /// Exact browser-registry authority failure. + source: BrowserRegistryError, + }, +} + +impl fmt::Display for WebDriverBiDiNavigationCommittedSubscriptionAdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CommandIntentMismatch => formatter.write_str( + "WebDriver BiDi navigation subscription binding differs from its sent command", + ), + Self::CommandIdMismatch { .. } => formatter.write_str( + "WebDriver BiDi navigation subscription response does not match its command binding", + ), + Self::ContextBinding { .. } => formatter.write_str( + "WebDriver BiDi navigation subscription context is no longer registered authority", + ), + } + } +} + +impl Error for WebDriverBiDiNavigationCommittedSubscriptionAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::CommandIdMismatch { .. } | Self::CommandIntentMismatch => None, + Self::ContextBinding { source } => Some(source), + } + } +} + +/// Fail-closed failures while admitting an event through one active subscription capability. +#[derive(Debug)] +pub enum WebDriverBiDiNavigationCommittedSubscriptionEventError { + /// The event message was assembled on a different verified connection from the subscription. + EventConnectionMismatch, + /// The original external context no longer maps to the exact registered session/context pair. + ContextBinding { + /// Exact browser-registry authority failure. + source: BrowserRegistryError, + }, + /// The bounded committed-navigation observation itself could not be admitted. + Observation { + /// Underlying typed observation failure. + source: WebDriverBiDiNavigationCommittedObservationError, + }, + /// The event did not carry a non-null navigation identity suitable for state-changing evidence. + MissingNavigationIdentity, + /// The same WebDriver BiDi navigation identity was already admitted by this active subscription. + ReplayedNavigation, + /// The bounded replay-prevention history is full and must not evict older evidence. + ReplayHistoryExhausted { + /// Maximum distinct navigation identities retained by one active subscription admission. + maximum_events: usize, + }, +} + +impl fmt::Display for WebDriverBiDiNavigationCommittedSubscriptionEventError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EventConnectionMismatch => formatter.write_str( + "WebDriver BiDi navigation event arrived on a different subscription connection", + ), + Self::ContextBinding { .. } => formatter.write_str( + "WebDriver BiDi navigation subscription context is no longer registered authority", + ), + Self::Observation { .. } => { + formatter.write_str("WebDriver BiDi navigation-committed event is not admissible") + } + Self::MissingNavigationIdentity => formatter.write_str( + "WebDriver BiDi navigation-committed event has no reusable-safe navigation identity", + ), + Self::ReplayedNavigation => formatter.write_str( + "WebDriver BiDi navigation-committed event was already admitted by this active subscription", + ), + Self::ReplayHistoryExhausted { maximum_events } => write!( + formatter, + "WebDriver BiDi navigation subscription reached its {maximum_events}-event replay-history limit" + ), + } + } +} + +impl Error for WebDriverBiDiNavigationCommittedSubscriptionEventError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::ContextBinding { source } => Some(source), + Self::Observation { source } => Some(source), + Self::EventConnectionMismatch + | Self::MissingNavigationIdentity + | Self::ReplayedNavigation + | Self::ReplayHistoryExhausted { .. } => None, + } + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs index c4e41f01b..7f0d622d6 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs @@ -1,9 +1,10 @@ -use std::{error::Error, fmt}; +use std::{error::Error, fmt, sync::Arc}; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, - WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiJsonEnvelopeRouting, WebDriverBiDiReceivedTextMessage, + webdriver_bidi_connection::WebDriverBiDiConnectionGeneration, }; /// Maximum decoded UTF-8 bytes retained from a WebDriver BiDi `session.Subscription` identifier. @@ -15,13 +16,16 @@ 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. +/// This value retains the exact correlated command id, private original-command identity, exact +/// verified receive-connection generation, and 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, + pub(crate) subscription_intent: Arc<()>, + pub(crate) connection_generation: WebDriverBiDiConnectionGeneration, } impl fmt::Debug for WebDriverBiDiNavigationCommittedSubscriptionResult { @@ -30,38 +34,39 @@ impl fmt::Debug for WebDriverBiDiNavigationCommittedSubscriptionResult { .debug_struct("WebDriverBiDiNavigationCommittedSubscriptionResult") .field("command_id", &self.command_id) .field("subscription_id_len", &self.subscription_id.len()) + .field("connection_bound", &true) .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. + /// Parse one connection-bound local-end message and consume its exact outstanding 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. + /// consumed, so malformed or ambiguous success bodies cannot silently retire a command id. + /// Success requires both the private command-instance provenance registered by the typed sender + /// and equality between the sender's private connection generation and the exact connection that + /// assembled this received message. Public correlation registration cannot mint a subscription + /// receipt. A correlatable protocol-error response is subject to the same connection check before + /// consuming its matching id. Events, null-id errors, malformed envelopes, unknown ids, and + /// crossed-connection responses fail closed without consuming unrelated outstanding state. pub fn parse_and_correlate( - message: &WebDriverBiDiReceivedTextMessage, + received: &WebDriverBiDiReceivedTextMessage, correlation: &mut WebDriverBiDiCommandCorrelation, ) -> Result { - let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()).map_err(|source| { + let message = received.message(); + let envelope = WebDriverBiDiJsonEnvelope::parse(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(), + match envelope.routing() { + WebDriverBiDiJsonEnvelopeRouting::CommandSuccess { command_id } => { + let projected = SubscriptionProjection::parse(message.as_str())?; + let (subscription_intent, connection_generation) = correlation + .complete_subscription_command_on_connection( + command_id, + received.connection_generation(), ) .map_err(|source| { WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { @@ -69,17 +74,19 @@ impl WebDriverBiDiNavigationCommittedSubscriptionResult { } })?; Ok(Self { - command_id: completed.command_id(), + command_id, subscription_id: projected.subscription_id, + subscription_intent, + connection_generation, }) } - WebDriverBiDiJsonEnvelopeKind::Error => { + WebDriverBiDiJsonEnvelopeRouting::CommandError { .. } => { retain_validated_error_code(envelope.error_code()).and_then(|error_code| { let completed = correlation .correlate_response_for_connection( &envelope, WebDriverBiDiCommandKind::NavigationCommittedSubscription, - message.connection_generation(), + received.connection_generation(), ) .map_err(|source| { WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { @@ -94,7 +101,7 @@ impl WebDriverBiDiNavigationCommittedSubscriptionResult { ) }) } - WebDriverBiDiJsonEnvelopeKind::Event => Err( + WebDriverBiDiJsonEnvelopeRouting::Event => Err( WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { source: WebDriverBiDiCommandCorrelationError::EventIsNotResponse, }, @@ -744,20 +751,6 @@ mod tests { } } - #[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!( diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe.rs index 35fd48aa3..2a6845c99 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe.rs @@ -1,5 +1,6 @@ use std::{error::Error, fmt, time::Duration}; +use crate::webdriver_bidi_connection::WebDriverBiDiConnectionGeneration; use crate::webdriver_bidi_websocket_frame::validate_frame_timeout; use crate::{ MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiCommandCorrelation, @@ -16,10 +17,11 @@ const SESSION_UNSUBSCRIBE_METHOD: &str = "session.unsubscribe"; /// `session.subscribe` response boundary. It cannot introduce arbitrary event names, contexts, /// user contexts, or ambient subscription identifiers. Writing the frame does not prove remote /// teardown; callers must admit and correlate the later protocol response separately. -#[derive(Clone, Eq, PartialEq)] +#[derive(Eq, PartialEq)] pub struct WebDriverBiDiNavigationCommittedUnsubscribeCommand { command_id: u64, subscription_id: String, + connection_generation: WebDriverBiDiConnectionGeneration, } impl fmt::Debug for WebDriverBiDiNavigationCommittedUnsubscribeCommand { @@ -33,10 +35,28 @@ impl fmt::Debug for WebDriverBiDiNavigationCommittedUnsubscribeCommand { } impl WebDriverBiDiNavigationCommittedUnsubscribeCommand { - /// Construct one unsubscribe command from an already validated typed subscription receipt. + /// Consume one validated receipt, ending its availability for local event admission. + /// + /// Even an invalid command identifier consumes the receipt; failure never restores admission. + /// A receipt transferred to teardown cannot be used to construct another admission owner. + /// + /// ```compile_fail,E0382 + /// use originweave_core::BrowserAuthorityRegistry; + /// use originweave_network::{WebDriverBiDiNavigationCommittedSubscriptionResult, + /// WebDriverBiDiNavigationCommittedSubscriptionBinding, + /// WebDriverBiDiNavigationCommittedSubscriptionAdmission, + /// WebDriverBiDiNavigationCommittedUnsubscribeCommand}; + /// fn retained_admission(receipt: WebDriverBiDiNavigationCommittedSubscriptionResult, + /// binding: WebDriverBiDiNavigationCommittedSubscriptionBinding, + /// registry: &BrowserAuthorityRegistry) { + /// let _teardown = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, receipt); + /// let _admission = WebDriverBiDiNavigationCommittedSubscriptionAdmission::new( + /// receipt, binding, registry); + /// } + /// ``` pub fn new( command_id: u64, - subscription: &WebDriverBiDiNavigationCommittedSubscriptionResult, + subscription: WebDriverBiDiNavigationCommittedSubscriptionResult, ) -> Result { if command_id > MAX_WEBDRIVER_BIDI_JS_UINT { return Err( @@ -49,6 +69,7 @@ impl WebDriverBiDiNavigationCommittedUnsubscribeCommand { Ok(Self { command_id, subscription_id: subscription.subscription_id().to_owned(), + connection_generation: subscription.connection_generation, }) } @@ -79,16 +100,21 @@ impl WebDriverBiDiNavigationCommittedUnsubscribeCommand { validate_frame_timeout(frame_timeout).map_err(|source| { WebDriverBiDiNavigationCommittedUnsubscribeCommandError::FrameWrite { source } })?; + if self.connection_generation != established.transport_evidence().connection_generation() { + return Err(WebDriverBiDiNavigationCommittedUnsubscribeCommandError::SubscriptionConnectionMismatch); + } correlation - .register_command_for( + .register_command_for_connection( self.command_id, WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe, + self.connection_generation, ) .map_err(|source| { WebDriverBiDiNavigationCommittedUnsubscribeCommandError::Correlation { source } })?; let message = self.serialized(); - match established.write_text_frame(&message, masking_key, frame_timeout) { + match established.write_command_frame(self.command_id, &message, masking_key, frame_timeout) + { Ok(established) => Ok(established), Err(source) => Err(map_frame_failure(correlation, self.command_id, source)), } @@ -119,6 +145,8 @@ fn map_frame_failure( /// Fail-closed errors while constructing or sending one typed `session.unsubscribe` command. #[derive(Debug)] pub enum WebDriverBiDiNavigationCommittedUnsubscribeCommandError { + /// The supplied connection is not the one that issued the consumed subscription receipt. + SubscriptionConnectionMismatch, /// The requested command identifier is outside WebDriver BiDi's `js-uint` range. CommandIdOutOfRange { /// Rejected command identifier. @@ -141,6 +169,9 @@ pub enum WebDriverBiDiNavigationCommittedUnsubscribeCommandError { impl fmt::Display for WebDriverBiDiNavigationCommittedUnsubscribeCommandError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::SubscriptionConnectionMismatch => { + formatter.write_str("WebDriver BiDi subscription belongs to a different connection") + } Self::CommandIdOutOfRange { .. } => formatter.write_str( "WebDriver BiDi session.unsubscribe command id is outside the js-uint range", ), @@ -156,7 +187,7 @@ impl fmt::Display for WebDriverBiDiNavigationCommittedUnsubscribeCommandError { impl Error for WebDriverBiDiNavigationCommittedUnsubscribeCommandError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { - Self::CommandIdOutOfRange { .. } => None, + Self::CommandIdOutOfRange { .. } | Self::SubscriptionConnectionMismatch => None, Self::Correlation { source } => Some(source), Self::FrameWrite { source } => Some(source), } @@ -238,6 +269,13 @@ mod tests { #[test] fn command_errors_have_stable_messages_and_typed_sources() { + let mismatch = + WebDriverBiDiNavigationCommittedUnsubscribeCommandError::SubscriptionConnectionMismatch; + assert_eq!( + mismatch.to_string(), + "WebDriver BiDi subscription belongs to a different connection" + ); + assert!(mismatch.source().is_none()); let range = WebDriverBiDiNavigationCommittedUnsubscribeCommandError::CommandIdOutOfRange { command_id: MAX_WEBDRIVER_BIDI_JS_UINT + 1, maximum_command_id: MAX_WEBDRIVER_BIDI_JS_UINT, diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe_response.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe_response.rs index 9334ea680..6ce65c1f1 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe_response.rs @@ -3,7 +3,7 @@ use std::{error::Error, fmt}; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, - WebDriverBiDiJsonEnvelopeError, WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiReceivedTextMessage, }; /// Typed protocol acknowledgment for one correlated WebDriver BiDi `session.unsubscribe` command. @@ -27,16 +27,17 @@ impl WebDriverBiDiNavigationCommittedUnsubscribeResult { /// unknown ids, and responses for another command family fail closed without consuming the /// outstanding command. pub fn parse_and_correlate( - message: &WebDriverBiDiWebSocketTextMessage, + message: &WebDriverBiDiReceivedTextMessage, correlation: &mut WebDriverBiDiCommandCorrelation, ) -> Result { - let envelope = WebDriverBiDiJsonEnvelope::parse(message).map_err(|source| { + let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()).map_err(|source| { WebDriverBiDiNavigationCommittedUnsubscribeResponseError::Envelope { source } })?; let completed = correlation - .correlate_response_for( + .correlate_response_for_connection( &envelope, WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe, + message.connection_generation(), ) .map_err(|source| { WebDriverBiDiNavigationCommittedUnsubscribeResponseError::Correlation { source } diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_document_advance.rs b/crates/originweave-network/src/webdriver_bidi_navigation_document_advance.rs index 9af15216f..fb06d4447 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_document_advance.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_document_advance.rs @@ -5,9 +5,9 @@ use originweave_core::{ DocumentEpoch, }; -use crate::WebDriverBiDiNavigationCommittedObservation; +use crate::WebDriverBiDiNavigationCommittedSubscribedObservation; -/// Immutable evidence that one accepted navigation observation rotated one exact document epoch. +/// Immutable evidence that one subscribed navigation observation rotated one exact document epoch. /// /// The value records only the registry-local session/context transition. It does not bind the new /// document origin, authenticate the browser adapter, prove which action caused the navigation, or @@ -46,7 +46,7 @@ impl WebDriverBiDiNavigationCommittedDocumentAdvance { } } -/// Fail-closed failures while rotating document authority from an accepted navigation observation. +/// Fail-closed failures while rotating document authority from a subscribed navigation observation. #[derive(Debug)] pub enum WebDriverBiDiNavigationCommittedDocumentAdvanceError { /// Registered session/context state could not be revalidated or advanced. @@ -93,12 +93,14 @@ fn advance_registered_document_if_expected( registry.advance_document(browsing_context).map(Some) } -/// Consume one exact accepted navigation observation and rotate that context's document authority. +/// Consume one exact subscription-admitted navigation and rotate that context's document authority. /// /// The caller must supply the document epoch captured before dispatching the action whose -/// post-condition is being evaluated. The observation is consumed so one admitted event cannot be -/// reused to rotate the registry twice. The exact session/context pair and caller-captured epoch -/// are revalidated immediately before mutation, and stale state fails closed without mutation. +/// post-condition is being evaluated. The subscribed observation is consumed so one admitted event +/// cannot be reused to rotate the registry twice, and raw protocol observations cannot cross this +/// state-changing boundary without first being bound to an active exact `session.subscribe` +/// command/receipt. The original registry instance, exact session/context pair and caller-captured +/// epoch are revalidated immediately before mutation, and stale state fails closed without mutation. /// /// A successful advance delegates to [`BrowserAuthorityRegistry::advance_document`], which clears /// the previous canonical-origin binding and all node bindings owned by the context. The new @@ -107,13 +109,16 @@ fn advance_registered_document_if_expected( /// exhaustion, remain available as the typed [`BrowserRegistryError`] source instead of being /// converted into a panic or successful authority transition. pub fn advance_webdriver_bidi_navigation_document_epoch( - observation: WebDriverBiDiNavigationCommittedObservation, + observation: WebDriverBiDiNavigationCommittedSubscribedObservation, registry: &mut BrowserAuthorityRegistry, expected_previous_epoch: DocumentEpoch, ) -> Result< WebDriverBiDiNavigationCommittedDocumentAdvance, WebDriverBiDiNavigationCommittedDocumentAdvanceError, > { + observation.require_registry(registry).map_err(|source| { + WebDriverBiDiNavigationCommittedDocumentAdvanceError::RegistryState { source } + })?; let browser_session = observation.browser_session(); let browsing_context = observation.browsing_context(); match advance_registered_document_if_expected( diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_document_origin.rs b/crates/originweave-network/src/webdriver_bidi_navigation_document_origin.rs index 1ef7a6fcb..8eac490e1 100644 --- a/crates/originweave-network/src/webdriver_bidi_navigation_document_origin.rs +++ b/crates/originweave-network/src/webdriver_bidi_navigation_document_origin.rs @@ -7,16 +7,17 @@ use originweave_core::{ use crate::{ WebDriverBiDiNavigationCommittedDocumentAdvanceError, - WebDriverBiDiNavigationCommittedObservation, advance_webdriver_bidi_navigation_document_epoch, + WebDriverBiDiNavigationCommittedSubscribedObservation, + advance_webdriver_bidi_navigation_document_epoch, }; -/// Immutable evidence that one accepted navigation rotated its document and bound its observed origin. +/// Immutable evidence that one subscribed navigation rotated its document and bound its observed origin. /// /// The value records only the registry-local session/context/document transition and the canonical -/// origin derived from the exact serialized URL carried by the accepted WebDriver BiDi navigation -/// observation. It does not authenticate the browser adapter, prove which action caused the -/// navigation, authorize the destination, or grant browser, policy, node, credential, process, or -/// reusable Agent authority. +/// origin derived from the exact serialized URL carried by the subscription-admitted WebDriver BiDi +/// navigation observation. It does not authenticate the browser adapter, prove which action caused +/// the navigation, authorize the destination, or grant browser, policy, node, credential, process, +/// or reusable Agent authority. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WebDriverBiDiNavigationCommittedDocumentOrigin { browser_session: BrowserSessionId, @@ -121,19 +122,21 @@ fn bind_advanced_document_origin( ) } -/// Consume one accepted navigation, rotate the exact expected document, and bind its canonical origin. +/// Consume one subscription-admitted navigation, rotate its document, and bind its canonical origin. /// /// Origin derivation is completed before any registry mutation. Only serialized HTTP(S) URLs whose /// authority can enter [`Origin`] are accepted; credential-bearing, opaque, malformed, insecure /// remote HTTP, and otherwise unsupported authorities therefore fail before document rotation. -/// The accepted observation is then consumed by the existing exact-epoch document-advance boundary, -/// which clears stale origin and node authority. Finally, the derived canonical origin is bound to -/// that newly advanced document through the canonical browser registry lifecycle. +/// The observation must already have crossed the exact active-subscription admission boundary; raw +/// protocol observations cannot reach this state-changing API. The subscribed observation is then +/// consumed by the exact-epoch document-advance boundary, which clears stale origin and node +/// authority. Finally, the derived canonical origin is bound to that newly advanced document +/// through the canonical browser registry lifecycle. /// /// The caller must still treat the returned value as immediate-use registry evidence rather than as /// proof of action causality, browser authenticity, destination authorization, or reusable authority. pub fn advance_and_bind_webdriver_bidi_navigation_document_origin( - observation: WebDriverBiDiNavigationCommittedObservation, + observation: WebDriverBiDiNavigationCommittedSubscribedObservation, registry: &mut BrowserAuthorityRegistry, expected_previous_epoch: DocumentEpoch, ) -> Result< diff --git a/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs b/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs index 57c3d5112..dcdb4fc43 100644 --- a/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs +++ b/crates/originweave-network/src/webdriver_bidi_pointer_click_transport.rs @@ -80,7 +80,12 @@ pub fn send_webdriver_bidi_pointer_click( return Err(WebDriverBiDiPointerClickSendError::Correlation { source }); } } - match established.write_text_frame(command.as_json(), masking_key, frame_timeout) { + match established.write_command_frame( + command.command_id(), + command.as_json(), + masking_key, + frame_timeout, + ) { Ok(established) => Ok(established), Err(source) => Err(map_frame_failure(correlation, command.command_id(), source)), } diff --git a/crates/originweave-network/src/webdriver_bidi_session_end_command.rs b/crates/originweave-network/src/webdriver_bidi_session_end_command.rs index 82dc95cf7..f64b63167 100644 --- a/crates/originweave-network/src/webdriver_bidi_session_end_command.rs +++ b/crates/originweave-network/src/webdriver_bidi_session_end_command.rs @@ -71,7 +71,8 @@ impl WebDriverBiDiSessionEndCommand { ) .map_err(|source| WebDriverBiDiSessionEndCommandError::Correlation { source })?; let message = self.serialized(); - match established.write_text_frame(&message, masking_key, frame_timeout) { + match established.write_command_frame(self.command_id, &message, masking_key, frame_timeout) + { Ok(established) => Ok(established), Err(source) => Err(map_frame_failure(correlation, self.command_id, source)), } diff --git a/crates/originweave-network/src/webdriver_bidi_session_status_command.rs b/crates/originweave-network/src/webdriver_bidi_session_status_command.rs index e6341c416..b92cd330c 100644 --- a/crates/originweave-network/src/webdriver_bidi_session_status_command.rs +++ b/crates/originweave-network/src/webdriver_bidi_session_status_command.rs @@ -67,7 +67,8 @@ impl WebDriverBiDiSessionStatusCommand { .register_command_for(self.command_id, WebDriverBiDiCommandKind::SessionStatus) .map_err(|source| WebDriverBiDiSessionStatusCommandError::Correlation { source })?; let message = self.serialized(); - match established.write_text_frame(&message, masking_key, frame_timeout) { + match established.write_command_frame(self.command_id, &message, masking_key, frame_timeout) + { Ok(established) => Ok(established), Err(source) => Err(map_frame_failure(correlation, self.command_id, source)), } diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs b/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs index e931dd4b8..7f2197673 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs @@ -58,6 +58,16 @@ struct ClientMaskKeyHistory { previous_key: Option<[u8; 4]>, } +#[derive(Default)] +enum CommandWriteState { + #[default] + NoCommands, + RawText, + TypedCommand { + command_id: u64, + }, +} + impl ClientMaskKeyHistory { fn reserve( &mut self, @@ -172,6 +182,7 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { WebDriverBiDiWebSocketEstablished { raw, client_mask_keys: ClientMaskKeyHistory::default(), + command_write_state: CommandWriteState::default(), } }) } @@ -186,6 +197,7 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { pub struct WebDriverBiDiWebSocketEstablished { raw: handshake::WebDriverBiDiWebSocketEstablished, client_mask_keys: ClientMaskKeyHistory, + command_write_state: CommandWriteState, } impl fmt::Debug for WebDriverBiDiWebSocketEstablished { @@ -237,16 +249,63 @@ impl WebDriverBiDiWebSocketEstablished { self.raw.write_timeout() } - /// Write one final masked UTF-8 text frame on this verified stream. + /// Write one final masked UTF-8 text frame on a raw-text-only verified stream. /// /// The state is consumed. Invalid bounds, adjacent masking-key reuse, partial writes, deadline /// expiry, I/O failure, and timeout-cleanup failure return no reusable stream. No retry changes /// destination or connection authority. + /// Raw text and typed commands cannot share one connection in either order: arbitrary text + /// cannot establish or preserve the typed command identifier history. Use a separate connection + /// for raw protocol work. Pong frames do not select or change this connection's text lane. pub fn write_text_frame( mut self, text: &str, masking_key: WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, + ) -> Result { + if matches!( + self.command_write_state, + CommandWriteState::TypedCommand { .. } + ) { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "raw text and typed commands cannot share an established WebSocket", + }); + } + self.command_write_state = CommandWriteState::RawText; + self.write_text_payload(text, masking_key, frame_timeout) + } + + pub(crate) fn write_command_frame( + mut self, + command_id: u64, + text: &str, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + match self.command_write_state { + CommandWriteState::RawText => { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "raw text and typed commands cannot share an established WebSocket", + }); + } + CommandWriteState::TypedCommand { + command_id: previous_id, + } if command_id <= previous_id => { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "typed command identifiers must increase on an established WebSocket", + }); + } + _ => {} + } + self.command_write_state = CommandWriteState::TypedCommand { command_id }; + self.write_text_payload(text, masking_key, frame_timeout) + } + + fn write_text_payload( + mut self, + text: &str, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, ) -> Result { validate_frame_timeout(frame_timeout)?; if text.len() > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES { diff --git a/crates/originweave-network/tests/support/mod.rs b/crates/originweave-network/tests/support/mod.rs new file mode 100644 index 000000000..2b47dd3f8 --- /dev/null +++ b/crates/originweave-network/tests/support/mod.rs @@ -0,0 +1,195 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserSessionId, BrowsingContextId, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiNavigationCommittedSubscribedObservation, + WebDriverBiDiNavigationCommittedSubscriptionAdmission, + WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, +}; + +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] = + br#"{"type":"success","id":7,"result":{"subscription":"subscription-a"}}"#; + +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, + "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, + "fixture subscribe 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 write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + stream.write_all(&[0x81])?; + match payload.len() { + 0..=125 => stream.write_all(&[payload.len() as u8])?, + 126..=65_535 => { + stream.write_all(&[126])?; + stream.write_all(&(payload.len() as u16).to_be_bytes())?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fixture event unexpectedly required 64-bit framing", + )); + } + } + stream.write_all(payload) +} + +fn next_text( + established: WebDriverBiDiWebSocketEstablished, +) -> Result< + ( + WebDriverBiDiWebSocketEstablished, + WebDriverBiDiReceivedTextMessage, + ), + Box, +> { + match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { + established, + message, + } => Ok((established, message)), + other => Err(io::Error::other(format!( + "expected a complete connection-bound WebDriver BiDi text message, got {other:?}" + )) + .into()), + } +} + +pub fn receive_subscribed_navigation_event( + registry: &BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + session_id: &str, + external_context: &str, + expected_url: &str, +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let expected_command = format!( + "{{\"id\":7,\"method\":\"session.subscribe\",\"params\":{{\"events\":[\"browsingContext.navigationCommitted\"],\"contexts\":[\"{external_context}\"]}}}}" + ) + .into_bytes(); + let event = format!( + "{{\"type\":\"event\",\"method\":\"browsingContext.navigationCommitted\",\"params\":{{\"context\":\"{external_context}\",\"navigation\":\"navigation-a\",\"timestamp\":17,\"url\":\"{expected_url}\"}}}}" + ) + .into_bytes(); + 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 != expected_command { + return Err(io::Error::other( + "unexpected session.subscribe fixture command", + )); + } + write_text_frame(&mut stream, SUBSCRIBE_RESPONSE)?; + write_text_frame(&mut stream, &event) + }); + + 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 established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + registry, + browser_session, + browsing_context, + external_context, + )?; + let binding = command.admission_binding(); + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = command.send( + registry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + + let (established, response) = next_text(established)?; + let result = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &response, + &mut correlation, + )?; + let mut admission = + WebDriverBiDiNavigationCommittedSubscriptionAdmission::new(result, binding, registry)?; + let (_established, event) = next_text(established)?; + let observation = admission.admit(&event, registry, expected_url)?; + + server + .join() + .map_err(|_| io::Error::other("subscribed navigation fixture server panicked"))??; + Ok(observation) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs index 075fbbe31..1ec19342e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription.rs @@ -130,23 +130,28 @@ fn navigation_committed_subscription_round_trips_on_the_registered_context() )?; assert_eq!(correlation.outstanding_count(), 1); - let text = match WebDriverBiDiWebSocketMessageReader::new(established) + let received = 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:?}" + "session.subscribe response produced unexpected connection-bound state: {other:?}" )) .into()); } }; let result = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( - &text, + &received, &mut correlation, )?; assert_eq!(result.command_id(), 7); assert_eq!(result.subscription_id(), "subscription-a"); + let debug = format!("{result:?}"); + assert!(debug.contains("command_id")); + assert!(debug.contains("subscription_id_len")); + assert!(debug.contains("connection_bound: true")); + assert!(!debug.contains("subscription-a")); assert_eq!(correlation.outstanding_count(), 0); server diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_admission.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_admission.rs new file mode 100644 index 000000000..ebdcbdfe2 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_admission.rs @@ -0,0 +1,923 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserSessionId, BrowsingContextId, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + MAX_WEBDRIVER_BIDI_NAVIGATION_COMMITTED_ADMISSIONS, WebDriverBiDiCommandCorrelation, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiNavigationCommittedSubscriptionAdmission, + WebDriverBiDiNavigationCommittedSubscriptionBinding, + WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, + advance_webdriver_bidi_navigation_document_epoch, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CONTEXT_ID: &str = "context-a"; +const EXPECTED_URL: &str = "https://example.test/after"; +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] = + br#"{"type":"success","id":7,"result":{"subscription":"subscription-a"}}"#; +const NAVIGATION_EVENT: &[u8] = br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":"nav-8","timestamp":1234,"url":"https://example.test/after"}}"#; + +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, + "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) +} + +fn write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + stream.write_all(&[0x81])?; + match payload.len() { + 0..=125 => stream.write_all(&[payload.len() as u8])?, + 126..=65_535 => { + stream.write_all(&[126])?; + stream.write_all(&(payload.len() as u16).to_be_bytes())?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test payload unexpectedly required 64-bit framing", + )); + } + } + stream.write_all(payload) +} + +fn next_text( + established: WebDriverBiDiWebSocketEstablished, +) -> Result< + ( + WebDriverBiDiWebSocketEstablished, + WebDriverBiDiReceivedTextMessage, + ), + Box, +> { + match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { + established, + message, + } => Ok((established, message)), + other => Err(io::Error::other(format!( + "expected a complete connection-bound WebDriver BiDi text message, got {other:?}" + )) + .into()), + } +} + +fn receive_subscription_result( + registry: &BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + command_id: u64, +) -> Result< + ( + WebDriverBiDiNavigationCommittedSubscriptionResult, + WebDriverBiDiNavigationCommittedSubscriptionBinding, + originweave_network::WebDriverBiDiReceivedTextMessage, + ), + Box, +> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let expected_command = format!( + "{{\"id\":{command_id},\"method\":\"session.subscribe\",\"params\":{{\"events\":[\"browsingContext.navigationCommitted\"],\"contexts\":[\"{CONTEXT_ID}\"]}}}}" + ) + .into_bytes(); + let response = format!( + "{{\"type\":\"success\",\"id\":{command_id},\"result\":{{\"subscription\":\"subscription-{command_id}\"}}}}" + ) + .into_bytes(); + 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 != expected_command { + return Err(io::Error::other( + "unexpected session.subscribe failure-contract command", + )); + } + write_text_frame(&mut stream, &response)?; + write_text_frame(&mut stream, NAVIGATION_EVENT) + }); + + 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 established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + command_id, + registry, + browser_session, + browsing_context, + CONTEXT_ID, + )?; + let binding = command.admission_binding(); + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = command.send( + registry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([9, 8, 7, 6]), + Duration::from_millis(500), + )?; + let (established, response) = next_text(established)?; + let result = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &response, + &mut correlation, + )?; + + let (_established, event) = next_text(established)?; + server + .join() + .map_err(|_| io::Error::other("subscription result test server panicked"))??; + Ok((result, binding, event)) +} + +fn establish_connection( + local_addr: std::net::SocketAddr, +) -> Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +fn reject_stale_response_after_actual_resend(lifecycle: &str) -> Result<(), Box> { + for first_payload in [ + br#"{"type":"error","id":7,"error":"invalid argument","message":"old rejection"}"# + .as_slice(), + SUBSCRIBE_RESPONSE, + ] { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let (release_sender, release_receiver) = std::sync::mpsc::channel(); + let (response_sender, response_receiver) = std::sync::mpsc::channel(); + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let expected = br#"{"id":7,"method":"session.subscribe","params":{"events":["browsingContext.navigationCommitted"],"contexts":["context-a"]}}"#; + assert_eq!(read_masked_text_frame(&mut stream)?, expected); + write_text_frame(&mut stream, first_payload)?; + response_sender.send(()).map_err(io::Error::other)?; + let mut next_byte = [0_u8; 1]; + match stream.peek(&mut next_byte) { + Ok(0) => Ok(false), + Err(error) if error.kind() == io::ErrorKind::ConnectionReset => Ok(false), + Err(error) => Err(error), + Ok(_) => { + let second_command = read_masked_text_frame(&mut stream)?; + assert_eq!(second_command, expected); + release_receiver + .recv_timeout(Duration::from_secs(2)) + .map_err(io::Error::other)?; + write_text_frame(&mut stream, + br#"{"type":"success","id":7,"result":{"subscription":"subscription-new"}}"#)?; + Ok(true) + } + } + }); + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )?; + let mut established = command.send( + ®istry, + establish_connection(local_addr)?, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + let mut old_message = None; + response_receiver.recv_timeout(Duration::from_secs(2))?; + if lifecycle != "buffered" { + let (next_stream, message) = next_text(established)?; + established = next_stream; + old_message = Some(message); + } + if lifecycle == "completed" { + if let Some(message) = &old_message { + let first_result = + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + message, + &mut correlation, + ); + assert_eq!(first_result.is_ok(), first_payload == SUBSCRIBE_RESPONSE); + assert_eq!(correlation.outstanding_count(), 0); + } + } else { + correlation.retire_command_for( + 7, + originweave_network::WebDriverBiDiCommandKind::NavigationCommittedSubscription, + )?; + } + if lifecycle == "replacement" { + correlation = WebDriverBiDiCommandCorrelation::new(); + } + let second_command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )?; + let second_binding = second_command.admission_binding(); + let second_send = second_command.send( + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::from_millis(500), + ); + match second_send { + Err(error) => { + drop(release_sender); + let emitted = server + .join() + .map_err(|_| io::Error::other("resend server panicked"))??; + assert!(!emitted, "preflight rejection must emit no second command"); + assert!(matches!(error, + originweave_network::WebDriverBiDiNavigationCommittedSubscriptionCommandError::FrameWrite { + source: originweave_network::WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + })); + assert_eq!(correlation.outstanding_count(), 0); + } + Ok(mut next_stream) => { + if old_message.is_none() { + let (read_stream, message) = next_text(next_stream)?; + next_stream = read_stream; + old_message = Some(message); + } + let message = + old_message.ok_or_else(|| io::Error::other("old response missing"))?; + let old_result = + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &message, + &mut correlation, + ); + let pending_after_old = correlation.outstanding_count(); + let old_admission = old_result.ok().map(|result| { + WebDriverBiDiNavigationCommittedSubscriptionAdmission::new( + result, + second_binding, + ®istry, + ) + .is_ok() + }); + release_sender.send(())?; + let (final_stream, fresh_message) = next_text(next_stream)?; + let fresh_result = + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &fresh_message, + &mut correlation, + ); + drop(final_stream); + assert!( + server + .join() + .map_err(|_| io::Error::other("resend server panicked"))?? + ); + assert_eq!( + pending_after_old, 1, + "old response consumed the actual second command; old admission={old_admission:?}" + ); + assert_eq!(fresh_result?.subscription_id(), "subscription-new"); + assert_eq!(correlation.outstanding_count(), 0); + } + } + } + Ok(()) +} + +#[test] +fn completed_response_cannot_complete_an_actual_resend() -> Result<(), Box> { + reject_stale_response_after_actual_resend("completed") +} + +#[test] +fn unparsed_retired_response_cannot_complete_an_actual_resend() -> Result<(), Box> { + reject_stale_response_after_actual_resend("retired") +} + +#[test] +fn replacement_correlation_cannot_accept_a_response_from_before_actual_resend() +-> Result<(), Box> { + reject_stale_response_after_actual_resend("replacement") +} + +#[test] +fn buffered_response_cannot_complete_an_actual_resend() -> Result<(), Box> { + reject_stale_response_after_actual_resend("buffered") +} + +#[test] +fn foreign_connection_cannot_complete_a_subscription_command() -> Result<(), Box> { + for foreign_payload in [ + SUBSCRIBE_RESPONSE, + br#"{"type":"error","id":7,"error":"invalid argument","message":"rejected"}"#, + ] { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut original, _) = listener.accept()?; + read_opening_request(&mut original)?; + original.write_all(OPENING_RESPONSE)?; + assert_eq!(read_masked_text_frame(&mut original)?, + br#"{"id":7,"method":"session.subscribe","params":{"events":["browsingContext.navigationCommitted"],"contexts":["context-a"]}}"#); + let (mut foreign, _) = listener.accept()?; + read_opening_request(&mut foreign)?; + foreign.write_all(OPENING_RESPONSE)?; + write_text_frame(&mut foreign, foreign_payload)?; + write_text_frame(&mut original, foreign_payload) + }); + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let original = command.send( + ®istry, + establish_connection(local_addr)?, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + let foreign = establish_connection(local_addr)?; + let (_, foreign_message) = next_text(foreign)?; + let foreign_result = + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &foreign_message, + &mut correlation, + ); + server + .join() + .map_err(|_| io::Error::other("crossed response server panicked"))??; + assert!( + foreign_result.is_err(), + "foreign success must not mint a subscription receipt" + ); + assert_eq!( + correlation.outstanding_count(), + 1, + "foreign success or error must not consume the original command" + ); + let (_, original_message) = next_text(original)?; + let original_result = + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &original_message, + &mut correlation, + ); + if foreign_payload == SUBSCRIBE_RESPONSE { + assert_eq!(original_result?.subscription_id(), "subscription-a"); + } else { + assert_eq!(original_result, Err( + originweave_network::WebDriverBiDiNavigationCommittedSubscriptionResponseError::RemoteProtocolError { + command_id: 7, + error_code: "invalid argument".to_owned(), + } + )); + } + assert_eq!(correlation.outstanding_count(), 0); + } + Ok(()) +} + +#[test] +fn foreign_connection_event_cannot_mutate_a_subscribed_document() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + let original_epoch = registry.current_context_epoch(session, context)?; + let (subscription, binding, original_event) = + receive_subscription_result(®istry, session, context, 7)?; + let mut admission = WebDriverBiDiNavigationCommittedSubscriptionAdmission::new( + subscription, + binding, + ®istry, + )?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut foreign, _) = listener.accept()?; + read_opening_request(&mut foreign)?; + foreign.write_all(OPENING_RESPONSE)?; + write_text_frame(&mut foreign, NAVIGATION_EVENT) + }); + let (_, foreign_event) = next_text(establish_connection(local_addr)?)?; + server + .join() + .map_err(|_| io::Error::other("crossed event server panicked"))??; + let rejection = admission + .admit(&foreign_event, ®istry, EXPECTED_URL) + .err() + .ok_or_else(|| io::Error::other("foreign event unexpectedly admitted"))?; + assert_eq!( + rejection.to_string(), + "WebDriver BiDi navigation event arrived on a different subscription connection" + ); + assert!(rejection.source().is_none()); + assert_eq!( + registry.current_context_epoch(session, context)?, + original_epoch + ); + let observation = admission.admit(&original_event, ®istry, EXPECTED_URL)?; + let advanced = advance_webdriver_bidi_navigation_document_epoch( + observation, + &mut registry, + original_epoch, + )?; + assert_eq!( + registry.current_context_epoch(session, context)?, + advanced.current_epoch() + ); + assert_ne!(advanced.current_epoch(), original_epoch); + Ok(()) +} + +#[test] +fn committed_navigation_requires_the_exact_active_subscription_before_document_mutation() +-> 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"]}}"# + { + return Err(io::Error::other("unexpected session.subscribe command")); + } + write_text_frame(&mut stream, SUBSCRIBE_RESPONSE)?; + write_text_frame(&mut stream, NAVIGATION_EVENT)?; + for index in 1..MAX_WEBDRIVER_BIDI_NAVIGATION_COMMITTED_ADMISSIONS { + let event = format!( + "{{\"type\":\"event\",\"method\":\"browsingContext.navigationCommitted\",\"params\":{{\"context\":\"{CONTEXT_ID}\",\"navigation\":\"nav-fill-{index}\",\"timestamp\":{},\"url\":\"{EXPECTED_URL}\"}}}}", + 2_000 + index + ); + write_text_frame(&mut stream, event.as_bytes())?; + } + let overflow_event = format!( + "{{\"type\":\"event\",\"method\":\"browsingContext.navigationCommitted\",\"params\":{{\"context\":\"{CONTEXT_ID}\",\"navigation\":\"nav-overflow\",\"timestamp\":9999,\"url\":\"{EXPECTED_URL}\"}}}}" + ); + write_text_frame(&mut stream, overflow_event.as_bytes()) + }); + + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + let pre_navigation_epoch = registry.current_context_epoch(session, context)?; + + 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 established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )?; + let binding = command.admission_binding(); + assert_eq!(binding.command_id(), 7); + assert_eq!(binding.browser_session(), session); + assert_eq!(binding.browsing_context(), context); + let binding_debug = format!("{binding:?}"); + assert!(binding_debug.contains("command_id: 7")); + assert!(!binding_debug.contains(CONTEXT_ID)); + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = command.send( + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + + let (established, response) = next_text(established)?; + let subscription = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &response, + &mut correlation, + )?; + let mut admission = WebDriverBiDiNavigationCommittedSubscriptionAdmission::new( + subscription, + binding, + ®istry, + )?; + assert_eq!(admission.browser_session(), session); + assert_eq!(admission.browsing_context(), context); + let admission_debug = format!("{admission:?}"); + assert!(admission_debug.contains("command_id: 7")); + assert!(admission_debug.contains("connection_bound: true")); + assert!(!admission_debug.contains("subscription-a")); + + let (mut established, event) = next_text(established)?; + let observation_error = admission + .admit(&event, ®istry, "https://example.test/unexpected") + .err() + .ok_or_else(|| io::Error::other("mismatched navigation URL unexpectedly admitted"))?; + assert_eq!( + observation_error.to_string(), + "WebDriver BiDi navigation-committed event is not admissible" + ); + assert!(observation_error.source().is_some()); + + let observation = admission.admit(&event, ®istry, EXPECTED_URL)?; + assert_eq!(observation.browser_session(), session); + assert_eq!(observation.browsing_context(), context); + assert_eq!(observation.navigation_id(), Some("nav-8")); + assert_eq!(observation.timestamp(), 1234); + assert!( + format!("{observation:?}") + .contains("WebDriverBiDiNavigationCommittedSubscribedObservation") + ); + + let advanced = advance_webdriver_bidi_navigation_document_epoch( + observation, + &mut registry, + pre_navigation_epoch, + )?; + assert_eq!(advanced.browser_session(), session); + assert_eq!(advanced.browsing_context(), context); + + let replay_error = admission + .admit(&event, ®istry, EXPECTED_URL) + .err() + .ok_or_else(|| io::Error::other("replayed navigation event unexpectedly readmitted"))?; + assert_eq!( + replay_error.to_string(), + "WebDriver BiDi navigation-committed event was already admitted by this active subscription" + ); + assert!(replay_error.source().is_none()); + assert_eq!( + registry.current_context_epoch(session, context)?, + advanced.current_epoch() + ); + + for _ in 1..MAX_WEBDRIVER_BIDI_NAVIGATION_COMMITTED_ADMISSIONS { + let (next_established, fill_event) = next_text(established)?; + established = next_established; + admission.admit(&fill_event, ®istry, EXPECTED_URL)?; + } + let (_established, overflow_event) = next_text(established)?; + let exhausted = admission + .admit(&overflow_event, ®istry, EXPECTED_URL) + .err() + .ok_or_else(|| { + io::Error::other("full navigation replay history unexpectedly admitted another event") + })?; + assert_eq!( + exhausted.to_string(), + format!( + "WebDriver BiDi navigation subscription reached its {MAX_WEBDRIVER_BIDI_NAVIGATION_COMMITTED_ADMISSIONS}-event replay-history limit" + ) + ); + assert!(exhausted.source().is_none()); + + registry.remove_context(context)?; + let stale_error = admission + .admit(&event, ®istry, EXPECTED_URL) + .err() + .ok_or_else(|| { + io::Error::other("retired context unexpectedly admitted navigation event") + })?; + assert!(stale_error.source().is_some()); + + let unsubscribe = admission.into_unsubscribe(8)?; + assert_eq!(unsubscribe.command_id(), 8); + + server + .join() + .map_err(|_| io::Error::other("subscription admission test server panicked"))??; + correlation.register_command_for( + 7, + originweave_network::WebDriverBiDiCommandKind::NavigationCommittedSubscription, + )?; + assert!( + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &response, + &mut correlation, + ) + .is_err(), + "re-registering an id without another send must not reconstruct a consumed receipt" + ); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn sent_subscription_cannot_be_rebound_to_an_unsent_same_id_context() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + let other_context = registry.register_context(session, "context-b")?; + let (subscription, _, _) = receive_subscription_result(®istry, session, context, 7)?; + let unsent_binding = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + ®istry, + session, + other_context, + "context-b", + )? + .admission_binding(); + let rejected = WebDriverBiDiNavigationCommittedSubscriptionAdmission::new( + subscription, + unsent_binding, + ®istry, + ) + .err() + .ok_or_else(|| { + io::Error::other("sent context-a receipt admitted an unsent context-b binding") + })?; + assert!(rejected.source().is_none()); + assert_eq!( + rejected.to_string(), + "WebDriver BiDi navigation subscription binding differs from its sent command" + ); + Ok(()) +} + +#[test] +fn identical_unsent_command_fields_do_not_recreate_sent_command_identity() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + let (subscription, _, _) = receive_subscription_result(®istry, session, context, 7)?; + let unsent_binding = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )? + .admission_binding(); + assert!( + WebDriverBiDiNavigationCommittedSubscriptionAdmission::new( + subscription, + unsent_binding, + ®istry, + ) + .is_err(), + "identical fields must not recreate the original command instance" + ); + Ok(()) +} + +#[test] +fn original_binding_rejects_replacement_registry_at_receipt_admission() -> Result<(), Box> +{ + let mut original = BrowserAuthorityRegistry::new(); + let session = original.register_session(SESSION_ID)?; + let context = original.register_context(session, CONTEXT_ID)?; + let (receipt, binding, _) = receive_subscription_result(&original, session, context, 7)?; + drop(original); + let mut replacement = BrowserAuthorityRegistry::with_identifier_limit(8); + let replacement_session = replacement.register_session(SESSION_ID)?; + let replacement_context = replacement.register_context(replacement_session, CONTEXT_ID)?; + assert_eq!( + (session, context), + (replacement_session, replacement_context) + ); + let error = + WebDriverBiDiNavigationCommittedSubscriptionAdmission::new(receipt, binding, &replacement) + .err() + .ok_or_else(|| { + io::Error::other( + "an original receipt and binding must not authorize a replacement registry", + ) + })?; + let source = error + .source() + .ok_or_else(|| io::Error::other("missing registry failure"))?; + assert_eq!( + source.downcast_ref::(), + Some(&originweave_core::BrowserRegistryError::RegistryInstanceMismatch), + ); + assert_eq!( + source.to_string(), + "browser authority belongs to another registry instance" + ); + Ok(()) +} + +#[test] +fn original_event_rejects_replacement_registry_without_consuming_replay_state() +-> Result<(), Box> { + let mut original = BrowserAuthorityRegistry::new(); + let session = original.register_session(SESSION_ID)?; + let context = original.register_context(session, CONTEXT_ID)?; + let (receipt, binding, event) = receive_subscription_result(&original, session, context, 7)?; + let mut admission = + WebDriverBiDiNavigationCommittedSubscriptionAdmission::new(receipt, binding, &original)?; + let mut replacement = BrowserAuthorityRegistry::new(); + let replacement_session = replacement.register_session(SESSION_ID)?; + let replacement_context = replacement.register_context(replacement_session, CONTEXT_ID)?; + assert_eq!( + (session, context), + (replacement_session, replacement_context) + ); + let before = original.current_epoch(context)?; + assert!( + admission.admit(&event, &replacement, EXPECTED_URL).is_err(), + "a valid original-connection event must reject another registry with colliding IDs" + ); + assert_eq!(replacement.current_epoch(replacement_context)?, before); + let mut original = Box::new(original); + let admitted = admission.admit(&event, &original, EXPECTED_URL)?; + let advanced = + advance_webdriver_bidi_navigation_document_epoch(admitted, &mut original, before)?; + assert_eq!(advanced.current_epoch().value(), before.value() + 1); + assert_eq!(replacement.current_epoch(replacement_context)?, before); + Ok(()) +} + +#[test] +fn admitted_observation_rejects_replacement_registry_at_document_mutation() +-> Result<(), Box> { + let mut original = BrowserAuthorityRegistry::new(); + let session = original.register_session(SESSION_ID)?; + let context = original.register_context(session, CONTEXT_ID)?; + let (receipt, binding, event) = receive_subscription_result(&original, session, context, 7)?; + let mut admission = + WebDriverBiDiNavigationCommittedSubscriptionAdmission::new(receipt, binding, &original)?; + let admitted = admission.admit(&event, &original, EXPECTED_URL)?; + let mut replacement = BrowserAuthorityRegistry::new(); + let replacement_session = replacement.register_session("unrelated-session")?; + let replacement_context = replacement.register_context(replacement_session, "unrelated-tab")?; + assert_eq!( + (session, context), + (replacement_session, replacement_context) + ); + let origin = originweave_core::Origin::parse("https://unrelated.test") + .map_err(|error| io::Error::other(format!("{error:?}")))?; + let before = + replacement.bind_context_origin(replacement_session, replacement_context, &origin)?; + assert!( + advance_webdriver_bidi_navigation_document_epoch(admitted, &mut replacement, before) + .is_err(), + "an observation admitted in one registry must not mutate another registry" + ); + assert_eq!(original.current_epoch(context)?, before); + assert_eq!( + replacement.require_context_origin(replacement_session, replacement_context, &origin)?, + before + ); + Ok(()) +} + +#[test] +fn subscription_identity_does_not_collide_across_registries() -> Result<(), Box> { + let mut original_registry = BrowserAuthorityRegistry::new(); + let original_session = original_registry.register_session(SESSION_ID)?; + let original_context = original_registry.register_context(original_session, CONTEXT_ID)?; + let (subscription, _, _) = + receive_subscription_result(&original_registry, original_session, original_context, 7)?; + let mut replacement_registry = BrowserAuthorityRegistry::new(); + let replacement_session = replacement_registry.register_session("replacement-session")?; + let replacement_context = + replacement_registry.register_context(replacement_session, "context-b")?; + assert_eq!(original_session, replacement_session); + assert_eq!(original_context, replacement_context); + let unsent_binding = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + &replacement_registry, + replacement_session, + replacement_context, + "context-b", + )? + .admission_binding(); + assert!( + WebDriverBiDiNavigationCommittedSubscriptionAdmission::new( + subscription, + unsent_binding, + &replacement_registry, + ) + .is_err(), + "registry-local identifiers must not substitute for the actual sent command" + ); + Ok(()) +} + +#[test] +fn subscription_admission_rejects_mismatched_command_and_retired_context() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + + let (subscription, _, _) = receive_subscription_result(®istry, session, context, 8)?; + let wrong_binding = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 9, ®istry, session, context, CONTEXT_ID, + )? + .admission_binding(); + let mismatch = WebDriverBiDiNavigationCommittedSubscriptionAdmission::new( + subscription, + wrong_binding, + ®istry, + ) + .err() + .ok_or_else(|| io::Error::other("mismatched subscription command unexpectedly admitted"))?; + assert_eq!( + mismatch.to_string(), + "WebDriver BiDi navigation subscription response does not match its command binding" + ); + assert!(mismatch.source().is_none()); + + let (subscription, binding, _) = receive_subscription_result(®istry, session, context, 10)?; + registry.remove_context(context)?; + let retired = WebDriverBiDiNavigationCommittedSubscriptionAdmission::new( + subscription, + binding, + ®istry, + ) + .err() + .ok_or_else(|| io::Error::other("retired subscription context unexpectedly admitted"))?; + assert_eq!( + retired.to_string(), + "WebDriver BiDi navigation subscription context is no longer registered authority" + ); + assert!(retired.source().is_some()); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_admission_failures.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_admission_failures.rs new file mode 100644 index 000000000..a3b8fd07f --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_admission_failures.rs @@ -0,0 +1,241 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserSessionId, BrowsingContextId, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiNavigationCommittedSubscriptionAdmission, + WebDriverBiDiNavigationCommittedSubscriptionBinding, + WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionEventError, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CONTEXT_ID: &str = "context-a"; +const EXPECTED_URL: &str = "https://example.test/after"; +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 MISSING_NAVIGATION_EVENT: &[u8] = br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":null,"timestamp":1234,"url":"https://example.test/after"}}"#; + +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, + "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) +} + +fn write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + stream.write_all(&[0x81])?; + match payload.len() { + 0..=125 => stream.write_all(&[payload.len() as u8])?, + 126..=65_535 => { + stream.write_all(&[126])?; + stream.write_all(&(payload.len() as u16).to_be_bytes())?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test payload unexpectedly required 64-bit framing", + )); + } + } + stream.write_all(payload) +} + +fn next_text( + established: WebDriverBiDiWebSocketEstablished, +) -> Result< + ( + WebDriverBiDiWebSocketEstablished, + WebDriverBiDiReceivedTextMessage, + ), + Box, +> { + match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { + established, + message, + } => Ok((established, message)), + other => Err(io::Error::other(format!( + "expected a complete connection-bound WebDriver BiDi text message, got {other:?}" + )) + .into()), + } +} + +fn establish( + local_addr: std::net::SocketAddr, +) -> Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +fn receive_subscription_result_and_event( + registry: &BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, +) -> Result< + ( + WebDriverBiDiNavigationCommittedSubscriptionResult, + WebDriverBiDiNavigationCommittedSubscriptionBinding, + WebDriverBiDiReceivedTextMessage, + ), + 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"]}}"# + { + return Err(io::Error::other("unexpected session.subscribe command")); + } + write_text_frame( + &mut stream, + br#"{"type":"success","id":7,"result":{"subscription":"subscription-a"}}"#, + )?; + write_text_frame(&mut stream, MISSING_NAVIGATION_EVENT) + }); + + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + registry, + browser_session, + browsing_context, + CONTEXT_ID, + )?; + let binding = command.admission_binding(); + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = command.send( + registry, + establish(local_addr)?, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + let (established, response) = next_text(established)?; + let result = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &response, + &mut correlation, + )?; + let (_established, event) = next_text(established)?; + server + .join() + .map_err(|_| io::Error::other("subscription failure-contract server panicked"))??; + Ok((result, binding, event)) +} + +#[test] +fn subscription_event_failures_keep_specific_public_diagnostics() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + let (subscription, binding, missing_navigation_event) = + receive_subscription_result_and_event(®istry, session, context)?; + let mut admission = WebDriverBiDiNavigationCommittedSubscriptionAdmission::new( + subscription, + binding, + ®istry, + )?; + + let crossed_connection = + WebDriverBiDiNavigationCommittedSubscriptionEventError::EventConnectionMismatch; + assert_eq!( + crossed_connection.to_string(), + "WebDriver BiDi navigation event arrived on a different subscription connection" + ); + assert!(crossed_connection.source().is_none()); + + let missing_navigation = admission + .admit(&missing_navigation_event, ®istry, EXPECTED_URL) + .err() + .ok_or_else(|| io::Error::other("null navigation identity unexpectedly admitted"))?; + assert_eq!( + missing_navigation.to_string(), + "WebDriver BiDi navigation-committed event has no reusable-safe navigation identity" + ); + assert!(missing_navigation.source().is_none()); + + registry.remove_context(context)?; + let stale_context = admission + .admit(&missing_navigation_event, ®istry, EXPECTED_URL) + .err() + .ok_or_else(|| io::Error::other("retired context unexpectedly admitted an event"))?; + assert_eq!( + stale_context.to_string(), + "WebDriver BiDi navigation subscription context is no longer registered authority" + ); + assert!(stale_context.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 index e7decd258..1fa02877d 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_failures.rs @@ -81,6 +81,53 @@ fn establish_websocket( .read_opening_response(Duration::from_millis(500))?) } +#[test] +fn replacement_registry_is_rejected_before_correlation_or_command_write() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let address = listener.local_addr()?; + let server = spawn_no_command_server(listener); + let mut original = BrowserAuthorityRegistry::new(); + let session = original.register_session(SESSION_ID)?; + let context = original.register_context(session, "context-a")?; + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, + &original, + session, + context, + "context-a", + )?; + let mut replacement = BrowserAuthorityRegistry::new(); + let replacement_session = replacement.register_session(SESSION_ID)?; + let replacement_context = replacement.register_context(replacement_session, "context-a")?; + assert_eq!( + (session, context), + (replacement_session, replacement_context) + ); + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(8, WebDriverBiDiCommandKind::SessionStatus)?; + let outcome = command.send( + &replacement, + establish_websocket(address)?, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ); + let rejected = outcome.is_err(); + drop(outcome); + let server_outcome = server + .join() + .map_err(|_| io::Error::other("registry fixture panicked"))?; + assert!( + rejected, + "a replacement registry must not send the original command" + ); + server_outcome?; + assert_eq!(correlation.outstanding_count(), 1); + correlation.register_command_for(7, WebDriverBiDiCommandKind::SessionStatus)?; + Ok(()) +} + #[test] fn retired_context_is_rejected_before_correlation_or_command_write() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs index 2fe4b3806..a90bbca42 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_subscription_response_failures.rs @@ -14,11 +14,12 @@ use originweave_network::{ WebDriverBiDiNavigationCommittedSubscriptionResponseError, WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiReceivedTextMessage, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, - WebDriverBiDiWebSocketMessageReader, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CONTEXT_ID: &str = "context-a"; 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":"#; @@ -51,25 +52,6 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { 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)?; @@ -89,7 +71,7 @@ fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { _ => { return Err(io::Error::new( io::ErrorKind::InvalidData, - "test command unexpectedly required 64-bit framing", + "fixture command unexpectedly required 64-bit framing", )); } }; @@ -103,86 +85,123 @@ fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { Ok(payload) } -fn read_text_over_loopback( - document: &'static [u8], +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 establish( + local_addr: std::net::SocketAddr, +) -> Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +fn next_text( + established: WebDriverBiDiWebSocketEstablished, ) -> Result> { - read_response_over_loopback(document, None) + match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => Ok(message), + other => Err(io::Error::other(format!( + "subscription response produced unexpected connection-bound state: {other:?}" + )) + .into()), + } } -fn read_response_over_loopback( +fn read_text_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 text = next_text(establish(local_addr)?)?; + server + .join() + .map_err(|_| io::Error::other("subscription response test server panicked"))??; + Ok(text) +} - 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()); +fn sent_subscription_response( + document: &'static [u8], +) -> Result< + ( + WebDriverBiDiReceivedTextMessage, + WebDriverBiDiCommandCorrelation, + ), + 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"]}}"# + { + return Err(io::Error::other("unexpected session.subscribe command")); } - }; + write_unmasked_text_frame(&mut stream, document) + }); + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = command.send( + ®istry, + establish(local_addr)?, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + let response = next_text(established)?; server .join() - .map_err(|_| io::Error::other("subscription response test server panicked"))??; - Ok(text) + .map_err(|_| io::Error::other("sent-subscription response server panicked"))??; + Ok((response, correlation)) } #[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)?; + let (malformed, mut correlation) = sent_subscription_response(MALFORMED_SUCCESS_RESPONSE)?; assert_eq!( WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( &malformed, @@ -196,7 +215,7 @@ fn malformed_and_invalid_success_responses_preserve_outstanding_correlation() ); assert_eq!(correlation.outstanding_count(), 1); - let missing = read_text_over_loopback(MISSING_SUBSCRIPTION_RESPONSE)?; + let (missing, mut correlation) = sent_subscription_response(MISSING_SUBSCRIPTION_RESPONSE)?; assert_eq!( WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( &missing, @@ -206,7 +225,7 @@ fn malformed_and_invalid_success_responses_preserve_outstanding_correlation() ); assert_eq!(correlation.outstanding_count(), 1); - let unknown = read_text_over_loopback(UNKNOWN_SUCCESS_RESPONSE)?; + let (unknown, mut correlation) = sent_subscription_response(UNKNOWN_SUCCESS_RESPONSE)?; assert_eq!( WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( &unknown, @@ -223,11 +242,8 @@ fn malformed_and_invalid_success_responses_preserve_outstanding_correlation() } #[test] -fn protocol_error_consumes_only_its_exact_outstanding_command() -> Result<(), Box> { - let mut correlation = WebDriverBiDiCommandCorrelation::new(); - correlation.register_command_for(43, WebDriverBiDiCommandKind::SessionStatus)?; - - let unknown = read_text_over_loopback(UNKNOWN_ERROR_RESPONSE)?; +fn protocol_error_consumes_only_its_exact_sent_command() -> Result<(), Box> { + let (unknown, mut correlation) = sent_subscription_response(UNKNOWN_ERROR_RESPONSE)?; assert_eq!( WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( &unknown, @@ -241,7 +257,8 @@ fn protocol_error_consumes_only_its_exact_outstanding_command() -> Result<(), Bo ); assert_eq!(correlation.outstanding_count(), 1); - let matched = read_response_over_loopback(MATCHED_ERROR_RESPONSE, Some(&mut correlation))?; + let (matched, mut correlation) = sent_subscription_response(MATCHED_ERROR_RESPONSE)?; + correlation.register_command_for(43, WebDriverBiDiCommandKind::SessionStatus)?; assert_eq!(correlation.outstanding_count(), 2); assert_eq!( WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( @@ -259,6 +276,45 @@ fn protocol_error_consumes_only_its_exact_outstanding_command() -> Result<(), Bo Ok(()) } +#[test] +fn protocol_error_without_sent_connection_provenance_preserves_command() +-> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation + .register_command_for(7, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; + + let unknown = read_text_over_loopback(UNKNOWN_ERROR_RESPONSE)?; + assert_eq!( + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &unknown, + &mut correlation, + ), + Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandNotOutstanding, + } + ) + ); + assert_eq!(correlation.outstanding_count(), 1); + + let matched = read_text_over_loopback(MATCHED_ERROR_RESPONSE)?; + assert_eq!( + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &matched, + &mut correlation, + ), + Err( + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { + command_id: 7 + }, + } + ) + ); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + #[test] fn subscription_response_cannot_consume_another_command_kind() -> Result<(), Box> { let mut correlation = WebDriverBiDiCommandCorrelation::new(); @@ -307,24 +363,33 @@ fn event_response_is_rejected_without_consuming_outstanding_command() -> Result< } #[test] -fn manually_registered_subscription_cannot_supply_sender_provenance() -> Result<(), Box> -{ - for document in [MATCHED_SUCCESS_RESPONSE, MATCHED_ERROR_RESPONSE] { +fn generic_registration_cannot_mint_subscription_receipts() -> Result<(), Box> { + for (document, expected) in [ + ( + MATCHED_SUCCESS_RESPONSE, + WebDriverBiDiCommandCorrelationError::CommandSubscriptionProvenanceMissing { + command_id: 7, + }, + ), + ( + MATCHED_ERROR_RESPONSE, + WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { + command_id: 7, + }, + ), + ] { let mut correlation = WebDriverBiDiCommandCorrelation::new(); correlation .register_command_for(7, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; - let message = read_text_over_loopback(document)?; + let received = read_text_over_loopback(document)?; assert_eq!( WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( - &message, + &received, &mut correlation ), Err( WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { - source: - WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { - command_id: 7 - }, + source: expected } ) ); diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe.rs index 08cfe8ba9..34a0c34fa 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe.rs @@ -14,8 +14,7 @@ use originweave_network::{ WebDriverBiDiNavigationCommittedUnsubscribeCommand, WebDriverBiDiNavigationCommittedUnsubscribeResult, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, - WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketMessageReader, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -154,7 +153,7 @@ fn validated_subscription_can_be_unsubscribed_without_losing_opaque_text() Duration::from_millis(500), )?; - let (established, text) = match WebDriverBiDiWebSocketMessageReader::new(established) + let (established, received) = match WebDriverBiDiWebSocketMessageReader::new(established) .read_next(Duration::from_millis(500))? { WebDriverBiDiConnectionMessageRead::Text { @@ -163,18 +162,18 @@ fn validated_subscription_can_be_unsubscribed_without_losing_opaque_text() } => (established, message), other => { return Err(io::Error::other(format!( - "session.subscribe response produced unexpected assembly state: {other:?}" + "session.subscribe response produced unexpected connection-bound state: {other:?}" )) .into()); } }; let subscription = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( - &text, + &received, &mut correlation, )?; assert_eq!(subscription.subscription_id(), "sub-\"\\\n\u{0001}-구독"); - let unsubscribe = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, &subscription)?; + let unsubscribe = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, subscription)?; assert_eq!(unsubscribe.command_id(), 8); let established = unsubscribe.send( established, @@ -184,10 +183,10 @@ fn validated_subscription_can_be_unsubscribed_without_losing_opaque_text() )?; assert_eq!(correlation.outstanding_count(), 1); - let (_established, frame) = established.read_frame(Duration::from_millis(500))?; - let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); - let text = match assembler.push_frame(frame)? { - WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + let text = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, other => { return Err(io::Error::other(format!( "session.unsubscribe response produced unexpected assembly state: {other:?}" diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe_failures.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe_failures.rs index 989a2da7e..95a6288c8 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe_failures.rs @@ -15,12 +15,11 @@ use originweave_network::{ WebDriverBiDiNavigationCommittedUnsubscribeCommand, WebDriverBiDiNavigationCommittedUnsubscribeCommandError, WebDriverBiDiNavigationCommittedUnsubscribeResponseError, - WebDriverBiDiNavigationCommittedUnsubscribeResult, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, - WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, - WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketMessageReader, - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiNavigationCommittedUnsubscribeResult, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -150,13 +149,28 @@ fn require_no_client_command(stream: &mut TcpStream) -> io::Result<()> { 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)?; + let mut stream = accept_subscription(listener)?; require_no_client_command(&mut stream) }) } +fn accept_subscription(listener: TcpListener) -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let subscribe = read_masked_text_frame(&mut stream)?; + if subscribe + != br#"{"id":7,"method":"session.subscribe","params":{"events":["browsingContext.navigationCommitted"],"contexts":["context-a"]}}"# + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected session.subscribe command: {}", String::from_utf8_lossy(&subscribe)), + )); + } + write_unmasked_text_frame(&mut stream, SUBSCRIBE_RESPONSE.as_bytes())?; + Ok(stream) +} + fn establish_websocket( local_addr: SocketAddr, ) -> Result> { @@ -172,34 +186,18 @@ fn establish_websocket( .read_opening_response(Duration::from_millis(500))?) } -fn obtain_subscription_receipt() --> Result> { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let local_addr = listener.local_addr()?; - let server = thread::spawn(move || -> io::Result<()> { - let (mut stream, _) = listener.accept()?; - read_opening_request(&mut stream)?; - stream.write_all(OPENING_RESPONSE)?; - - let subscribe = read_masked_text_frame(&mut stream)?; - if subscribe - != br#"{"id":7,"method":"session.subscribe","params":{"events":["browsingContext.navigationCommitted"],"contexts":["context-a"]}}"# - { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "unexpected session.subscribe command: {}", - String::from_utf8_lossy(&subscribe) - ), - )); - } - write_unmasked_text_frame(&mut stream, SUBSCRIBE_RESPONSE.as_bytes()) - }); - +fn obtain_subscription_receipt( + established: WebDriverBiDiWebSocketEstablished, +) -> Result< + ( + WebDriverBiDiWebSocketEstablished, + WebDriverBiDiNavigationCommittedSubscriptionResult, + ), + Box, +> { let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session(SESSION_ID)?; let context = registry.register_context(session, CONTEXT_ID)?; - let established = establish_websocket(local_addr)?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); let subscribe = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( 7, ®istry, session, context, CONTEXT_ID, @@ -211,7 +209,7 @@ fn obtain_subscription_receipt() WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), Duration::from_millis(500), )?; - let (_established, text) = match WebDriverBiDiWebSocketMessageReader::new(established) + let (established, received) = match WebDriverBiDiWebSocketMessageReader::new(established) .read_next(Duration::from_millis(500))? { WebDriverBiDiConnectionMessageRead::Text { @@ -220,25 +218,59 @@ fn obtain_subscription_receipt() } => (established, message), other => { return Err(io::Error::other(format!( - "session.subscribe response produced unexpected assembly state: {other:?}" + "session.subscribe response produced unexpected connection-bound state: {other:?}" )) .into()); } }; let subscription = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( - &text, + &received, &mut correlation, )?; + Ok((established, subscription)) +} + +fn standalone_subscription_receipt() +-> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = spawn_no_command_server(listener); + let (established, subscription) = + obtain_subscription_receipt(establish_websocket(local_addr)?)?; + drop(established); server .join() .map_err(|_| io::Error::other("subscription receipt test server panicked"))??; Ok(subscription) } +fn read_received_text( + established: WebDriverBiDiWebSocketEstablished, +) -> Result< + ( + WebDriverBiDiWebSocketEstablished, + WebDriverBiDiReceivedTextMessage, + ), + Box, +> { + match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { + established, + message, + } => Ok((established, message)), + other => Err(io::Error::other(format!( + "session.unsubscribe response produced unexpected connection-bound state: {other:?}" + )) + .into()), + } +} + fn read_text_over_loopback( document: &'static [u8], -) -> Result> { +) -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { @@ -248,18 +280,8 @@ fn read_text_over_loopback( write_unmasked_text_frame(&mut stream, document) }); - let established = establish_websocket(local_addr)?; - let (_established, frame) = established.read_frame(Duration::from_millis(500))?; - let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); - let text = match assembler.push_frame(frame)? { - WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, - other => { - return Err(io::Error::other(format!( - "session.unsubscribe response produced unexpected assembly state: {other:?}" - )) - .into()); - } - }; + let (established, text) = read_received_text(establish_websocket(local_addr)?)?; + drop(established); server .join() @@ -269,10 +291,10 @@ fn read_text_over_loopback( #[test] fn command_validation_and_debug_are_public_and_subscription_safe() -> Result<(), Box> { - let subscription = obtain_subscription_receipt()?; + let subscription = standalone_subscription_receipt()?; let range = match WebDriverBiDiNavigationCommittedUnsubscribeCommand::new( MAX_WEBDRIVER_BIDI_JS_UINT + 1, - &subscription, + subscription, ) { Ok(_) => { return Err( @@ -295,25 +317,24 @@ fn command_validation_and_debug_are_public_and_subscription_safe() -> Result<(), && *maximum_command_id == MAX_WEBDRIVER_BIDI_JS_UINT )); - let command = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, &subscription)?; + let subscription = standalone_subscription_receipt()?; + let subscription_id = subscription.subscription_id().to_owned(); + let command = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, subscription)?; let debug = format!("{command:?}"); assert!(debug.contains("command_id: 8")); - assert!(debug.contains(&format!( - "subscription_id_len: {}", - subscription.subscription_id().len() - ))); - assert!(!debug.contains(subscription.subscription_id())); + assert!(debug.contains(&format!("subscription_id_len: {}", subscription_id.len()))); + assert!(!debug.contains(&subscription_id)); Ok(()) } #[test] fn duplicate_command_id_is_rejected_before_unsubscribe_write() -> Result<(), Box> { - let subscription = obtain_subscription_receipt()?; let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = spawn_no_command_server(listener); - let established = establish_websocket(local_addr)?; - let command = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, &subscription)?; + let (established, subscription) = + obtain_subscription_receipt(establish_websocket(local_addr)?)?; + let command = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, subscription)?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); correlation @@ -324,6 +345,10 @@ fn duplicate_command_id_is_rejected_before_unsubscribe_write() -> Result<(), Box WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), Duration::from_millis(500), ); + let result = result.map(drop); + server + .join() + .map_err(|_| io::Error::other("duplicate-command test server panicked"))??; let error = match result { Ok(_) => { return Err(io::Error::other("duplicate command id sent unsubscribe command").into()); @@ -341,21 +366,18 @@ fn duplicate_command_id_is_rejected_before_unsubscribe_write() -> Result<(), Box )); assert_eq!(correlation.outstanding_count(), 1); - server - .join() - .map_err(|_| io::Error::other("duplicate-command test server panicked"))??; Ok(()) } #[test] fn invalid_frame_timeout_fails_before_unsubscribe_correlation_or_write() -> Result<(), Box> { - let subscription = obtain_subscription_receipt()?; let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = spawn_no_command_server(listener); - let established = establish_websocket(local_addr)?; - let command = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, &subscription)?; + let (established, subscription) = + obtain_subscription_receipt(establish_websocket(local_addr)?)?; + let command = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, subscription)?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); let result = command.send( @@ -364,6 +386,10 @@ fn invalid_frame_timeout_fails_before_unsubscribe_correlation_or_write() WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), Duration::ZERO, ); + let result = result.map(drop); + server + .join() + .map_err(|_| io::Error::other("frame-timeout test server panicked"))??; let error = match result { Ok(_) => { return Err(io::Error::other("zero frame timeout sent unsubscribe command").into()); @@ -381,33 +407,25 @@ fn invalid_frame_timeout_fails_before_unsubscribe_correlation_or_write() )); assert_eq!(correlation.outstanding_count(), 0); - server - .join() - .map_err(|_| io::Error::other("frame-timeout test server panicked"))??; Ok(()) } #[test] fn adjacent_mask_key_reuse_is_rejected_inside_unsubscribe_send_and_retires_correlation() -> Result<(), Box> { - let subscription = obtain_subscription_receipt()?; 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 stream = accept_subscription(listener)?; read_empty_masked_pong_frame(&mut stream, [5, 6, 7, 8])?; require_no_client_command(&mut stream) }); let masking_key = WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); - let established = establish_websocket(local_addr)?.write_pong_frame( - &[], - masking_key, - Duration::from_millis(500), - )?; - let command = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, &subscription)?; + let (established, subscription) = + obtain_subscription_receipt(establish_websocket(local_addr)?)?; + let established = established.write_pong_frame(&[], masking_key, Duration::from_millis(500))?; + let command = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, subscription)?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); let result = command.send( established, @@ -415,6 +433,10 @@ fn adjacent_mask_key_reuse_is_rejected_inside_unsubscribe_send_and_retires_corre masking_key, Duration::from_millis(500), ); + let result = result.map(drop); + server + .join() + .map_err(|_| io::Error::other("mask-reuse test server panicked"))??; let error = match result { Ok(_) => { return Err(io::Error::other("reused masking key sent unsubscribe command").into()); @@ -429,9 +451,6 @@ fn adjacent_mask_key_reuse_is_rejected_inside_unsubscribe_send_and_retires_corre )); assert_eq!(correlation.outstanding_count(), 0); - server - .join() - .map_err(|_| io::Error::other("mask-reuse test server panicked"))??; Ok(()) } @@ -517,15 +536,42 @@ fn unsubscribe_response_cannot_consume_subscription_command_kind() -> Result<(), #[test] fn matched_unsubscribe_protocol_error_consumes_only_its_command() -> 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 = accept_subscription(listener)?; + let unsubscribe = read_masked_text_frame(&mut stream)?; + if unsubscribe + != r#"{"id":8,"method":"session.unsubscribe","params":{"subscriptions":["sub-\"\\\n\u0001-구독"]}}"# + .as_bytes() + { + return Err(io::Error::other("unexpected session.unsubscribe command")); + } + write_unmasked_text_frame(&mut stream, MATCHED_UNSUBSCRIBE_ERROR)?; + require_no_client_command(&mut stream) + }); + let (established, subscription) = + obtain_subscription_receipt(establish_websocket(local_addr)?)?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); - correlation - .register_command_for(8, WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe)?; - let matched = read_text_over_loopback(MATCHED_UNSUBSCRIBE_ERROR)?; - - let error = match WebDriverBiDiNavigationCommittedUnsubscribeResult::parse_and_correlate( + correlation.register_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + let established = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, subscription)? + .send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::from_millis(500), + )?; + let before_response = correlation.outstanding_count(); + let (established, matched) = read_received_text(established)?; + let result = WebDriverBiDiNavigationCommittedUnsubscribeResult::parse_and_correlate( &matched, &mut correlation, - ) { + ); + drop(established); + server + .join() + .map_err(|_| io::Error::other("matched unsubscribe error server panicked"))??; + let error = match result { Ok(_) => { return Err( io::Error::other("protocol-error unsubscribe response was accepted").into(), @@ -544,6 +590,42 @@ fn matched_unsubscribe_protocol_error_consumes_only_its_command() -> Result<(), command_id: 8, } )); + assert_eq!(before_response, 2); + assert_eq!(correlation.outstanding_count(), 1); + correlation.retire_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; assert_eq!(correlation.outstanding_count(), 0); Ok(()) } + +#[test] +fn generic_unsubscribe_registration_cannot_admit_received_success_or_error() +-> Result<(), Box> { + for document in [MATCHED_UNSUBSCRIBE_SUCCESS, MATCHED_UNSUBSCRIBE_ERROR] { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation + .register_command_for(8, WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe)?; + correlation.register_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + let received = read_text_over_loopback(document)?; + let result = WebDriverBiDiNavigationCommittedUnsubscribeResult::parse_and_correlate( + &received, + &mut correlation, + ); + assert!(matches!( + result, + Err( + WebDriverBiDiNavigationCommittedUnsubscribeResponseError::Correlation { + source: + WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { + command_id: 8, + } + } + ) + )); + assert_eq!(correlation.outstanding_count(), 2); + correlation + .retire_command_for(8, WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe)?; + correlation.retire_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + assert_eq!(correlation.outstanding_count(), 0); + } + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_document_advance.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_document_advance.rs index fe086fb8d..6f531fbf1 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_document_advance.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_document_advance.rs @@ -1,115 +1,35 @@ -use std::{ - error::Error, - io::{self, Read, Write}, - net::{TcpListener, TcpStream}, - thread, - time::Duration, -}; +mod support; -use originweave_core::{ - BrowserAuthorityRegistry, BrowserRegistryError, Origin, WebDriverBiDiWebSocketEndpoint, -}; +use std::{error::Error, io}; + +use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, Origin}; use originweave_network::{ WebDriverBiDiNavigationCommittedDocumentAdvanceError, - WebDriverBiDiNavigationCommittedObservation, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, - WebDriverBiDiWebSocketTextMessage, advance_webdriver_bidi_navigation_document_epoch, + advance_webdriver_bidi_navigation_document_epoch, }; +use support::receive_subscribed_navigation_event; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; -const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const CONTEXT_ID: &str = "context-a"; const EXPECTED_URL: &str = "https://example.test/after"; -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 write_unmasked_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { - stream.write_all(&[0x81])?; - match payload.len() { - 0..=125 => stream.write_all(&[payload.len() as u8])?, - 126..=65_535 => { - stream.write_all(&[126])?; - stream.write_all(&(payload.len() as u16).to_be_bytes())?; - } - _ => { - stream.write_all(&[127])?; - stream.write_all(&(payload.len() as u64).to_be_bytes())?; - } - } - stream.write_all(payload) -} - -fn receive_navigation_event() -> Result> { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let local_addr = listener.local_addr()?; - let payload = br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":"navigation-a","timestamp":17,"url":"https://example.test/after"}}"#.to_vec(); - let server = thread::spawn(move || -> io::Result<()> { - let (mut stream, _) = listener.accept()?; - read_opening_request(&mut stream)?; - stream.write_all(OPENING_RESPONSE)?; - write_unmasked_text_frame(&mut stream, &payload) - }); - - let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); - let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? - .correlate_session_id(SESSION_ID)? - .into_explicit_connect_target()?; - let connection = - WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; - let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? - .write_opening_request(Duration::from_millis(500))? - .read_opening_response(Duration::from_millis(500))?; - let (_established, frame) = established.read_frame(Duration::from_millis(500))?; - let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); - let text = match assembler.push_frame(frame)? { - WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, - other => { - return Err(io::Error::other(format!( - "navigation document-advance event produced unexpected assembly state: {other:?}" - )) - .into()); - } - }; - server - .join() - .map_err(|_| io::Error::other("navigation document-advance server panicked"))??; - Ok(text) -} #[test] fn accepted_navigation_advances_only_the_exact_pre_action_document_epoch() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session(SESSION_ID)?; - let context = registry.register_context(session, "context-a")?; + let context = registry.register_context(session, CONTEXT_ID)?; let before = registry.current_context_epoch(session, context)?; let previous_origin = Origin::parse("https://example.test") .map_err(|error| io::Error::other(format!("fixture origin parse failed: {error:?}")))?; registry.bind_context_origin(session, context, &previous_origin)?; - let event = receive_navigation_event()?; - let observation = WebDriverBiDiNavigationCommittedObservation::parse_and_match( - &event, + let observation = receive_subscribed_navigation_event( ®istry, session, context, + SESSION_ID, + CONTEXT_ID, EXPECTED_URL, )?; let advance = @@ -128,12 +48,12 @@ fn accepted_navigation_advances_only_the_exact_pre_action_document_epoch() Err(BrowserRegistryError::ContextOriginNotBound) ); - let replay_event = receive_navigation_event()?; - let replay = WebDriverBiDiNavigationCommittedObservation::parse_and_match( - &replay_event, + let replay = receive_subscribed_navigation_event( ®istry, session, context, + SESSION_ID, + CONTEXT_ID, EXPECTED_URL, )?; let replay_error = @@ -161,14 +81,14 @@ fn retired_context_between_observation_and_advance_fails_closed_with_typed_sourc -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session(SESSION_ID)?; - let context = registry.register_context(session, "context-a")?; + let context = registry.register_context(session, CONTEXT_ID)?; let before = registry.current_context_epoch(session, context)?; - let event = receive_navigation_event()?; - let observation = WebDriverBiDiNavigationCommittedObservation::parse_and_match( - &event, + let observation = receive_subscribed_navigation_event( ®istry, session, context, + SESSION_ID, + CONTEXT_ID, EXPECTED_URL, )?; diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_origin_binding.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_origin_binding.rs index 6ac899ca5..081d89b46 100644 --- a/crates/originweave-network/tests/webdriver_bidi_navigation_origin_binding.rs +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_origin_binding.rs @@ -1,99 +1,16 @@ -use std::{ - error::Error, - io::{self, Read, Write}, - net::{TcpListener, TcpStream}, - thread, - time::Duration, -}; +mod support; + +use std::{error::Error, io}; -use originweave_core::{BrowserAuthorityRegistry, Origin, WebDriverBiDiWebSocketEndpoint}; +use originweave_core::{BrowserAuthorityRegistry, Origin}; use originweave_network::{ WebDriverBiDiNavigationCommittedDocumentAdvanceError, - WebDriverBiDiNavigationCommittedObservation, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, - WebDriverBiDiWebSocketTextMessage, advance_and_bind_webdriver_bidi_navigation_document_origin, + advance_and_bind_webdriver_bidi_navigation_document_origin, }; +use support::receive_subscribed_navigation_event; 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 write_unmasked_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { - stream.write_all(&[0x81])?; - match payload.len() { - 0..=125 => stream.write_all(&[payload.len() as u8])?, - 126..=65_535 => { - stream.write_all(&[126])?; - stream.write_all(&(payload.len() as u16).to_be_bytes())?; - } - _ => { - stream.write_all(&[127])?; - stream.write_all(&(payload.len() as u64).to_be_bytes())?; - } - } - stream.write_all(payload) -} - -fn receive_navigation_event( - url: &str, -) -> Result> { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let local_addr = listener.local_addr()?; - let payload = format!( - "{{\"type\":\"event\",\"method\":\"browsingContext.navigationCommitted\",\"params\":{{\"context\":\"context-a\",\"navigation\":\"navigation-a\",\"timestamp\":17,\"url\":\"{url}\"}}}}" - ) - .into_bytes(); - let server = thread::spawn(move || -> io::Result<()> { - let (mut stream, _) = listener.accept()?; - read_opening_request(&mut stream)?; - stream.write_all(OPENING_RESPONSE)?; - write_unmasked_text_frame(&mut stream, &payload) - }); - - let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); - let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? - .correlate_session_id(SESSION_ID)? - .into_explicit_connect_target()?; - let connection = - WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; - let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? - .write_opening_request(Duration::from_millis(500))? - .read_opening_response(Duration::from_millis(500))?; - let (_established, frame) = established.read_frame(Duration::from_millis(500))?; - let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); - let text = match assembler.push_frame(frame)? { - WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, - other => { - return Err(io::Error::other(format!( - "navigation origin-binding event produced unexpected assembly state: {other:?}" - )) - .into()); - } - }; - server - .join() - .map_err(|_| io::Error::other("navigation origin-binding server panicked"))??; - Ok(text) -} +const CONTEXT_ID: &str = "context-a"; fn fixture_origin(value: &str) -> Result> { Origin::parse(value) @@ -105,18 +22,18 @@ fn committed_navigation_rotates_document_and_binds_canonical_observed_origin() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session(SESSION_ID)?; - let context = registry.register_context(session, "context-a")?; + let context = registry.register_context(session, CONTEXT_ID)?; let before = registry.current_context_epoch(session, context)?; let previous_origin = fixture_origin("https://before.example")?; registry.bind_context_origin(session, context, &previous_origin)?; let observed_url = "https://EXAMPLE.TEST:443/after?from=originweave#done"; - let event = receive_navigation_event(observed_url)?; - let observation = WebDriverBiDiNavigationCommittedObservation::parse_and_match( - &event, + let observation = receive_subscribed_navigation_event( ®istry, session, context, + SESSION_ID, + CONTEXT_ID, observed_url, )?; let binding = advance_and_bind_webdriver_bidi_navigation_document_origin( @@ -143,18 +60,18 @@ fn invalid_observed_origin_fails_before_document_authority_is_rotated() -> Resul { let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session(SESSION_ID)?; - let context = registry.register_context(session, "context-a")?; + let context = registry.register_context(session, CONTEXT_ID)?; let before = registry.current_context_epoch(session, context)?; let previous_origin = fixture_origin("https://before.example")?; registry.bind_context_origin(session, context, &previous_origin)?; let observed_url = "https://user@example.test/after"; - let event = receive_navigation_event(observed_url)?; - let observation = WebDriverBiDiNavigationCommittedObservation::parse_and_match( - &event, + let observation = receive_subscribed_navigation_event( ®istry, session, context, + SESSION_ID, + CONTEXT_ID, observed_url, )?; let error = advance_and_bind_webdriver_bidi_navigation_document_origin( @@ -182,18 +99,18 @@ fn invalid_observed_origin_fails_before_document_authority_is_rotated() -> Resul fn stale_pre_action_epoch_fails_before_observed_origin_is_bound() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session(SESSION_ID)?; - let context = registry.register_context(session, "context-a")?; + let context = registry.register_context(session, CONTEXT_ID)?; let before = registry.current_context_epoch(session, context)?; let previous_origin = fixture_origin("https://before.example")?; registry.bind_context_origin(session, context, &previous_origin)?; let observed_url = "https://example.test/after"; - let event = receive_navigation_event(observed_url)?; - let observation = WebDriverBiDiNavigationCommittedObservation::parse_and_match( - &event, + let observation = receive_subscribed_navigation_event( ®istry, session, context, + SESSION_ID, + CONTEXT_ID, observed_url, )?; diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_subscription_registry_transport_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_subscription_registry_transport_provenance.rs new file mode 100644 index 000000000..12485de50 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_subscription_registry_transport_provenance.rs @@ -0,0 +1,133 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionCommandError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const REGISTRY_SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const FOREIGN_TRANSPORT_SESSION_ID: &str = "fedcba98-7654-3210-fedc-ba9876543210"; +const CONTEXT_ID: &str = "context-a"; +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, + "opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn spawn_foreign_transport_server(listener: TcpListener) -> thread::JoinHandle> { + thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + stream.set_read_timeout(Some(Duration::from_millis(500)))?; + + let mut first_command_byte = [0_u8; 1]; + match stream.read(&mut first_command_byte) { + Ok(0) => Ok(false), + Ok(_) => Ok(true), + Err(source) + if matches!( + source.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + Ok(false) + } + Err(source) => Err(source), + } + }) +} + +fn establish( + local_addr: SocketAddr, + session_id: &str, +) -> 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))?) +} + +#[test] +fn registry_bound_subscription_is_rejected_before_writing_to_a_foreign_session_transport() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(REGISTRY_SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )?; + + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = spawn_foreign_transport_server(listener); + let established = establish(local_addr, FOREIGN_TRANSPORT_SESSION_ID)?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + + let send_result = command.send( + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ); + let command_byte_seen = server + .join() + .map_err(|_| io::Error::other("foreign-session fixture server panicked"))??; + + assert!( + send_result.is_err(), + "registry session A unexpectedly dispatched on transport session B" + ); + assert!(matches!( + send_result, + Err( + WebDriverBiDiNavigationCommittedSubscriptionCommandError::ContextBinding { + source: BrowserRegistryError::SessionExternalIdentifierMismatch + } + ) + )); + assert_eq!( + correlation.outstanding_count(), + 0, + "foreign-session rejection must happen before correlation registration" + ); + assert!( + !command_byte_seen, + "foreign-session rejection must happen before any command-frame byte" + ); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_subscription_transport_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_subscription_transport_provenance.rs new file mode 100644 index 000000000..c598fd7c2 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_subscription_transport_provenance.rs @@ -0,0 +1,264 @@ +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, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiNavigationCommittedSubscriptionAdmission, + WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionEventError, + WebDriverBiDiNavigationCommittedSubscriptionResponseError, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CONTEXT_ID: &str = "context-a"; +const EXPECTED_URL: &str = "https://example.test/after"; +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] = + br#"{"type":"success","id":7,"result":{"subscription":"subscription-a"}}"#; +const NAVIGATION_EVENT: &[u8] = br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":"nav-crossed","timestamp":33,"url":"https://example.test/after"}}"#; + +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, + "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, + "fixture 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 write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + stream.write_all(&[0x81])?; + match payload.len() { + 0..=125 => stream.write_all(&[payload.len() as u8])?, + 126..=65_535 => { + stream.write_all(&[126])?; + stream.write_all(&(payload.len() as u16).to_be_bytes())?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fixture payload unexpectedly required 64-bit framing", + )); + } + } + stream.write_all(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 next_text( + established: WebDriverBiDiWebSocketEstablished, +) -> Result> { + match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => Ok(message), + other => Err(io::Error::other(format!( + "expected a complete connection-bound WebDriver BiDi text message, got {other:?}" + )) + .into()), + } +} + +fn spawn_subscription_sender( + listener: TcpListener, + response: Option<&'static [u8]>, +) -> thread::JoinHandle> { + thread::spawn(move || { + 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"]}}"# + { + return Err(io::Error::other("unexpected session.subscribe command")); + } + if let Some(response) = response { + write_text_frame(&mut stream, response)?; + } + Ok(()) + }) +} + +fn spawn_unsolicited_message_sender( + listener: TcpListener, + payload: &'static [u8], +) -> thread::JoinHandle> { + thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + write_text_frame(&mut stream, payload) + }) +} + +#[test] +fn subscription_receipt_from_another_verified_connection_is_rejected() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + + let sent_listener = TcpListener::bind(("127.0.0.1", 0))?; + let sent_addr = sent_listener.local_addr()?; + let sent_server = spawn_subscription_sender(sent_listener, None); + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let sent_established = command.send( + ®istry, + establish(sent_addr)?, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + drop(sent_established); + sent_server + .join() + .map_err(|_| io::Error::other("sent-connection fixture server panicked"))??; + assert_eq!(correlation.outstanding_count(), 1); + + let foreign_listener = TcpListener::bind(("127.0.0.1", 0))?; + let foreign_addr = foreign_listener.local_addr()?; + let foreign_server = spawn_unsolicited_message_sender(foreign_listener, SUBSCRIBE_RESPONSE); + let response = next_text(establish(foreign_addr)?)?; + let error = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &response, + &mut correlation, + ) + .err() + .ok_or_else(|| io::Error::other("crossed-connection receipt unexpectedly correlated"))?; + assert!(matches!( + error, + WebDriverBiDiNavigationCommittedSubscriptionResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { + command_id: 7 + } + } + )); + assert_eq!(correlation.outstanding_count(), 1); + foreign_server + .join() + .map_err(|_| io::Error::other("foreign-receipt fixture server panicked"))??; + Ok(()) +} + +#[test] +fn subscription_event_from_another_verified_connection_is_rejected() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + + let sent_listener = TcpListener::bind(("127.0.0.1", 0))?; + let sent_addr = sent_listener.local_addr()?; + let sent_server = spawn_subscription_sender(sent_listener, Some(SUBSCRIBE_RESPONSE)); + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )?; + let binding = command.admission_binding(); + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = command.send( + ®istry, + establish(sent_addr)?, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([4, 3, 2, 1]), + Duration::from_millis(500), + )?; + let response = next_text(established)?; + let subscription = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &response, + &mut correlation, + )?; + let mut admission = WebDriverBiDiNavigationCommittedSubscriptionAdmission::new( + subscription, + binding, + ®istry, + )?; + sent_server + .join() + .map_err(|_| io::Error::other("subscription fixture server panicked"))??; + + let foreign_listener = TcpListener::bind(("127.0.0.1", 0))?; + let foreign_addr = foreign_listener.local_addr()?; + let foreign_server = spawn_unsolicited_message_sender(foreign_listener, NAVIGATION_EVENT); + let event = next_text(establish(foreign_addr)?)?; + let error = admission + .admit(&event, ®istry, EXPECTED_URL) + .err() + .ok_or_else(|| io::Error::other("crossed-connection event unexpectedly admitted"))?; + assert!(matches!( + error, + WebDriverBiDiNavigationCommittedSubscriptionEventError::EventConnectionMismatch + )); + foreign_server + .join() + .map_err(|_| io::Error::other("foreign-event fixture server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_unsubscribe_transport_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_unsubscribe_transport_provenance.rs new file mode 100644 index 000000000..aab6eed8d --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_unsubscribe_transport_provenance.rs @@ -0,0 +1,329 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + sync::mpsc, + thread, + time::Duration, +}; + +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionResult, + WebDriverBiDiNavigationCommittedUnsubscribeCommand, + WebDriverBiDiNavigationCommittedUnsubscribeCommandError, + WebDriverBiDiNavigationCommittedUnsubscribeResponseError, + WebDriverBiDiNavigationCommittedUnsubscribeResult, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CONTEXT_ID: &str = "context-a"; +const FRAME_TIMEOUT: Duration = Duration::from_millis(500); +const SUBSCRIBE_MASK: [u8; 4] = [1, 2, 3, 4]; +const UNSUBSCRIBE_MASK: [u8; 4] = [5, 6, 7, 8]; +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_COMMAND: &[u8] = br#"{"id":7,"method":"session.subscribe","params":{"events":["browsingContext.navigationCommitted"],"contexts":["context-a"]}}"#; +const SUBSCRIBE_RESPONSE: &[u8] = + br#"{"type":"success","id":7,"result":{"subscription":"subscription-a"}}"#; +const UNSUBSCRIBE_COMMAND: &[u8] = + br#"{"id":8,"method":"session.unsubscribe","params":{"subscriptions":["subscription-a"]}}"#; +const UNSUBSCRIBE_SUCCESS: &[u8] = br#"{"type":"success","id":8,"result":{}}"#; +const UNSUBSCRIBE_ERROR: &[u8] = + br#"{"type":"error","id":8,"error":"invalid argument","message":"foreign unsubscribe error"}"#; + +type TestResult = Result>; + +fn accept_websocket(listener: TcpListener) -> io::Result { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.set_write_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0_u8]; + stream.read_exact(&mut byte)?; + request.push(byte[0]); + } + stream.write_all(OPENING_RESPONSE)?; + Ok(stream) +} + +fn receive_exact_command( + stream: &mut TcpStream, + payload: &[u8], + masking_key: [u8; 4], +) -> io::Result<()> { + let mut expected = vec![0x81]; + if payload.len() <= 125 { + expected.push(0x80 | payload.len() as u8); + } else { + expected.push(0xfe); + expected.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } + expected.extend_from_slice(&masking_key); + expected.extend( + payload + .iter() + .enumerate() + .map(|(index, byte)| byte ^ masking_key[index % masking_key.len()]), + ); + let mut received = vec![0_u8; expected.len()]; + stream.read_exact(&mut received)?; + if received != expected { + return Err(io::Error::other(format!( + "client command differs from literal expected wire bytes: {received:?}" + ))); + } + Ok(()) +} + +fn write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + if payload.len() > 125 { + return Err(io::Error::other( + "fixture reply exceeded short-frame encoding", + )); + } + stream.write_all(&[0x81, payload.len() as u8])?; + stream.write_all(payload) +} + +fn read_until_closed(mut stream: TcpStream) -> io::Result> { + let mut received = Vec::new(); + stream.read_to_end(&mut received)?; + Ok(received) +} + +fn join_server(server: thread::JoinHandle>>) -> TestResult> { + Ok(server + .join() + .map_err(|_| io::Error::other("unsubscribe transport fixture server panicked"))??) +} + +fn establish(local_addr: SocketAddr) -> TestResult { + 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("dGhlIHNhbXBsZSBub25jZQ==")?, + )? + .write_opening_request(FRAME_TIMEOUT)? + .read_opening_response(FRAME_TIMEOUT)?) +} + +fn subscribe( + established: WebDriverBiDiWebSocketEstablished, + correlation: &mut WebDriverBiDiCommandCorrelation, +) -> TestResult<( + WebDriverBiDiWebSocketEstablished, + WebDriverBiDiNavigationCommittedSubscriptionResult, +)> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + let command = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )?; + let established = command.send( + ®istry, + established, + correlation, + WebDriverBiDiWebSocketMaskKey::new(SUBSCRIBE_MASK), + FRAME_TIMEOUT, + )?; + match WebDriverBiDiWebSocketMessageReader::new(established).read_next(FRAME_TIMEOUT)? { + WebDriverBiDiConnectionMessageRead::Text { + established, + message, + } => Ok(( + established, + WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &message, + correlation, + )?, + )), + other => Err(io::Error::other(format!( + "expected actual connection-bound subscription receipt, got {other:?}" + )) + .into()), + } +} + +fn read_unsubscribe_text( + established: WebDriverBiDiWebSocketEstablished, +) -> TestResult<( + WebDriverBiDiWebSocketEstablished, + WebDriverBiDiReceivedTextMessage, +)> { + match WebDriverBiDiWebSocketMessageReader::new(established).read_next(FRAME_TIMEOUT)? { + WebDriverBiDiConnectionMessageRead::Text { + established, + message, + } => Ok((established, message)), + other => Err(io::Error::other(format!( + "expected actual unsubscribe response text frame, got {other:?}" + )) + .into()), + } +} + +#[test] +fn subscription_receipt_cannot_dispatch_unsubscribe_on_another_connection() -> TestResult<()> { + let sent_listener = TcpListener::bind(("127.0.0.1", 0))?; + let sent_addr = sent_listener.local_addr()?; + let sent_server = thread::spawn(move || { + let mut stream = accept_websocket(sent_listener)?; + receive_exact_command(&mut stream, SUBSCRIBE_COMMAND, SUBSCRIBE_MASK)?; + write_text_frame(&mut stream, SUBSCRIBE_RESPONSE)?; + read_until_closed(stream) + }); + let foreign_listener = TcpListener::bind(("127.0.0.1", 0))?; + let foreign_addr = foreign_listener.local_addr()?; + let foreign_server = + thread::spawn(move || read_until_closed(accept_websocket(foreign_listener)?)); + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + let (sent_established, subscription) = subscribe(establish(sent_addr)?, &mut correlation)?; + let before_send = correlation.outstanding_count(); + let result = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, subscription)?.send( + establish(foreign_addr)?, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new(UNSUBSCRIBE_MASK), + FRAME_TIMEOUT, + ); + let rejected = + matches!( + &result, + Err(WebDriverBiDiNavigationCommittedUnsubscribeCommandError::SubscriptionConnectionMismatch) + ); + let after_send = correlation.outstanding_count(); + drop(result); + drop(sent_established); + let sent_extra = join_server(sent_server)?; + let foreign_bytes = join_server(foreign_server)?; + let unrelated_retained = correlation + .retire_command_for(99, WebDriverBiDiCommandKind::SessionStatus) + .is_ok(); + let unsubscribe_registered = correlation + .retire_command_for(8, WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe) + .is_ok(); + + assert!(sent_extra.is_empty(), "unexpected A bytes: {sent_extra:?}"); + assert!( + foreign_bytes.is_empty(), + "foreign connection emitted unsubscribe wire bytes: {foreign_bytes:?}" + ); + assert!( + rejected, + "a receipt from A must not authorize dispatch on B" + ); + assert_eq!((before_send, after_send), (1, 1)); + assert!(unrelated_retained); + assert!(!unsubscribe_registered); + Ok(()) +} + +fn foreign_unsubscribe_reply_preserves_original_command( + foreign_reply: &'static [u8], +) -> TestResult<()> { + let sent_listener = TcpListener::bind(("127.0.0.1", 0))?; + let sent_addr = sent_listener.local_addr()?; + let (release_sender, release_receiver) = mpsc::sync_channel::<()>(0); + let sent_server = thread::spawn(move || { + let mut stream = accept_websocket(sent_listener)?; + receive_exact_command(&mut stream, SUBSCRIBE_COMMAND, SUBSCRIBE_MASK)?; + write_text_frame(&mut stream, SUBSCRIBE_RESPONSE)?; + receive_exact_command(&mut stream, UNSUBSCRIBE_COMMAND, UNSUBSCRIBE_MASK)?; + release_receiver + .recv_timeout(Duration::from_secs(2)) + .map_err(|error| { + io::Error::other(format!("original response barrier failed: {error}")) + })?; + write_text_frame(&mut stream, UNSUBSCRIBE_SUCCESS)?; + read_until_closed(stream) + }); + let foreign_listener = TcpListener::bind(("127.0.0.1", 0))?; + let foreign_addr = foreign_listener.local_addr()?; + let foreign_server = thread::spawn(move || { + let mut stream = accept_websocket(foreign_listener)?; + write_text_frame(&mut stream, foreign_reply)?; + read_until_closed(stream) + }); + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + let (sent_established, subscription) = subscribe(establish(sent_addr)?, &mut correlation)?; + let sent_established = + WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, subscription)?.send( + sent_established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new(UNSUBSCRIBE_MASK), + FRAME_TIMEOUT, + )?; + let before_foreign = correlation.outstanding_count(); + let (foreign_established, foreign_message) = read_unsubscribe_text(establish(foreign_addr)?)?; + let foreign_result = WebDriverBiDiNavigationCommittedUnsubscribeResult::parse_and_correlate( + &foreign_message, + &mut correlation, + ); + let after_foreign = correlation.outstanding_count(); + + release_sender.send(())?; + let (sent_established, original_message) = read_unsubscribe_text(sent_established)?; + let original_result = WebDriverBiDiNavigationCommittedUnsubscribeResult::parse_and_correlate( + &original_message, + &mut correlation, + ); + let after_original = correlation.outstanding_count(); + drop(sent_established); + drop(foreign_established); + let sent_extra = join_server(sent_server)?; + let foreign_bytes = join_server(foreign_server)?; + let unrelated_retained = correlation + .retire_command_for(99, WebDriverBiDiCommandKind::SessionStatus) + .is_ok(); + + assert!(sent_extra.is_empty(), "unexpected A bytes: {sent_extra:?}"); + assert!( + foreign_bytes.is_empty(), + "unexpected B bytes: {foreign_bytes:?}" + ); + assert_eq!( + (before_foreign, after_foreign, after_original), + (2, 2, 1), + "foreign response must preserve pending A until its genuine reply; foreign={foreign_result:?}, original={original_result:?}" + ); + assert!(matches!( + foreign_result, + Err( + WebDriverBiDiNavigationCommittedUnsubscribeResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { + command_id: 8 + } + } + ) + )); + assert_eq!(original_result?.command_id(), 8); + assert!(unrelated_retained); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn foreign_unsubscribe_success_preserves_pending_until_original_reply() -> TestResult<()> { + foreign_unsubscribe_reply_preserves_original_command(UNSUBSCRIBE_SUCCESS) +} + +#[test] +fn foreign_unsubscribe_error_preserves_pending_until_original_reply() -> TestResult<()> { + foreign_unsubscribe_reply_preserves_original_command(UNSUBSCRIBE_ERROR) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_pointer_click_send.rs b/crates/originweave-network/tests/webdriver_bidi_pointer_click_send.rs index 92bcc845f..6f66e1e2c 100644 --- a/crates/originweave-network/tests/webdriver_bidi_pointer_click_send.rs +++ b/crates/originweave-network/tests/webdriver_bidi_pointer_click_send.rs @@ -38,10 +38,10 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { +fn read_masked_client_frame(stream: &mut TcpStream, expected_header: u8) -> io::Result> { let mut header = [0_u8; 2]; stream.read_exact(&mut header)?; - if header[0] != 0x81 || header[1] & 0x80 == 0 { + if header[0] != expected_header || header[1] & 0x80 == 0 { return Err(io::Error::new( io::ErrorKind::InvalidData, "expected one final masked client text frame", @@ -104,7 +104,7 @@ fn pointer_click_command_writes_exact_masked_bidi_frame_and_stays_outstanding() let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; stream.write_all(OPENING_RESPONSE)?; - let command = read_masked_text_frame(&mut stream)?; + let command = read_masked_client_frame(&mut stream, 0x81)?; if command != expected_json { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -154,7 +154,7 @@ fn pointer_click_reused_mask_key_rejection_retires_correlation() -> Result<(), B let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; stream.write_all(OPENING_RESPONSE)?; - let seed = read_masked_text_frame(&mut stream)?; + let seed = read_masked_client_frame(&mut stream, 0x8a)?; if seed != b"{}" { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -176,7 +176,7 @@ fn pointer_click_reused_mask_key_rejection_retires_correlation() -> Result<(), B .read_opening_response(Duration::from_millis(500))?; let repeated_key = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); let established = - established.write_text_frame("{}", repeated_key, Duration::from_millis(500))?; + established.write_pong_frame(b"{}", repeated_key, Duration::from_millis(500))?; let command = WebDriverBiDiPointerClickCommand::new( 43, @@ -210,12 +210,16 @@ fn pointer_click_ambiguous_socket_write_keeps_correlation() -> Result<(), Box io::Result<()> { let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; stream.write_all(OPENING_RESPONSE)?; let mut first_frame_byte = [0_u8; 1]; stream.read_exact(&mut first_frame_byte)?; + seed_ready_receiver + .recv_timeout(Duration::from_secs(1)) + .map_err(|_| io::Error::other("seed Pong did not finish before peer closure"))?; drop(stream); closed_sender .send(()) @@ -232,22 +236,24 @@ fn pointer_click_ambiguous_socket_write_keeps_correlation() -> Result<(), Box Result<(), Box { - correlation.retire_command_for(44, WebDriverBiDiCommandKind::PointerClick)?; + correlation + .retire_command_for(command_id, WebDriverBiDiCommandKind::PointerClick)?; established = next; } Err(error) => { diff --git a/crates/originweave-network/tests/webdriver_bidi_session_end_command.rs b/crates/originweave-network/tests/webdriver_bidi_session_end_command.rs index 71eeecc7c..b5e99ff6e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_end_command.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_end_command.rs @@ -34,10 +34,10 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { +fn read_masked_client_frame(stream: &mut TcpStream, expected_header: u8) -> io::Result> { let mut header = [0_u8; 2]; stream.read_exact(&mut header)?; - if header[0] != 0x81 || header[1] & 0x80 == 0 { + if header[0] != expected_header || header[1] & 0x80 == 0 { return Err(io::Error::new( io::ErrorKind::InvalidData, "expected one final masked client text frame", @@ -69,7 +69,7 @@ fn session_end_command_writes_the_exact_typed_frame_without_claiming_completion( let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; stream.write_all(OPENING_RESPONSE)?; - let command = read_masked_text_frame(&mut stream)?; + let command = read_masked_client_frame(&mut stream, 0x81)?; if command != br#"{"id":11,"method":"session.end","params":{}}"# { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -119,7 +119,7 @@ fn session_end_reused_mask_key_rejection_retires_exact_correlation() -> Result<( let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; stream.write_all(OPENING_RESPONSE)?; - let seed = read_masked_text_frame(&mut stream)?; + let seed = read_masked_client_frame(&mut stream, 0x8a)?; if seed != b"{}" { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -141,7 +141,7 @@ fn session_end_reused_mask_key_rejection_retires_exact_correlation() -> Result<( .read_opening_response(Duration::from_millis(500))?; let repeated_key = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); let established = - established.write_text_frame("{}", repeated_key, Duration::from_millis(500))?; + established.write_pong_frame(b"{}", repeated_key, Duration::from_millis(500))?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); let command = WebDriverBiDiSessionEndCommand::new(13)?; diff --git a/crates/originweave-network/tests/webdriver_bidi_session_status_command_failures.rs b/crates/originweave-network/tests/webdriver_bidi_session_status_command_failures.rs index d19ae45dd..e4a394d81 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_status_command_failures.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_status_command_failures.rs @@ -40,10 +40,10 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { +fn read_masked_client_frame(stream: &mut TcpStream, expected_header: u8) -> io::Result> { let mut header = [0_u8; 2]; stream.read_exact(&mut header)?; - if header[0] != 0x81 || header[1] & 0x80 == 0 { + if header[0] != expected_header || header[1] & 0x80 == 0 { return Err(io::Error::new( io::ErrorKind::InvalidData, "expected one final masked client text frame", @@ -168,7 +168,7 @@ fn session_status_reused_mask_key_rejection_does_not_leave_correlation_outstandi let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; stream.write_all(OPENING_RESPONSE)?; - let seed = read_masked_text_frame(&mut stream)?; + let seed = read_masked_client_frame(&mut stream, 0x8a)?; if seed != b"{}" { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -190,7 +190,7 @@ fn session_status_reused_mask_key_rejection_does_not_leave_correlation_outstandi .read_opening_response(Duration::from_millis(500))?; let repeated_key = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); let established = - established.write_text_frame("{}", repeated_key, Duration::from_millis(500))?; + established.write_pong_frame(b"{}", repeated_key, Duration::from_millis(500))?; let mut correlation = WebDriverBiDiCommandCorrelation::new(); let command = WebDriverBiDiSessionStatusCommand::new(13)?; diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index a08549581..22fb9c155 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -1,15 +1,9 @@ -use std::{ - net::{Shutdown, TcpListener}, - sync::mpsc, - thread, - time::Duration, -}; +use std::{net::TcpListener, thread, time::Duration}; use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -139,63 +133,6 @@ fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { } } -#[test] -fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { - let listener = TcpListener::bind(("127.0.0.1", 0)); - assert!(listener.is_ok(), "{listener:?}"); - let Ok(listener) = listener else { - return; - }; - let local_addr = listener.local_addr(); - assert!(local_addr.is_ok(), "{local_addr:?}"); - let Ok(local_addr) = local_addr else { - return; - }; - let (release_server, await_release) = mpsc::sync_channel(0); - let server = thread::spawn(move || { - let accepted = listener.accept()?; - await_release.recv().map_err(std::io::Error::other)?; - drop(accepted); - Ok::<(), std::io::Error>(()) - }); - - let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); - let connection = connect(&endpoint); - let shutdown = connection.stream().shutdown(Shutdown::Both); - assert!(shutdown.is_ok(), "{shutdown:?}"); - - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); - assert!(key.is_ok(), "{key:?}"); - let Ok(key) = key else { - return; - }; - let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); - assert!(plan.is_ok(), "{plan:?}"); - let Ok(plan) = plan else { - return; - }; - - let write = plan.write_opening_request(Duration::from_secs(1)); - let failed_closed_without_writing = match write { - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, .. - }) => true, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - source, - }) => source.kind() == std::io::ErrorKind::InvalidInput, - _ => false, - }; - assert!(failed_closed_without_writing); - assert!(release_server.send(()).is_ok()); - - let server_result = server.join(); - assert!(server_result.is_ok(), "{server_result:?}"); - if let Ok(accept_result) = server_result { - assert!(accept_result.is_ok(), "{accept_result:?}"); - } -} - #[test] fn handshake_errors_render_actionable_fail_closed_messages() { assert_eq!( @@ -208,6 +145,39 @@ fn handshake_errors_render_actionable_fail_closed_messages() { ); } +#[test] +fn opening_request_cannot_outlive_a_one_nanosecond_deadline() +-> Result<(), Box> { + use std::io::{self, Read}; + + use originweave_network::WebDriverBiDiWebSocketOpeningWriteError; + + 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()?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut received = Vec::new(); + stream.read_to_end(&mut received)?; + Ok(received) + }); + let connection = connect(&format!("ws://{local_addr}/session/{SESSION_ID}")); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let request = plan.request_bytes().to_vec(); + let error = plan.write_opening_request(Duration::from_nanos(1)).err(); + let received = server + .join() + .map_err(|_| io::Error::other("opening deadline server panicked"))??; + + assert!(matches!( + error, + Some(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { .. }) + )); + assert!(request.starts_with(&received)); + Ok(()) +} + #[test] fn handshake_plan_rejects_tls_required_stream_and_noncanonical_client_keys() { let invalid_length = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ="); diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs index 34ce97174..61f834bf6 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -8,9 +8,13 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiSessionEndCommand, WebDriverBiDiSessionEndResult, + WebDriverBiDiSessionStatusCommand, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -119,6 +123,251 @@ fn require_peer_closed_without_frame(stream: &mut TcpStream) -> io::Result<()> { } } +fn read_received_text( + established: WebDriverBiDiWebSocketEstablished, +) -> Result< + ( + WebDriverBiDiWebSocketEstablished, + WebDriverBiDiReceivedTextMessage, + ), + Box, +> { + match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { + established, + message, + } => Ok((established, message)), + other => Err(io::Error::other(format!( + "expected a complete command response, got {other:?}" + )) + .into()), + } +} + +fn write_command_success(stream: &mut TcpStream, command_id: u64) -> io::Result<()> { + let response = format!("{{\"type\":\"success\",\"id\":{command_id},\"result\":{{}}}}"); + let length = u8::try_from(response.len()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + stream.write_all(&[0x81, length])?; + stream.write_all(response.as_bytes()) +} + +#[test] +fn established_stream_rejects_typed_command_after_raw_text_and_reader_move() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<(String, Vec)> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let first = read_masked_text(&mut stream)?; + write_command_success(&mut stream, 7)?; + let mut trailing = Vec::new(); + stream.read_to_end(&mut trailing)?; + Ok((first, trailing)) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let established = establish(&endpoint)?.write_text_frame( + r#"{"id":7,"method":"session.end","params":{}}"#, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + let (established, response) = read_received_text(established)?; + drop(response); + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + let rejected = match WebDriverBiDiSessionEndCommand::new(7)?.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::from_millis(500), + ) { + Ok(established) => { + drop(established); + None + } + Err(error) => Some(error), + }; + let (first, trailing) = server + .join() + .map_err(|_| io::Error::other("raw-before-typed test server panicked"))??; + + assert_eq!(first, r#"{"id":7,"method":"session.end","params":{}}"#); + assert!( + trailing.is_empty(), + "typed command followed untracked raw text: {trailing:?}" + ); + let error = + rejected.ok_or_else(|| io::Error::other("typed send accepted a raw-text stream"))?; + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + correlation.retire_command_for(99, WebDriverBiDiCommandKind::SessionStatus)?; + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn established_stream_rejects_raw_text_while_typed_command_is_outstanding() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<(String, Vec)> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let first = read_masked_text(&mut stream)?; + let mut trailing = Vec::new(); + stream.read_to_end(&mut trailing)?; + Ok((first, trailing)) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = WebDriverBiDiSessionEndCommand::new(7)?.send( + establish(&endpoint)?, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]), + Duration::from_millis(500), + )?; + let rejected = match established.write_text_frame( + r#"{"id":7,"method":"session.end","params":{}}"#, + WebDriverBiDiWebSocketMaskKey::new([13, 14, 15, 16]), + Duration::from_millis(500), + ) { + Ok(established) => { + drop(established); + None + } + Err(error) => Some(error), + }; + let (first, trailing) = server + .join() + .map_err(|_| io::Error::other("raw-during-typed test server panicked"))??; + + assert_eq!(first, r#"{"id":7,"method":"session.end","params":{}}"#); + assert!( + trailing.is_empty(), + "raw text bypassed pending typed dispatch: {trailing:?}" + ); + assert!( + rejected.is_some(), + "raw text write accepted a typed-command stream" + ); + assert_eq!(correlation.outstanding_count(), 1); + correlation.retire_command_for(7, WebDriverBiDiCommandKind::SessionEnd)?; + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn established_stream_preserves_dispatch_history_across_pong_readers_and_correlation() +-> 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 pongs = vec![read_masked_frame(&mut stream, 0x0a)?]; + let commands = vec![ + read_masked_text(&mut stream)?, + read_masked_text(&mut stream)?, + ]; + pongs.push(read_masked_frame(&mut stream, 0x0a)?); + write_command_success(&mut stream, MAX_WEBDRIVER_BIDI_JS_UINT)?; + write_command_success(&mut stream, 0)?; + pongs.push(read_masked_frame(&mut stream, 0x0a)?); + let mut trailing = Vec::new(); + stream.read_to_end(&mut trailing)?; + Ok((pongs, commands, trailing)) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let established = establish(&endpoint)?.write_pong_frame( + b"before", + WebDriverBiDiWebSocketMaskKey::new([17, 18, 19, 20]), + Duration::from_millis(500), + )?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = WebDriverBiDiSessionEndCommand::new(0)?.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([21, 22, 23, 24]), + Duration::from_millis(500), + )?; + let established = WebDriverBiDiSessionEndCommand::new(MAX_WEBDRIVER_BIDI_JS_UINT)?.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([25, 26, 27, 28]), + Duration::from_millis(500), + )?; + let established = established.write_pong_frame( + b"between", + WebDriverBiDiWebSocketMaskKey::new([29, 30, 31, 32]), + Duration::from_millis(500), + )?; + let mut outstanding_counts = vec![correlation.outstanding_count()]; + let (established, later_response) = read_received_text(established)?; + let later_result = + WebDriverBiDiSessionEndResult::parse_and_correlate(&later_response, &mut correlation)?; + outstanding_counts.push(correlation.outstanding_count()); + let (established, earlier_response) = read_received_text(established)?; + let earlier_result = + WebDriverBiDiSessionEndResult::parse_and_correlate(&earlier_response, &mut correlation)?; + outstanding_counts.push(correlation.outstanding_count()); + let established = established.write_pong_frame( + b"after", + WebDriverBiDiWebSocketMaskKey::new([33, 34, 35, 36]), + Duration::from_millis(500), + )?; + let mut replacement = WebDriverBiDiCommandCorrelation::new(); + let rejected = match WebDriverBiDiSessionStatusCommand::new(MAX_WEBDRIVER_BIDI_JS_UINT)?.send( + established, + &mut replacement, + WebDriverBiDiWebSocketMaskKey::new([37, 38, 39, 40]), + Duration::from_millis(500), + ) { + Ok(established) => { + drop(established); + None + } + Err(error) => Some(error), + }; + let (pongs, commands, trailing) = server + .join() + .map_err(|_| io::Error::other("dispatch-history test server panicked"))??; + + assert_eq!( + pongs, + [b"before".to_vec(), b"between".to_vec(), b"after".to_vec()] + ); + assert_eq!( + commands, + [ + r#"{"id":0,"method":"session.end","params":{}}"#.to_owned(), + format!( + "{{\"id\":{MAX_WEBDRIVER_BIDI_JS_UINT},\"method\":\"session.end\",\"params\":{{}}}}" + ), + ] + ); + assert_eq!(outstanding_counts, [2, 1, 0]); + assert_eq!(later_result.command_id(), MAX_WEBDRIVER_BIDI_JS_UINT); + assert_eq!(earlier_result.command_id(), 0); + assert!( + trailing.is_empty(), + "dispatch history reset after completed replies: {trailing:?}" + ); + let error = + rejected.ok_or_else(|| io::Error::other("another command family reused the last id"))?; + assert!(error.source().is_some()); + assert_eq!(replacement.outstanding_count(), 0); + Ok(()) +} + #[test] fn established_stream_rejects_client_mask_reuse_across_sequential_frames() -> Result<(), Box> { diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 4ac62061d..9319e2a31 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -12,6 +12,11 @@ OriginWeave needs one stable authority model even though different deployments m This is a contract baseline, not a claim that the complete network service or SDK is implemented on protected `main`. +The active #264 source proposal ends local navigation-event admission when shutdown begins and +keeps shutdown requests and replies on the original connection. Failure requires a new subscription +before admission resumes. This proposal is not shipped browser behavior; its scope and alternatives +are recorded in [ADR 0107](adr/0107-browser-protocol-adapter-strategy.md). + ## 2. Protocol design goals - transport-neutral; @@ -364,6 +369,12 @@ Protocol transport authentication proves the calling human/workload identity. Au Maps session/user-context/browsing-context capabilities into scoped OriginWeave identities. BiDi element/context identifiers remain adapter-local. +Non-shipped #264 source requires typed request identifiers to increase across a connection's entire +lifetime, including after completion, retirement or replacement of local correlation state. Raw text +uses a separate connection; Pong and reading responses do not reset this rule. Callers migrating from +the earlier draft must stop reusing request numbers and use the consuming handoff for raw sockets. +This local correlation policy does not authenticate the browser or prove an action's visible result. + ### Chrome DevTools Protocol Maps selected versioned Network/DOMSnapshot/Accessibility/Tracing and other explicitly reviewed domains. `Runtime.evaluate` is not automatically mapped to standard `browser.act`. diff --git a/docs/TRD.md b/docs/TRD.md index 3e8030012..dd1b9d80c 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -319,6 +319,11 @@ Generic network evidence retains bounded names and canonical locators while valu **Planned.** WebDriver BiDi is an evolving W3C adapter contract. Its session/user-context/browsing-context identifiers are translated into OriginWeave-scoped internal identities. Core lifetime authority is already Implemented; active PR #40 is non-shipped registry implementation evidence. +The non-shipped #264 dispatch refinement retains exclusive connection ownership and strictly +increasing typed command IDs so old local replies cannot complete a later reused request. Raw-text +work uses a separate connection. This local socket evidence does not change the adapter's Planned +status or satisfy real-browser, protected-foundation or release acceptance. + ### Chrome DevTools Protocol **Planned.** The **Chrome DevTools Protocol** supplies Chromium-specific observation, diagnostics and experimental capabilities. OriginWeave binds supported protocol versions and does not expose unrestricted Runtime evaluation as a normal agent action. diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index 8923616be..be20513a1 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -36,6 +36,90 @@ MCP version negotiation is independent of the OriginWeave Protocol version. As o ## Consequences +### Proposed refinement: registry-to-transport session provenance (2026-09-06) + +In the context of dispatching a registry-bound subscription on an established transport, facing +a valid context from session A being sent over session B, we decided for a read-only comparison +against the existing canonical external-session mapping and against a duplicate reverse registry +or registration from transport text, to reject mismatches before correlation and command bytes, +accepting one bounded lookup per subscription and rejection of adapters using unrelated session aliases. + +This supplements original-registry and current-context checks rather than replacing them. The +existing connection-generation binding continues to govern replies and teardown. Unknown or retired +session mappings fail closed without creating state. Matching protocol text is not browser-process +authentication or navigation causality. The actual loopback RED at `b4702cd5` and its successor +check the no-correlation/no-wire boundary; status remains Proposed pending exact-head gates and +protected parent-first integration, not a release or architecture approval. + +### Proposed refinement: consuming subscription teardown (2026-09-06) + +In the context of ending a navigation subscription, facing a borrowed receipt that permits continued +event admission and teardown messages crossing connections, we decided for consuming the existing +non-cloneable receipt and retaining its connection identity through dispatch and acknowledgment, +and against shared revocation flags or matching session text alone, to close local admission before +teardown without duplicating lifecycle state, accepting that even construction or transport failure +requires a new subscription before local admission can resume. + +This source proposal reuses the existing received-message wrapper and connection-aware correlation +owner. It adds no registry, dependency, reconnection or mutable revocation service. A different +connection is rejected before pending-command insertion or wire emission; a foreign success or error +cannot consume the original pending command. An unrelated outstanding command remains untouched. +The receipt and teardown command cannot be cloned. Previously admitted observations are not revoked, +and acknowledgment does not prove that buffered events have drained or that browser cleanup occurred. + +The real-socket lifetime failure is retained in commit `2c45cea8`; its successor is the constructor's +`E0382` compile-fail example, because the repaired API makes the offending receipt reuse unrepresentable. +Commit `ce6f6fd4` records three separate real-socket transport failures. The existing admission-to-teardown +path and escaped-identifier round trip remain runtime checks. Shared flags would require extra checks +at every lifetime consumer while ownership already enforces this transition. Session or identifier +equality cannot distinguish two connections. Status remains Proposed, pending exact-head gates and +parent-first protected integration; this is not a browser-runtime acceptance or release decision. + +### Proposed refinement: sealed command dispatch history (2026-09-06) + +In the context of successive browser commands on one verified connection, facing retained or +buffered replies completing a later command with the same wire identifier, we decided for one +connection-owned strictly increasing typed-command namespace and mutually exclusive raw-text and +typed-command lanes, and against receive-order counters, consuming response wrappers alone, or a +resettable correlation-table ledger, to prevent ambiguity between local dispatches with constant +memory, accepting that callers must choose increasing IDs and use separate connections for raw text. + +WebDriver BiDi permits identifier reuse; this is an OriginWeave local policy, not a protocol mandate. +The first typed ID may be zero and the JavaScript-safe maximum is usable once; exhaustion requires +an explicit new connection rather than wraparound. Pong frames and message reads preserve the mode +and last ID. All typed senders use the shared frame owner. Raw text cannot precede typed dispatch or +be inserted while typed responses are pending. The nonconsuming TCP stream borrow is removed because +a cloned handle could write outside that owner; the consuming raw-stream handoff remains available +but cannot reconstruct an upgradeable connection. No generic HTTP/TLS stream contract changes. + +The real locally-revoked opening-write test moves into the connection owner's unit module so it can +retain its OS-socket failure checks without publishing a cloneable socket. Preflight failures still +retire only the newly registered command; ambiguous I/O failures retain pending correlation. A +bounded tombstone set would eventually force arbitrary eviction or reconnection, while an unbounded +set creates lifetime memory growth. Receive ordering cannot distinguish a buffered old reply first +read after resend, and consuming wrappers misses retirement before parsing. This proposal does not +authenticate the remote browser, reject invented peer replies, or prove navigation causality. +The adapter remains non-shipped, and this refinement remains Proposed pending governance and gates. + +### Proposed refinement: original registry identity (2026-09-06) + +In the context of a connection-bound navigation subscription whose local session/context numbers +can also exist in another registry, facing the risk that a genuine receipt or admitted event +changes unrelated document authority, we decided for a core-owned opaque registry witness retained +from command construction through event admission to the shared document-mutation boundary, and +against numeric/text equality, a constructor-only check, or globally renumbering every browser +identifier, to preserve exact-owner authority before side effects, accepting one small allocation +per registry and reference-counted witnesses while commands or observations remain live. + +The proposal reuses current context-liveness and expected-epoch checks. It does not freeze a +context-wide subscription at its initial document epoch, authenticate the browser session associated +with a stream, or turn the witness into a durable ID or capability grant. The witness has no public +constructor or serialization. It cannot preserve a removed context or recreate a retired registry. +The four real-socket failures recorded at `b3ffeac9` exercise send, receipt admission, event admission +and a correctly admitted observation presented to a different mutation target. Status remains +Proposed; local source acceptance does not approve the architecture or complete hosted, protected-main +or browser compatibility gates. + OriginWeave carries adapter maintenance and version negotiation but gains a durable customer API. Multiple browser/control transports can coexist. New upstream capabilities do not silently change risk or action semantics. Compatibility matrices become release artifacts. ## Failure and degraded behavior diff --git a/docs/doctoring.md b/docs/doctoring.md index 703e1c8eb..42018a593 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -4,8 +4,182 @@ This document records external evidence that changes OriginWeave architecture, t ## Decision trace +### Subscription registry-to-transport session provenance + +The real-loopback test at `b4702cd5`, executed locally before repair, dispatched a subscription +bound to registry session A over a transport correlated to session B. The repaired sender checks +the existing canonical external-session mapping before correlation registration or command bytes. +The regression now requires a typed mismatch, zero pending commands and zero command bytes; +registry tests also cover matching, unknown, malformed, retired and re-registered identities. + +The current W3C Editor's Draft associates each WebSocket connection with at most one BiDi session. +Its connection acceptance algorithm maps the resource's session ID to an active session. OriginWeave's +read-only registry comparison is a local fail-closed policy connecting that transport evidence to +existing authority. It does not authenticate a browser process, authorize an action, or create a +session from transport text. Proposed ADR 0107 records the boundary and alternatives. + +World Wide Web Consortium. (2026, September 3). *WebDriver BiDi: Transport* [Editor's Draft]. +Retrieved September 6, 2026, from https://w3c.github.io/webdriver-bidi/#transport + +### Subscription teardown ownership and connection provenance + +The actual-socket regression at `2c45cea8` shows that constructing teardown from a borrowed receipt +still permits event admission. Three more failures at `ce6f6fd4` show 91 masked unsubscribe bytes +emitted on another connection, and foreign success/error replies consuming the original pending +command. Genuine original replies then fail as no longer outstanding. Both fixture servers are +joined before assertions; these failures are not timeouts or setup errors. + +The repair consumes the existing non-cloneable subscription receipt, stores its existing connection +identity in the teardown command, and reuses connection-aware registration and received-message +correlation. The lifetime regression becomes an `E0382` compile-fail example proving that the moved +receipt cannot create admission; its original failing runtime test remains in history. Existing +admission-to-teardown and exact-wire escaping checks retain runtime coverage. Invalid construction +also consumes local admission authority, and no failure restores it. Already admitted observations +are not revoked. Proposed ADR 0107 records why shared revocation flags add unnecessary state. + +W3C's current Editor's Draft defines by-ID removal against the session's known subscriptions and an +`EmptyResult` return type. Same-connection use and consuming local admission are stricter OriginWeave +policy, not additional requirements attributed to W3C. A protocol acknowledgment is not event-drain +or process-cleanup evidence. Browser authentication, navigation causality, hosted exact-head checks, +protected integration and release acceptance remain separate. Full repair verification is pending. + +World Wide Web Consortium. (2026, September 3). *WebDriver BiDi: The session.unsubscribe command* +[Editor's Draft]. Retrieved September 6, 2026, from https://w3c.github.io/webdriver-bidi/#command-session-unsubscribe + +### Successive command responses and exclusive stream ownership + +Actual-socket regressions at `92fd0b07` send the same subscription command ID twice on one connection. +An old successful response consumes the second pending command and creates admission against its +new binding. At `15aea15e`, the same four lifecycles show an old error retiring the new command: +completed response reuse, unparsed response after explicit retirement, replacement correlation, and +an old response buffered until after the second send. The server validates both emitted commands, +withholds the new reply until old-response parsing, and is joined before assertions. These failures +are separate from the earlier original-registry repair and its coverage evidence. + +Independent review also traced public raw text writes and a pre-upgrade TCP stream borrow. A +compile-fail test at `15aea15e` unexpectedly compiles because callers can retain a socket clone +before handing the original connection to the WebSocket owner. The Rust documentation states that +cloned handles share the same stream and socket options. Removing the nonconsuming borrow closes +that bypass; consuming raw handoff remains, without any public reconstruction path. The original +real locally-revoked socket test is preserved inside the connection owner rather than deleted. + +The bounded repair uses the existing frame owner, one private text-lane state and the last typed +command ID. IDs must strictly increase across all five typed senders on a connection. Raw and typed +text cannot mix in either order; Pong and received messages preserve dispatch history. Three further +real-socket regressions failed on forbidden wire bytes before this repair and now exercise both lane +directions and cross-kind reuse after out-of-order replies, Pong, reader moves and a new correlation +table. Masking-key fixtures now seed Pong; the ambiguous pointer-write probe uses increasing IDs so +a local freshness rejection cannot masquerade as a socket failure. Existing preflight retirement and +ambiguous-write retention are unchanged. No new dependency, parser, unbounded ledger or gate is added. + +W3C's current Editor's Draft permits command IDs to recur; it does not require recurrence. The stricter +local policy trades caller flexibility for unambiguous replies to previous local dispatches. A receive +counter cannot identify old replies first read after resend; a by-value wrapper cannot cover retirement +before parsing; a replaceable correlation table cannot own connection-lifetime history. See Proposed +ADR 0107 for consequences and alternatives. Neither local socket checks nor GitHub page inspection +prove browser authentication, unsolicited-peer response truth, navigation causality, unsubscribe +lifetime, protected integration or release readiness. Rust 1.97.1 remains the build baseline; the online +standard-library reference currently describes 1.98.1. + +Rust Project Developers. (2026). *TcpStream::try_clone*. Rust standard library documentation. +Retrieved September 6, 2026, from https://doc.rust-lang.org/std/net/struct.TcpStream.html#method.try_clone + +World Wide Web Consortium. (2026, September 3). *WebDriver BiDi: Commands* [Editor's Draft]. +Retrieved September 6, 2026, from https://w3c.github.io/webdriver-bidi/#commands + +### Original registry ownership through subscribed navigation + +Four real-socket regressions at `b3ffeac9` on published #264 `2a9fdc54` fail at distinct +use boundaries: a command created in registry A can send using registry B; its original receipt and +binding can create admission in B; a genuine original-connection event can enter B's admission; +and an observation correctly admitted in A can advance B's document. Separate real registries +legitimately allocate the same local numeric IDs. The last case does not even require matching wire +context text. Earlier unsent-binding tests covered command-instance identity, not this owner swap. +The initial fixture compile error at `c41e737b` was corrected before claiming these behavioral failures. + +The repair gives the canonical core registry one opaque process-local allocation identity and +retains its witness through the existing command, binding and subscribed observation. The existing +context validator checks ownership before numeric/text liveness checks; the sender checks before +correlation or I/O, event admission checks before replay insertion, and the shared document-advance +sink checks before mutation. Origin binding already delegates to that sink. Existing connection +and command-instance identities remain independent requirements. A rejected foreign event leaves +replay state available for the original event; moving the original registry preserves its identity. +The receipt regression also drops the original registry before constructing its replacement. The +core diagnostic contract verifies that a cloned witness matches its live owner but not a new registry +after the owner is dropped. Initial `e686b3a0` coverage passed all functions and branches but missed +one line and three regions in the core unit-crate diagnostic copy; exercising this real failure in +the existing diagnostic test closes that test gap without exclusions or production changes. +No new transport, registry implementation, global counter, dependency, exclusion or quality gate is introduced. + +Rust's standard-library documentation defines `Arc::ptr_eq` as allocation identity rather than +value equality; clones retain the same allocation. A retained witness keeps only that identity +alive, not mutable registry state, preventing a later allocation from impersonating a dropped owner. +The API uses no raw address or unsafe operation and is compiled against Rust 1.97.1. The online +reference currently describes Rust 1.98.1; it is not substituted for the pinned build evidence. +The small per-registry allocation and reference-count cost are accepted in the Proposed refinement +of ADR 0107. Numeric/text matching and constructor-only checks miss the reproduced sink swap; +global identifier renumbering would affect unrelated consumers without expressing this ownership invariant. + +This slice pins the registry that constructed the command; it does not prove that its registered +browser session is authenticated by the transport. Same-connection resend freshness, action causality, +unsubscribe lifetime/transport provenance and broader adapter ownership remain separate unfinished +boundaries. A document change alone must not retire a context-wide subscription. #264 stays Draft +behind #195/#279; local tests and numerical coverage do not prove hosted, protected-main or browser acceptance. + +### Subscription response and event receive-connection integrity + +On September 6, 2026, two real-loopback regressions against #264 `43d3b5a3a2b5ce4f51a93d1152a0ee82620f4f3e` exposed the remaining inbound transport gap. A successful response received on a second connection completed the first connection's pending subscription when session and command identifiers matched. A matching navigation event on another connection also became a state-changing observation. Test-first commit `918c4ebeb27e1eb7e03567eb52e6e547dfd499df` records both failures. + +The repair reuses the already implemented connection-owned message reader and private process-local connection generation. The typed subscription sender stores its established connection generation beside the existing private command identity. Successful response admission requires both identities; protocol-error retirement requires the same sent connection. Foreign success and error messages preserve the original pending command. The successful receipt retains that generation, and active event admission compares it before context validation, parsing or replay-history mutation. Raw assembled text remains usable for inert parsing but cannot enter these state-changing admission paths. No socket is reconnected, no caller-provided generation is accepted, and no parser, registry, dependency or transport implementation is copied. + +The real-socket tests send the same success/error payload on the foreign and original connections: foreign data is rejected, then the original success completes or original protocol error retires exactly its pending command. The event regression rejects foreign data without changing the document epoch, then admits the same event on the original connection and advances the document. Existing malformed, wrong-kind, stale-context, replay, capacity, no-write and ambiguous-write tests remain required. Fixtures that previously combined a subscription and event from separate sockets now retain the actual original socket instead of weakening those checks. + +This is an implementation repair of the existing exact-connection provenance boundary, not a new accepted architectural decision or process-authentication claim. Retaining one existing generation adds fixed receipt metadata and requires consumers to keep the connection-owned receive proof. Caller-supplied session/context strings are insufficient because distinct connections can reuse them; a second transport or registry abstraction would duplicate the current owner. The original-command identity repair below remains necessary because connection identity alone cannot distinguish separate command instances. + +Unresolved boundaries remain explicit: this slice does not bind a caller-supplied authority registry to its transport, prove freshness after a real resend reuses an id on the same connection, establish action causality, or complete unsubscribe lifecycle provenance. The current unsubscribe constructor borrows a receipt and retains its opaque identifier; its send/response path and detached admission lifetime need a separate test-first lifecycle repair. #264 stays Draft behind its unprotected prerequisite stack and #195/#279. Local socket evidence does not prove real Chromium acceptance, hosted exact-head checks, protected-main integration or release readiness. + +Private checkpoint `bd29f405a6c927b2f7d1c437dccbfb8bcec424f1` passed seven admission tests, 23 focused subscription/unsubscribe tests, all 144 Python contracts, locked Rust 1.97.1 workspace checks/tests, strict all-feature Clippy, warning-denying rustdoc, formatting and compileall. Its coverage was exactly 1273/1273 functions, 13311/13311 lines, 16973/16973 regions and 1432/1432 branches with the pinned nightly's unstable branch-instrumentation warning retained. The debug-redaction assertion exercises a genuinely received receipt in the existing socket test rather than constructing private receipt fields in a separate unit fixture. Independent read-only review found no actionable defect; it is not a counted GitHub approval. + +The separate published checkpoint `8ebcc6131a5dd6bf0b4720c2f1ff8d40d1cc39f8` also binds subscription responses and events to the sent connection. Its actual typed-send malformed, unknown and matched-error fixtures, stronger transport diagnostics and boundary documentation remain valid. An unchanged local reproduction passed all 145 Python contracts and the coverage test run, but formatting failed in five test/support files and exact coverage failed at 13327/13330 lines and 17004/17008 regions; functions were 1274/1274 and branches 1434/1434. The diagnostic artifact SHA-256 is `f352e3f04bdc3ed1912be7c452c5c426d9caaec7dd1a613b7d3ab9c6486ab69e`. + +The local integration candidate preserves both commit histories and both sets of socket regressions. It retains the published event-error variant and typed-send fixtures, carries the private original-connection recovery and document-state checks, and uses the existing shared connection-generation validator for both completion paths. A successful subscription receipt always retains a generation; an optional missing-generation state used only by a private debug fixture is removed, while the same redaction/accessor assertions run against a real receipt. No shared-branch push or combined-candidate verification is claimed by either checkpoint's earlier results. + +Follow-up local integration `bc7a51666250f08e0e2b090e63b9417a71eee0c5` ordinarily incorporates both checkpoints and the intervening remote lineage through `f87f09524b8741109c8c80c13065030b4333408f`, including `da4a11f0` shared-validation intent and `701fcaad` direct event-error diagnostics. One shared presence-and-equality validator retains the earlier command-instance diagnostic precedence without duplicating validation. Actual Rust 1.97.1 formatting reconciles the inherited style-only changes without changing any test body. Fresh locked all-target/all-feature workspace check and tests, strict Clippy, warning-denying rustdoc, formatting, all 145 Python contracts and compileall pass. Pinned coverage and its unchanged verifier pass at 1273/1273 functions, 13317/13317 lines, 16984/16984 regions and 1432/1432 branches; coverage artifact SHA-256 is `7e6f2d1450814f1c52ff36a85c62ba7918fe7bff365cb2e73d62ef6b55601082`. Independent read-only comparison found no actionable loss of valid contributor changes or authority regression. At that observation this local candidate was unpublished and excluded later remote commits; it did not inherit shared-writer authority, hosted acceptance, protected-main status or release eligibility from those results. + +After explicit shared-writer release [5558261278](https://github.com/ContextualWisdomLab/OriginWeave/pull/264#issuecomment-5558261278), ordinary merge `ad14ce1f` also incorporates the final shared checkpoint `52cff96954c3fec7b8cda5409e4560e970bfcbdf`. Its Rust and test tree is identical to verified `bc7a5166` after running the pinned formatter, while all contributor histories remain ancestors. This lineage comparison is not a substitute for fresh pre-publication gates or subsequent exact-head hosted acceptance. + +### Subscription receipt and command-instance integrity + +The following records the preceding command-identity-only repair at `43d3b5a3a2b5ce4f51a93d1152a0ee82620f4f3e`; its then-unresolved receive-connection boundary is addressed by the separate repair above. + +On September 6, 2026, real-loopback regressions against #264 `cf0f2452ea0612106f1076dcb2df58c7d6428943` reproduced two existing admission defects. A genuinely sent context-A subscription receipt accepted an unsent same-id context-B binding. After one completed subscription was consumed into unsubscribe, public typed correlation re-registration also allowed the retained response text to create another receipt without another send. Test-first commit `73f11de2232060ac7680e188db88ef7609123296` records both failures; these were not parent-adoption regressions. + +The first candidate retained numeric session/context identifiers in the existing correlation entry. A second realistic regression rejected that candidate: independent registries allocate the same local numbers, so an unsent binding in another registry still matched. The final repair instead retains one private standard-library allocation identity from command construction through its captured binding, typed sender registration and successful receipt. Admission requires the exact same command instance before the existing current-context check. Generic registration cannot provide the private identity. Successful receipt parsing validates command kind and private provenance before consuming correlation; malformed, unknown, wrong-kind and missing-provenance responses leave outstanding state untouched. Remote protocol-error retirement, local no-write retirement and ambiguous-write retention retain their previous semantics. There is no second registry, dependency or global counter. + +This is an implementation repair of the existing exact-command admission invariant, not a new browser authority or accepted architectural decision. The retained identity has a small allocation/reference-count cost and lives only as long as its command, captured bindings, outstanding entry or consumed receipt. It does not authenticate a received response or event connection, prevent a stale response being matched after an actual new send reuses the same protocol id, establish click causality, or bind a caller-supplied registry instance to a transport. Those broader provenance requirements remain unreleased work. #264 remains Draft behind #195/#279; local regressions do not prove protected-main or real-browser acceptance. + +Fresh final-tree verification passes five admission loopback tests, all 144 Python contracts without skips, the complete locked Rust 1.97.1 workspace checks/tests, strict all-feature Clippy, warning-denying rustdoc, formatting, compileall and CodeGraph sync. Pinned nightly coverage is exactly 1273/1273 functions, 13294/13294 lines, 16963/16963 regions and 1430/1430 branches; the unstable branch-instrumentation warning is retained. Independent read-only review found no production defect in the bounded identity repair and requested the now-added identical-fields/different-command regression. This review is not a counted GitHub approval. + +### Active-subscription admission parent adoption + +On September 6, 2026, #264 predecessor `9c4116b23e5b35e50bb66fff9f72d52bba3adbd0` still inherited #263's older `24fc763f0c4ae4e0dd2c62b9dca4b5bc0d23a94b` tree. Replaying the unchanged current-parent reconnect regression produced compiler RED: the connection-bound received-message reader types and `TransportConnectionMismatch` rejection variant were absent. Test-first commit `44c44b46` records that missing contract before ordinary adoption of #263 `3f22de94b63da83eaa8b5b1270912b21a3ecd006`. + +The ordinary merge preserves the complete parent production tree and the existing child admission delta. The two textual conflicts are resolved by retaining both public-contract descriptions and both required imports, not by selecting one whole side. The child still requires an exact subscription command identifier and current registry mapping, rejects null or replayed navigation identifiers, fails closed at replay-history capacity, consumes admission before unsubscribe, and admits only the subscribed observation type at document/epoch/origin mutation boundaries. Parent command-family correlation, local no-write retirement, ambiguous-write retention, and connection-bound session-end acknowledgment/closure checks remain intact. + +These are separate authority guarantees: the parent's received-response connection provenance protects session-end acknowledgment and teardown. The existing subscription/event admission still uses command identity and registry mapping; this merge does not add connection-authenticated event provenance or prove action causality. Neither a local loopback pass nor the parent CI `34009256997` proves this child's hosted acceptance. #264 remains Draft, and the earlier #195 protected-asset/workflow-generation repair and #279 protected workflow prerequisite remain unresolved. This change does not modify workflows, dependencies, protocol pins, deadline limits or acceptance gates, and does not claim protected-main delivery or browser-runtime completion. + +Fresh combined-tree verification passes nine focused reconnect/admission/document-transition loopback tests and all 144 Python repository contracts without skips. Full Rust `1.97.1` formatting, locked workspace check/tests, all-feature strict Clippy and warning-denying rustdoc pass, along with compileall, CodeGraph sync and diff checks. Pinned `nightly-2026-08-01` coverage is exactly 1271/1271 functions, 13244/13244 lines, 16919/16919 regions and 1428/1428 branches; the unstable `--branch` measurement warning remains separate. An independent read-only merge review found no changed parent transport/correlation/teardown bodies or changed child admission/document-transition bodies. Current REST formal reviews and inline comments are empty; a GraphQL rate-limit rejection prevents a new thread-resolution claim, and absence of a review is not approval. + ### Browser automation and interoperability +The subscription repair paragraph below records the historical #277 parent-only cut. +Current #264 already retains registry-to-transport session validation and consuming, +connection-bound subscription teardown described above. Ordinary parent adoption +`92e576c8` preserves those stronger child contracts and adds the parent's sealed pointer +responses while retaining monotonic typed dispatch. Actual replacement-pointer success +and error both failed at `637fd97d` before adoption. This combined source still requires +its own complete verification and does not establish protected-main or browser acceptance. + 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. @@ -156,6 +330,9 @@ Review `5120077272` remains actionable: this intermediate closure value retains ## References +The Rust Project Developers. (n.d.). *Arc in std::sync*. Rust standard library documentation. +Retrieved September 6, 2026, from https://doc.rust-lang.org/std/sync/struct.Arc.html#method.ptr_eq + Amazon Web Services. (n.d.). *Set up the Amazon EKS Pod Identity Agent*. Retrieved August 6, 2026, from https://docs.aws.amazon.com/eks/latest/userguide/pod-id-agent-setup.html Autio, C., Schwartz, R., Dunietz, J., Jain, S., Stanley, M., Tabassi, E., Hall, P., & Roberts, K. (2024). *Artificial intelligence risk management framework: Generative artificial intelligence profile* (NIST AI 600-1). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.AI.600-1 diff --git a/docs/traceability/webdriver-bidi-navigation-unsubscribe.md b/docs/traceability/webdriver-bidi-navigation-unsubscribe.md index a60b80a87..b16195059 100644 --- a/docs/traceability/webdriver-bidi-navigation-unsubscribe.md +++ b/docs/traceability/webdriver-bidi-navigation-unsubscribe.md @@ -19,6 +19,14 @@ OriginWeave uses only the by-id form and accepts the identifier only through its ## Decision +The current #264 source refinement consumes the non-cloneable subscription receipt instead of +borrowing it. The teardown command retains that receipt's private connection generation and cannot +be cloned. Its sender rejects a different connection before correlation or I/O; its response parser +accepts only the existing connection-owned received-message type and checks the registered generation +before consuming pending state. Generic public correlation registration cannot manufacture an ACK. +Receipt reuse for event admission is a compile-time ownership error, including after failed teardown +construction. Already admitted observations remain unchanged; this is not retroactive revocation. + 1. Give committed-navigation unsubscribe its own `WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe` provenance rather than reusing the subscription kind or a generic correlation path. 2. Reject an invalid frame timeout before registering correlation or writing command bytes. 3. Register the exact unsubscribe kind immediately before the first possible remote side effect. @@ -36,6 +44,9 @@ OriginWeave uses only the by-id form and accepts the identifier only through its ## Executable evidence required on the exact head +- consuming receipt reuse rejected as `E0382`, with the original actual-socket RED retained at `2c45cea8`; +- foreign-connection dispatch emits no bytes or new correlation; +- foreign success and error preserve pending state until the genuine original-connection reply; - loopback TCP → RFC 6455 opening exchange → typed committed-navigation subscribe → opaque subscription receipt → by-id unsubscribe → exact correlated `EmptyResult` success; - opaque identifier escaping across quote, backslash, control and Unicode text without logging the identifier itself; - command-id range and duplicate outstanding-id rejection; @@ -48,7 +59,16 @@ OriginWeave uses only the by-id form and accepts the identifier only through its ## Authority and follow-up -### Subscription receipt parent adoption +### Historical parent-only subscription receipt adoption + +The following checkpoint describes #263, not the current #264 tree. This child retains +consuming unsubscribe receipt ownership, original-connection dispatch and sealed reply +admission, plus registry-session and monotonic command-ID guards. Its adoption of +#263 at `92e576c8` additionally binds pointer replies to their sending connection. +Regression `637fd97d` first reproduced both replacement pointer success and error +consuming the original request (0/2 passing). The integration combines the parent's +connection registration with the child's existing typed dispatch, rather than replacing +either safeguard. Current-head verification is required for this combined tree. Ordinary merge `9e85cadc` adopts #277 `46ae62aa31e35c702cd61c16322d05c7a9c35da1` without changing either unsubscribe production module. Canonical regression replay diff --git a/tests/test_navigation_subscription_doctoring_contract.py b/tests/test_navigation_subscription_doctoring_contract.py index 407fa2df2..830c410fe 100644 --- a/tests/test_navigation_subscription_doctoring_contract.py +++ b/tests/test_navigation_subscription_doctoring_contract.py @@ -7,6 +7,8 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] SOURCE = ROOT / "crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription.rs" +RESPONSE_SOURCE = ROOT / "crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_response.rs" +ADMISSION_SOURCE = ROOT / "crates/originweave-network/src/webdriver_bidi_navigation_committed_subscription_admission.rs" DOCTORING = ROOT / "docs/doctoring.md" ADR = ROOT / "docs/adr/0103-semantic-observation-and-stale-node-identity.md" @@ -21,7 +23,7 @@ def test_doctoring_matches_provably_local_subscription_failure_retirement(self) self.assertLess( source.index("validate_frame_timeout(frame_timeout)"), - source.index(".register_command_for("), + source.index(".register_subscription_command_for_connection("), ) self.assertIn("WebDriverBiDiWebSocketFrameError::MalformedFrame", source) self.assertIn("correlation.retire_command_for(", source) @@ -43,6 +45,24 @@ def test_doctoring_matches_provably_local_subscription_failure_retirement(self) doctoring, ) + def test_subscription_receipt_and_event_use_connection_bound_messages(self) -> None: + """The typed subscription boundary must retain and compare receive-connection provenance.""" + source = SOURCE.read_text(encoding="utf-8") + response_source = RESPONSE_SOURCE.read_text(encoding="utf-8") + admission_source = ADMISSION_SOURCE.read_text(encoding="utf-8") + + self.assertIn("established.transport_evidence().connection_generation()", source) + self.assertIn("register_subscription_command_for_connection", source) + self.assertIn("&WebDriverBiDiReceivedTextMessage", response_source) + self.assertIn("received.connection_generation()", response_source) + self.assertIn("&WebDriverBiDiReceivedTextMessage", admission_source) + self.assertIn("EventConnectionMismatch", admission_source) + self.assertIn("received.connection_generation()", admission_source) + self.assertIn( + "WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(command_id, self.subscription)", + admission_source, + ) + 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")