diff --git a/CHANGELOG.md b/CHANGELOG.md index 04110b007..5ac406ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Carried replacement-connection click-reply rejection into the navigation-observation stack while preserving its context and URL checks; observing a navigation still does not prove that a click caused it. +- Integrated current click-response prerequisites into bounded navigation observation, preserving exact registered-context and URL admission and keeping event evidence separate from causal action or document advancement. - Rejected pointer-click replies received on replacement connections without losing the original pending click; a later reply on the originating connection can still complete that protocol exchange, without claiming that the page changed. - Integrated the current click-transport prerequisites into typed click-response handling, preserving the response contracts and bounded socket-observation test adjustment without claiming a browser post-condition. - Retained the originating connection when sending a pointer click so later response validation can reject acknowledgments received through a replacement connection; sending still does not prove that the click completed. @@ -20,6 +22,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Deterministic WebDriver BiDi primary-button click serialization for an already admitted remote node: it emits one fixed `input.performActions` mouse sequence from bounded command/context/node identifiers and remains inert until a trusted adapter binds it to current session, origin, document, policy, and approval authority. - Typed outbound WebDriver BiDi primary-button click transport over the bounded client WebSocket stream: it rejects invalid frame deadlines before correlation registration, retires only the just-registered id when local frame preflight proves no command bytes were emitted, preserves correlation across ambiguous writes, and does not treat frame-write success as proof that the browser performed the click. - Typed pointer-click response admission that consumes only the exact outstanding command-kind correlation after complete envelope validation, keeps remote protocol errors distinct from success, and does not treat a protocol acknowledgment as proof that the target activated or the document changed. +- Typed WebDriver BiDi navigation-committed observation that admits only the exact event, registered browsing context, bounded navigation metadata, and caller-declared URL while granting no document-epoch, origin, action-cause, or Agent authority. - Typed outbound WebDriver BiDi `session.end` over the bounded client WebSocket stream: it serializes only the standards-defined method with empty params, rejects invalid frame deadlines before correlation registration, retires only the just-registered id when frame preflight proves no command bytes were emitted, preserves exact command-kind correlation across ambiguous writes, and does not treat frame-write success as proof that the browser session ended. - Typed `session.end` response admission that consumes only the exact outstanding command-kind correlation after complete envelope validation, preserves remote protocol errors as failures, and does not claim browser-process exit or resource cleanup from a protocol acknowledgment. - Fail-closed `session.end` teardown assessment that binds only the typed observation produced by consuming the exact transport, keeps browser-process-exit and task-profile-removal evidence unavailable until their runtime owners exist, and therefore cannot report operational completion from caller-supplied booleans. diff --git a/crates/originweave-core/src/browser_registry_external_context.rs b/crates/originweave-core/src/browser_registry_external_context.rs new file mode 100644 index 000000000..d19512663 --- /dev/null +++ b/crates/originweave-core/src/browser_registry_external_context.rs @@ -0,0 +1,22 @@ +use crate::{BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId}; + +impl BrowserAuthorityRegistry { + /// Require an external browser-protocol context identifier to name one exact registered context. + /// + /// This is a read-only adapter boundary. It reuses the registry's existing validation and + /// session/context ownership checks and never registers a context as a side effect of untrusted + /// protocol evidence. Success does not authenticate a browser process, authorize an action, + /// advance a document epoch, bind an origin, or grant reusable browser authority. + pub fn require_registered_context_external_identifier( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_identifier: &str, + ) -> Result<(), BrowserRegistryError> { + self.require_context_external_identifier( + browser_session, + browsing_context, + external_identifier, + ) + } +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 423654544..3fa97bffa 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -30,6 +30,7 @@ mod browser_protocol_operation; mod browser_registry; #[cfg(test)] mod browser_registry_coverage; +mod browser_registry_external_context; mod contracts; mod webdriver_bidi_command; mod webdriver_bidi_error_code; diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index aebb69f2c..47ee94c08 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -10,7 +10,8 @@ //! binds received fragmented text to one exact verified connection, classifies //! complete local-end JSON envelopes, tracks bounded command-response correlation, //! transports a narrowly typed pointer click, admits its typed correlated protocol -//! response, sends narrowly typed +//! response, admits a bounded navigation-committed post-condition observation for +//! one exact registered context and URL, 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 @@ -25,6 +26,7 @@ mod connection; mod webdriver_bidi_command_correlation; mod webdriver_bidi_connection; mod webdriver_bidi_json_envelope; +mod webdriver_bidi_navigation_committed_postcondition; mod webdriver_bidi_pointer_click_response; mod webdriver_bidi_pointer_click_transport; mod webdriver_bidi_received_message; @@ -60,6 +62,12 @@ pub use webdriver_bidi_json_envelope::{ MAX_WEBDRIVER_BIDI_JS_UINT, MAX_WEBDRIVER_BIDI_JSON_DEPTH, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, }; +pub use webdriver_bidi_navigation_committed_postcondition::{ + MAX_WEBDRIVER_BIDI_NAVIGATION_IDENTIFIER_BYTES, MAX_WEBDRIVER_BIDI_NAVIGATION_URL_BYTES, + WEBDRIVER_BIDI_NAVIGATION_COMMITTED_METHOD, WebDriverBiDiNavigationCommittedObservation, + WebDriverBiDiNavigationCommittedObservationError, + WebDriverBiDiNavigationCommittedProjectionError, +}; pub use webdriver_bidi_pointer_click_response::{ WebDriverBiDiPointerClickResponseError, WebDriverBiDiPointerClickResult, }; diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs index 1e97bff2f..beaa64564 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs @@ -6,11 +6,12 @@ use std::{ time::Duration, }; -use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, + WebDriverBiDiNavigationCommittedObservation, WebDriverBiDiNavigationCommittedObservationError, WebDriverBiDiSessionStatusResponseError, WebDriverBiDiSessionStatusResult, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, @@ -23,6 +24,12 @@ const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: w const SUCCESS_MESSAGE: &[u8] = br#"{"type":"success","id":7,"result":{"ready":true,"slash":"\/","upper":"\uABCD"}}"#; const EMPTY_STATUS_RESULT: &[u8] = br#"{"type":"success","id":7,"result":{}}"#; +const NAVIGATION_COMMITTED_EVENT: &[u8] = br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"a","navigation":null,"timestamp":0,"url":"x"}}"#; +const NAVIGATION_COMMITTED_UTF8_EVENT: &str = r#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"a","navigation":null,"timestamp":1,"url":"https://example.test/café"}}"#; +const NAVIGATION_COMMITTED_MISSING_CONTEXT: &[u8] = br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"navigation":null,"timestamp":0,"url":"x"}}"#; +const MALFORMED_NAVIGATION_COMMITTED_EVENT: &[u8] = + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":"#; +const OTHER_EVENT: &[u8] = br#"{"type":"event","method":"browsingContext.load","params":{}}"#; fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; @@ -41,21 +48,35 @@ 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, + "unit JSON document exceeds two-byte frame length", + ) + })?; + stream.write_all(&[0x81, 126])?; + stream.write_all(&length.to_be_bytes())?; + } + stream.write_all(document) +} + fn read_text_over_loopback( document: &'static [u8], ) -> Result> { - if document.len() > 125 { - return Err(io::Error::other("unit JSON document exceeded one-byte frame length").into()); - } - let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; stream.write_all(OPENING_RESPONSE)?; - stream.write_all(&[0x81, document.len() as u8])?; - stream.write_all(document) + write_unmasked_text_frame(&mut stream, document) }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); @@ -162,3 +183,117 @@ fn public_session_status_empty_result_fails_closed_from_unit_build() -> Result<( assert_eq!(correlation.outstanding_count(), 1); Ok(()) } + +#[test] +fn public_navigation_committed_boundary_is_exercised_from_unit_build() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "a")?; + + let event = read_text_over_loopback(NAVIGATION_COMMITTED_EVENT)?; + let observation = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, ®istry, session, context, "x", + )?; + assert_eq!(observation.browser_session(), session); + assert_eq!(observation.browsing_context(), context); + assert_eq!(observation.navigation_id(), None); + assert_eq!(observation.timestamp(), 0); + assert_eq!(observation.url(), "x"); + let debug = format!("{observation:?}"); + assert!(debug.contains("WebDriverBiDiNavigationCommittedObservation")); + assert!(debug.contains("has_navigation_id: false")); + assert!(!debug.contains("url: \"x\"")); + + let utf8_event = read_text_over_loopback(NAVIGATION_COMMITTED_UTF8_EVENT.as_bytes())?; + let utf8_observation = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &utf8_event, + ®istry, + session, + context, + "https://example.test/café", + )?; + assert_eq!(utf8_observation.timestamp(), 1); + assert_eq!(utf8_observation.url(), "https://example.test/café"); + + let wrong_url = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, ®istry, session, context, "y", + ); + let Err(wrong_url @ WebDriverBiDiNavigationCommittedObservationError::UnexpectedUrl) = + wrong_url + else { + return Err(io::Error::other("wrong URL did not fail closed").into()); + }; + assert!(!wrong_url.to_string().is_empty()); + assert!(wrong_url.source().is_none()); + + let other_context = registry.register_context(session, "b")?; + let wrong_context = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + other_context, + "x", + ); + let Err( + wrong_context @ WebDriverBiDiNavigationCommittedObservationError::ContextBinding { .. }, + ) = wrong_context + else { + return Err(io::Error::other("wrong registered context did not fail closed").into()); + }; + assert!(!wrong_context.to_string().is_empty()); + assert!(wrong_context.source().is_some()); + + let missing_context = read_text_over_loopback(NAVIGATION_COMMITTED_MISSING_CONTEXT)?; + let projection = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &missing_context, + ®istry, + session, + context, + "x", + ); + let Err(projection @ WebDriverBiDiNavigationCommittedObservationError::Projection { .. }) = + projection + else { + return Err(io::Error::other("missing context did not fail at projection").into()); + }; + assert!(!projection.to_string().is_empty()); + assert!(projection.source().is_some()); + + let malformed = read_text_over_loopback(MALFORMED_NAVIGATION_COMMITTED_EVENT)?; + let envelope = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &malformed, ®istry, session, context, "x", + ); + let Err(envelope @ WebDriverBiDiNavigationCommittedObservationError::Envelope { .. }) = + envelope + else { + return Err(io::Error::other("malformed event did not fail at envelope validation").into()); + }; + assert!(!envelope.to_string().is_empty()); + assert!(envelope.source().is_some()); + + let other_event = read_text_over_loopback(OTHER_EVENT)?; + let unexpected = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &other_event, + ®istry, + session, + context, + "x", + ); + let Err(unexpected @ WebDriverBiDiNavigationCommittedObservationError::UnexpectedEvent) = + unexpected + else { + return Err(io::Error::other("different event method was not rejected").into()); + }; + assert!(!unexpected.to_string().is_empty()); + assert!(unexpected.source().is_none()); + + let non_event = read_text_over_loopback(SUCCESS_MESSAGE)?; + assert!(matches!( + WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &non_event, ®istry, session, context, "x", + ), + Err(WebDriverBiDiNavigationCommittedObservationError::UnexpectedEvent) + )); + Ok(()) +} diff --git a/crates/originweave-network/src/webdriver_bidi_navigation_committed_postcondition.rs b/crates/originweave-network/src/webdriver_bidi_navigation_committed_postcondition.rs new file mode 100644 index 000000000..a0cadada3 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_navigation_committed_postcondition.rs @@ -0,0 +1,899 @@ +use std::{error::Error, fmt}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, +}; + +use crate::{ + MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, + WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiWebSocketTextMessage, +}; + +/// WebDriver BiDi event method that reports a committed browsing-context navigation. +pub const WEBDRIVER_BIDI_NAVIGATION_COMMITTED_METHOD: &str = "browsingContext.navigationCommitted"; + +/// Maximum UTF-8 bytes retained for one opaque WebDriver BiDi navigation identifier. +pub const MAX_WEBDRIVER_BIDI_NAVIGATION_IDENTIFIER_BYTES: usize = + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES; + +/// Maximum UTF-8 bytes retained for one observed serialized navigation URL. +pub const MAX_WEBDRIVER_BIDI_NAVIGATION_URL_BYTES: usize = 16 * 1024; + +/// A typed local-end observation that one exact registered browser context committed the expected URL. +/// +/// This value is evidence of the WebDriver BiDi event only. It does not advance an OriginWeave +/// document epoch, bind an origin, prove which action caused the navigation, or grant browser, +/// policy, node, destination, credential, process, or reusable Agent authority. +pub struct WebDriverBiDiNavigationCommittedObservation { + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + navigation_id: Option, + timestamp: u64, + url: String, +} + +impl fmt::Debug for WebDriverBiDiNavigationCommittedObservation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiNavigationCommittedObservation") + .field("browser_session", &self.browser_session.value()) + .field("browsing_context", &self.browsing_context.value()) + .field("has_navigation_id", &self.navigation_id.is_some()) + .field("timestamp", &self.timestamp) + .field("url_bytes", &self.url.len()) + .finish() + } +} + +impl WebDriverBiDiNavigationCommittedObservation { + /// Parse one complete event, bind its external context to the exact registered context, and + /// require the observed URL to equal the caller's declared post-condition URL exactly. + pub fn parse_and_match( + message: &WebDriverBiDiWebSocketTextMessage, + registry: &BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + expected_url: &str, + ) -> Result { + let envelope = WebDriverBiDiJsonEnvelope::parse(message).map_err(|source| { + WebDriverBiDiNavigationCommittedObservationError::Envelope { source } + })?; + if envelope.kind() != WebDriverBiDiJsonEnvelopeKind::Event + || envelope.method() != Some(WEBDRIVER_BIDI_NAVIGATION_COMMITTED_METHOD) + { + return Err(WebDriverBiDiNavigationCommittedObservationError::UnexpectedEvent); + } + + let projected = + NavigationCommittedProjection::parse(message.as_str()).map_err(|source| { + WebDriverBiDiNavigationCommittedObservationError::Projection { source } + })?; + registry + .require_registered_context_external_identifier( + browser_session, + browsing_context, + &projected.context, + ) + .map_err( + |source| WebDriverBiDiNavigationCommittedObservationError::ContextBinding { + source, + }, + )?; + if projected.url != expected_url { + return Err(WebDriverBiDiNavigationCommittedObservationError::UnexpectedUrl); + } + + Ok(Self { + browser_session, + browsing_context, + navigation_id: projected.navigation_id, + timestamp: projected.timestamp, + url: projected.url, + }) + } + + /// Return the exact OriginWeave browser session whose registered context matched the event. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the exact OriginWeave browsing context whose external identifier matched the event. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } + + /// Borrow the optional opaque WebDriver BiDi navigation identifier. + #[must_use] + pub fn navigation_id(&self) -> Option<&str> { + self.navigation_id.as_deref() + } + + /// Return the WebDriver BiDi monotonic event timestamp value admitted as a JavaScript uint. + #[must_use] + pub const fn timestamp(&self) -> u64 { + self.timestamp + } + + /// Borrow the exact bounded serialized URL observed in the committed-navigation event. + #[must_use] + pub fn url(&self) -> &str { + &self.url + } +} + +/// Fail-closed failures while admitting one committed-navigation post-condition observation. +#[derive(Debug)] +pub enum WebDriverBiDiNavigationCommittedObservationError { + /// The complete local-end WebDriver BiDi JSON envelope was malformed. + Envelope { + /// Underlying complete-envelope validation failure. + source: WebDriverBiDiJsonEnvelopeError, + }, + /// The message was not the exact `browsingContext.navigationCommitted` event. + UnexpectedEvent, + /// Required navigation-info fields could not be projected safely. + Projection { + /// Underlying typed navigation-info projection failure. + source: WebDriverBiDiNavigationCommittedProjectionError, + }, + /// The event's external context did not map to the exact registered OriginWeave context. + ContextBinding { + /// Underlying browser-registry authority-binding failure. + source: BrowserRegistryError, + }, + /// The committed URL did not equal the caller's declared post-condition URL exactly. + UnexpectedUrl, +} + +impl fmt::Display for WebDriverBiDiNavigationCommittedObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Envelope { .. } => formatter + .write_str("WebDriver BiDi navigation-committed envelope is invalid"), + Self::UnexpectedEvent => formatter.write_str( + "WebDriver BiDi message is not the expected navigation-committed event", + ), + Self::Projection { .. } => formatter + .write_str("WebDriver BiDi navigation-committed params are invalid"), + Self::ContextBinding { .. } => formatter.write_str( + "WebDriver BiDi navigation-committed context does not match registered authority", + ), + Self::UnexpectedUrl => formatter.write_str( + "WebDriver BiDi navigation-committed URL does not match the declared post-condition", + ), + } + } +} + +impl Error for WebDriverBiDiNavigationCommittedObservationError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Envelope { source } => Some(source), + Self::Projection { source } => Some(source), + Self::ContextBinding { source } => Some(source), + Self::UnexpectedEvent | Self::UnexpectedUrl => None, + } + } +} + +/// Fail-closed failures while projecting W3C `browsingContext.NavigationInfo` fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiNavigationCommittedProjectionError { + /// The projection cursor encountered an impossible structure after common JSON validation. + InvalidStructure, + /// A required `NavigationInfo` member was absent. + MissingRequiredMember { + /// Missing member name. + member: &'static str, + }, + /// A required `NavigationInfo` member appeared more than once. + DuplicateRequiredMember { + /// Duplicated member name. + member: &'static str, + }, + /// The external browsing-context identifier was malformed or exceeded the reviewed bound. + InvalidContextIdentifier, + /// The optional navigation identifier was malformed or exceeded the reviewed bound. + InvalidNavigationIdentifier, + /// The navigation timestamp was not a canonical WebDriver BiDi JavaScript uint. + InvalidTimestamp, + /// The serialized URL exceeded the reviewed observation resource bound. + UrlTooLarge { + /// Maximum admitted UTF-8 URL bytes. + maximum_bytes: usize, + }, +} + +impl fmt::Display for WebDriverBiDiNavigationCommittedProjectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidStructure => formatter + .write_str("navigation-committed projection encountered invalid JSON structure"), + Self::MissingRequiredMember { member } => { + write!( + formatter, + "navigation-committed params are missing {member}" + ) + } + Self::DuplicateRequiredMember { member } => write!( + formatter, + "navigation-committed params contain duplicate {member}" + ), + Self::InvalidContextIdentifier => { + formatter.write_str("navigation-committed context identifier is invalid") + } + Self::InvalidNavigationIdentifier => { + formatter.write_str("navigation-committed navigation identifier is invalid") + } + Self::InvalidTimestamp => { + formatter.write_str("navigation-committed timestamp is not a JavaScript uint") + } + Self::UrlTooLarge { maximum_bytes } => write!( + formatter, + "navigation-committed URL exceeds the {maximum_bytes}-byte observation limit" + ), + } + } +} + +impl Error for WebDriverBiDiNavigationCommittedProjectionError {} + +struct NavigationCommittedProjection { + context: String, + navigation_id: Option, + timestamp: u64, + url: String, +} + +impl NavigationCommittedProjection { + fn parse(input: &str) -> Result { + let mut cursor = ProjectionCursor::new(input); + cursor.skip_whitespace(); + if !cursor.consume_byte(b'{') { + return Err(WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure); + } + cursor.skip_whitespace(); + if cursor.consume_byte(b'}') { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::MissingRequiredMember { + member: "params", + }, + ); + } + + let mut projection = None; + loop { + cursor.skip_whitespace(); + let key = cursor + .parse_string() + .ok_or(WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure)?; + cursor.skip_whitespace(); + if !cursor.consume_byte(b':') { + return Err(WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure); + } + cursor.skip_whitespace(); + if key == "params" { + if projection.is_some() { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::DuplicateRequiredMember { + member: "params", + }, + ); + } + projection = Some(cursor.parse_params_object()?); + } else if !cursor.skip_value() { + return Err(WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure); + } + cursor.skip_whitespace(); + if cursor.consume_byte(b'}') { + break; + } + if !cursor.consume_byte(b',') { + return Err(WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure); + } + } + cursor.skip_whitespace(); + if cursor.current_byte().is_some() { + return Err(WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure); + } + projection.ok_or( + WebDriverBiDiNavigationCommittedProjectionError::MissingRequiredMember { + member: "params", + }, + ) + } +} + +struct ProjectionCursor<'a> { + input: &'a str, + index: usize, +} + +impl<'a> ProjectionCursor<'a> { + const fn new(input: &'a str) -> Self { + Self { input, index: 0 } + } + + fn current_byte(&self) -> Option { + self.input.as_bytes().get(self.index).copied() + } + + fn consume_byte(&mut self, expected: u8) -> bool { + if self.current_byte() == Some(expected) { + self.index += 1; + true + } else { + false + } + } + + fn skip_whitespace(&mut self) { + while matches!(self.current_byte(), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.index += 1; + } + } + + fn parse_params_object( + &mut self, + ) -> Result + { + if !self.consume_byte(b'{') { + return Err(WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure); + } + self.skip_whitespace(); + if self.consume_byte(b'}') { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::MissingRequiredMember { + member: "context", + }, + ); + } + + let mut context = None; + let mut navigation_seen = false; + let mut navigation_id = None; + let mut timestamp = None; + let mut url = None; + + loop { + self.skip_whitespace(); + let key = self + .parse_string() + .ok_or(WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure)?; + self.skip_whitespace(); + if !self.consume_byte(b':') { + return Err(WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure); + } + self.skip_whitespace(); + match key.as_str() { + "context" => { + if context.is_some() { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::DuplicateRequiredMember { + member: "context", + }, + ); + } + let parsed = self.parse_string().ok_or( + WebDriverBiDiNavigationCommittedProjectionError::InvalidContextIdentifier, + )?; + if !protocol_identifier_is_valid(&parsed, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES) + { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::InvalidContextIdentifier, + ); + } + context = Some(parsed); + } + "navigation" => { + if navigation_seen { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::DuplicateRequiredMember { + member: "navigation", + }, + ); + } + navigation_seen = true; + if self.consume_literal(b"null") { + navigation_id = None; + } else { + let parsed = self.parse_string().ok_or( + WebDriverBiDiNavigationCommittedProjectionError::InvalidNavigationIdentifier, + )?; + if !protocol_identifier_is_valid( + &parsed, + MAX_WEBDRIVER_BIDI_NAVIGATION_IDENTIFIER_BYTES, + ) { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::InvalidNavigationIdentifier, + ); + } + navigation_id = Some(parsed); + } + } + "timestamp" => { + if timestamp.is_some() { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::DuplicateRequiredMember { + member: "timestamp", + }, + ); + } + timestamp = Some(self.parse_js_uint()?); + } + "url" => { + if url.is_some() { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::DuplicateRequiredMember { + member: "url", + }, + ); + } + let parsed = self + .parse_string() + .ok_or(WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure)?; + if parsed.len() > MAX_WEBDRIVER_BIDI_NAVIGATION_URL_BYTES { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::UrlTooLarge { + maximum_bytes: MAX_WEBDRIVER_BIDI_NAVIGATION_URL_BYTES, + }, + ); + } + url = Some(parsed); + } + _ => { + if !self.skip_value() { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure, + ); + } + } + } + + self.skip_whitespace(); + if self.consume_byte(b'}') { + break; + } + if !self.consume_byte(b',') { + return Err(WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure); + } + } + + Ok(NavigationCommittedProjection { + context: context.ok_or( + WebDriverBiDiNavigationCommittedProjectionError::MissingRequiredMember { + member: "context", + }, + )?, + navigation_id: if navigation_seen { + navigation_id + } else { + return Err( + WebDriverBiDiNavigationCommittedProjectionError::MissingRequiredMember { + member: "navigation", + }, + ); + }, + timestamp: timestamp.ok_or( + WebDriverBiDiNavigationCommittedProjectionError::MissingRequiredMember { + member: "timestamp", + }, + )?, + url: url.ok_or( + WebDriverBiDiNavigationCommittedProjectionError::MissingRequiredMember { + member: "url", + }, + )?, + }) + } + + fn parse_js_uint(&mut self) -> Result { + let start = self.index; + if !self.skip_number() { + // Common envelope validation already proved that a complete JSON value exists here. + // Consume that non-number value so this projection cursor preserves its position while + // reporting the semantic timestamp failure at the typed boundary. + let _ = self.skip_value(); + return Err(WebDriverBiDiNavigationCommittedProjectionError::InvalidTimestamp); + } + let raw = &self.input[start..self.index]; + if !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(WebDriverBiDiNavigationCommittedProjectionError::InvalidTimestamp); + } + let value = raw + .parse::() + .map_err(|_error| WebDriverBiDiNavigationCommittedProjectionError::InvalidTimestamp)?; + if value > MAX_WEBDRIVER_BIDI_JS_UINT { + return Err(WebDriverBiDiNavigationCommittedProjectionError::InvalidTimestamp); + } + Ok(value) + } + + fn consume_literal(&mut self, literal: &[u8]) -> bool { + let end = self.index.saturating_add(literal.len()); + if self.input.as_bytes().get(self.index..end) == Some(literal) { + self.index = end; + true + } else { + false + } + } + + fn skip_value(&mut self) -> bool { + self.skip_whitespace(); + match self.current_byte() { + Some(b'"') => self.parse_string().is_some(), + Some(b'{') => self.skip_object(), + Some(b'[') => self.skip_array(), + Some(b't') => self.consume_literal(b"true"), + Some(b'f') => self.consume_literal(b"false"), + Some(b'n') => self.consume_literal(b"null"), + Some(b'-' | b'0'..=b'9') => self.skip_number(), + _ => false, + } + } + + fn skip_object(&mut self) -> bool { + if !self.consume_byte(b'{') { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b'}') { + return true; + } + loop { + self.skip_whitespace(); + if self.parse_string().is_none() { + return false; + } + self.skip_whitespace(); + if !self.consume_byte(b':') { + return false; + } + if !self.skip_value() { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b'}') { + return true; + } + if !self.consume_byte(b',') { + return false; + } + } + } + + fn skip_array(&mut self) -> bool { + if !self.consume_byte(b'[') { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b']') { + return true; + } + loop { + if !self.skip_value() { + return false; + } + self.skip_whitespace(); + if self.consume_byte(b']') { + return true; + } + if !self.consume_byte(b',') { + return false; + } + } + } + + fn skip_number(&mut self) -> bool { + let start = self.index; + while matches!( + self.current_byte(), + Some(b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9') + ) { + self.index += 1; + } + self.index > start + } + + fn parse_string(&mut self) -> Option { + if !self.consume_byte(b'"') { + return None; + } + let mut output = String::new(); + loop { + let byte = self.current_byte()?; + match byte { + b'"' => { + self.index += 1; + return Some(output); + } + b'\\' => { + self.index += 1; + if !self.parse_escape(&mut output) { + return None; + } + } + 0x00..=0x1f => return None, + _ if byte.is_ascii() => { + output.push(char::from(byte)); + self.index += 1; + } + _ => { + let width = byte.leading_ones() as usize; + let end = self.index + width; + output.push_str(&self.input[self.index..end]); + self.index = end; + } + } + } + } + + fn parse_escape(&mut self, output: &mut String) -> bool { + let Some(escape) = self.current_byte() else { + return false; + }; + self.index += 1; + match escape { + b'"' => output.push('"'), + b'\\' => output.push('\\'), + b'/' => output.push('/'), + b'b' => output.push('\u{0008}'), + b'f' => output.push('\u{000c}'), + b'n' => output.push('\n'), + b'r' => output.push('\r'), + b't' => output.push('\t'), + b'u' => return self.parse_unicode_escape(output), + _ => return false, + } + true + } + + fn parse_unicode_escape(&mut self, output: &mut String) -> bool { + let Some(first) = self.parse_hex_u16() else { + return false; + }; + if (0xd800..=0xdbff).contains(&first) { + if !self.consume_byte(b'\\') || !self.consume_byte(b'u') { + return false; + } + let Some(second) = self.parse_hex_u16() else { + return false; + }; + if !(0xdc00..=0xdfff).contains(&second) { + return false; + } + output.push_str(&String::from_utf16_lossy(&[first, second])); + true + } else if (0xdc00..=0xdfff).contains(&first) { + false + } else { + output.push_str(&String::from_utf16_lossy(&[first])); + true + } + } + + fn parse_hex_u16(&mut self) -> Option { + let mut value = 0_u16; + for _ in 0..4 { + let byte = self.current_byte()?; + let digit = match byte { + b'0'..=b'9' => u16::from(byte - b'0'), + b'a'..=b'f' => u16::from(byte - b'a' + 10), + b'A'..=b'F' => u16::from(byte - b'A' + 10), + _ => return None, + }; + value = (value << 4) | digit; + self.index += 1; + } + Some(value) + } +} + +fn protocol_identifier_is_valid(value: &str, maximum_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= maximum_bytes + && !value.chars().any(|character| { + character.is_control() + || character.is_whitespace() + || UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS.contains(&character) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projection_accepts_extensible_navigation_info_and_null_navigation() { + let projected = NavigationCommittedProjection::parse( + r#"{"type":"event","method":"browsingContext.navigationCommitted","meta":[null,true,false,-2.5e+3,{"nested":"value"}],"params":{"url":"https://example.test/\u0061fter","timestamp":42,"navigation":"nav-\ud83d\ude80","context":"context-a","vendor":{"ignored":true}}}"#, + ); + assert!(projected.is_ok()); + let projected = projected.ok(); + assert_eq!( + projected.as_ref().map(|value| value.context.as_str()), + Some("context-a") + ); + assert_eq!( + projected + .as_ref() + .and_then(|value| value.navigation_id.as_deref()), + Some("nav-🚀") + ); + assert_eq!(projected.as_ref().map(|value| value.timestamp), Some(42)); + assert_eq!( + projected.as_ref().map(|value| value.url.as_str()), + Some("https://example.test/after") + ); + + let projected = NavigationCommittedProjection::parse( + r#"{"params":{"context":"context-a","navigation":null,"timestamp":0,"url":"about:blank"}}"#, + ); + assert!(projected.is_ok()); + assert_eq!(projected.ok().and_then(|value| value.navigation_id), None); + } + + #[test] + fn projection_rejects_missing_duplicate_invalid_and_oversized_required_fields() { + let oversized_url = "x".repeat(MAX_WEBDRIVER_BIDI_NAVIGATION_URL_BYTES + 1); + let oversized_navigation = "n".repeat(MAX_WEBDRIVER_BIDI_NAVIGATION_IDENTIFIER_BYTES + 1); + let oversized_context = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1); + let cases = [ + "{}".to_owned(), + r#"{"params":{}}"#.to_owned(), + r#"{"params":{"navigation":null,"timestamp":1,"url":"x"}}"#.to_owned(), + r#"{"params":{"context":"context-a","timestamp":1,"url":"x"}}"#.to_owned(), + r#"{"params":{"context":"context-a","navigation":null,"url":"x"}}"#.to_owned(), + r#"{"params":{"context":"context-a","navigation":null,"timestamp":1}}"#.to_owned(), + r#"{"params":{},"params":{}}"#.to_owned(), + r#"{"params":{"context":"a","navigation":null,"timestamp":1,"url":"x"},"params":{"context":"b","navigation":null,"timestamp":2,"url":"y"}}"#.to_owned(), + r#"{"params":{"context":"a","context":"b","navigation":null,"timestamp":1,"url":"x"}}"#.to_owned(), + r#"{"params":{"context":"a","navigation":null,"navigation":"b","timestamp":1,"url":"x"}}"#.to_owned(), + r#"{"params":{"context":"a","navigation":null,"timestamp":1,"timestamp":2,"url":"x"}}"#.to_owned(), + r#"{"params":{"context":"a","navigation":null,"timestamp":1,"url":"x","url":"y"}}"#.to_owned(), + r#"{"params":{"context":"bad context","navigation":null,"timestamp":1,"url":"x"}}"#.to_owned(), + r#"{"params":{"context":"a","navigation":"bad nav","timestamp":1,"url":"x"}}"#.to_owned(), + r#"{"params":{"context":"a","navigation":false,"timestamp":1,"url":"x"}}"#.to_owned(), + r#"{"params":{"context":"a","navigation":null,"timestamp":1,"url":false}}"#.to_owned(), + r#"{"params":{"context":"a","navigation":null,"timestamp":-1,"url":"x"}}"#.to_owned(), + r#"{"params":{"context":"a","navigation":null,"timestamp":1.5,"url":"x"}}"#.to_owned(), + r#"{"params":{"context":"a","navigation":null,"timestamp":1,"url":"x"}}?"#.to_owned(), + r#"{"params":{"context":"a","vendor":?,"navigation":null,"timestamp":1,"url":"x"}}"#.to_owned(), + format!( + "{{\"params\":{{\"context\":\"a\",\"navigation\":null,\"timestamp\":{},\"url\":\"x\"}}}}", + MAX_WEBDRIVER_BIDI_JS_UINT + 1 + ), + format!( + "{{\"params\":{{\"context\":\"{}\",\"navigation\":null,\"timestamp\":1,\"url\":\"x\"}}}}", + oversized_context + ), + format!( + "{{\"params\":{{\"context\":\"a\",\"navigation\":\"{}\",\"timestamp\":1,\"url\":\"x\"}}}}", + oversized_navigation + ), + format!( + "{{\"params\":{{\"context\":\"a\",\"navigation\":null,\"timestamp\":1,\"url\":\"{}\"}}}}", + oversized_url + ), + ]; + for document in cases { + assert!(NavigationCommittedProjection::parse(&document).is_err()); + } + } + + #[test] + fn projection_cursor_defensive_helpers_cover_private_hostile_edges() { + for document in [ + "", + "[]", + "{?}", + r#"{"x" 1}"#, + r#"{"x":?}"#, + r#"{"x":1 ?}"#, + r#"{"params":[]}"#, + r#"{"params":{?}}"#, + r#"{"params":{"context" "a"}}"#, + r#"{"params":{"context":"a" ?}}"#, + r#"{"params":{"context":"\uD800","navigation":null,"timestamp":1,"url":"x"}}"#, + r#"{"params":{"context":"\q","navigation":null,"timestamp":1,"url":"x"}}"#, + r#"{"params":{"context":"a","navigation":null,"timestamp":?,"url":"x"}}"#, + r#"{"params":{"context":"a","navigation":null,"timestamp":18446744073709551616,"url":"x"}}"#, + ] { + assert!(NavigationCommittedProjection::parse(document).is_err()); + } + + let mut object = ProjectionCursor::new("[]"); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new("{}"); + assert!(object.skip_object()); + let mut object = ProjectionCursor::new("{?}"); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x" 1}"#); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x":?}"#); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x":1 ?}"#); + assert!(!object.skip_object()); + let mut object = ProjectionCursor::new(r#"{"x":1,"y":2}"#); + assert!(object.skip_object()); + + let mut array = ProjectionCursor::new("{}"); + assert!(!array.skip_array()); + let mut array = ProjectionCursor::new("[]"); + assert!(array.skip_array()); + let mut array = ProjectionCursor::new("[?]"); + assert!(!array.skip_array()); + let mut array = ProjectionCursor::new("[1 ?]"); + assert!(!array.skip_array()); + + for document in [r#""x""#, "{}", "[]", "true", "false", "null", "-2.5e+3"] { + let mut value = ProjectionCursor::new(document); + assert!(value.skip_value(), "{document}"); + } + let mut value = ProjectionCursor::new("?"); + assert!(!value.skip_value()); + let mut number = ProjectionCursor::new("x"); + assert!(!number.skip_number()); + let mut number = ProjectionCursor::new("+1"); + assert!(number.skip_number()); + + for invalid in [ + "\"abc\\", + "\"abc", + "\"a\n\"", + "\"\\u12", + r#""\uD800""#, + r#""\uDC00""#, + r#""\uD800\x""#, + r#""\uD800\uZZZZ""#, + r#""\uD800\u0041""#, + r#""\uZZZZ""#, + r#""\q""#, + ] { + let mut string = ProjectionCursor::new(invalid); + assert!(string.parse_string().is_none()); + } + + let mut escaped = ProjectionCursor::new(r#""\"\\\/\b\f\n\r\t""#); + assert_eq!( + escaped.parse_string().as_deref(), + Some("\"\\/\u{0008}\u{000c}\n\r\t") + ); + + assert!(!protocol_identifier_is_valid( + "", + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + )); + assert!(!protocol_identifier_is_valid( + "\u{0000}", + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + )); + } + + #[test] + fn projection_error_display_is_specific_and_source_free() { + let errors = [ + WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure, + WebDriverBiDiNavigationCommittedProjectionError::MissingRequiredMember { + member: "url", + }, + WebDriverBiDiNavigationCommittedProjectionError::DuplicateRequiredMember { + member: "url", + }, + WebDriverBiDiNavigationCommittedProjectionError::InvalidContextIdentifier, + WebDriverBiDiNavigationCommittedProjectionError::InvalidNavigationIdentifier, + WebDriverBiDiNavigationCommittedProjectionError::InvalidTimestamp, + WebDriverBiDiNavigationCommittedProjectionError::UrlTooLarge { + maximum_bytes: MAX_WEBDRIVER_BIDI_NAVIGATION_URL_BYTES, + }, + ]; + for error in errors { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_postcondition.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_postcondition.rs new file mode 100644 index 000000000..52f1412b3 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_postcondition.rs @@ -0,0 +1,492 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + WebDriverBiDiPointerClickCommand, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiNavigationCommittedObservation, WebDriverBiDiNavigationCommittedObservationError, + WebDriverBiDiNavigationCommittedProjectionError, WebDriverBiDiPointerClickResult, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiWebSocketMessageReader, WebDriverBiDiWebSocketTextMessage, + send_webdriver_bidi_pointer_click, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +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"; +const CLICK_SUCCESS_RESPONSE: &[u8] = br#"{"type":"success","id":42,"result":{}}"#; +const NAVIGATION_COMMITTED_EVENT: &[u8] = br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":"nav-42","timestamp":1234,"url":"https://example.test/after","vendorExtension":{"ignored":true}}}"#; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + let marker = header[1] & 0x7f; + let length = match marker { + 0..=125 => usize::from(marker), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client frame length exceeds usize", + ) + })? + } + _ => unreachable!(), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn 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 assemble_text( + assembler: &mut WebDriverBiDiWebSocketMessageAssembler, + frame: originweave_network::WebDriverBiDiWebSocketFrame, +) -> Result> { + match assembler.push_frame(frame)? { + WebDriverBiDiWebSocketMessageAssembly::Text(text) => Ok(text), + other => Err(io::Error::other(format!( + "navigation post-condition produced unexpected assembly state: {other:?}" + )) + .into()), + } +} + +fn click_then_observe_navigation_with_event( + event_payload: &[u8], +) -> Result< + ( + WebDriverBiDiWebSocketTextMessage, + BrowserAuthorityRegistry, + originweave_core::BrowserSessionId, + originweave_core::BrowsingContextId, + ), + Box, +> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let expected = WebDriverBiDiPointerClickCommand::new( + 42, + "context-a", + &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, + )?; + let expected_json = expected.as_json().as_bytes().to_vec(); + let event_payload = event_payload.to_vec(); + + 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_json { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected input.performActions pointer-click command", + )); + } + write_unmasked_text_frame(&mut stream, CLICK_SUCCESS_RESPONSE)?; + write_unmasked_text_frame(&mut stream, &event_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 command = WebDriverBiDiPointerClickCommand::new( + 42, + "context-a", + &WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?, + )?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = send_webdriver_bidi_pointer_click( + &command, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + + let WebDriverBiDiConnectionMessageRead::Text { + established, + message: response_text, + } = WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + else { + return Err(io::Error::other("expected a complete connection-bound click reply").into()); + }; + let response = + WebDriverBiDiPointerClickResult::parse_and_correlate(&response_text, &mut correlation)?; + if response.command_id() != 42 || correlation.outstanding_count() != 0 { + return Err( + io::Error::other("pointer-click acknowledgment was not correlated exactly").into(), + ); + } + + let (_established, event_frame) = established.read_frame(Duration::from_millis(500))?; + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + let event_text = assemble_text(&mut assembler, event_frame)?; + server + .join() + .map_err(|_| io::Error::other("navigation post-condition test server panicked"))??; + + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "context-a")?; + Ok((event_text, registry, session, context)) +} + +fn click_then_observe_navigation() -> Result< + ( + WebDriverBiDiWebSocketTextMessage, + BrowserAuthorityRegistry, + originweave_core::BrowserSessionId, + originweave_core::BrowsingContextId, + ), + Box, +> { + click_then_observe_navigation_with_event(NAVIGATION_COMMITTED_EVENT) +} + +#[test] +fn navigation_committed_event_proves_exact_context_and_declared_url_post_condition() +-> Result<(), Box> { + let (event, registry, session, context) = click_then_observe_navigation()?; + let observation = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + )?; + + assert_eq!(observation.browser_session(), session); + assert_eq!(observation.browsing_context(), context); + assert_eq!(observation.navigation_id(), Some("nav-42")); + assert_eq!(observation.timestamp(), 1234); + assert_eq!(observation.url(), EXPECTED_URL); + let debug = format!("{observation:?}"); + assert!(debug.contains("has_navigation_id: true")); + assert!(debug.contains("url_bytes")); + assert!(!debug.contains("nav-42")); + assert!(!debug.contains(EXPECTED_URL)); + Ok(()) +} + +#[test] +fn navigation_observation_accepts_extensible_valid_json_without_retaining_extension_values() +-> Result<(), Box> { + let payload = r#"{"type":"event","method":"browsingContext.navigationCommitted","meta":{"escaped":"\"\\\/\b\f\n\r\t\u0061\ud83d\ude80","items":[null,true,false,-2.5e+3,{"nested":"value"}]},"params":{"context":"context-a","navigation":null,"timestamp":0,"url":"https:\/\/example.test\/after","vendor":["café",{"nested":true}]}}"#.as_bytes(); + let (event, registry, session, context) = click_then_observe_navigation_with_event(payload)?; + let observation = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + )?; + assert_eq!(observation.navigation_id(), None); + assert_eq!(observation.timestamp(), 0); + assert_eq!(observation.url(), EXPECTED_URL); + let debug = format!("{observation:?}"); + assert!(debug.contains("has_navigation_id: false")); + assert!(!debug.contains("café")); + Ok(()) +} + +#[test] +fn navigation_observation_fails_closed_for_envelope_event_and_projection_errors() +-> Result<(), Box> { + let malformed = br#"{"type":"event","method":"browsingContext.navigationCommitted","params":"#; + let (event, registry, session, context) = click_then_observe_navigation_with_event(malformed)?; + let envelope_error = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + ); + let Err(WebDriverBiDiNavigationCommittedObservationError::Envelope { source }) = envelope_error + else { + return Err(io::Error::other("malformed event did not fail at envelope validation").into()); + }; + assert!(!source.to_string().is_empty()); + let envelope_error = WebDriverBiDiNavigationCommittedObservationError::Envelope { source }; + assert!(!envelope_error.to_string().is_empty()); + assert!(envelope_error.source().is_some()); + + let other_event = br#"{"type":"event","method":"browsingContext.load","params":{}}"#; + let (event, registry, session, context) = + click_then_observe_navigation_with_event(other_event)?; + let unexpected = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + ); + let Err(unexpected @ WebDriverBiDiNavigationCommittedObservationError::UnexpectedEvent) = + unexpected + else { + return Err(io::Error::other("different event method was not rejected").into()); + }; + assert!(!unexpected.to_string().is_empty()); + assert!(unexpected.source().is_none()); + + let non_event = CLICK_SUCCESS_RESPONSE; + let (event, registry, session, context) = click_then_observe_navigation_with_event(non_event)?; + let unexpected = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + ); + let Err(unexpected @ WebDriverBiDiNavigationCommittedObservationError::UnexpectedEvent) = + unexpected + else { + return Err(io::Error::other("non-event WebDriver BiDi envelope was not rejected").into()); + }; + assert!(!unexpected.to_string().is_empty()); + assert!(unexpected.source().is_none()); + + let missing_context = br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"navigation":null,"timestamp":1,"url":"https://example.test/after"}}"#; + let (event, registry, session, context) = + click_then_observe_navigation_with_event(missing_context)?; + let projection = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + ); + let Err(WebDriverBiDiNavigationCommittedObservationError::Projection { source }) = projection + else { + return Err(io::Error::other("missing context did not fail at typed projection").into()); + }; + assert!(matches!( + source, + WebDriverBiDiNavigationCommittedProjectionError::MissingRequiredMember { + member: "context" + } + )); + let projection_error = WebDriverBiDiNavigationCommittedObservationError::Projection { source }; + assert!(!projection_error.to_string().is_empty()); + assert!(projection_error.source().is_some()); + Ok(()) +} + +#[test] +fn navigation_observation_rejects_valid_json_with_invalid_required_values() +-> Result<(), Box> { + let cases: &[&[u8]] = &[ + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"bad context","navigation":null,"timestamp":1,"url":"https://example.test/after"}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"","navigation":null,"timestamp":1,"url":"https://example.test/after"}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"bad\u0001context","navigation":null,"timestamp":1,"url":"https://example.test/after"}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":false,"timestamp":1,"url":"https://example.test/after"}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":null,"timestamp":-1,"url":"https://example.test/after"}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":null,"timestamp":1.5,"url":"https://example.test/after"}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":null,"timestamp":"1","url":"https://example.test/after"}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":null,"timestamp":1,"url":false}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":null,"timestamp":18446744073709551616,"url":"https://example.test/after"}}"#, + ]; + for payload in cases { + let (event, registry, session, context) = + click_then_observe_navigation_with_event(payload)?; + let result = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + ); + assert!(matches!( + result, + Err(WebDriverBiDiNavigationCommittedObservationError::Projection { .. }) + )); + } + + let oversized_context = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1); + let oversized_payload = format!( + r#"{{"type":"event","method":"browsingContext.navigationCommitted","params":{{"context":"{oversized_context}","navigation":null,"timestamp":1,"url":"https://example.test/after"}}}}"# + ); + let (event, registry, session, context) = + click_then_observe_navigation_with_event(oversized_payload.as_bytes())?; + let result = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + ); + assert!(matches!( + result, + Err( + WebDriverBiDiNavigationCommittedObservationError::Projection { + source: WebDriverBiDiNavigationCommittedProjectionError::InvalidContextIdentifier + } + ) + )); + Ok(()) +} + +#[test] +fn navigation_projection_errors_expose_specific_public_diagnostics() { + let cases = [ + ( + WebDriverBiDiNavigationCommittedProjectionError::InvalidStructure, + "navigation-committed projection encountered invalid JSON structure", + ), + ( + WebDriverBiDiNavigationCommittedProjectionError::MissingRequiredMember { + member: "url", + }, + "navigation-committed params are missing url", + ), + ( + WebDriverBiDiNavigationCommittedProjectionError::DuplicateRequiredMember { + member: "url", + }, + "navigation-committed params contain duplicate url", + ), + ( + WebDriverBiDiNavigationCommittedProjectionError::InvalidContextIdentifier, + "navigation-committed context identifier is invalid", + ), + ( + WebDriverBiDiNavigationCommittedProjectionError::InvalidNavigationIdentifier, + "navigation-committed navigation identifier is invalid", + ), + ( + WebDriverBiDiNavigationCommittedProjectionError::InvalidTimestamp, + "navigation-committed timestamp is not a JavaScript uint", + ), + ( + WebDriverBiDiNavigationCommittedProjectionError::UrlTooLarge { + maximum_bytes: originweave_network::MAX_WEBDRIVER_BIDI_NAVIGATION_URL_BYTES, + }, + "navigation-committed URL exceeds the 16384-byte observation limit", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + let source: &dyn Error = &error; + assert!(source.source().is_none()); + } +} + +#[test] +fn navigation_observation_fails_closed_for_wrong_url_or_registered_context() +-> Result<(), Box> { + let (event, mut registry, session, context) = click_then_observe_navigation()?; + + let wrong_url = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + "https://example.test/not-the-post-condition", + ); + let Err(wrong_url @ WebDriverBiDiNavigationCommittedObservationError::UnexpectedUrl) = + wrong_url + else { + return Err(io::Error::other("wrong URL did not fail closed").into()); + }; + assert!(!wrong_url.to_string().is_empty()); + assert!(wrong_url.source().is_none()); + + let other_context = registry.register_context(session, "context-b")?; + let wrong_context = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + other_context, + EXPECTED_URL, + ); + let Err(WebDriverBiDiNavigationCommittedObservationError::ContextBinding { source }) = + wrong_context + else { + return Err(io::Error::other("wrong registered context did not fail closed").into()); + }; + assert!(!source.to_string().is_empty()); + let context_error = WebDriverBiDiNavigationCommittedObservationError::ContextBinding { source }; + assert!(!context_error.to_string().is_empty()); + assert!(context_error.source().is_some()); + assert_eq!(registry.current_context_epoch(session, context)?.value(), 1); + assert_eq!( + registry + .current_context_epoch(session, other_context)? + .value(), + 1 + ); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_semantic_boundaries.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_semantic_boundaries.rs new file mode 100644 index 000000000..b960fffac --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_semantic_boundaries.rs @@ -0,0 +1,146 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; +use originweave_network::{ + WebDriverBiDiNavigationCommittedObservation, WebDriverBiDiNavigationCommittedObservationError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketTextMessage, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +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_event(payload: &[u8]) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let payload = payload.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 semantic-boundary event produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + server + .join() + .map_err(|_| io::Error::other("navigation semantic-boundary server panicked"))??; + Ok(text) +} + +#[test] +fn navigation_committed_rejects_valid_json_semantic_type_and_js_uint_boundaries_after_transport() +-> Result<(), Box> { + let cases: &[&[u8]] = &[ + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":null,"timestamp":"1","url":"https://example.test/after"}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":null,"timestamp":9007199254740992,"url":"https://example.test/after"}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":null,"timestamp":1,"url":false}}"#, + ]; + + for payload in cases { + let event = receive_event(payload)?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "context-a")?; + let result = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + ); + assert!(matches!( + result, + Err(WebDriverBiDiNavigationCommittedObservationError::Projection { .. }) + )); + } + Ok(()) +} + +#[test] +fn navigation_committed_ignores_extension_strings_with_all_json_escapes_after_transport() +-> Result<(), Box> { + let event = receive_event( + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"context-a","navigation":null,"timestamp":1,"url":"https://example.test/after","extension":"\"\\\/\b\f\n\r\t"}}"#, + )?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "context-a")?; + + let observation = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + )?; + + assert_eq!(observation.browser_session(), session); + assert_eq!(observation.browsing_context(), context); + assert_eq!(observation.navigation_id(), None); + assert_eq!(observation.timestamp(), 1); + assert_eq!(observation.url(), EXPECTED_URL); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unicode_fail_closed.rs b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unicode_fail_closed.rs new file mode 100644 index 000000000..a75273137 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_navigation_committed_unicode_fail_closed.rs @@ -0,0 +1,148 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{BrowserAuthorityRegistry, WebDriverBiDiWebSocketEndpoint}; +use originweave_network::{ + WebDriverBiDiNavigationCommittedObservation, WebDriverBiDiNavigationCommittedObservationError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketTextMessage, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +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( + payload: &[u8], +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let payload = payload.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 event produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + server + .join() + .map_err(|_| io::Error::other("navigation unicode test server panicked"))??; + Ok(text) +} + +#[test] +fn navigation_committed_unicode_scalar_failures_remain_fail_closed_after_real_transport() +-> Result<(), Box> { + let cases: &[&[u8]] = &[ + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"\uD800","navigation":null,"timestamp":1,"url":"https://example.test/after"}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"\uDC00","navigation":null,"timestamp":1,"url":"https://example.test/after"}}"#, + br#"{"type":"event","method":"browsingContext.navigationCommitted","params":{"context":"\uD800\u0041","navigation":null,"timestamp":1,"url":"https://example.test/after"}}"#, + ]; + + for payload in cases { + let event = receive_navigation_event(payload)?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "context-a")?; + let result = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + ); + assert!(matches!( + result, + Err(WebDriverBiDiNavigationCommittedObservationError::Envelope { .. }) + | Err(WebDriverBiDiNavigationCommittedObservationError::Projection { .. }) + )); + } + Ok(()) +} + +#[test] +fn navigation_committed_extensible_string_escapes_survive_real_transport() +-> Result<(), Box> { + let payload = r#"{"type":"event","method":"browsingContext.navigationCommitted","meta":"quote:\" slash:\/ backslash:\\ back:\b form:\f newline:\n return:\r tab:\t bmp:\u00AF raw:é","params":{"context":"context-a","navigation":"nav-\u0061","timestamp":1,"url":"https://example.test/after"}}"#; + let event = receive_navigation_event(payload.as_bytes())?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "context-a")?; + + let observed = WebDriverBiDiNavigationCommittedObservation::parse_and_match( + &event, + ®istry, + session, + context, + EXPECTED_URL, + )?; + + assert_eq!(observed.browser_session(), session); + assert_eq!(observed.browsing_context(), context); + assert_eq!(observed.navigation_id(), Some("nav-a")); + assert_eq!(observed.timestamp(), 1); + assert_eq!(observed.url(), EXPECTED_URL); + Ok(()) +} diff --git a/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md index 20185e98d..d7b4affce 100644 --- a/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md +++ b/docs/doctoring/webdriver-bidi-received-response-connection-provenance.md @@ -64,7 +64,29 @@ PR #258 predecessor `f2ceabb3ea50b1959e936503c50cae12f3e6e480` failed the expect Fresh integrated-tree verification passed nine click response/send tests, five foreign-response/teardown tests, 142 Python contracts, compileall and the complete Rust 1.97.1 quality gates. Pinned coverage measured 1100 functions, 11185 lines, 14272 regions and 1214 branches at 100%, with the unstable branch-option warning retained. The current typed click response is protocol correlation only; the inherited `session.end` received-connection proof does not automatically authenticate this separate response API or prove browser navigation. -## References +### Navigation-observation child integration + +PR #259 predecessor `e1105ddf86f6c79443af8b4d306b9d34cb703c17` collected zero inherited release-contract tests and failed the expected-one assertion before ordinary adoption of current #258 `5f830324f6d5a47ac213a57528ef95649bdfd0df`. Seven child production/test blobs remain byte-identical, including registered-context admission, navigation projection, public-envelope boundary tests and all three navigation integration suites. The crate-documentation conflict retains both the current connection-provenance description and the child observation boundary. Matching an event to an existing context and exact URL does not authenticate its transport, prove click causation or advance a document epoch. + +Fresh verification passed ten navigation tests, five received-connection/teardown tests, all 142 Python contracts, compileall and complete Rust 1.97.1 quality gates. Pinned coverage measured 1140 functions, 11841 lines, 15129 regions and 1332 branches at 100%, with its unstable branch-option warning retained. Local event projection, context binding and URL admission remain distinct from hosted security acceptance, origin rebinding, document advancement and a released Chromium workflow. + +### Navigation owner adopts the pointer receipt repair + +On #259 `91d95423cf31947f691db5ebbd3072c481d86542`, canonical regression replay +`6999252e` reproduced the replacement-connection failure (zero passed, one failed). +Ordinary merge `6cb911f2` retains parent #258 +`5417ce32ed957aa166807f1023647caccc2920cb` and both release-note histories. +The parent's stronger receipt test replaces only the replayed older test form. +The merged fixture then failed compilation because it still passed bare text to +the click-response consumer. `d1fd06bf` reads that acknowledgment with the sealed +reader and keeps its returned connection for the following navigation event. +No child navigation assertion or production logic was removed or weakened. + +All sixteen focused navigation and pointer-response tests pass locally after this +integration. The event path still does not establish received-event provenance, +click causality, browser ownership, document advancement, or released browser +behavior. Those remain separately owned acceptance gaps; parent or focused success +does not replace this child's full exact-head checks and coverage. ### Pointer-response receipt repair evidence @@ -78,6 +100,8 @@ merge `0234b587d1bca9286eb5b597f9dab33be47ff518` includes #257 `9451fd8a` and passes all six focused response tests. These are local implementation findings, not new standards claims, browser authentication, page effects, or release proof. +## References + Fette, I., & Melnikov, A. (2011). *The WebSocket Protocol* (RFC 6455). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc6455 The Rust Project Developers. (n.d.). *AtomicU64 in std::sync::atomic*. Rust standard library documentation. Retrieved September 5, 2026, from https://doc.rust-lang.org/std/sync/atomic/type.AtomicU64.html