diff --git a/AGENTS.md b/AGENTS.md index 6f747c38e..3b4b55fde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,8 @@ The organization currently documents a **solo-maintainer** governance condition. ## Testing expectations +For an ACK coverage deficit, trace every pre-consumption guard and the lower correlation result before adding tests. Remove a duplicate post-check only when the same validated envelope and exact-id correlation make its failure impossible. Cover reachable malformed, event, remote-error, wrong-id and foreign-connection paths with real receipts, assert pending-state counts, and compare opaque diagnostics without exposing text. Keep reusable test helpers public only within integration-test crates; never widen production provenance access to make a fixture compile. + Use realistic cases, including: - malformed origins, IPv4/IPv6 loopback, user information, paths, ports, and Unicode/control input; diff --git a/CHANGELOG.md b/CHANGELOG.md index 036fc72cb..60a6a1904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Strengthened text-entry acknowledgment checks with real-connection regression tests for unrelated replies, malformed responses and private diagnostics; removed a redundant check without changing pending-request protection. + +- Reject field-value replies received on a replacement connection, even when their request identifier and text match. + - Reject field-observation requests on another browser session and preserve pending requests only when a write may have reached the peer. - Preserve fixed field-observation checks while adopting current input safeguards; constructing a request still does not verify that the field changed. @@ -97,6 +101,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Registry-issued admitted node handles and authority-bound WebDriver BiDi pointer-click construction that revalidate the exact session, context, canonical origin, document epoch, registry provenance, and retained `sharedId` before serializing `input.performActions`; caller-constructed node tuples or arbitrary wire identifiers cannot become typed-input authority, and the command itself grants no policy or Agent authority. - Node-bound WebDriver BiDi text input that revalidates the exact session, browsing context, canonical origin, current document epoch, registry-issued node provenance, and admitted `sharedId` before serializing bounded protocol-safe non-secret `input.performActions`; the command focuses the admitted element before keyboard input, and its diagnostic representation exposes only command metadata and text byte length rather than typed text or the serialized wire payload. - Fixed sandboxed text-value observation for an admitted current node, with product-owned `script.callFunction` source and no generic script surface; transport correlation and observed post-condition comparison remain separate active-stack work. +- Typed text-value post-condition verification that admits only the exact correlated observation response, compares it with the already-authorized expected text, discards page-controlled text, and reports mismatch without treating command acknowledgement as success. - Fail-closed rejection of reviewed Unicode format and bidirectional-override characters in accessibility roles, accessible names, BiDi `sharedId` values, and registry external identifiers, while ordinary spaces in accessible names remain valid. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 9f7de9f4a..73de6a389 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -10,7 +10,10 @@ //! binds received fragmented text to one exact verified connection, classifies //! complete local-end JSON envelopes, tracks bounded command-response correlation, //! transports narrowly typed pointer-click and node-bound non-secret text-input -//! actions and fixed sandboxed text-value observations, admits typed correlated protocol responses, sends a context-bound committed-navigation subscription and retains +//! actions and fixed sandboxed text-value observations, admits typed correlated +//! protocol responses, binds positive text-value evidence to a sender-minted +//! acknowledged typed-input intent, sends a context-bound committed-navigation +//! subscription and retains //! its typed bounded correlated identifier, binds navigation-event admission to //! that active command/receipt lifecycle with bounded fail-closed navigation replay //! prevention, explicitly unsubscribes that exact @@ -49,7 +52,10 @@ mod webdriver_bidi_session_end_response; mod webdriver_bidi_session_status_command; mod webdriver_bidi_session_status_response; mod webdriver_bidi_session_teardown; +mod webdriver_bidi_text_value_observation_response; mod webdriver_bidi_text_value_observation_transport; +mod webdriver_bidi_text_value_postcondition; +mod webdriver_bidi_type_text_intent; mod webdriver_bidi_type_text_response; mod webdriver_bidi_type_text_transport; mod webdriver_bidi_websocket_frame; @@ -60,6 +66,8 @@ mod webdriver_bidi_websocket_transport_closure; #[cfg(test)] mod webdriver_bidi_json_envelope_public_boundary_tests; +#[cfg(test)] +mod webdriver_bidi_text_value_observation_public_boundary_tests; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, @@ -147,9 +155,22 @@ pub use webdriver_bidi_session_teardown::{ WebDriverBiDiSessionTeardownAssessment, WebDriverBiDiSessionTeardownAssessmentError, WebDriverBiDiSessionTeardownDisposition, WebDriverBiDiSessionTeardownObservations, }; +pub use webdriver_bidi_text_value_observation_response::{ + WebDriverBiDiTextValueObservationProjectionError, + WebDriverBiDiTextValueObservationResponseError, WebDriverBiDiTextValueObservationResult, +}; pub use webdriver_bidi_text_value_observation_transport::{ WebDriverBiDiTextValueObservationSendError, send_webdriver_bidi_text_value_observation, }; +pub use webdriver_bidi_text_value_postcondition::{ + WebDriverBiDiTextValuePostcondition, WebDriverBiDiTextValuePostconditionError, + verify_webdriver_bidi_text_value_postcondition, +}; +pub use webdriver_bidi_type_text_intent::{ + WebDriverBiDiAcknowledgedTypeTextIntent, WebDriverBiDiTypeTextIntentAcknowledgementError, + WebDriverBiDiTypeTextIntentWitness, acknowledge_webdriver_bidi_type_text_intent, + send_webdriver_bidi_type_text_with_postcondition_intent, +}; pub use webdriver_bidi_type_text_response::{ WebDriverBiDiTypeTextResponseError, WebDriverBiDiTypeTextResult, }; diff --git a/crates/originweave-network/src/webdriver_bidi_text_value_observation_public_boundary_tests.rs b/crates/originweave-network/src/webdriver_bidi_text_value_observation_public_boundary_tests.rs new file mode 100644 index 000000000..70725b778 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_text_value_observation_public_boundary_tests.rs @@ -0,0 +1,208 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTextValueObservationResponseError, WebDriverBiDiTextValueObservationResult, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const ERROR_UNKNOWN_COMMAND: &[u8] = + br#"{"type":"error","id":71,"error":"unknown error","message":"remote failure"}"#; +const MALFORMED_PROJECTION: &[u8] = br#"{"type":"success","id":70,"result":{"type":"success","result":{"type":"string","value":"expected"}}}"#; +const SUCCESS_UNKNOWN_COMMAND: &[u8] = br#"{"type":"success","id":72,"result":{"type":"success","realm":"realm-1","result":{"type":"string","value":"expected"}}}"#; +const FINAL_EXPECTED_TEXT: &str = "Quarterly review"; +const VALID_SUCCESS: &[u8] = br#"{"type":"success","id":70,"result":{"type":"success","realm":"realm-1","result":{"type":"string","value":"Quarterly review"}}}"#; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn write_unmasked_text_frame(stream: &mut TcpStream, document: &[u8]) -> io::Result<()> { + if document.len() <= 125 { + let length = u8::try_from(document.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "short frame length exceeds u8") + })?; + stream.write_all(&[0x81, length])?; + } else { + let length = u16::try_from(document.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "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], + correlation: Option<&mut WebDriverBiDiCommandCorrelation>, +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + write_unmasked_text_frame(&mut stream, document) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + if let Some(correlation) = correlation { + correlation.register_command_for_connection( + 70, + WebDriverBiDiCommandKind::TextValueObservation, + established.transport_evidence().connection_generation(), + )?; + } + let text = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err(io::Error::other(format!( + "validated text frame produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + + server + .join() + .map_err(|_| io::Error::other("text-value unit server panicked"))??; + Ok(text) +} + +#[test] +fn public_text_value_boundary_covers_error_adapters_and_credential_safe_result() +-> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(70, WebDriverBiDiCommandKind::TextValueObservation)?; + + let invalid = read_text_over_loopback(b"not-json", None)?; + let envelope_result = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &invalid, + "expected", + &mut correlation, + ); + assert!(matches!( + &envelope_result, + Err(WebDriverBiDiTextValueObservationResponseError::Envelope { .. }) + )); + assert_eq!( + envelope_result + .as_ref() + .err() + .map(ToString::to_string) + .as_deref(), + Some("WebDriver BiDi text-value observation envelope is invalid") + ); + assert_eq!(correlation.outstanding_count(), 1); + + let error_unknown = read_text_over_loopback(ERROR_UNKNOWN_COMMAND, None)?; + assert!(matches!( + WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &error_unknown, + "expected", + &mut correlation, + ), + Err(WebDriverBiDiTextValueObservationResponseError::Correlation { .. }) + )); + assert_eq!(correlation.outstanding_count(), 1); + + let malformed_projection = read_text_over_loopback(MALFORMED_PROJECTION, None)?; + let projection_result = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &malformed_projection, + "expected", + &mut correlation, + ); + assert!(matches!( + &projection_result, + Err(WebDriverBiDiTextValueObservationResponseError::Projection { .. }) + )); + assert_eq!( + projection_result + .as_ref() + .err() + .map(ToString::to_string) + .as_deref(), + Some("WebDriver BiDi text-value observation result is invalid") + ); + assert_eq!(correlation.outstanding_count(), 1); + + let success_unknown = read_text_over_loopback(SUCCESS_UNKNOWN_COMMAND, None)?; + let correlation_result = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &success_unknown, + "expected", + &mut correlation, + ); + assert!(matches!( + &correlation_result, + Err(WebDriverBiDiTextValueObservationResponseError::Correlation { .. }) + )); + assert_eq!( + correlation_result + .as_ref() + .err() + .map(ToString::to_string) + .as_deref(), + Some("WebDriver BiDi text-value observation response correlation failed") + ); + assert_eq!(correlation.outstanding_count(), 1); + + correlation.retire_command_for(70, WebDriverBiDiCommandKind::TextValueObservation)?; + let valid_success = read_text_over_loopback(VALID_SUCCESS, Some(&mut correlation))?; + let result = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &valid_success, + FINAL_EXPECTED_TEXT, + &mut correlation, + )?; + assert_eq!(result.command_id(), 70); + assert_eq!(result.observed_text_bytes(), FINAL_EXPECTED_TEXT.len()); + assert!(result.matches_expected_text()); + assert_eq!(correlation.outstanding_count(), 0); + + let debug = format!("{result:?}"); + assert!(debug.contains("WebDriverBiDiTextValueObservationResult")); + assert!(!debug.contains(FINAL_EXPECTED_TEXT)); + Ok(()) +} diff --git a/crates/originweave-network/src/webdriver_bidi_text_value_observation_response.rs b/crates/originweave-network/src/webdriver_bidi_text_value_observation_response.rs new file mode 100644 index 000000000..1740ba756 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_text_value_observation_response.rs @@ -0,0 +1,1010 @@ +use std::{collections::HashSet, error::Error, fmt}; + +use originweave_core::{ + MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, +}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCommandKind, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, + WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiReceivedTextMessage, +}; + +const MAX_SCRIPT_RESULT_OBJECT_MEMBERS: usize = 64; +const MAX_SCRIPT_RESULT_MEMBER_NAME_BYTES: usize = 128; +const MAX_SCRIPT_RESULT_NESTING_DEPTH: usize = 64; + +/// Credential-minimal result of comparing one correlated text-value observation with the exact +/// already-authorized non-secret text that preceded it. +/// +/// The observed page string is never retained. This value keeps only the matched command id, the +/// observed UTF-8 byte count, and whether the observed string exactly matched the caller-supplied +/// expected text. A mismatch is valid negative post-condition evidence rather than transport or +/// parser success for the preceding action. +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct WebDriverBiDiTextValueObservationResult { + command_id: u64, + observed_text_bytes: usize, + matches_expected_text: bool, +} + +impl fmt::Debug for WebDriverBiDiTextValueObservationResult { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiTextValueObservationResult") + .field("command_id", &self.command_id) + .field("observed_text_bytes", &self.observed_text_bytes) + .field("matches_expected_text", &self.matches_expected_text) + .finish() + } +} + +impl WebDriverBiDiTextValueObservationResult { + /// Parse one bounded local-end response, consume its exact outstanding command, and compare + /// the successful string RemoteValue with the already-authorized expected non-secret text. + /// + /// The expected text is revalidated against the same reviewed local byte and character policy + /// used by node-bound text input before any response or correlation state is touched. Common + /// WebDriver BiDi envelope validation and command-specific `script.EvaluateResult` projection + /// likewise complete before correlation can be consumed. Malformed, duplicate, unsupported, + /// or over-budget result shapes therefore leave unrelated outstanding command state intact. + /// + /// A correlatable top-level protocol error and a valid `script` exception consume their exact + /// command id and return typed failures. A successful string result consumes the exact id and + /// immediately drops the page-controlled string after computing byte count and equality. This + /// boundary does not retry, grant browser or policy authority, retain a realm identifier, or + /// claim success when the observed value differs from the expected text. + /// The received message must belong to the exact connection generation registered by the + /// sender. Missing or foreign connection provenance leaves the pending request untouched, + /// including for protocol errors and script exceptions. + pub fn parse_correlate_and_compare( + message: &WebDriverBiDiReceivedTextMessage, + expected_text: &str, + correlation: &mut WebDriverBiDiCommandCorrelation, + ) -> Result { + validate_expected_text(expected_text)?; + let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()).map_err(|source| { + WebDriverBiDiTextValueObservationResponseError::Envelope { source } + })?; + + match envelope.kind() { + WebDriverBiDiJsonEnvelopeKind::Event => { + Err(WebDriverBiDiTextValueObservationResponseError::UnexpectedEvent) + } + WebDriverBiDiJsonEnvelopeKind::Error => { + let completed = correlation + .correlate_response_for_connection( + &envelope, + WebDriverBiDiCommandKind::TextValueObservation, + message.connection_generation(), + ) + .map_err(|source| { + WebDriverBiDiTextValueObservationResponseError::Correlation { source } + })?; + Err( + WebDriverBiDiTextValueObservationResponseError::RemoteProtocolError { + command_id: completed.command_id(), + }, + ) + } + WebDriverBiDiJsonEnvelopeKind::Success => { + let projection = + project_script_result(message.message().as_str()).map_err(|source| { + WebDriverBiDiTextValueObservationResponseError::Projection { source } + })?; + let completed = correlation + .correlate_response_for_connection( + &envelope, + WebDriverBiDiCommandKind::TextValueObservation, + message.connection_generation(), + ) + .map_err(|source| { + WebDriverBiDiTextValueObservationResponseError::Correlation { source } + })?; + match projection { + ScriptResultProjection::Exception => Err( + WebDriverBiDiTextValueObservationResponseError::ScriptException { + command_id: completed.command_id(), + }, + ), + ScriptResultProjection::String(observed_text) => { + let observed_text_bytes = observed_text.len(); + let matches_expected_text = observed_text == expected_text; + Ok(Self { + command_id: completed.command_id(), + observed_text_bytes, + matches_expected_text, + }) + } + } + } + } + } + + /// Return the exact local command identifier consumed by this observation response. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the UTF-8 byte length of the observed string without retaining the string itself. + #[must_use] + pub const fn observed_text_bytes(&self) -> usize { + self.observed_text_bytes + } + + /// Return whether the observed page value exactly matched the expected non-secret text. + #[must_use] + pub const fn matches_expected_text(&self) -> bool { + self.matches_expected_text + } +} + +/// Fail-closed failures while admitting and comparing one text-value post-condition response. +#[derive(Debug)] +pub enum WebDriverBiDiTextValueObservationResponseError { + /// The caller supplied an empty expected text value. + EmptyExpectedText, + /// The caller supplied expected text above the reviewed text-input byte budget. + ExpectedTextTooLong, + /// The expected text contains a disallowed control, whitespace, or reviewed format character. + InvalidExpectedText, + /// Common bounded WebDriver BiDi local-end envelope validation failed. + Envelope { + /// Exact common-envelope validation failure. + source: WebDriverBiDiJsonEnvelopeError, + }, + /// A WebDriver BiDi event was supplied where this command-specific response boundary requires + /// a correlated success or error response. + UnexpectedEvent, + /// The command-specific `script.EvaluateResult` shape was malformed or unsupported. + Projection { + /// Exact non-sensitive projection failure. + source: WebDriverBiDiTextValueObservationProjectionError, + }, + /// Exact command-response correlation failed without consuming unrelated state. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// The remote end returned a correlatable top-level WebDriver BiDi protocol error. + RemoteProtocolError { + /// Exact local command identifier consumed by the protocol-error response. + command_id: u64, + }, + /// The remote end completed `script.callFunction` with a typed script exception. + ScriptException { + /// Exact local command identifier consumed by the script-exception result. + command_id: u64, + }, +} + +impl fmt::Display for WebDriverBiDiTextValueObservationResponseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::EmptyExpectedText => "expected text-value postcondition must not be empty", + Self::ExpectedTextTooLong => { + "expected text-value postcondition exceeds the local byte budget" + } + Self::InvalidExpectedText => { + "expected text-value postcondition contains a disallowed character" + } + Self::Envelope { .. } => "WebDriver BiDi text-value observation envelope is invalid", + Self::UnexpectedEvent => { + "WebDriver BiDi text-value observation received an event instead of a command response" + } + Self::Projection { .. } => "WebDriver BiDi text-value observation result is invalid", + Self::Correlation { .. } => { + "WebDriver BiDi text-value observation response correlation failed" + } + Self::RemoteProtocolError { .. } => { + "WebDriver BiDi text-value observation returned a protocol error" + } + Self::ScriptException { .. } => { + "WebDriver BiDi text-value observation returned a script exception" + } + }) + } +} + +impl Error for WebDriverBiDiTextValueObservationResponseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Envelope { source } => Some(source), + Self::Projection { source } => Some(source), + Self::Correlation { source } => Some(source), + Self::EmptyExpectedText + | Self::ExpectedTextTooLong + | Self::InvalidExpectedText + | Self::UnexpectedEvent + | Self::RemoteProtocolError { .. } + | Self::ScriptException { .. } => None, + } + } +} + +/// Non-sensitive structural failures while projecting a successful `script.callFunction` result. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WebDriverBiDiTextValueObservationProjectionError { + /// An object required by the command-specific result contract was absent or malformed. + InvalidObject { + /// Stable non-sensitive protocol member path. + member: &'static str, + }, + /// A required command-specific member was absent. + MissingMember { + /// Stable non-sensitive protocol member path. + member: &'static str, + }, + /// A command-specific object repeated a member name. + DuplicateMember, + /// A command-specific object exceeded the reviewed member-count budget. + TooManyMembers, + /// A command-specific member name exceeded the reviewed byte budget. + MemberNameTooLong, + /// A JSON string could not be projected without violating the bounded decoder contract. + InvalidString, + /// The `script.EvaluateResult` discriminator was neither `success` nor `exception`. + UnsupportedScriptResultType, + /// A successful script RemoteValue was not a string value. + UnsupportedRemoteValueType, + /// The observed page string exceeded the reviewed text-input byte budget. + ObservedTextTooLong, + /// A nested value exceeded the reviewed command-specific projection depth budget. + NestingTooDeep, + /// A command-specific value ended unexpectedly despite prior common-envelope validation. + InvalidValue, +} + +impl fmt::Display for WebDriverBiDiTextValueObservationProjectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidObject { member } => write!(formatter, "invalid object at {member}"), + Self::MissingMember { member } => write!(formatter, "missing member {member}"), + Self::DuplicateMember => { + formatter.write_str("duplicate command-specific result member") + } + Self::TooManyMembers => { + formatter.write_str("command-specific result object has too many members") + } + Self::MemberNameTooLong => { + formatter.write_str("command-specific result member name is too long") + } + Self::InvalidString => formatter.write_str("invalid command-specific JSON string"), + Self::UnsupportedScriptResultType => { + formatter.write_str("unsupported script result type") + } + Self::UnsupportedRemoteValueType => { + formatter.write_str("text-value observation did not return a string RemoteValue") + } + Self::ObservedTextTooLong => formatter + .write_str("observed text-value postcondition exceeds the local byte budget"), + Self::NestingTooDeep => { + formatter.write_str("command-specific result nesting is too deep") + } + Self::InvalidValue => formatter.write_str("invalid command-specific result value"), + } + } +} + +impl Error for WebDriverBiDiTextValueObservationProjectionError {} + +fn validate_expected_text( + expected_text: &str, +) -> Result<(), WebDriverBiDiTextValueObservationResponseError> { + if expected_text.is_empty() { + return Err(WebDriverBiDiTextValueObservationResponseError::EmptyExpectedText); + } + if expected_text.len() > MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES { + return Err(WebDriverBiDiTextValueObservationResponseError::ExpectedTextTooLong); + } + if expected_text.chars().any(|character| { + (character.is_whitespace() && character != ' ') + || character.is_control() + || UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS.contains(&character) + }) { + return Err(WebDriverBiDiTextValueObservationResponseError::InvalidExpectedText); + } + Ok(()) +} + +#[derive(Debug, Eq, PartialEq)] +enum ScriptResultProjection { + String(String), + Exception, +} + +fn project_script_result( + text: &str, +) -> Result { + let top_level = parse_object_members(text, "response")?; + let script_result = required_object_member(&top_level, "result", "result")?; + let script_members = parse_object_members(script_result, "result")?; + let result_type = required_string_member(&script_members, "type", "result.type")?; + let _realm = required_string_member(&script_members, "realm", "result.realm")?; + + match result_type.as_str() { + "success" => { + let remote = required_object_member(&script_members, "result", "result.result")?; + let remote_members = parse_object_members(remote, "result.result")?; + let remote_type = + required_string_member(&remote_members, "type", "result.result.type")?; + if remote_type != "string" { + return Err( + WebDriverBiDiTextValueObservationProjectionError::UnsupportedRemoteValueType, + ); + } + let observed = required_string_member(&remote_members, "value", "result.result.value")?; + if observed.len() > MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES { + return Err(WebDriverBiDiTextValueObservationProjectionError::ObservedTextTooLong); + } + Ok(ScriptResultProjection::String(observed)) + } + "exception" => { + let _details = required_object_member( + &script_members, + "exceptionDetails", + "result.exceptionDetails", + )?; + Ok(ScriptResultProjection::Exception) + } + _ => Err(WebDriverBiDiTextValueObservationProjectionError::UnsupportedScriptResultType), + } +} + +fn required_object_member<'a>( + members: &'a [(String, &'a str)], + name: &str, + path: &'static str, +) -> Result<&'a str, WebDriverBiDiTextValueObservationProjectionError> { + let value = members + .iter() + .find_map(|(member, value)| (member == name).then_some(*value)) + .ok_or(WebDriverBiDiTextValueObservationProjectionError::MissingMember { member: path })?; + let trimmed = value.trim(); + if !trimmed.starts_with('{') { + return Err( + WebDriverBiDiTextValueObservationProjectionError::InvalidObject { member: path }, + ); + } + Ok(trimmed) +} + +fn required_string_member( + members: &[(String, &str)], + name: &str, + path: &'static str, +) -> Result { + let value = members + .iter() + .find_map(|(member, value)| (member == name).then_some(*value)) + .ok_or(WebDriverBiDiTextValueObservationProjectionError::MissingMember { member: path })?; + decode_json_string(value.trim()) +} + +fn parse_object_members<'a>( + text: &'a str, + path: &'static str, +) -> Result, WebDriverBiDiTextValueObservationProjectionError> { + let bytes = text.as_bytes(); + let mut index = skip_whitespace(bytes, 0); + if bytes.get(index) != Some(&b'{') { + return Err( + WebDriverBiDiTextValueObservationProjectionError::InvalidObject { member: path }, + ); + } + index += 1; + let mut members = Vec::new(); + let mut names = HashSet::new(); + + loop { + index = skip_whitespace(bytes, index); + if bytes.get(index) == Some(&b'}') { + index += 1; + index = skip_whitespace(bytes, index); + if index != bytes.len() { + return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidValue); + } + return Ok(members); + } + if members.len() >= MAX_SCRIPT_RESULT_OBJECT_MEMBERS { + return Err(WebDriverBiDiTextValueObservationProjectionError::TooManyMembers); + } + let key_end = scan_string_end(bytes, index)?; + let key = decode_json_string(&text[index..key_end])?; + if key.len() > MAX_SCRIPT_RESULT_MEMBER_NAME_BYTES { + return Err(WebDriverBiDiTextValueObservationProjectionError::MemberNameTooLong); + } + if !names.insert(key.clone()) { + return Err(WebDriverBiDiTextValueObservationProjectionError::DuplicateMember); + } + index = skip_whitespace(bytes, key_end); + if bytes.get(index) != Some(&b':') { + return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidValue); + } + index += 1; + index = skip_whitespace(bytes, index); + let value_start = index; + let value_end = scan_value_end(bytes, value_start, 0)?; + members.push((key, &text[value_start..value_end])); + index = skip_whitespace(bytes, value_end); + match bytes.get(index) { + Some(b',') => index += 1, + Some(b'}') => {} + _ => return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidValue), + } + } +} + +fn skip_whitespace(bytes: &[u8], mut index: usize) -> usize { + while matches!(bytes.get(index), Some(b' ' | b'\t' | b'\n' | b'\r')) { + index += 1; + } + index +} + +fn scan_value_end( + bytes: &[u8], + index: usize, + depth: usize, +) -> Result { + if depth >= MAX_SCRIPT_RESULT_NESTING_DEPTH { + return Err(WebDriverBiDiTextValueObservationProjectionError::NestingTooDeep); + } + match bytes.get(index) { + Some(b'"') => scan_string_end(bytes, index), + Some(b'{') => scan_container_end(bytes, index, b'{', b'}', depth + 1), + Some(b'[') => scan_container_end(bytes, index, b'[', b']', depth + 1), + Some(_) => { + let mut end = index; + while let Some(byte) = bytes.get(end) { + if matches!(byte, b',' | b'}' | b']' | b' ' | b'\t' | b'\n' | b'\r') { + break; + } + end += 1; + } + if end == index { + Err(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) + } else { + Ok(end) + } + } + None => Err(WebDriverBiDiTextValueObservationProjectionError::InvalidValue), + } +} + +fn scan_container_end( + bytes: &[u8], + start: usize, + open: u8, + close: u8, + depth: usize, +) -> Result { + if depth > MAX_SCRIPT_RESULT_NESTING_DEPTH { + return Err(WebDriverBiDiTextValueObservationProjectionError::NestingTooDeep); + } + if bytes.get(start) != Some(&open) { + return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidValue); + } + let mut stack = vec![close]; + let mut index = start + 1; + while let Some(byte) = bytes.get(index).copied() { + match byte { + b'"' => index = scan_string_end(bytes, index)?, + b'{' => { + if stack.len() >= MAX_SCRIPT_RESULT_NESTING_DEPTH { + return Err(WebDriverBiDiTextValueObservationProjectionError::NestingTooDeep); + } + stack.push(b'}'); + index += 1; + } + b'[' => { + if stack.len() >= MAX_SCRIPT_RESULT_NESTING_DEPTH { + return Err(WebDriverBiDiTextValueObservationProjectionError::NestingTooDeep); + } + stack.push(b']'); + index += 1; + } + b'}' | b']' => { + if stack.pop() != Some(byte) { + return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidValue); + } + index += 1; + if stack.is_empty() { + return Ok(index); + } + } + _ => index += 1, + } + } + Err(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) +} + +fn scan_string_end( + bytes: &[u8], + start: usize, +) -> Result { + if bytes.get(start) != Some(&b'"') { + return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidString); + } + let mut index = start + 1; + while let Some(byte) = bytes.get(index).copied() { + match byte { + b'"' => return Ok(index + 1), + b'\\' => { + index += 2; + if index > bytes.len() { + return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidString); + } + } + 0x00..=0x1f => { + return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidString); + } + _ => index += 1, + } + } + Err(WebDriverBiDiTextValueObservationProjectionError::InvalidString) +} + +fn decode_json_string( + value: &str, +) -> Result { + if !value.starts_with('"') || !value.ends_with('"') || value.len() < 2 { + return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidString); + } + let inner = &value[1..value.len() - 1]; + let mut characters = inner.chars(); + let mut output = String::new(); + while let Some(character) = characters.next() { + if character != '\\' { + if character.is_control() { + return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidString); + } + output.push(character); + continue; + } + let escaped = characters + .next() + .ok_or(WebDriverBiDiTextValueObservationProjectionError::InvalidString)?; + match escaped { + '"' => output.push('"'), + '\\' => output.push('\\'), + '/' => output.push('/'), + 'b' => output.push('\u{0008}'), + 'f' => output.push('\u{000c}'), + 'n' => output.push('\n'), + 'r' => output.push('\r'), + 't' => output.push('\t'), + 'u' => { + let first = decode_hex_quad(&mut characters)?; + if (0xd800..=0xdbff).contains(&first) { + if characters.next() != Some('\\') || characters.next() != Some('u') { + return Err( + WebDriverBiDiTextValueObservationProjectionError::InvalidString, + ); + } + let second = decode_hex_quad(&mut characters)?; + if !(0xdc00..=0xdfff).contains(&second) { + return Err( + WebDriverBiDiTextValueObservationProjectionError::InvalidString, + ); + } + let units = [first, second]; + output.push_str(&String::from_utf16_lossy(&units)); + } else if (0xdc00..=0xdfff).contains(&first) { + return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidString); + } else { + let units = [first]; + output.push_str(&String::from_utf16_lossy(&units)); + } + } + _ => return Err(WebDriverBiDiTextValueObservationProjectionError::InvalidString), + } + } + Ok(output) +} + +fn decode_hex_quad( + characters: &mut std::str::Chars<'_>, +) -> Result { + let mut value = 0_u16; + for _ in 0..4 { + let digit = characters + .next() + .and_then(|character| character.to_digit(16)) + .ok_or(WebDriverBiDiTextValueObservationProjectionError::InvalidString)?; + value = (value << 4) | digit as u16; + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projection_accepts_extensions_and_decodes_string_escapes() { + let response = r#"{"type":"success","id":7,"vendor":true,"result":{"realm":"realm-1","type":"success","vendor":{"nested":[1,true,null]},"result":{"value":"A\"B\\C\/D\b\f\n\r\t\u20ac\ud83d\ude00","type":"string","vendor":0}}}"#; + assert_eq!( + project_script_result(response), + Ok(ScriptResultProjection::String( + "A\"B\\C/D\u{0008}\u{000c}\n\r\t€😀".to_owned(), + )) + ); + } + + #[test] + fn projection_accepts_typed_script_exception_without_retaining_details() { + let response = r#"{"type":"success","id":8,"result":{"type":"exception","realm":"realm-1","exceptionDetails":{"text":"page-secret","columnNumber":1,"lineNumber":1,"stackTrace":{"callFrames":[]}}}}"#; + assert_eq!( + project_script_result(response), + Ok(ScriptResultProjection::Exception) + ); + } + + #[test] + fn projection_rejects_unsupported_and_oversized_remote_values() { + let wrong_type = r#"{"type":"success","id":9,"result":{"type":"success","realm":"realm-1","result":{"type":"number","value":1}}}"#; + assert_eq!( + project_script_result(wrong_type).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::UnsupportedRemoteValueType) + ); + + let oversized = "x".repeat(MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES + 1); + let response = format!( + "{{\"type\":\"success\",\"id\":10,\"result\":{{\"type\":\"success\",\"realm\":\"realm-1\",\"result\":{{\"type\":\"string\",\"value\":\"{oversized}\"}}}}}}" + ); + assert_eq!( + project_script_result(&response).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::ObservedTextTooLong) + ); + } + + #[test] + fn projection_rejects_missing_duplicate_and_invalid_members() { + let missing = r#"{"type":"success","id":11,"result":{"type":"success","realm":"realm-1"}}"#; + assert_eq!( + project_script_result(missing).err(), + Some( + WebDriverBiDiTextValueObservationProjectionError::MissingMember { + member: "result.result" + } + ) + ); + + let duplicate = r#"{"type":"success","id":12,"result":{"type":"success","type":"success","realm":"realm-1","result":{"type":"string","value":"x"}}}"#; + assert_eq!( + project_script_result(duplicate).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::DuplicateMember) + ); + + let invalid_object = r#"{"type":"success","id":13,"result":false}"#; + assert_eq!( + project_script_result(invalid_object).err(), + Some( + WebDriverBiDiTextValueObservationProjectionError::InvalidObject { + member: "result" + } + ) + ); + + let unsupported = + r#"{"type":"success","id":14,"result":{"type":"future","realm":"realm-1"}}"#; + assert_eq!( + project_script_result(unsupported).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::UnsupportedScriptResultType) + ); + } + + #[test] + fn projection_helpers_reject_invalid_strings_values_and_resource_exhaustion() { + assert_eq!( + decode_json_string("not-a-string").err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + decode_json_string(r#""\uDC00""#).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + decode_json_string(r#""\uD800x""#).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + decode_json_string(r#""\uD800\x""#).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + decode_json_string(r#""\uD800\u12xz""#).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + decode_json_string(r#""\uD800\u0041""#).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + decode_json_string(r#""\q""#).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + decode_json_string(r#""\u12xz""#).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + + assert_eq!( + parse_object_members("[]", "root").err(), + Some( + WebDriverBiDiTextValueObservationProjectionError::InvalidObject { member: "root" } + ) + ); + assert_eq!( + parse_object_members("{\"a\":1} trailing", "root").err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) + ); + assert_eq!( + scan_value_end(b"", 0, 0).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) + ); + assert_eq!( + scan_value_end(b"x", 0, MAX_SCRIPT_RESULT_NESTING_DEPTH).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::NestingTooDeep) + ); + assert_eq!( + scan_container_end(b"[]", 0, b'{', b'}', 1).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) + ); + assert_eq!( + scan_string_end(b"x", 0).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + } + + #[test] + fn expected_text_validation_covers_budget_and_injection_policy() { + assert_eq!( + validate_expected_text("") + .err() + .map(|error| error.to_string()) + .as_deref(), + Some("expected text-value postcondition must not be empty") + ); + let oversized = "x".repeat(MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES + 1); + assert_eq!( + validate_expected_text(&oversized) + .err() + .map(|error| error.to_string()) + .as_deref(), + Some("expected text-value postcondition exceeds the local byte budget") + ); + assert_eq!( + validate_expected_text("ordinary space").map_err(|_| ()), + Ok(()) + ); + for rejected in ["tab\tvalue", "control\u{0001}", "bidi\u{202e}override"] { + assert_eq!( + validate_expected_text(rejected) + .err() + .map(|error| error.to_string()) + .as_deref(), + Some("expected text-value postcondition contains a disallowed character") + ); + } + } + + #[test] + fn projection_helpers_cover_structural_and_terminal_failures() { + let missing_exception_details = + r#"{"type":"success","id":15,"result":{"type":"exception","realm":"realm-1"}}"#; + assert_eq!( + project_script_result(missing_exception_details).err(), + Some( + WebDriverBiDiTextValueObservationProjectionError::MissingMember { + member: "result.exceptionDetails" + } + ) + ); + let invalid_exception_details = r#"{"type":"success","id":16,"result":{"type":"exception","realm":"realm-1","exceptionDetails":false}}"#; + assert_eq!( + project_script_result(invalid_exception_details).err(), + Some( + WebDriverBiDiTextValueObservationProjectionError::InvalidObject { + member: "result.exceptionDetails" + } + ) + ); + let many_members = (0..=MAX_SCRIPT_RESULT_OBJECT_MEMBERS) + .map(|index| format!("\"k{index}\":0")) + .collect::>() + .join(","); + let too_many = format!("{{{many_members}}}"); + assert_eq!( + parse_object_members(&too_many, "root").err(), + Some(WebDriverBiDiTextValueObservationProjectionError::TooManyMembers) + ); + + let long_name = "k".repeat(MAX_SCRIPT_RESULT_MEMBER_NAME_BYTES + 1); + let long_member = format!("{{\"{long_name}\":0}}"); + assert_eq!( + parse_object_members(&long_member, "root").err(), + Some(WebDriverBiDiTextValueObservationProjectionError::MemberNameTooLong) + ); + assert_eq!( + parse_object_members(r#"{"a" 1}"#, "root").err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) + ); + assert_eq!( + parse_object_members(r#"{"a":1]"#, "root").err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) + ); + + assert_eq!(scan_value_end(b"[]", 0, 0), Ok(2)); + assert_eq!(scan_value_end(b"terminal", 0, 0), Ok(b"terminal".len())); + assert_eq!( + scan_value_end(b",", 0, 0).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) + ); + assert_eq!( + scan_container_end(b"{}", 0, b'{', b'}', MAX_SCRIPT_RESULT_NESTING_DEPTH + 1).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::NestingTooDeep) + ); + + let deep_objects = "{".repeat(MAX_SCRIPT_RESULT_NESTING_DEPTH + 1); + assert_eq!( + scan_container_end(deep_objects.as_bytes(), 0, b'{', b'}', 1).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::NestingTooDeep) + ); + let deep_arrays = "[".repeat(MAX_SCRIPT_RESULT_NESTING_DEPTH + 1); + assert_eq!( + scan_container_end(deep_arrays.as_bytes(), 0, b'[', b']', 1).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::NestingTooDeep) + ); + assert_eq!( + scan_container_end(b"{]", 0, b'{', b'}', 1).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) + ); + assert_eq!( + scan_container_end(b"{", 0, b'{', b'}', 1).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) + ); + + assert_eq!( + scan_string_end(b"\"\\", 0).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + scan_string_end(b"\"\x01\"", 0).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + scan_string_end(b"\"x", 0).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + + assert_eq!( + decode_json_string("\"").err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + decode_json_string("\"x").err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + let raw_control = format!("\"{}\"", '\u{0001}'); + assert_eq!( + decode_json_string(&raw_control).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!(decode_json_string(r#""\uD83D\uDE00""#).as_deref(), Ok("😀")); + assert_eq!(decode_json_string(r#""\u20AC""#).as_deref(), Ok("€")); + assert_eq!( + decode_json_string(r#""\u12""#).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + } + + #[test] + fn projection_propagates_malformed_nested_json_without_consuming_detail() { + assert_eq!( + project_script_result("[]").err(), + Some( + WebDriverBiDiTextValueObservationProjectionError::InvalidObject { + member: "response" + } + ) + ); + + let invalid_result_type = + r#"{"type":"success","id":17,"result":{"type":"\q","realm":"realm-1"}}"#; + assert_eq!( + project_script_result(invalid_result_type).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + + let malformed_remote = r#"{"type":"success","id":18,"result":{"type":"success","realm":"realm-1","result":{"type" 1}}}"#; + assert_eq!( + project_script_result(malformed_remote).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) + ); + + let invalid_remote_type = r#"{"type":"success","id":19,"result":{"type":"success","realm":"realm-1","result":{"type":"\q","value":"x"}}}"#; + assert_eq!( + project_script_result(invalid_remote_type).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + + let invalid_remote_value = r#"{"type":"success","id":20,"result":{"type":"success","realm":"realm-1","result":{"type":"string","value":"\q"}}}"#; + assert_eq!( + project_script_result(invalid_remote_value).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + + assert_eq!( + parse_object_members(r#"{"unterminated}"#, "root").err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + parse_object_members(r#"{"\q":1}"#, "root").err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + assert_eq!( + parse_object_members(r#"{"a":"#, "root").err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidValue) + ); + + assert_eq!( + scan_container_end(b"{\"unterminated", 0, b'{', b'}', 1).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + + let dangling_escape_json_string = format!("{}{}{}", '"', '\\', '"'); + assert_eq!( + decode_json_string(&dangling_escape_json_string).err(), + Some(WebDriverBiDiTextValueObservationProjectionError::InvalidString) + ); + } + + #[test] + fn response_and_projection_errors_are_stable_and_non_sensitive() { + let cases: Vec = vec![ + WebDriverBiDiTextValueObservationResponseError::EmptyExpectedText, + WebDriverBiDiTextValueObservationResponseError::ExpectedTextTooLong, + WebDriverBiDiTextValueObservationResponseError::InvalidExpectedText, + WebDriverBiDiTextValueObservationResponseError::UnexpectedEvent, + WebDriverBiDiTextValueObservationResponseError::RemoteProtocolError { command_id: 7 }, + WebDriverBiDiTextValueObservationResponseError::ScriptException { command_id: 8 }, + ]; + for error in cases { + assert_eq!(error.source().map(|_| ()), None); + assert_ne!(error.to_string(), ""); + } + + let envelope = WebDriverBiDiTextValueObservationResponseError::Envelope { + source: WebDriverBiDiJsonEnvelopeError::InvalidJson, + }; + assert_eq!(envelope.source().map(|_| ()), Some(())); + let projection = WebDriverBiDiTextValueObservationResponseError::Projection { + source: WebDriverBiDiTextValueObservationProjectionError::InvalidValue, + }; + assert_eq!(projection.source().map(|_| ()), Some(())); + let correlation = WebDriverBiDiTextValueObservationResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandNotOutstanding, + }; + assert_eq!(correlation.source().map(|_| ()), Some(())); + + let projection_errors = [ + WebDriverBiDiTextValueObservationProjectionError::InvalidObject { member: "result" }, + WebDriverBiDiTextValueObservationProjectionError::MissingMember { member: "result" }, + WebDriverBiDiTextValueObservationProjectionError::DuplicateMember, + WebDriverBiDiTextValueObservationProjectionError::TooManyMembers, + WebDriverBiDiTextValueObservationProjectionError::MemberNameTooLong, + WebDriverBiDiTextValueObservationProjectionError::InvalidString, + WebDriverBiDiTextValueObservationProjectionError::UnsupportedScriptResultType, + WebDriverBiDiTextValueObservationProjectionError::UnsupportedRemoteValueType, + WebDriverBiDiTextValueObservationProjectionError::ObservedTextTooLong, + WebDriverBiDiTextValueObservationProjectionError::NestingTooDeep, + WebDriverBiDiTextValueObservationProjectionError::InvalidValue, + ]; + for error in projection_errors { + assert_ne!(error.to_string(), ""); + } + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_text_value_postcondition.rs b/crates/originweave-network/src/webdriver_bidi_text_value_postcondition.rs new file mode 100644 index 000000000..162f1faef --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_text_value_postcondition.rs @@ -0,0 +1,153 @@ +use std::{error::Error, fmt}; + +use crate::{ + WebDriverBiDiAcknowledgedTypeTextIntent, WebDriverBiDiCommandCorrelation, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiTextValueObservationResponseError, + WebDriverBiDiTextValueObservationResult, +}; + +/// Credential-minimal proof that one exact correlated text observation matched the exact +/// sender-minted and acknowledged typed-input intent that preceded it. +/// +/// The page-controlled string is discarded by the lower observation boundary before this value is +/// constructed. This type therefore carries only the typed-input command identifier, the consumed +/// observation command identifier, and observed byte count. A caller can obtain this value only +/// after the original one-shot typed-input intent received its exact protocol ACK and the later +/// observation exactly matched that retained intent on the same verified WebDriver BiDi connection; +/// a command ACK, parser success, same textual value on another connection, or verification-time +/// caller value is not sufficient post-condition evidence. +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct WebDriverBiDiTextValuePostcondition { + type_text_command_id: u64, + command_id: u64, + observed_text_bytes: usize, +} + +impl fmt::Debug for WebDriverBiDiTextValuePostcondition { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiTextValuePostcondition") + .field("type_text_command_id", &self.type_text_command_id) + .field("command_id", &self.command_id) + .field("observed_text_bytes", &self.observed_text_bytes) + .finish() + } +} + +impl WebDriverBiDiTextValuePostcondition { + /// Return the exact local typed-input command identifier whose acknowledged intent was verified. + #[must_use] + pub const fn type_text_command_id(&self) -> u64 { + self.type_text_command_id + } + + /// Return the exact local command identifier consumed by the verified observation response. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the UTF-8 byte count of the matched page value without retaining that value. + #[must_use] + pub const fn observed_text_bytes(&self) -> usize { + self.observed_text_bytes + } +} + +/// Failure to produce positive text-value post-condition evidence from one correlated response. +#[derive(Debug)] +pub enum WebDriverBiDiTextValuePostconditionError { + /// The received observation belongs to a different verified connection than the acknowledged + /// typed-input intent. This check runs before observation correlation can consume pending state. + ObservationConnectionMismatch, + /// The underlying bounded response admission or correlation failed. + Observation { + /// Exact typed lower-boundary failure. + source: WebDriverBiDiTextValueObservationResponseError, + }, + /// The response was structurally valid and correlated, but the observed page value differed + /// from the exact sender-minted typed-input intent. + PostconditionMismatch { + /// Exact acknowledged typed-input command whose retained intent was compared. + type_text_command_id: u64, + /// Exact local observation command identifier consumed by the negative observation. + command_id: u64, + /// UTF-8 byte count of the mismatched page value; the page text itself is not retained. + observed_text_bytes: usize, + }, +} + +impl fmt::Display for WebDriverBiDiTextValuePostconditionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ObservationConnectionMismatch => formatter.write_str( + "WebDriver BiDi text-value postcondition observation arrived on a different connection than the acknowledged typed-input intent", + ), + Self::Observation { .. } => { + formatter.write_str("WebDriver BiDi text-value postcondition observation failed") + } + Self::PostconditionMismatch { .. } => formatter.write_str( + "WebDriver BiDi text-value postcondition did not match the acknowledged typed-input intent", + ), + } + } +} + +impl Error for WebDriverBiDiTextValuePostconditionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Observation { source } => Some(source), + Self::ObservationConnectionMismatch | Self::PostconditionMismatch { .. } => None, + } + } +} + +/// Admit one bounded correlated text observation and return success only when its page value +/// exactly matches the sender-minted typed-input intent that already received its exact ACK. +/// +/// The caller must transfer a one-shot [`WebDriverBiDiAcknowledgedTypeTextIntent`]. That value is +/// produced only from the reviewed typed-input sender and its exact connection-bound protocol ACK, +/// so the expected value can no longer be selected at verification time. The received observation +/// must first match the acknowledged intent's private connection generation; that check happens +/// before lower response admission or correlation can consume pending state. The lower observation +/// boundary then validates response structure, script result shape, and exact observation-command +/// correlation before comparison. A mismatching observation consumes its correlated observation +/// command because the response is complete, but returns a typed negative result rather than `Ok`. +/// +/// No page-controlled text, expected text, connection-generation identifier, realm identifier, +/// credential, secret, browser authority, or policy authority is retained in the returned value or +/// error diagnostics. The acknowledged intent is consumed exactly once by this call and its private +/// text and connection generation are dropped afterward. +pub fn verify_webdriver_bidi_text_value_postcondition( + message: &WebDriverBiDiReceivedTextMessage, + acknowledged_intent: WebDriverBiDiAcknowledgedTypeTextIntent, + correlation: &mut WebDriverBiDiCommandCorrelation, +) -> Result { + if !acknowledged_intent.matches_connection_generation(message.connection_generation()) { + return Err(WebDriverBiDiTextValuePostconditionError::ObservationConnectionMismatch); + } + + let type_text_command_id = acknowledged_intent.command_id(); + let observation = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + message, + acknowledged_intent.expected_text(), + correlation, + ) + .map_err(|source| WebDriverBiDiTextValuePostconditionError::Observation { source })?; + + if !observation.matches_expected_text() { + return Err( + WebDriverBiDiTextValuePostconditionError::PostconditionMismatch { + type_text_command_id, + command_id: observation.command_id(), + observed_text_bytes: observation.observed_text_bytes(), + }, + ); + } + + Ok(WebDriverBiDiTextValuePostcondition { + type_text_command_id, + command_id: observation.command_id(), + observed_text_bytes: observation.observed_text_bytes(), + }) +} diff --git a/crates/originweave-network/src/webdriver_bidi_type_text_intent.rs b/crates/originweave-network/src/webdriver_bidi_type_text_intent.rs new file mode 100644 index 000000000..c7631eb98 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_type_text_intent.rs @@ -0,0 +1,217 @@ +use std::{error::Error, fmt, time::Duration}; + +use originweave_core::{ + AdmittedNodeHandle, BrowserAuthorityRegistry, ValidatedBrowserProtocolUse, + WebDriverBiDiRemoteNodeReference, +}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiJsonEnvelope, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiTypeTextResponseError, WebDriverBiDiTypeTextResult, + WebDriverBiDiTypeTextSendError, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketMaskKey, send_webdriver_bidi_type_text, + webdriver_bidi_connection::WebDriverBiDiConnectionGeneration, + webdriver_bidi_json_envelope::WebDriverBiDiJsonEnvelopeRouting, +}; + +/// Sender-minted one-shot witness for the exact non-secret text intent dispatched by one typed-input command. +/// +/// The witness is created only after the reviewed typed-input sender successfully writes its frame. +/// It binds the exact command id, the private process-local connection generation, and the validated +/// text value. The text is retained only inside this opaque value until its exact protocol ACK is +/// admitted; `Debug`, errors, and public accessors never expose it. The witness is neither `Clone` +/// nor `Copy`, so a caller cannot duplicate one successful dispatch into multiple action intents. +pub struct WebDriverBiDiTypeTextIntentWitness { + command_id: u64, + connection_generation: WebDriverBiDiConnectionGeneration, + expected_text: Box, +} + +impl fmt::Debug for WebDriverBiDiTypeTextIntentWitness { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiTypeTextIntentWitness") + .field("command_id", &self.command_id) + .field("expected_text_bytes", &self.expected_text.len()) + .finish() + } +} + +/// One-shot proof that the exact sender-minted typed-input intent received its correlated protocol ACK. +/// +/// This value keeps the original non-secret text and its private connection generation until +/// post-condition verification. It can only be constructed by +/// [`acknowledge_webdriver_bidi_type_text_intent`] after the ACK matches both the witness command id +/// and the witness's private connection generation. Neither private value is exposed through +/// `Debug`, errors, or public accessors. +pub struct WebDriverBiDiAcknowledgedTypeTextIntent { + command_id: u64, + connection_generation: WebDriverBiDiConnectionGeneration, + expected_text: Box, +} + +impl fmt::Debug for WebDriverBiDiAcknowledgedTypeTextIntent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiAcknowledgedTypeTextIntent") + .field("command_id", &self.command_id) + .field("expected_text_bytes", &self.expected_text.len()) + .finish() + } +} + +impl WebDriverBiDiAcknowledgedTypeTextIntent { + /// Return the exact local typed-input command identifier acknowledged for this intent. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + pub(crate) fn expected_text(&self) -> &str { + &self.expected_text + } + + pub(crate) fn matches_connection_generation( + &self, + connection_generation: WebDriverBiDiConnectionGeneration, + ) -> bool { + self.connection_generation == connection_generation + } +} + +/// Fail-closed failures while binding a typed-input ACK to its sender-minted intent witness. +#[derive(Debug)] +pub enum WebDriverBiDiTypeTextIntentAcknowledgementError { + /// The received message belongs to another verified connection. + ResponseConnectionMismatch, + /// The received response names a different command identifier than the witness. + ResponseCommandMismatch, + /// The existing typed response boundary rejected the ACK. + Response { + /// Exact typed response-admission failure. + source: WebDriverBiDiTypeTextResponseError, + }, +} + +impl fmt::Display for WebDriverBiDiTypeTextIntentAcknowledgementError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::ResponseConnectionMismatch => { + "WebDriver BiDi text-input ACK arrived on a different connection than its intent" + } + Self::ResponseCommandMismatch => { + "WebDriver BiDi text-input ACK does not match its sender-minted intent" + } + Self::Response { .. } => "WebDriver BiDi text-input intent acknowledgement failed", + }) + } +} + +impl Error for WebDriverBiDiTypeTextIntentAcknowledgementError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Response { source } => Some(source), + Self::ResponseConnectionMismatch | Self::ResponseCommandMismatch => None, + } + } +} + +/// Dispatch one node-bound text action and mint the only witness that can later authorize its +/// text-value post-condition comparison. +/// +/// The underlying sender performs all existing protocol-use, node/session/document authority, +/// correlation, deadline, and frame-write checks. A witness is returned only after that sender +/// succeeds. It is bound to the exact private connection generation and command id and retains the +/// validated non-secret text only in an opaque non-cloneable value. Command ACK still is not browser +/// success: callers must admit the exact ACK with [`acknowledge_webdriver_bidi_type_text_intent`] +/// and then consume the acknowledged intent in the text-value post-condition boundary. +#[expect( + clippy::too_many_arguments, + reason = "this immediate-use wrapper preserves the typed sender's explicit authority, transport, correlation, masking, and deadline inputs while adding only a one-shot post-condition intent witness" +)] +pub fn send_webdriver_bidi_type_text_with_postcondition_intent( + validated: ValidatedBrowserProtocolUse, + command_id: u64, + browsing_context: &str, + text: &str, + handle: &AdmittedNodeHandle, + node: &WebDriverBiDiRemoteNodeReference, + registry: &BrowserAuthorityRegistry, + established: WebDriverBiDiWebSocketEstablished, + correlation: &mut WebDriverBiDiCommandCorrelation, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, +) -> Result< + ( + WebDriverBiDiWebSocketEstablished, + WebDriverBiDiTypeTextIntentWitness, + ), + WebDriverBiDiTypeTextSendError, +> { + let connection_generation = established.transport_evidence().connection_generation(); + let established = send_webdriver_bidi_type_text( + validated, + command_id, + browsing_context, + text, + handle, + node, + registry, + established, + correlation, + masking_key, + frame_timeout, + )?; + + Ok(( + established, + WebDriverBiDiTypeTextIntentWitness { + command_id, + connection_generation, + expected_text: text.into(), + }, + )) +} + +/// Admit one typed-input protocol ACK and consume the exact sender-minted intent witness. +/// +/// Connection and response-id checks run before typed response correlation can consume pending +/// state. A foreign connection or a response naming another command therefore cannot retire an +/// unrelated action. Successful admission returns a one-shot acknowledged intent whose private text +/// is the original value passed to the reviewed sender, not a value supplied at verification time. +pub fn acknowledge_webdriver_bidi_type_text_intent( + message: &WebDriverBiDiReceivedTextMessage, + witness: WebDriverBiDiTypeTextIntentWitness, + correlation: &mut WebDriverBiDiCommandCorrelation, +) -> Result +{ + if message.connection_generation() != witness.connection_generation { + return Err(WebDriverBiDiTypeTextIntentAcknowledgementError::ResponseConnectionMismatch); + } + + let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()).map_err(|source| { + WebDriverBiDiTypeTextIntentAcknowledgementError::Response { + source: WebDriverBiDiTypeTextResponseError::Envelope { source }, + } + })?; + let response_command_id = match envelope.routing() { + WebDriverBiDiJsonEnvelopeRouting::CommandSuccess { command_id } + | WebDriverBiDiJsonEnvelopeRouting::CommandError { + command_id: Some(command_id), + } => Some(command_id), + WebDriverBiDiJsonEnvelopeRouting::Event + | WebDriverBiDiJsonEnvelopeRouting::CommandError { command_id: None } => None, + }; + if response_command_id.is_some_and(|command_id| command_id != witness.command_id) { + return Err(WebDriverBiDiTypeTextIntentAcknowledgementError::ResponseCommandMismatch); + } + + WebDriverBiDiTypeTextResult::parse_and_correlate(message, correlation) + .map_err(|source| WebDriverBiDiTypeTextIntentAcknowledgementError::Response { source })?; + + Ok(WebDriverBiDiAcknowledgedTypeTextIntent { + command_id: witness.command_id, + connection_generation: witness.connection_generation, + expected_text: witness.expected_text, + }) +} diff --git a/crates/originweave-network/tests/support/text_observation.rs b/crates/originweave-network/tests/support/text_observation.rs new file mode 100644 index 000000000..0f009f98c --- /dev/null +++ b/crates/originweave-network/tests/support/text_observation.rs @@ -0,0 +1,235 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, + send_webdriver_bidi_text_value_observation, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type AdmittedTextFieldFixture = ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, +); + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +fn admitted_text_field_fixture() -> Result> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session(SESSION_ID)?; + let browsing_context = registry.register_context(browser_session, "context-a")?; + let origin = Origin::parse("https://app.example").map_err(|error| { + io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) + })?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task title"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = locate + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + Ok((registry, handle, remote)) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let marker = header[1] & 0x7f; + let length = match marker { + 0..=125 => usize::from(marker), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "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_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + stream.write_all(&[0x81])?; + match payload.len() { + 0..=125 => stream.write_all(&[payload.len() as u8])?, + 126..=65_535 => { + stream.write_all(&[126])?; + stream.write_all(&(payload.len() as u16).to_be_bytes())?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fixture response unexpectedly required 64-bit framing", + )); + } + } + stream.write_all(payload) +} + +pub fn receive_command_responses( + responses: &[&[u8]], + command_id: u64, + correlation: &mut WebDriverBiDiCommandCorrelation, +) -> Result, Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let payloads: Vec> = responses.iter().map(|value| value.to_vec()).collect(); + let count = payloads.len(); + 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)?; + let prefix = format!("{{\"id\":{command_id},\"method\":\"script.callFunction\""); + if !command.starts_with(prefix.as_bytes()) { + return Err(io::Error::other("unexpected observation request")); + } + for payload in payloads { + write_text_frame(&mut stream, &payload)?; + } + Ok(()) + }); + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let (registry, handle, remote) = admitted_text_field_fixture()?; + let mut established = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + command_id, + "context-a", + &handle, + &remote, + ®istry, + established, + correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + let mut messages = Vec::new(); + for _ in 0..count { + match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { + established: next, + message, + } => { + established = next; + messages.push(message); + } + other => { + return Err(io::Error::other(format!("expected response text: {other:?}")).into()); + } + } + } + server + .join() + .map_err(|_| io::Error::other("observation response server panicked"))??; + Ok(messages) +} diff --git a/crates/originweave-network/tests/support/type_text_intent.rs b/crates/originweave-network/tests/support/type_text_intent.rs new file mode 100644 index 000000000..4c5b0c322 --- /dev/null +++ b/crates/originweave-network/tests/support/type_text_intent.rs @@ -0,0 +1,446 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiAcknowledgedTypeTextIntent, WebDriverBiDiCommandCorrelation, + WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiSessionStatusCommand, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, + acknowledge_webdriver_bidi_type_text_intent, send_webdriver_bidi_text_value_observation, + send_webdriver_bidi_type_text_with_postcondition_intent, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type AdmittedTypeTextFixture = ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, +); + +type AcknowledgedObservationFixture = ( + WebDriverBiDiAcknowledgedTypeTextIntent, + WebDriverBiDiReceivedTextMessage, + WebDriverBiDiCommandCorrelation, +); + +type EstablishedPeerFixture = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn protocol_proof( + capability: BrowserProtocolCapability, +) -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[capability], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capability, + )?) +} + +fn semantic_observation_proof() -> Result> { + protocol_proof(BrowserProtocolCapability::SemanticObservation) +} + +pub fn typed_input_proof() -> Result> { + protocol_proof(BrowserProtocolCapability::TypedInput) +} + +pub fn admitted_type_text_fixture() -> Result> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session(SESSION_ID)?; + let browsing_context = registry.register_context(browser_session, "context-a")?; + let origin = Origin::parse("https://app.example").map_err(|error| { + io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) + })?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task title"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = locate + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + Ok((registry, handle, remote)) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +pub 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, "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) +} + +pub fn write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + if payload.len() > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fixture response unexpectedly exceeded one-byte framing", + )); + } + stream.write_all(&[0x81, payload.len() as u8])?; + stream.write_all(payload) +} + +fn assert_command_prefix(command: &[u8], expected_prefix: &[u8], name: &str) -> io::Result<()> { + if command.starts_with(expected_prefix) { + Ok(()) + } else { + Err(io::Error::other(format!("unexpected {name} command"))) + } +} + +fn open_established_connection( + local_addr: std::net::SocketAddr, +) -> Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +pub fn established_with_peer_script( + script: impl FnOnce(&mut TcpStream) -> io::Result<()> + Send + 'static, +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + script(&mut stream) + }); + Ok((open_established_connection(local_addr)?, server)) +} + +pub fn acknowledged_type_text_intent( + command_id: u64, + text: &str, +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let expected_prefix = + format!(r#"{{"id":{command_id},"method":"input.performActions""#).into_bytes(); + let ack = format!(r#"{{"type":"success","id":{command_id},"result":{{}}}}"#).into_bytes(); + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let command = read_masked_text_frame(&mut stream)?; + assert_command_prefix(&command, &expected_prefix, "typed-input")?; + write_text_frame(&mut stream, &ack) + }); + + let established = open_established_connection(local_addr)?; + let (registry, handle, remote) = admitted_type_text_fixture()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (established, witness) = send_webdriver_bidi_type_text_with_postcondition_intent( + typed_input_proof()?, + command_id, + "context-a", + text, + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + let action_ack = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err(io::Error::other(format!("expected typed-input ACK: {other:?}")).into()); + } + }; + let acknowledged = + acknowledge_webdriver_bidi_type_text_intent(&action_ack, witness, &mut correlation)?; + if correlation.outstanding_count() != 0 { + return Err(io::Error::other("typed-input ACK did not consume its correlation").into()); + } + server + .join() + .map_err(|_| io::Error::other("typed-input fixture server panicked"))??; + Ok(acknowledged) +} + +pub fn acknowledged_type_text_intent_and_observation( + type_text_command_id: u64, + text: &str, + observation_command_id: u64, + observation_response: &[u8], +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let type_text_prefix = + format!(r#"{{"id":{type_text_command_id},"method":"input.performActions""#).into_bytes(); + let observation_prefix = + format!(r#"{{"id":{observation_command_id},"method":"script.callFunction""#).into_bytes(); + let ack = + format!(r#"{{"type":"success","id":{type_text_command_id},"result":{{}}}}"#).into_bytes(); + let response = observation_response.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 type_text_command = read_masked_text_frame(&mut stream)?; + assert_command_prefix(&type_text_command, &type_text_prefix, "typed-input")?; + write_text_frame(&mut stream, &ack)?; + let observation_command = read_masked_text_frame(&mut stream)?; + assert_command_prefix( + &observation_command, + &observation_prefix, + "text-observation", + )?; + write_text_frame(&mut stream, &response) + }); + + let established = open_established_connection(local_addr)?; + let (registry, handle, remote) = admitted_type_text_fixture()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (established, witness) = send_webdriver_bidi_type_text_with_postcondition_intent( + typed_input_proof()?, + type_text_command_id, + "context-a", + text, + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + let (established, action_ack) = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { + established, + message, + } => (established, message), + other => { + return Err(io::Error::other(format!("expected typed-input ACK: {other:?}")).into()); + } + }; + let acknowledged = + acknowledge_webdriver_bidi_type_text_intent(&action_ack, witness, &mut correlation)?; + if correlation.outstanding_count() != 0 { + return Err(io::Error::other("typed-input ACK did not consume its correlation").into()); + } + + let established = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + observation_command_id, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::from_millis(500), + )?; + let observation = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err( + io::Error::other(format!("expected observation response: {other:?}")).into(), + ); + } + }; + server + .join() + .map_err(|_| io::Error::other("combined postcondition fixture server panicked"))??; + Ok((acknowledged, observation, correlation)) +} + +pub fn acknowledged_type_text_intent_and_registered_response( + type_text_command_id: u64, + text: &str, + response_command_id: u64, + response_kind: WebDriverBiDiCommandKind, + response: &[u8], +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let type_text_prefix = + format!(r#"{{"id":{type_text_command_id},"method":"input.performActions""#).into_bytes(); + let ack = + format!(r#"{{"type":"success","id":{type_text_command_id},"result":{{}}}}"#).into_bytes(); + if response_kind != WebDriverBiDiCommandKind::SessionStatus { + return Err( + io::Error::other("registered-response fixture only supports session.status").into(), + ); + } + let response_prefix = + format!(r#"{{"id":{response_command_id},"method":"session.status""#).into_bytes(); + let response = response.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 type_text_command = read_masked_text_frame(&mut stream)?; + assert_command_prefix(&type_text_command, &type_text_prefix, "typed-input")?; + write_text_frame(&mut stream, &ack)?; + let response_command = read_masked_text_frame(&mut stream)?; + assert_command_prefix(&response_command, &response_prefix, "session.status")?; + write_text_frame(&mut stream, &response) + }); + + let established = open_established_connection(local_addr)?; + let (registry, handle, remote) = admitted_type_text_fixture()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (established, witness) = send_webdriver_bidi_type_text_with_postcondition_intent( + typed_input_proof()?, + type_text_command_id, + "context-a", + text, + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + let (established, action_ack) = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { + established, + message, + } => (established, message), + other => { + return Err(io::Error::other(format!("expected typed-input ACK: {other:?}")).into()); + } + }; + let acknowledged = + acknowledge_webdriver_bidi_type_text_intent(&action_ack, witness, &mut correlation)?; + let established = WebDriverBiDiSessionStatusCommand::new(response_command_id)?.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::from_millis(500), + )?; + let message = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err( + io::Error::other(format!("expected registered response: {other:?}")).into(), + ); + } + }; + server + .join() + .map_err(|_| io::Error::other("registered-response fixture server panicked"))??; + Ok((acknowledged, message, correlation)) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_correlation.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_correlation.rs new file mode 100644 index 000000000..560838f20 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_correlation.rs @@ -0,0 +1,209 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES, WebDriverBiDiWebSocketEndpoint}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTextValueObservationResponseError, WebDriverBiDiTextValueObservationResult, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; + +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_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + stream.write_all(&[0x81])?; + match payload.len() { + 0..=125 => stream.write_all(&[payload.len() as u8])?, + 126..=65_535 => { + stream.write_all(&[126])?; + stream.write_all(&(payload.len() as u16).to_be_bytes())?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fixture response unexpectedly required 64-bit framing", + )); + } + } + stream.write_all(payload) +} + +fn receive_server_text(payload: &[u8]) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let response = 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_text_frame(&mut stream, &response) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let message = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err(io::Error::other(format!( + "fixture produced unexpected message assembly state: {other:?}" + )) + .into()); + } + }; + server + .join() + .map_err(|_| io::Error::other("response fixture server panicked"))??; + Ok(message) +} + +fn require_observation_error( + message: &WebDriverBiDiReceivedTextMessage, + correlation: &mut WebDriverBiDiCommandCorrelation, +) -> Result> { + match WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + message, + "expected", + correlation, + ) { + Err(error) => Ok(error), + Ok(_) => Err(io::Error::other("fixture unexpectedly admitted an invalid response").into()), + } +} + +fn require_expected_text_error( + message: &WebDriverBiDiReceivedTextMessage, + expected_text: &str, + correlation: &mut WebDriverBiDiCommandCorrelation, +) -> Result> { + match WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + message, + expected_text, + correlation, + ) { + Err(error) => Ok(error), + Ok(_) => { + Err(io::Error::other("fixture unexpectedly admitted invalid expected text").into()) + } + } +} + +#[test] +fn protocol_error_correlation_and_diagnostics_fail_closed_without_consuming_other_state() +-> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(70, WebDriverBiDiCommandKind::TextValueObservation)?; + + let unknown_protocol_error = receive_server_text( + br#"{"type":"error","id":71,"error":"unknown error","message":"remote failure"}"#, + )?; + let correlation_error = require_observation_error(&unknown_protocol_error, &mut correlation)?; + assert!(matches!( + &correlation_error, + WebDriverBiDiTextValueObservationResponseError::Correlation { .. } + )); + assert_eq!( + correlation_error.to_string(), + "WebDriver BiDi text-value observation response correlation failed" + ); + assert_eq!(correlation.outstanding_count(), 1); + + let invalid_envelope = receive_server_text(b"not-json")?; + let envelope_error = require_observation_error(&invalid_envelope, &mut correlation)?; + assert!(matches!( + &envelope_error, + WebDriverBiDiTextValueObservationResponseError::Envelope { .. } + )); + assert_eq!( + envelope_error.to_string(), + "WebDriver BiDi text-value observation envelope is invalid" + ); + assert_eq!(correlation.outstanding_count(), 1); + + let malformed_projection = receive_server_text( + br#"{"type":"success","id":70,"result":{"type":"success","result":{"type":"string","value":"expected"}}}"#, + )?; + let projection_error = require_observation_error(&malformed_projection, &mut correlation)?; + assert!(matches!( + &projection_error, + WebDriverBiDiTextValueObservationResponseError::Projection { .. } + )); + assert_eq!( + projection_error.to_string(), + "WebDriver BiDi text-value observation result is invalid" + ); + assert_eq!(correlation.outstanding_count(), 1); + + Ok(()) +} + +#[test] +fn invalid_expected_text_fails_before_response_or_correlation_state_is_touched() +-> Result<(), Box> { + let invalid_envelope = receive_server_text(b"not-json")?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(70, WebDriverBiDiCommandKind::TextValueObservation)?; + + let empty_error = require_expected_text_error(&invalid_envelope, "", &mut correlation)?; + assert!(matches!( + empty_error, + WebDriverBiDiTextValueObservationResponseError::EmptyExpectedText + )); + assert_eq!(correlation.outstanding_count(), 1); + + let oversized = "x".repeat(MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES + 1); + let oversized_error = + require_expected_text_error(&invalid_envelope, &oversized, &mut correlation)?; + assert!(matches!( + oversized_error, + WebDriverBiDiTextValueObservationResponseError::ExpectedTextTooLong + )); + assert_eq!(correlation.outstanding_count(), 1); + + let control_error = + require_expected_text_error(&invalid_envelope, "bad\u{0001}value", &mut correlation)?; + assert!(matches!( + control_error, + WebDriverBiDiTextValueObservationResponseError::InvalidExpectedText + )); + assert_eq!(correlation.outstanding_count(), 1); + + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_response.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_response.rs new file mode 100644 index 000000000..63213c31f --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_response.rs @@ -0,0 +1,527 @@ +#[path = "support/text_observation.rs"] +mod text_observation; + +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiTextValueObservationCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTextValueObservationResponseError, WebDriverBiDiTextValueObservationResult, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketMessageReader, send_webdriver_bidi_text_value_observation, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const OBSERVATION_SUCCESS_RESPONSE: &[u8] = br#"{"type":"success","id":43,"result":{"type":"success","result":{"type":"string","value":"Quarterly review"},"realm":"realm-1"}}"#; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type AdmittedTextFieldFixture = ( + BrowserAuthorityRegistry, + AdmittedNodeHandle, + WebDriverBiDiRemoteNodeReference, +); + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +fn establish_response_connection( + local_addr: SocketAddr, +) -> Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + Ok(WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?) +} + +fn read_response_text( + established: WebDriverBiDiWebSocketEstablished, +) -> Result> { + match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => Ok(message), + _ => Err(io::Error::other("fixture expected a complete text response").into()), + } +} + +#[test] +fn replacement_connection_cannot_complete_text_observation() -> Result<(), Box> { + for payload in [ + OBSERVATION_SUCCESS_RESPONSE, + br#"{"type":"error","id":43,"error":"invalid argument","message":"rejected"}"#, + br#"{"type":"success","id":43,"result":{"type":"exception","realm":"realm-1","exceptionDetails":{}}}"#, + ] { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut first, _) = listener.accept()?; + read_opening_request(&mut first)?; + first.write_all(OPENING_RESPONSE)?; + let command = read_masked_text_frame(&mut first)?; + assert!(command.starts_with(br#"{"id":43,"method":"script.callFunction""#)); + let (mut second, _) = listener.accept()?; + read_opening_request(&mut second)?; + second.write_all(OPENING_RESPONSE)?; + write_text_frame(&mut second, payload)?; + write_text_frame(&mut first, OBSERVATION_SUCCESS_RESPONSE) + }); + let (registry, handle, remote) = admitted_text_field_fixture()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(44, WebDriverBiDiCommandKind::TextValueObservation)?; + let first = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 43, + "context-a", + &handle, + &remote, + ®istry, + establish_response_connection(local_addr)?, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + assert_eq!(correlation.outstanding_count(), 2); + let foreign = read_response_text(establish_response_connection(local_addr)?)?; + let rejected = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare(&foreign, "Quarterly review", &mut correlation); + let original = read_response_text(first)?; + server + .join() + .map_err(|_| io::Error::other("response server panicked"))??; + assert!( + matches!( + rejected, + Err(WebDriverBiDiTextValueObservationResponseError::Correlation { + source: originweave_network::WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { + command_id: 43, + }, + }) + ), + "replacement socket must not acknowledge the original request: {rejected:?}" + ); + assert_eq!(correlation.outstanding_count(), 2); + let result = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare(&original, "Quarterly review", &mut correlation)?; + assert_eq!(result.command_id(), 43); + assert!(result.matches_expected_text()); + assert_eq!(correlation.outstanding_count(), 1); + } + Ok(()) +} + +fn admitted_text_field_fixture() -> Result> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session(SESSION_ID)?; + let browsing_context = registry.register_context(browser_session, "context-a")?; + let origin = Origin::parse("https://app.example").map_err(|error| { + io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) + })?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task title"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[{"type":"node","sharedId":"shared-node-42"}]}}"#, + )?; + let handle = locate + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + Ok((registry, handle, remote)) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let marker = header[1] & 0x7f; + let length = match marker { + 0..=125 => usize::from(marker), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "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_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + stream.write_all(&[0x81])?; + match payload.len() { + 0..=125 => stream.write_all(&[payload.len() as u8])?, + 126..=65_535 => { + stream.write_all(&[126])?; + stream.write_all(&(payload.len() as u16).to_be_bytes())?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fixture response unexpectedly required 64-bit framing", + )); + } + } + stream.write_all(payload) +} + +fn receive_server_text(payload: &[u8]) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let response = 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_text_frame(&mut stream, &response) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let message = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err(io::Error::other(format!( + "fixture produced unexpected message assembly state: {other:?}" + )) + .into()); + } + }; + server + .join() + .map_err(|_| io::Error::other("response fixture server panicked"))??; + Ok(message) +} + +#[test] +fn observed_text_postcondition_consumes_exact_command_without_exposing_page_text() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let (registry, handle, remote) = admitted_text_field_fixture()?; + let expected_json = WebDriverBiDiTextValueObservationCommand::new_for_current_node( + 43, + "context-a", + &handle, + &remote, + ®istry, + )? + .as_json() + .as_bytes() + .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 script.callFunction text-value observation command", + )); + } + write_text_frame(&mut stream, OBSERVATION_SUCCESS_RESPONSE) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, + 43, + "context-a", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + assert_eq!(correlation.outstanding_count(), 1); + + let text = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err(io::Error::other(format!( + "text-value observation response produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let result = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &text, + "Quarterly review", + &mut correlation, + )?; + assert_eq!(result.command_id(), 43); + assert!(result.matches_expected_text()); + assert_eq!(result.observed_text_bytes(), "Quarterly review".len()); + assert_eq!(correlation.outstanding_count(), 0); + let debug = format!("{result:?}"); + assert!(!debug.contains("Quarterly review")); + + server + .join() + .map_err(|_| io::Error::other("text-value observation response server panicked"))??; + Ok(()) +} + +#[test] +fn response_admission_failures_preserve_or_consume_exact_correlation_state() +-> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let mut original_responses = text_observation::receive_command_responses( + &[ + b"not-json", + br#"{"type":"success","id":70,"result":{"type":"success","realm":"realm-1","result":{"type":"string","value":"expected"}}}"#, + ], 70, &mut correlation, + )?; + let invalid = original_responses.remove(0); + let Err(envelope_error) = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &invalid, + "expected", + &mut correlation, + ) else { + return Err(io::Error::other("invalid envelope unexpectedly admitted").into()); + }; + assert!(matches!( + &envelope_error, + WebDriverBiDiTextValueObservationResponseError::Envelope { .. } + )); + assert_eq!( + envelope_error.to_string(), + "WebDriver BiDi text-value observation envelope is invalid" + ); + assert!(envelope_error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + + let event = receive_server_text(br#"{"type":"event","method":"log.entryAdded","params":{}}"#)?; + assert!(matches!( + WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &event, + "expected", + &mut correlation, + ), + Err(WebDriverBiDiTextValueObservationResponseError::UnexpectedEvent) + )); + assert_eq!(correlation.outstanding_count(), 1); + + let protocol_error = text_observation::receive_command_responses( + &[br#"{"type":"error","id":71,"error":"unknown error","message":"remote failure"}"#], + 71, + &mut correlation, + )? + .remove(0); + assert!(matches!( + WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &protocol_error, + "expected", + &mut correlation, + ), + Err(WebDriverBiDiTextValueObservationResponseError::RemoteProtocolError { command_id: 71 }) + )); + assert_eq!(correlation.outstanding_count(), 1); + + let unknown_success = receive_server_text( + br#"{"type":"success","id":72,"result":{"type":"success","realm":"realm-1","result":{"type":"string","value":"expected"}}}"#, + )?; + let Err(correlation_error) = + WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &unknown_success, + "expected", + &mut correlation, + ) + else { + return Err(io::Error::other("unknown response id unexpectedly correlated").into()); + }; + assert!(matches!( + &correlation_error, + WebDriverBiDiTextValueObservationResponseError::Correlation { .. } + )); + assert_eq!( + correlation_error.to_string(), + "WebDriver BiDi text-value observation response correlation failed" + ); + assert!(correlation_error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + + let mut same_connection = text_observation::receive_command_responses( + &[ + br#"{"type":"success","id":73,"result":{"type":"success","result":{"type":"string","value":"expected"}}}"#, + br#"{"type":"success","id":73,"result":{"type":"exception","realm":"realm-1","exceptionDetails":{}}}"#, + ], 73, &mut correlation, + )?; + let malformed_projection = same_connection.remove(0); + let Err(projection_error) = + WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &malformed_projection, + "expected", + &mut correlation, + ) + else { + return Err(io::Error::other("malformed script result unexpectedly admitted").into()); + }; + assert!(matches!( + &projection_error, + WebDriverBiDiTextValueObservationResponseError::Projection { .. } + )); + assert_eq!( + projection_error.to_string(), + "WebDriver BiDi text-value observation result is invalid" + ); + assert!(projection_error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 2); + + let script_exception = same_connection.remove(0); + assert!(matches!( + WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &script_exception, + "expected", + &mut correlation, + ), + Err(WebDriverBiDiTextValueObservationResponseError::ScriptException { command_id: 73 }) + )); + assert_eq!(correlation.outstanding_count(), 1); + + let valid_success = original_responses.remove(0); + assert!(matches!( + WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &valid_success, + "bad\ttext", + &mut correlation, + ), + Err(WebDriverBiDiTextValueObservationResponseError::InvalidExpectedText) + )); + assert_eq!(correlation.outstanding_count(), 1); + + let result = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &valid_success, + "expected", + &mut correlation, + )?; + assert!(result.matches_expected_text()); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_unicode_response.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_unicode_response.rs new file mode 100644 index 000000000..870595a05 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_unicode_response.rs @@ -0,0 +1,135 @@ +#[path = "support/text_observation.rs"] +mod text_observation; + +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTextValueObservationResponseError, WebDriverBiDiTextValueObservationResult, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; + +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 receive_server_text(payload: &[u8]) -> Result> { + if payload.len() > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "fixture payload must fit one short server text frame", + ) + .into()); + } + + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let response = 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)?; + stream.write_all(&[0x81, response.len() as u8])?; + stream.write_all(&response) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let message = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err(io::Error::other(format!( + "fixture produced unexpected message assembly state: {other:?}" + )) + .into()); + } + }; + server + .join() + .map_err(|_| io::Error::other("response fixture server panicked"))??; + Ok(message) +} + +#[test] +fn escaped_unicode_is_compared_after_bounded_response_projection() -> Result<(), Box> { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let response = text_observation::receive_command_responses( + &[ + br#"{"type":"success","id":81,"result":{"type":"success","realm":"r","result":{"type":"string","value":"\u20ac"}}}"#, + ], 81, &mut correlation, + )?.remove(0); + + let result = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &response, + "€", + &mut correlation, + )?; + + assert_eq!(result.command_id(), 81); + assert_eq!(result.observed_text_bytes(), "€".len()); + assert!(result.matches_expected_text()); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn projection_error_diagnostic_remains_structural_and_non_sensitive() -> Result<(), Box> +{ + let response = receive_server_text( + br#"{"type":"success","id":82,"result":{"type":"success","result":{"type":"string","value":"x"}}}"#, + )?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(82, WebDriverBiDiCommandKind::TextValueObservation)?; + + let Err(WebDriverBiDiTextValueObservationResponseError::Projection { source }) = + WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &response, + "x", + &mut correlation, + ) + else { + return Err(io::Error::other("malformed projection unexpectedly admitted").into()); + }; + + assert_eq!(source.to_string(), "missing member result.realm"); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_postcondition_gate.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_postcondition_gate.rs new file mode 100644 index 000000000..1810b023c --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_postcondition_gate.rs @@ -0,0 +1,136 @@ +#[path = "support/type_text_intent.rs"] +pub mod type_text_intent; + +use std::{error::Error, io}; + +use originweave_network::{ + WebDriverBiDiCommandKind, WebDriverBiDiTextValuePostconditionError, + verify_webdriver_bidi_text_value_postcondition, +}; + +#[test] +fn exact_match_is_the_only_successful_text_postcondition() -> Result<(), Box> { + let (acknowledged_intent, response, mut correlation) = + type_text_intent::acknowledged_type_text_intent_and_observation( + 42, + "expected", + 70, + br#"{"type":"success","id":70,"result":{"type":"success","realm":"realm-1","result":{"type":"string","value":"expected"}}}"#, + )?; + + let verified = verify_webdriver_bidi_text_value_postcondition( + &response, + acknowledged_intent, + &mut correlation, + )?; + + assert_eq!(verified.type_text_command_id(), 42); + assert_eq!(verified.command_id(), 70); + assert_eq!(verified.observed_text_bytes(), "expected".len()); + assert_eq!(correlation.outstanding_count(), 0); + assert!(!format!("{verified:?}").contains("expected")); + Ok(()) +} + +#[test] +fn mismatch_is_typed_failure_after_consuming_its_exact_response() -> Result<(), Box> { + let (acknowledged_intent, response, mut correlation) = + type_text_intent::acknowledged_type_text_intent_and_observation( + 43, + "expected", + 71, + br#"{"type":"success","id":71,"result":{"type":"success","realm":"realm-1","result":{"type":"string","value":"unexpected"}}}"#, + )?; + + let Err(error) = verify_webdriver_bidi_text_value_postcondition( + &response, + acknowledged_intent, + &mut correlation, + ) else { + return Err(io::Error::other( + "a mismatched page value must not be returned as successful postcondition evidence", + ) + .into()); + }; + + assert!(matches!( + &error, + WebDriverBiDiTextValuePostconditionError::PostconditionMismatch { + type_text_command_id: 43, + command_id: 71, + observed_text_bytes: 10, + } + )); + assert_eq!(correlation.outstanding_count(), 0); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value postcondition did not match the acknowledged typed-input intent" + ); + assert!(error.source().is_none()); + let debug = format!("{error:?}"); + assert!(!debug.contains("expected")); + assert!(!debug.contains("unexpected")); + Ok(()) +} + +#[test] +fn malformed_observation_stays_a_typed_source_error_without_consuming_state() +-> Result<(), Box> { + let (acknowledged_intent, response, mut correlation) = + type_text_intent::acknowledged_type_text_intent_and_observation( + 44, + "expected", + 72, + b"not-json", + )?; + + let Err(error) = verify_webdriver_bidi_text_value_postcondition( + &response, + acknowledged_intent, + &mut correlation, + ) else { + return Err(io::Error::other("malformed observation must fail closed").into()); + }; + + assert!(matches!( + &error, + WebDriverBiDiTextValuePostconditionError::Observation { .. } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value postcondition observation failed" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn unrelated_outstanding_command_cannot_certify_text_postcondition() -> Result<(), Box> { + let (acknowledged_intent, response, mut correlation) = + type_text_intent::acknowledged_type_text_intent_and_registered_response( + 45, + "expected", + 73, + WebDriverBiDiCommandKind::SessionStatus, + br#"{"type":"success","id":73,"result":{"type":"success","realm":"realm-1","result":{"type":"string","value":"expected"}}}"#, + )?; + + let Err(error) = verify_webdriver_bidi_text_value_postcondition( + &response, + acknowledged_intent, + &mut correlation, + ) else { + return Err(io::Error::other( + "an unrelated outstanding command id must not certify a text-value postcondition", + ) + .into()); + }; + + assert!(matches!( + &error, + WebDriverBiDiTextValuePostconditionError::Observation { .. } + )); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_response_fail_closed.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_response_fail_closed.rs new file mode 100644 index 000000000..e2b7d8537 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_response_fail_closed.rs @@ -0,0 +1,148 @@ +#[path = "support/text_observation.rs"] +mod text_observation; + +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiConnectionMessageRead, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTextValueObservationResponseError, WebDriverBiDiTextValueObservationResult, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMessageReader, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +const EVENT: &[u8] = br#"{"type":"event","method":"browsingContext.load","params":{}}"#; +const PROTOCOL_ERROR: &[u8] = + br#"{"type":"error","id":73,"error":"unknown error","message":"page-controlled detail"}"#; +const SCRIPT_EXCEPTION: &[u8] = br#"{"type":"success","id":74,"result":{"type":"exception","realm":"realm-1","exceptionDetails":{"text":"page-controlled detail"}}}"#; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn write_unmasked_text_frame(stream: &mut TcpStream, document: &[u8]) -> io::Result<()> { + if document.len() <= 125 { + let length = u8::try_from(document.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "short frame length exceeds u8") + })?; + stream.write_all(&[0x81, length])?; + } else { + let length = u16::try_from(document.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test JSON document exceeds two-byte frame length", + ) + })?; + stream.write_all(&[0x81, 126])?; + stream.write_all(&length.to_be_bytes())?; + } + stream.write_all(document) +} + +fn read_text_over_loopback( + document: &'static [u8], +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + write_unmasked_text_frame(&mut stream, document) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let text = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, + other => { + return Err(io::Error::other(format!( + "validated text frame produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + + server + .join() + .map_err(|_| io::Error::other("text-value integration server panicked"))??; + Ok(text) +} + +#[test] +fn production_instantiation_fails_closed_for_event_protocol_error_and_script_exception() +-> Result<(), Box> { + let event = read_text_over_loopback(EVENT)?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + assert!(matches!( + WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &event, + "expected", + &mut correlation, + ), + Err(WebDriverBiDiTextValueObservationResponseError::UnexpectedEvent) + )); + assert_eq!(correlation.outstanding_count(), 0); + + let protocol_error = + text_observation::receive_command_responses(&[PROTOCOL_ERROR], 73, &mut correlation)? + .remove(0); + assert!(matches!( + WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &protocol_error, + "expected", + &mut correlation, + ), + Err(WebDriverBiDiTextValueObservationResponseError::RemoteProtocolError { command_id: 73 }) + )); + assert_eq!(correlation.outstanding_count(), 0); + + let script_exception = + text_observation::receive_command_responses(&[SCRIPT_EXCEPTION], 74, &mut correlation)? + .remove(0); + assert!(matches!( + WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &script_exception, + "expected", + &mut correlation, + ), + Err(WebDriverBiDiTextValueObservationResponseError::ScriptException { command_id: 74 }) + )); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_intent_ack.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_intent_ack.rs new file mode 100644 index 000000000..b373dc97c --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_intent_ack.rs @@ -0,0 +1,178 @@ +#[path = "support/type_text_intent.rs"] +pub mod type_text_intent; + +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiJsonEnvelopeError, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiTypeTextIntentAcknowledgementError as AckError, + WebDriverBiDiTypeTextIntentWitness, WebDriverBiDiTypeTextResponseError as ResponseError, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageReader, + acknowledge_webdriver_bidi_type_text_intent, + send_webdriver_bidi_type_text_with_postcondition_intent, +}; +use std::{error::Error, io, io::Read, time::Duration}; + +const PRIVATE_TEXT: &str = "private-action"; +const SUCCESS_ACK: &[u8] = br#"{"type":"success","id":42,"result":{}}"#; +type IntentReply = ( + WebDriverBiDiTypeTextIntentWitness, + WebDriverBiDiReceivedTextMessage, + WebDriverBiDiCommandCorrelation, +); + +fn intent_reply(reply: &[u8]) -> Result> { + let reply = reply.to_vec(); + let (established, peer) = type_text_intent::established_with_peer_script(move |stream| { + let command = type_text_intent::read_masked_text_frame(stream)?; + assert!(command.starts_with(br#"{"id":42,"method":"input.performActions""#)); + type_text_intent::write_text_frame(stream, &reply) + })?; + let (registry, handle, remote) = type_text_intent::admitted_type_text_fixture()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let (established, witness) = send_webdriver_bidi_type_text_with_postcondition_intent( + type_text_intent::typed_input_proof()?, + 42, + "context-a", + PRIVATE_TEXT, + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_secs(1), + )?; + assert_eq!( + format!("{witness:?}"), + "WebDriverBiDiTypeTextIntentWitness { command_id: 42, expected_text_bytes: 14 }" + ); + let received = + WebDriverBiDiWebSocketMessageReader::new(established).read_next(Duration::from_secs(1)); + peer.join() + .map_err(|_| io::Error::other("ACK peer panicked"))??; + match received? { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => { + Ok((witness, message, correlation)) + } + other => Err(io::Error::other(format!("expected ACK text: {other:?}")).into()), + } +} + +#[test] +fn ack_admission_preserves_correlation_and_opaque_diagnostics() -> Result<(), Box> { + for (reply, expected, pending) in [ + (SUCCESS_ACK, Ok(()), 0), + ( + &b"{"[..], + Err(AckError::Response { + source: ResponseError::Envelope { + source: WebDriverBiDiJsonEnvelopeError::InvalidJson, + }, + }), + 1, + ), + ( + &br#"{"type":"success","id":43,"result":{}}"#[..], + Err(AckError::ResponseCommandMismatch), + 1, + ), + ( + &br#"{"type":"error","id":43,"error":"unknown error","message":"private-action"}"#[..], + Err(AckError::ResponseCommandMismatch), + 1, + ), + ( + &br#"{"type":"error","id":42,"error":"unknown error","message":"private-action"}"#[..], + Err(AckError::Response { + source: ResponseError::RemoteProtocolError { command_id: 42 }, + }), + 0, + ), + ( + &br#"{"type":"error","id":null,"error":"unknown error","message":"private-action"}"#[..], + Err(AckError::Response { + source: ResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::UncorrelatableErrorResponse, + }, + }), + 1, + ), + ( + &br#"{"type":"event","method":"log.entryAdded","params":{}}"#[..], + Err(AckError::Response { + source: ResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::EventIsNotResponse, + }, + }), + 1, + ), + ] { + let (witness, message, mut correlation) = intent_reply(reply)?; + let result = + acknowledge_webdriver_bidi_type_text_intent(&message, witness, &mut correlation); + if let (Err(actual), Err(expected)) = (&result, &expected) { + assert_eq!(actual.to_string(), expected.to_string()); + assert_eq!(actual.source().is_some(), expected.source().is_some()); + } + let result = result.map(|ack| { + assert_eq!(ack.command_id(), 42); + assert_eq!(format!("{ack:?}"), + "WebDriverBiDiAcknowledgedTypeTextIntent { command_id: 42, expected_text_bytes: 14 }"); + }); + assert_eq!(format!("{result:?}"), format!("{expected:?}")); + assert_eq!(correlation.outstanding_count(), pending); + } + Ok(()) +} + +#[test] +fn foreign_ack_cannot_consume_original_intent_correlation() -> Result<(), Box> { + let (witness, _original, mut correlation) = intent_reply(SUCCESS_ACK)?; + let (_foreign_witness, foreign, foreign_correlation) = intent_reply(SUCCESS_ACK)?; + let Err(error) = + acknowledge_webdriver_bidi_type_text_intent(&foreign, witness, &mut correlation) + else { + return Err(io::Error::other("foreign ACK must fail").into()); + }; + assert_eq!(format!("{error:?}"), "ResponseConnectionMismatch"); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-input ACK arrived on a different connection than its intent" + ); + assert!(error.source().is_none()); + assert_eq!(correlation.outstanding_count(), 1); + assert_eq!(foreign_correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn rejected_sender_cannot_mint_a_witness_or_emit_command_bytes() -> Result<(), Box> { + let (established, peer) = type_text_intent::established_with_peer_script(|stream| { + let mut byte = [0]; + assert_eq!(stream.read(&mut byte)?, 0); + Ok(()) + })?; + let (registry, handle, remote) = type_text_intent::admitted_type_text_fixture()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let result = send_webdriver_bidi_type_text_with_postcondition_intent( + type_text_intent::typed_input_proof()?, + 42, + "context-a", + "", + &handle, + &remote, + ®istry, + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_secs(1), + ); + peer.join() + .map_err(|_| io::Error::other("rejected sender peer panicked"))??; + assert_eq!( + format!("{result:?}"), + "Err(Authority { source: Command(EmptyText) })" + ); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_type_text_intent_postcondition_provenance.rs b/crates/originweave-network/tests/webdriver_bidi_type_text_intent_postcondition_provenance.rs new file mode 100644 index 000000000..20b639900 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_type_text_intent_postcondition_provenance.rs @@ -0,0 +1,87 @@ +#[path = "support/text_observation.rs"] +mod text_observation; +#[path = "support/type_text_intent.rs"] +pub mod type_text_intent; + +use std::{error::Error, io}; + +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTextValuePostconditionError, + verify_webdriver_bidi_text_value_postcondition, +}; + +#[test] +fn substituted_expected_text_cannot_certify_a_different_authorized_typed_input() +-> Result<(), Box> { + let (acknowledged_intent, observation, mut correlation) = + type_text_intent::acknowledged_type_text_intent_and_observation( + 42, + "authorized-value", + 70, + br#"{"type":"success","id":70,"result":{"type":"success","realm":"r","result":{"type":"string","value":"substituted-value"}}}"#, + )?; + + let substituted = verify_webdriver_bidi_text_value_postcondition( + &observation, + acknowledged_intent, + &mut correlation, + ); + let Err(error) = substituted else { + return Err(io::Error::other( + "a value chosen only at verification time must not certify a different authorized typed-input intent", + ) + .into()); + }; + assert!(matches!( + error, + WebDriverBiDiTextValuePostconditionError::PostconditionMismatch { + type_text_command_id: 42, + command_id: 70, + observed_text_bytes: 17, + } + )); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn observation_on_another_connection_cannot_certify_the_acknowledged_typed_input() +-> Result<(), Box> { + let acknowledged_intent = type_text_intent::acknowledged_type_text_intent(43, "same-value")?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let observation = text_observation::receive_command_responses( + &[br#"{"type":"success","id":71,"result":{"type":"success","realm":"realm-1","result":{"type":"string","value":"same-value"}}}"#], + 71, + &mut correlation, + )? + .remove(0); + + let Err(error) = verify_webdriver_bidi_text_value_postcondition( + &observation, + acknowledged_intent, + &mut correlation, + ) else { + return Err(io::Error::other( + "an observation from another verified connection must not certify an earlier typed-input intent", + ) + .into()); + }; + assert!(matches!( + &error, + WebDriverBiDiTextValuePostconditionError::ObservationConnectionMismatch + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value postcondition observation arrived on a different connection than the acknowledged typed-input intent" + ); + assert!(error.source().is_none()); + assert_eq!( + correlation.outstanding_count(), + 1, + "foreign post-condition evidence must not consume the pending observation" + ); + let debug = format!("{error:?}"); + assert!(!debug.contains("same-value")); + Ok(()) +} diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 72cdda1c7..480c0465a 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -18,6 +18,8 @@ OriginWeave also retains the registered command family until correlation complet The fixed field-observation sender now requires the admitted node's registered session to match the established transport session before writing. It validates the deadline before registration, binds pending requests to the exact connection generation, and uses the existing lifetime command-ID guard. Proven zero-write rejection retires that request; an ambiguous socket write keeps it pending. These transport checks do not prove the field value or a completed user action. +Field-value response admission consumes only sealed messages received on that sender's exact connection. A replacement connection cannot complete the request with a matching identifier, success value, protocol error, or script exception. Equality remains necessary for positive value evidence; response parsing and dispatch alone still do not prove a completed user action. + The same Working Draft defines `ErrorResponse.error` as `ErrorCode`. Its rendered local-end CDDL enumerates 30 values and omits `no such client window`, while §3.5 separately defines `no such client window` and normative client-window algorithms return that error code. OriginWeave therefore admits the finite rendered CDDL vocabulary plus this one separately defined normative error, and still rejects arbitrary error-code text fail closed. This is an explicit interoperability exception for a specification-internal inconsistency, not authority to infer or accept other strings; adding any further code requires fresh primary-source review and regression evidence. Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index 151ae31f2..c899c8705 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -1,5 +1,24 @@ # Action Post-Condition Evidence Traceability +## Intent acknowledgment verification repair — 2026-09-08 + +Exact #271 predecessor `14fb8e75` failed formatting and complete coverage in +run `34151114820`. The remaining production gaps were confined to the intent +acknowledgment boundary. Connection and command identity are checked before +pending work can be consumed. The lower response owner uses that same validated +envelope's identifier for its result, so a second identifier mismatch after +successful correlation was unreachable and has been removed without weakening +the pre-consumption check. + +Real loopback tests now exercise successful, malformed, event, null-id, remote-error, +wrong-id and foreign-connection replies. Only a matching success or remote error +retires the corresponding pending command; unrelated replies preserve pending work. +Rejected input emits no command and mints no intent witness. Exact diagnostic +comparisons retain only command identity and text length, never the input text or +private connection generation. Shared helpers stay in integration tests; production +provenance visibility is unchanged. Complete current-head verification is separate +from browser outcome evidence, protected-parent acceptance and release readiness. + ## Fixed field observation adopts current input safeguards — 2026-09-07 Ordinary merge `f883fd6f` adopts #268 `ff27220c` while retaining #269 @@ -173,7 +192,7 @@ checks, visual inspection, parent-first protected integration and runtime eviden - **Documentation status:** Active-stack evidence dossier; protected-main truth is called out separately - **Canonical owner:** issue #28 (`Complete the first real Chromium agent vertical slice`) - **Protected-main baseline:** `542ca1e9c0a863595b8b6697790005d2471f5413` -- **Active stack tip at this revision:** PR #269 (`feat/core: observe typed-text postconditions without ambient script authority`) +- **Active stack tip at this revision:** PR #271 (`feat/network: admit and compare typed-text postconditions`) - **Capability maturity:** **PARTIAL** - **Governing decisions:** Accepted ADR 0003 plus Proposed ADR 0106 preserve provenance-native evidence and separation of action execution from verification. @@ -281,6 +300,14 @@ PR #269 adds a fixed sandboxed text-value observation command on top of the node The command only serializes an observation request. It performs no browser I/O, accepts no page/model-supplied script, grants no policy or action authority, and does not prove browser execution or post-condition success. Descendant transport and response slices must correlate the exact response and compare the returned non-secret text with the intended value before any action outcome can become verified. +### PR #271 — correlated typed-text post-condition comparison + +**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_STACK` + +PR #271 admits only a `script.callFunction` response correlated to the exact outstanding text-value observation command family. A successful string result becomes positive evidence only when it exactly equals the already-authorized expected text; a different value returns `PostconditionMismatch`, so protocol acknowledgement and parser success cannot certify browser state. + +The page-controlled text is discarded at the comparison boundary. The public result retains only the command identifier and UTF-8 byte count, while errors expose neither observed nor expected text. This evidence does not prove the preceding action was authorized, transported, or executed through the intended browser process, and it remains active-stack evidence until the full vertical slice is integrated and accepted on protected main. + ## 4. Non-transitive success semantics The intended first-slice chain remains: @@ -320,7 +347,9 @@ The first real Chromium vertical slice remains distributed rather than shipped a - PRs #261–#264 committed-navigation origin/subscription/admission lifecycle; - PR #265 send-time pointer input revalidation against admitted node authority; and - PR #266 node-bound non-secret text input with privacy-safe diagnostics; and -- PR #269 fixed text-value observation construction, still without browser I/O or outcome verification. +- PR #269 fixed text-value observation construction, still without browser I/O or outcome verification; +- PR #270 exact transport of that fixed observation, still without response success; and +- PR #271 correlated response admission and exact text-value comparison with value-free evidence. These pieces do not transfer evidence across heads. A descendant must be revalidated after any parent movement, and protected-main shipment requires fresh integrated acceptance after dependency-ordered merge by an authorized integrator. diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index 9794eb012..28653933c 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -339,6 +339,23 @@ def test_typed_text_postcondition_observation_is_durably_scoped(self) -> None: self.assertIn(phrase, traceability) self.assertIn("Fixed sandboxed text-value observation", changelog) + def test_typed_text_postcondition_result_is_durably_scoped(self) -> None: + """A correlated response must not become success before exact value comparison.""" + + traceability = ( + ROOT / "docs/traceability/action-postcondition-evidence.md" + ).read_text(encoding="utf-8") + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + for phrase in ( + "PR #271", + "PostconditionMismatch", + "page-controlled text is discarded", + "does not prove the preceding action was authorized", + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, traceability) + self.assertIn("Typed text-value post-condition verification", changelog) + def test_traceability_labels_conversation_derived_future_work(self) -> None: """Conversation decisions must preserve canonical maturity instead of becoming shipped claims.""" traceability = (ROOT / "docs/traceability/README.md").read_text(encoding="utf-8")