diff --git a/CHANGELOG.md b/CHANGELOG.md index db4fe2fb5..4542111c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Carried replacement-connection 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. - Reject invalid navigation-subscription deadlines before reserving a pending request, preserving existing requests and leaving the rejected identifier reusable without sending subscription bytes. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 7060c9e64..b70e7f9dd 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -11,16 +11,18 @@ //! 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, admits a bounded navigation observation for -//! one exact registered context and URL, rotates that context's document epoch -//! only from an exact caller-captured pre-action epoch, derives and binds the -//! committed HTTP(S) URL's canonical origin to the new document, sends narrowly typed -//! `session.status` and `session.end` commands, admits typed -//! correlated status and end responses, binds `session.end` ACK and closure evidence -//! to one private process-local connection generation, observes bounded peer Close -//! or clean-EOF transport cessation, and keeps protocol/transport evidence separate -//! from explicit operational teardown observations without exposing generic JSON -//! bodies or granting browser, TLS, policy, secret, process, profile, or Agent authority. +//! its typed bounded correlated identifier, 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 +//! epoch, derives and binds the committed HTTP(S) URL's canonical origin to the new +//! document, sends narrowly typed `session.status` and `session.end` commands, +//! admits typed correlated status and end responses, binds `session.end` ACK and +//! closure evidence to one private process-local connection generation, observes +//! bounded peer Close or clean-EOF transport cessation, and keeps protocol/transport +//! evidence separate from explicit operational teardown observations without +//! exposing generic JSON bodies or granting browser, TLS, policy, secret, process, +//! profile, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -32,6 +34,8 @@ mod webdriver_bidi_json_envelope; mod webdriver_bidi_navigation_committed_postcondition; mod webdriver_bidi_navigation_committed_subscription; mod webdriver_bidi_navigation_committed_subscription_response; +mod webdriver_bidi_navigation_committed_unsubscribe; +mod webdriver_bidi_navigation_committed_unsubscribe_response; mod webdriver_bidi_navigation_document_advance; mod webdriver_bidi_navigation_document_origin; mod webdriver_bidi_pointer_click_response; @@ -84,6 +88,14 @@ pub use webdriver_bidi_navigation_committed_subscription_response::{ WebDriverBiDiNavigationCommittedSubscriptionResponseError, WebDriverBiDiNavigationCommittedSubscriptionResult, }; +pub use webdriver_bidi_navigation_committed_unsubscribe::{ + WebDriverBiDiNavigationCommittedUnsubscribeCommand, + WebDriverBiDiNavigationCommittedUnsubscribeCommandError, +}; +pub use webdriver_bidi_navigation_committed_unsubscribe_response::{ + WebDriverBiDiNavigationCommittedUnsubscribeResponseError, + WebDriverBiDiNavigationCommittedUnsubscribeResult, +}; pub use webdriver_bidi_navigation_document_advance::{ WebDriverBiDiNavigationCommittedDocumentAdvance, WebDriverBiDiNavigationCommittedDocumentAdvanceError, diff --git a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs index 55712f050..b70a8feba 100644 --- a/crates/originweave-network/src/webdriver_bidi_command_correlation.rs +++ b/crates/originweave-network/src/webdriver_bidi_command_correlation.rs @@ -28,6 +28,8 @@ pub enum WebDriverBiDiCommandKind { PointerClick, /// Context-scoped WebDriver BiDi `session.subscribe` for committed navigation. NavigationCommittedSubscription, + /// WebDriver BiDi `session.unsubscribe` for one retained committed-navigation subscription. + NavigationCommittedUnsubscribe, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe.rs new file mode 100644 index 000000000..35fd48aa3 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe.rs @@ -0,0 +1,272 @@ +use std::{error::Error, fmt, time::Duration}; + +use crate::webdriver_bidi_websocket_frame::validate_frame_timeout; +use crate::{ + MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiCommandCorrelation, + WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, + WebDriverBiDiNavigationCommittedSubscriptionResult, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_UNSUBSCRIBE_METHOD: &str = "session.unsubscribe"; + +/// One bounded WebDriver BiDi `session.unsubscribe` command for a validated subscription receipt. +/// +/// The command deliberately accepts only the typed opaque identifier returned by OriginWeave's +/// `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)] +pub struct WebDriverBiDiNavigationCommittedUnsubscribeCommand { + command_id: u64, + subscription_id: String, +} + +impl fmt::Debug for WebDriverBiDiNavigationCommittedUnsubscribeCommand { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiNavigationCommittedUnsubscribeCommand") + .field("command_id", &self.command_id) + .field("subscription_id_len", &self.subscription_id.len()) + .finish() + } +} + +impl WebDriverBiDiNavigationCommittedUnsubscribeCommand { + /// Construct one unsubscribe command from an already validated typed subscription receipt. + pub fn new( + command_id: u64, + subscription: &WebDriverBiDiNavigationCommittedSubscriptionResult, + ) -> Result { + if command_id > MAX_WEBDRIVER_BIDI_JS_UINT { + return Err( + WebDriverBiDiNavigationCommittedUnsubscribeCommandError::CommandIdOutOfRange { + command_id, + maximum_command_id: MAX_WEBDRIVER_BIDI_JS_UINT, + }, + ); + } + Ok(Self { + command_id, + subscription_id: subscription.subscription_id().to_owned(), + }) + } + + /// Return the exact local correlation identifier serialized for this command. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Register and write this exact unsubscribe command on an established verified BiDi stream. + /// + /// Invalid frame deadlines fail before correlation registration. Registration then occurs + /// before the first possible remote side effect and records the exact unsubscribe command + /// family. A frame-owner preflight rejection that proves no write began retires this exact + /// correlation; 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, + established: WebDriverBiDiWebSocketEstablished, + correlation: &mut WebDriverBiDiCommandCorrelation, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result< + WebDriverBiDiWebSocketEstablished, + WebDriverBiDiNavigationCommittedUnsubscribeCommandError, + > { + validate_frame_timeout(frame_timeout).map_err(|source| { + WebDriverBiDiNavigationCommittedUnsubscribeCommandError::FrameWrite { source } + })?; + correlation + .register_command_for( + self.command_id, + WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe, + ) + .map_err(|source| { + WebDriverBiDiNavigationCommittedUnsubscribeCommandError::Correlation { source } + })?; + let message = self.serialized(); + match established.write_text_frame(&message, masking_key, frame_timeout) { + Ok(established) => Ok(established), + Err(source) => Err(map_frame_failure(correlation, self.command_id, source)), + } + } + + fn serialized(&self) -> String { + serialize_unsubscribe_command(self.command_id, &self.subscription_id) + } +} + +fn map_frame_failure( + correlation: &mut WebDriverBiDiCommandCorrelation, + command_id: u64, + source: WebDriverBiDiWebSocketFrameError, +) -> WebDriverBiDiNavigationCommittedUnsubscribeCommandError { + if matches!( + source, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + ) { + let _retirement = correlation.retire_command_for( + command_id, + WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe, + ); + } + WebDriverBiDiNavigationCommittedUnsubscribeCommandError::FrameWrite { source } +} + +/// Fail-closed errors while constructing or sending one typed `session.unsubscribe` command. +#[derive(Debug)] +pub enum WebDriverBiDiNavigationCommittedUnsubscribeCommandError { + /// The requested command identifier is outside WebDriver BiDi's `js-uint` range. + CommandIdOutOfRange { + /// Rejected command identifier. + command_id: u64, + /// Largest JavaScript-safe identifier admitted by this boundary. + maximum_command_id: u64, + }, + /// The bounded local correlation registry rejected the command before network I/O. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// Preparing or writing the command frame failed and the transport is not reusable. + FrameWrite { + /// Exact typed bounded WebSocket frame-write failure. + source: WebDriverBiDiWebSocketFrameError, + }, +} + +impl fmt::Display for WebDriverBiDiNavigationCommittedUnsubscribeCommandError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CommandIdOutOfRange { .. } => formatter.write_str( + "WebDriver BiDi session.unsubscribe command id is outside the js-uint range", + ), + Self::Correlation { .. } => formatter + .write_str("WebDriver BiDi session.unsubscribe command correlation was rejected"), + Self::FrameWrite { .. } => { + formatter.write_str("WebDriver BiDi session.unsubscribe command frame write failed") + } + } + } +} + +impl Error for WebDriverBiDiNavigationCommittedUnsubscribeCommandError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::CommandIdOutOfRange { .. } => None, + Self::Correlation { source } => Some(source), + Self::FrameWrite { source } => Some(source), + } + } +} + +fn serialize_unsubscribe_command(command_id: u64, subscription_id: &str) -> String { + let mut message = format!( + "{{\"id\":{command_id},\"method\":\"{SESSION_UNSUBSCRIBE_METHOD}\",\"params\":{{\"subscriptions\":[\"" + ); + push_json_string_content(&mut message, subscription_id); + message.push_str("\"]}}"); + message +} + +fn push_json_string_content(output: &mut String, input: &str) { + for character in input.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\u{0008}' => output.push_str("\\b"), + '\u{000c}' => output.push_str("\\f"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + character if character <= '\u{001f}' => { + let code = character as usize; + let digits = b"0123456789abcdef"; + output.push_str("\\u00"); + output.push(char::from(digits[(code >> 4) & 0x0f])); + output.push(char::from(digits[code & 0x0f])); + } + character => output.push(character), + } + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use super::*; + + #[test] + fn serializer_preserves_utf8_and_escapes_every_json_control_class() { + let input = "quote\" slash\\ back\u{0008} form\u{000c} line\n return\r tab\t nul\u{0000} unit\u{0001} 구독"; + assert_eq!( + serialize_unsubscribe_command(42, input), + r#"{"id":42,"method":"session.unsubscribe","params":{"subscriptions":["quote\" slash\\ back\b form\f line\n return\r tab\t nul\u0000 unit\u0001 구독"]}}"# + ); + } + + #[test] + fn only_provably_local_frame_failures_retire_unsubscribe_correlation() { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + assert!( + correlation + .register_command_for(1, WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe) + .is_ok() + ); + let preflight = WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "test preflight rejection", + }; + let _ = map_frame_failure(&mut correlation, 1, preflight); + assert_eq!(correlation.outstanding_count(), 0); + + assert!( + correlation + .register_command_for(2, WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe) + .is_ok() + ); + let ambiguous = WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 1, + source: io::Error::other("test ambiguous write failure"), + }; + let _ = map_frame_failure(&mut correlation, 2, ambiguous); + assert_eq!(correlation.outstanding_count(), 1); + } + + #[test] + fn command_errors_have_stable_messages_and_typed_sources() { + let range = WebDriverBiDiNavigationCommittedUnsubscribeCommandError::CommandIdOutOfRange { + command_id: MAX_WEBDRIVER_BIDI_JS_UINT + 1, + maximum_command_id: MAX_WEBDRIVER_BIDI_JS_UINT, + }; + assert_eq!( + range.to_string(), + "WebDriver BiDi session.unsubscribe command id is outside the js-uint range" + ); + assert!(range.source().is_none()); + + let correlation = WebDriverBiDiNavigationCommittedUnsubscribeCommandError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandAlreadyOutstanding, + }; + assert_eq!( + correlation.to_string(), + "WebDriver BiDi session.unsubscribe command correlation was rejected" + ); + assert!(correlation.source().is_some()); + + let frame = WebDriverBiDiNavigationCommittedUnsubscribeCommandError::FrameWrite { + source: WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 0, + source: io::Error::other("test frame failure"), + }, + }; + assert_eq!( + frame.to_string(), + "WebDriver BiDi session.unsubscribe command frame write failed" + ); + assert!(frame.source().is_some()); + } +} 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 new file mode 100644 index 000000000..9334ea680 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_unsubscribe_response.rs @@ -0,0 +1,143 @@ +use std::{error::Error, fmt}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiWebSocketTextMessage, +}; + +/// Typed protocol acknowledgment for one correlated WebDriver BiDi `session.unsubscribe` command. +/// +/// WebDriver BiDi defines `session.UnsubscribeResult` as the extensible `EmptyResult` object. The +/// common local-end envelope parser validates the complete JSON document and requires a success +/// result object, so this boundary retains only the matched command identifier. A successful value +/// proves protocol acknowledgment only; it does not itself prove that no already-in-flight event can +/// arrive or grant any replacement browser, policy, origin, secret, or Agent authority. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiNavigationCommittedUnsubscribeResult { + command_id: u64, +} + +impl WebDriverBiDiNavigationCommittedUnsubscribeResult { + /// Parse one bounded local-end message and consume its exact typed unsubscribe correlation. + /// + /// Complete JSON and common WebDriver BiDi envelope validation occur before correlation state + /// can be consumed. A correlatable protocol-error response consumes only a matching unsubscribe + /// command and returns a typed remote failure. Events, null-id errors, malformed envelopes, + /// unknown ids, and responses for another command family fail closed without consuming the + /// outstanding command. + pub fn parse_and_correlate( + message: &WebDriverBiDiWebSocketTextMessage, + correlation: &mut WebDriverBiDiCommandCorrelation, + ) -> Result { + let envelope = WebDriverBiDiJsonEnvelope::parse(message).map_err(|source| { + WebDriverBiDiNavigationCommittedUnsubscribeResponseError::Envelope { source } + })?; + let completed = correlation + .correlate_response_for( + &envelope, + WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe, + ) + .map_err(|source| { + WebDriverBiDiNavigationCommittedUnsubscribeResponseError::Correlation { source } + })?; + + match completed.outcome() { + WebDriverBiDiCorrelatedResponseOutcome::Success => Ok(Self { + command_id: completed.command_id(), + }), + WebDriverBiDiCorrelatedResponseOutcome::Error => Err( + WebDriverBiDiNavigationCommittedUnsubscribeResponseError::RemoteProtocolError { + command_id: completed.command_id(), + }, + ), + } + } + + /// Return the exact local command identifier consumed by this protocol acknowledgment. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } +} + +/// Fail-closed failures while admitting one typed WebDriver BiDi `session.unsubscribe` response. +#[derive(Debug)] +pub enum WebDriverBiDiNavigationCommittedUnsubscribeResponseError { + /// Common local-end JSON envelope validation failed before correlation state was touched. + Envelope { + /// Exact common-envelope validation failure. + source: WebDriverBiDiJsonEnvelopeError, + }, + /// Exact command-response correlation failed without consuming unrelated state. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// The remote end returned a correlatable WebDriver BiDi protocol error for this command. + RemoteProtocolError { + /// Exact local command identifier consumed by the protocol-error response. + command_id: u64, + }, +} + +impl fmt::Display for WebDriverBiDiNavigationCommittedUnsubscribeResponseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Envelope { .. } => { + formatter.write_str("WebDriver BiDi session.unsubscribe envelope is invalid") + } + Self::Correlation { .. } => formatter + .write_str("WebDriver BiDi session.unsubscribe response correlation failed"), + Self::RemoteProtocolError { .. } => { + formatter.write_str("WebDriver BiDi session.unsubscribe returned a protocol error") + } + } + } +} + +impl Error for WebDriverBiDiNavigationCommittedUnsubscribeResponseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Envelope { source } => Some(source), + Self::Correlation { source } => Some(source), + Self::RemoteProtocolError { .. } => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_errors_have_stable_messages_and_typed_sources() { + let envelope = WebDriverBiDiNavigationCommittedUnsubscribeResponseError::Envelope { + source: WebDriverBiDiJsonEnvelopeError::InvalidJson, + }; + assert_eq!( + envelope.to_string(), + "WebDriver BiDi session.unsubscribe envelope is invalid" + ); + assert!(envelope.source().is_some()); + + let correlation = WebDriverBiDiNavigationCommittedUnsubscribeResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandNotOutstanding, + }; + assert_eq!( + correlation.to_string(), + "WebDriver BiDi session.unsubscribe response correlation failed" + ); + assert!(correlation.source().is_some()); + + let remote = + WebDriverBiDiNavigationCommittedUnsubscribeResponseError::RemoteProtocolError { + command_id: 8, + }; + assert_eq!( + remote.to_string(), + "WebDriver BiDi session.unsubscribe returned a protocol error" + ); + assert!(remote.source().is_none()); + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe.rs new file mode 100644 index 000000000..08cfe8ba9 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe.rs @@ -0,0 +1,209 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionResult, + WebDriverBiDiNavigationCommittedUnsubscribeCommand, + WebDriverBiDiNavigationCommittedUnsubscribeResult, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, 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 SUBSCRIBE_RESPONSE: &str = + r#"{"type":"success","id":7,"result":{"subscription":"sub-\"\\\n\u0001-구독"}}"#; +const UNSUBSCRIBE_RESPONSE: &[u8] = br#"{"type":"success","id":8,"result":{}}"#; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + let length = match header[1] & 0x7f { + length @ 0..=125 => usize::from(length), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test command unexpectedly required 64-bit framing", + )); + } + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn write_server_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + if payload.len() > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "test server payload unexpectedly exceeded short-frame encoding", + )); + } + stream.write_all(&[0x81, payload.len() as u8])?; + stream.write_all(payload) +} + +#[test] +fn validated_subscription_can_be_unsubscribed_without_losing_opaque_text() +-> 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 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_server_text_frame(&mut stream, SUBSCRIBE_RESPONSE.as_bytes())?; + + 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::new( + io::ErrorKind::InvalidData, + format!( + "unexpected session.unsubscribe command: {}", + String::from_utf8_lossy(&unsubscribe) + ), + )); + } + write_server_text_frame(&mut stream, UNSUBSCRIBE_RESPONSE) + }); + + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, CONTEXT_ID)?; + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let subscribe = WebDriverBiDiNavigationCommittedSubscriptionCommand::new( + 7, ®istry, session, context, CONTEXT_ID, + )?; + let established = subscribe.send( + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + + let (established, text) = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { + established, + message, + } => (established, message), + other => { + return Err(io::Error::other(format!( + "session.subscribe response produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let subscription = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &text, + &mut correlation, + )?; + assert_eq!(subscription.subscription_id(), "sub-\"\\\n\u{0001}-구독"); + + let unsubscribe = WebDriverBiDiNavigationCommittedUnsubscribeCommand::new(8, &subscription)?; + assert_eq!(unsubscribe.command_id(), 8); + let established = unsubscribe.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::from_millis(500), + )?; + assert_eq!(correlation.outstanding_count(), 1); + + let (_established, frame) = established.read_frame(Duration::from_millis(500))?; + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + let text = match assembler.push_frame(frame)? { + WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + other => { + return Err(io::Error::other(format!( + "session.unsubscribe response produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let result = WebDriverBiDiNavigationCommittedUnsubscribeResult::parse_and_correlate( + &text, + &mut correlation, + )?; + assert_eq!(result.command_id(), 8); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("session.unsubscribe command test server panicked"))??; + Ok(()) +} 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 new file mode 100644 index 000000000..989a2da7e --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unsubscribe_failures.rs @@ -0,0 +1,549 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; +use originweave_network::{ + MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiCommandCorrelation, + WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiNavigationCommittedSubscriptionCommand, + WebDriverBiDiNavigationCommittedSubscriptionResult, + WebDriverBiDiNavigationCommittedUnsubscribeCommand, + WebDriverBiDiNavigationCommittedUnsubscribeCommandError, + WebDriverBiDiNavigationCommittedUnsubscribeResponseError, + WebDriverBiDiNavigationCommittedUnsubscribeResult, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketMessageReader, + WebDriverBiDiWebSocketTextMessage, +}; + +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 SUBSCRIBE_RESPONSE: &str = + r#"{"type":"success","id":7,"result":{"subscription":"sub-\"\\\n\u0001-구독"}}"#; +const MALFORMED_UNSUBSCRIBE_RESPONSE: &[u8] = br#"{"type":"success","id":8,"result":"#; +const UNKNOWN_UNSUBSCRIBE_RESPONSE: &[u8] = br#"{"type":"success","id":9,"result":{}}"#; +const MATCHED_UNSUBSCRIBE_SUCCESS: &[u8] = br#"{"type":"success","id":8,"result":{}}"#; +const MATCHED_UNSUBSCRIBE_ERROR: &[u8] = + br#"{"type":"error","id":8,"error":"invalid argument","message":"denied"}"#; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + let length = match header[1] & 0x7f { + length @ 0..=125 => usize::from(length), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test command unexpectedly required 64-bit framing", + )); + } + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn read_empty_masked_pong_frame( + stream: &mut TcpStream, + expected_masking_key: [u8; 4], +) -> io::Result<()> { + let mut frame = [0_u8; 6]; + stream.read_exact(&mut frame)?; + let expected = [ + 0x8a, + 0x80, + expected_masking_key[0], + expected_masking_key[1], + expected_masking_key[2], + expected_masking_key[3], + ]; + if frame != expected { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one empty masked client pong frame", + )); + } + 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 document exceeds two-byte frame length", + ) + })?; + stream.write_all(&[0x81, 126])?; + stream.write_all(&length.to_be_bytes())?; + } + stream.write_all(document) +} + +fn require_no_client_command(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsubscribe command was written despite local rejection", + )), + Err(source) + if matches!( + source.kind(), + io::ErrorKind::ConnectionReset | io::ErrorKind::ConnectionAborted + ) => + { + Ok(()) + } + Err(source) => Err(source), + } +} + +fn spawn_no_command_server(listener: TcpListener) -> thread::JoinHandle> { + thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + require_no_client_command(&mut stream) + }) +} + +fn establish_websocket( + local_addr: SocketAddr, +) -> Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +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()) + }); + + 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, + )?; + let established = subscribe.send( + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + let (_established, text) = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { + established, + message, + } => (established, message), + other => { + return Err(io::Error::other(format!( + "session.subscribe response produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let subscription = WebDriverBiDiNavigationCommittedSubscriptionResult::parse_and_correlate( + &text, + &mut correlation, + )?; + + server + .join() + .map_err(|_| io::Error::other("subscription receipt test server panicked"))??; + Ok(subscription) +} + +fn read_text_over_loopback( + document: &'static [u8], +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + write_unmasked_text_frame(&mut stream, document) + }); + + let 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()); + } + }; + + server + .join() + .map_err(|_| io::Error::other("unsubscribe response test server panicked"))??; + Ok(text) +} + +#[test] +fn command_validation_and_debug_are_public_and_subscription_safe() -> Result<(), Box> { + let subscription = obtain_subscription_receipt()?; + let range = match WebDriverBiDiNavigationCommittedUnsubscribeCommand::new( + MAX_WEBDRIVER_BIDI_JS_UINT + 1, + &subscription, + ) { + Ok(_) => { + return Err( + io::Error::other("out-of-range unsubscribe command id was accepted").into(), + ); + } + Err(error) => error, + }; + assert_eq!( + range.to_string(), + "WebDriver BiDi session.unsubscribe command id is outside the js-uint range" + ); + assert!(range.source().is_none()); + assert!(matches!( + &range, + WebDriverBiDiNavigationCommittedUnsubscribeCommandError::CommandIdOutOfRange { + command_id, + maximum_command_id, + } if *command_id == MAX_WEBDRIVER_BIDI_JS_UINT + 1 + && *maximum_command_id == MAX_WEBDRIVER_BIDI_JS_UINT + )); + + 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())); + 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 mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation + .register_command_for(8, WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe)?; + let result = command.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::from_millis(500), + ); + let error = match result { + Ok(_) => { + return Err(io::Error::other("duplicate command id sent unsubscribe command").into()); + } + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi session.unsubscribe command correlation was rejected" + ); + assert!(error.source().is_some()); + assert!(matches!( + &error, + WebDriverBiDiNavigationCommittedUnsubscribeCommandError::Correlation { .. } + )); + 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 mut correlation = WebDriverBiDiCommandCorrelation::new(); + let result = command.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::ZERO, + ); + let error = match result { + Ok(_) => { + return Err(io::Error::other("zero frame timeout sent unsubscribe command").into()); + } + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi session.unsubscribe command frame write failed" + ); + assert!(error.source().is_some()); + assert!(matches!( + &error, + WebDriverBiDiNavigationCommittedUnsubscribeCommandError::FrameWrite { .. } + )); + 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)?; + 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 mut correlation = WebDriverBiDiCommandCorrelation::new(); + let result = command.send( + established, + &mut correlation, + masking_key, + Duration::from_millis(500), + ); + let error = match result { + Ok(_) => { + return Err(io::Error::other("reused masking key sent unsubscribe command").into()); + } + Err(error) => error, + }; + assert!(matches!( + &error, + WebDriverBiDiNavigationCommittedUnsubscribeCommandError::FrameWrite { + source: WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }, + } + )); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("mask-reuse test server panicked"))??; + Ok(()) +} + +#[test] +fn malformed_and_unknown_unsubscribe_responses_preserve_outstanding_correlation() +-> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation + .register_command_for(8, WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe)?; + + let malformed = read_text_over_loopback(MALFORMED_UNSUBSCRIBE_RESPONSE)?; + let error = match WebDriverBiDiNavigationCommittedUnsubscribeResult::parse_and_correlate( + &malformed, + &mut correlation, + ) { + Ok(_) => { + return Err(io::Error::other("malformed unsubscribe response was accepted").into()); + } + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi session.unsubscribe envelope is invalid" + ); + assert!(error.source().is_some()); + assert!(matches!( + &error, + WebDriverBiDiNavigationCommittedUnsubscribeResponseError::Envelope { .. } + )); + assert_eq!(correlation.outstanding_count(), 1); + + let unknown = read_text_over_loopback(UNKNOWN_UNSUBSCRIBE_RESPONSE)?; + let error = match WebDriverBiDiNavigationCommittedUnsubscribeResult::parse_and_correlate( + &unknown, + &mut correlation, + ) { + Ok(_) => { + return Err(io::Error::other("unknown unsubscribe response id was accepted").into()); + } + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi session.unsubscribe response correlation failed" + ); + assert!(error.source().is_some()); + assert!(matches!( + &error, + WebDriverBiDiNavigationCommittedUnsubscribeResponseError::Correlation { .. } + )); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn unsubscribe_response_cannot_consume_subscription_command_kind() -> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation + .register_command_for(8, WebDriverBiDiCommandKind::NavigationCommittedSubscription)?; + let matched = read_text_over_loopback(MATCHED_UNSUBSCRIBE_SUCCESS)?; + + let error = match WebDriverBiDiNavigationCommittedUnsubscribeResult::parse_and_correlate( + &matched, + &mut correlation, + ) { + Ok(_) => { + return Err(io::Error::other("unsubscribe consumed subscription correlation").into()); + } + Err(error) => error, + }; + assert!(matches!( + error, + WebDriverBiDiNavigationCommittedUnsubscribeResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandKindMismatch { + expected: WebDriverBiDiCommandKind::NavigationCommittedUnsubscribe, + actual: WebDriverBiDiCommandKind::NavigationCommittedSubscription, + }, + } + )); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn matched_unsubscribe_protocol_error_consumes_only_its_command() -> Result<(), Box> { + 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( + &matched, + &mut correlation, + ) { + Ok(_) => { + return Err( + io::Error::other("protocol-error unsubscribe response was accepted").into(), + ); + } + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi session.unsubscribe returned a protocol error" + ); + assert!(error.source().is_none()); + assert!(matches!( + &error, + WebDriverBiDiNavigationCommittedUnsubscribeResponseError::RemoteProtocolError { + command_id: 8, + } + )); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} diff --git a/docs/README.md b/docs/README.md index 03b573c54..8b5c52038 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ - [Conceptual ERD and durable domain model](erd/README.md) - [Data governance and privacy boundary](DATA_GOVERNANCE.md) - [Product and decision traceability](traceability/README.md) + - [WebDriver BiDi committed-navigation unsubscribe traceability](traceability/webdriver-bidi-navigation-unsubscribe.md) - [Documentation fitness assessment](DOCUMENTATION_FITNESS.md) - [Dated active-PR maturity evidence (2026-08-10)](evidence/2026-08-10-active-pr-maturity.md) - [Active-PR maturity delta (2026-08-11)](evidence/2026-08-11-active-pr-maturity-delta.md) diff --git a/docs/traceability/webdriver-bidi-navigation-unsubscribe.md b/docs/traceability/webdriver-bidi-navigation-unsubscribe.md new file mode 100644 index 000000000..a60b80a87 --- /dev/null +++ b/docs/traceability/webdriver-bidi-navigation-unsubscribe.md @@ -0,0 +1,69 @@ +# WebDriver BiDi committed-navigation unsubscribe traceability + +- **Status:** Active-PR evidence; not protected-main truth +- **Reviewed standard:** W3C WebDriver BiDi Working Draft, 3 September 2026 +- **Bounded context:** Navigation / WebDriver BiDi adapter +- **Consumer:** exact typed receipt from OriginWeave's committed-navigation `session.subscribe` boundary + +## Problem + +The committed-navigation subscription slice retains one bounded opaque `session.Subscription` identifier, but the predecessor unsubscribe implementation was still coupled to the earlier generic command-correlation API. After the parent stack introduced exact command-family correlation, that implementation could no longer compile or preserve the invariant that a response for one BiDi command family must not retire another outstanding command that happens to reuse the same numeric id. + +The predecessor failure contract also registered correlation before rejecting an invalid frame deadline. That contradicts the current local no-write invariant used by the adjacent subscription, pointer-click, status, and session-end transports: a deadline rejected before any command bytes can be emitted must not reserve an outstanding remote-effect correlation. + +## Standard boundary + +The 3 September 2026 WebDriver BiDi Working Draft defines `session.unsubscribe` with `session.UnsubscribeParameters = session.UnsubscribeByAttributesRequest / session.UnsubscribeByIDRequest`. The by-id request carries one or more opaque `session.Subscription` values in `subscriptions`, and `session.UnsubscribeResult` is `EmptyResult`. + +OriginWeave uses only the by-id form and accepts the identifier only through its already validated typed `session.subscribe` result. This intentionally narrower adapter does not expose arbitrary event names, context sets, user-context sets, or caller-supplied ambient subscription identifiers. + +## Decision + +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. +4. Retire that exact correlation only when the frame owner reports a local `MalformedFrame` preflight failure proving that command bytes were not emitted. Preserve correlation after partial or otherwise ambiguous write failures. +5. Admit success or protocol-error responses only through the matching unsubscribe command kind. A response with the same numeric id but a different command family fails closed without consuming the outstanding command. +6. Treat `EmptyResult` success as protocol acknowledgment only. It does not prove that already-in-flight subscribed events have drained, that navigation state is unchanged, or that any browser/process/profile cleanup completed. + +## Rejected alternatives + +- **Reuse `NavigationCommittedSubscription` for unsubscribe:** rejected because numeric command ids are routing values, not command provenance; a typed consumer must not cross-consume a different command family. +- **Restore generic `register_command` / `correlate_response`:** rejected because it would reopen the command-kind confusion repaired by the parent stack. +- **Keep correlation after an invalid local deadline:** rejected because no remote side effect can have begun; retaining a phantom outstanding command would reduce the bounded 256-command budget and make id reuse falsely ambiguous. +- **Retire correlation after any frame error:** rejected because a partial or complete command may have reached the remote end after I/O begins. +- **Treat unsubscribe ACK as event-drain evidence:** rejected because the protocol acknowledgment is not a post-condition proving absence of already-in-flight events. + +## Executable evidence required on the exact head + +- 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; +- invalid deadline rejection with zero newly outstanding unsubscribe correlations and no command write; +- malformed and unknown-id responses leaving the exact unsubscribe correlation outstanding; +- same-id wrong-command-kind response rejection without correlation consumption; +- matched protocol error consuming only its exact unsubscribe command; +- local `MalformedFrame` preflight retirement versus ambiguous frame-write retention; +- repository formatting, full Rust tests, strict Clippy, rustdoc, and owned-production function/line/region/branch coverage at 100% before any GREEN claim. + +## Authority and follow-up + +### Subscription receipt parent adoption + +Ordinary merge `9e85cadc` adopts #277 `46ae62aa31e35c702cd61c16322d05c7a9c35da1` +without changing either unsubscribe production module. Canonical regression replay +`90395f81` reproduced both replacement success and error consuming the original +subscription (0/2 passing). Adoption exposed two fixture calls that still supplied +raw messages; `cb0c4261` uses the existing sealed reader and preserves its returned +connection for the subsequent unsubscribe exchange. The escaped opaque identifier, +deadline/no-byte checks and all original unsubscribe assertions remain intact. +Unsubscribe dispatch and response provenance remain separate unfinished boundaries; +this integration must not be interpreted as authenticated subscription teardown. +Exact local and hosted gates must be revalidated for this combined head; neither +parent coverage nor predecessor screenshots establish its acceptance. + +This adapter performs no policy authorization, destination approval, browser authentication, action dispatch, semantic observation, or durable evidence escalation. The browser-domain owner remains OriginWeave; WebDriver BiDi remains an adapter. Integration into protected main remains parent-first and non-destructive, and exact-head hosted evidence does not transfer from predecessor heads. + +### References — APA 7th + +World Wide Web Consortium. (2026, September 3). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/