From c1884ba87cec26b48089c327620d43039625c42d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 15:24:05 -0700 Subject: [PATCH 01/38] test(network): require bounded BiDi JSON envelope parsing --- .../tests/webdriver_bidi_json_envelope.rs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_json_envelope.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs new file mode 100644 index 000000000..4dad5a90c --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -0,0 +1,84 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, +}; + +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 SUCCESS_MESSAGE: &[u8] = br#"{"type":"success","id":7,"result":{"ready":true}}"#; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +#[test] +fn real_transport_text_is_classified_as_bidi_success_envelope() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let payload_len = u8::try_from(SUCCESS_MESSAGE.len()) + .map_err(|_| io::Error::other("test payload exceeded one-byte frame length"))?; + stream.write_all(&[0x81, payload_len])?; + stream.write_all(SUCCESS_MESSAGE) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let (_established, frame) = established.read_frame(Duration::from_millis(500))?; + + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + let text = match assembler.push_frame(frame)? { + WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + other => { + return Err(io::Error::other(format!( + "validated text frame produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let envelope = WebDriverBiDiJsonEnvelope::parse(&text)?; + assert_eq!(envelope.kind(), WebDriverBiDiJsonEnvelopeKind::Success); + assert_eq!(envelope.command_id(), Some(7)); + assert_eq!(envelope.method(), None); + assert_eq!(envelope.error_code(), None); + + server + .join() + .map_err(|_| io::Error::other("JSON-envelope server panicked"))??; + Ok(()) +} From 052e5315f8d71f27811f558f037f567ce979513a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 16:06:18 -0700 Subject: [PATCH 02/38] feat(network): classify bounded BiDi JSON envelopes --- crates/originweave-network/src/lib.rs | 10 +- .../src/webdriver_bidi_json_envelope.rs | 856 ++++++++++++++++++ 2 files changed, 864 insertions(+), 2 deletions(-) create mode 100644 crates/originweave-network/src/webdriver_bidi_json_envelope.rs diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index aa6867f72..63e6f193d 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -6,14 +6,16 @@ //! It also bridges a session-correlated WebDriver BiDi loopback target from //! `originweave-core` into one bounded exact TCP connection, binds and validates //! the RFC 6455 opening exchange, provides bounded masked client writes and -//! unmasked server-frame reads, and assembles bounded WebDriver BiDi text messages -//! without granting browser, TLS, policy, or Agent authority. +//! unmasked server-frame reads, assembles bounded WebDriver BiDi text messages, and +//! classifies complete local-end JSON envelopes without exposing generic JSON bodies +//! or granting browser, TLS, policy, secret, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] mod connection; mod webdriver_bidi_connection; +mod webdriver_bidi_json_envelope; mod webdriver_bidi_websocket_frame; mod webdriver_bidi_websocket_handshake; mod webdriver_bidi_websocket_message; @@ -27,6 +29,10 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; +pub use webdriver_bidi_json_envelope::{ + MAX_WEBDRIVER_BIDI_JSON_DEPTH, MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiJsonEnvelope, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, +}; pub use webdriver_bidi_websocket_frame::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrame, diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs new file mode 100644 index 000000000..efbe0bde3 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs @@ -0,0 +1,856 @@ +use std::{collections::HashSet, error::Error, fmt}; + +use crate::WebDriverBiDiWebSocketTextMessage; + +/// Largest nesting depth accepted while validating one BiDi JSON envelope. +/// +/// The WebSocket text-message boundary already caps the aggregate document at 1 MiB. This +/// independent depth budget prevents a syntactically valid but pathologically nested document +/// from exhausting the Rust call stack while no browser or Agent authority has been granted. +pub const MAX_WEBDRIVER_BIDI_JSON_DEPTH: usize = 64; + +/// Largest integer admitted by WebDriver BiDi's `js-uint` production. +pub const MAX_WEBDRIVER_BIDI_JS_UINT: u64 = 9_007_199_254_740_991; + +/// Local-end WebDriver BiDi envelope kind after complete JSON syntax validation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebDriverBiDiJsonEnvelopeKind { + /// A command completed successfully. + Success, + /// A command completed with a protocol error. + Error, + /// The remote end emitted an event. + Event, +} + +/// Credential-minimal classification of one complete WebDriver BiDi local-end JSON envelope. +/// +/// Result and parameter bodies are deliberately validated and discarded at this boundary. They +/// remain untrusted protocol data for later command- or event-specific parsers and are not exposed +/// as generic JSON values that could become ambient browser or Agent authority. +#[derive(Eq, PartialEq)] +pub struct WebDriverBiDiJsonEnvelope { + kind: WebDriverBiDiJsonEnvelopeKind, + command_id: Option, + method: Option, + error_code: Option, +} + +impl fmt::Debug for WebDriverBiDiJsonEnvelope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiJsonEnvelope") + .field("kind", &self.kind) + .field("command_id", &self.command_id) + .field("has_method", &self.method.is_some()) + .field("has_error_code", &self.error_code.is_some()) + .finish() + } +} + +impl WebDriverBiDiJsonEnvelope { + /// Parse and classify one already bounded, validated UTF-8 WebSocket text message. + /// + /// This validates the complete RFC 8259 JSON grammar, rejects duplicate top-level member + /// names and excessive nesting, then enforces only the common local-end envelope shape from + /// WebDriver BiDi. Extensible result/parameter bodies remain opaque and are discarded. + pub fn parse( + message: &WebDriverBiDiWebSocketTextMessage, + ) -> Result { + Self::parse_str(message.as_str()) + } + + fn parse_str(text: &str) -> Result { + let mut cursor = JsonCursor::new(text); + let fields = cursor.parse_top_level_object()?; + cursor.skip_whitespace(); + if !cursor.is_finished() { + return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); + } + fields.into_envelope() + } + + /// Return the classified local-end envelope kind. + #[must_use] + pub const fn kind(&self) -> WebDriverBiDiJsonEnvelopeKind { + self.kind + } + + /// Return the command identifier for success and correlatable error responses. + /// + /// Events and error responses whose protocol `id` is `null` return `None`. + #[must_use] + pub const fn command_id(&self) -> Option { + self.command_id + } + + /// Borrow the event method when this is an event envelope. + #[must_use] + pub fn method(&self) -> Option<&str> { + self.method.as_deref() + } + + /// Borrow the protocol error code when this is an error envelope. + #[must_use] + pub fn error_code(&self) -> Option<&str> { + self.error_code.as_deref() + } +} + +/// Fail-closed JSON syntax and common-envelope failures for local-end WebDriver BiDi messages. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WebDriverBiDiJsonEnvelopeError { + /// The document violates RFC 8259 JSON syntax or contains trailing non-whitespace bytes. + InvalidJson, + /// The top-level JSON value is not an object. + RootMustBeObject, + /// A top-level object member name appears more than once. + DuplicateTopLevelMember, + /// JSON nesting exceeded the reviewed parser safety budget. + NestingTooDeep { + /// Maximum nesting depth admitted by this parser. + maximum_depth: usize, + }, + /// A required common-envelope member is absent. + MissingRequiredMember { + /// Stable non-sensitive member name. + member: &'static str, + }, + /// A common-envelope member has the wrong JSON type or value range. + InvalidMember { + /// Stable non-sensitive member name. + member: &'static str, + }, + /// The `type` discriminator is not one of the three local-end envelope kinds. + UnsupportedEnvelopeType, +} + +impl fmt::Display for WebDriverBiDiJsonEnvelopeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidJson => formatter.write_str("invalid WebDriver BiDi JSON document"), + Self::RootMustBeObject => { + formatter.write_str("WebDriver BiDi local-end message must be a JSON object") + } + Self::DuplicateTopLevelMember => { + formatter.write_str("WebDriver BiDi JSON object contains a duplicate top-level member") + } + Self::NestingTooDeep { maximum_depth } => write!( + formatter, + "WebDriver BiDi JSON nesting exceeds maximum depth {maximum_depth}" + ), + Self::MissingRequiredMember { member } => { + write!(formatter, "WebDriver BiDi envelope is missing required member {member}") + } + Self::InvalidMember { member } => { + write!(formatter, "WebDriver BiDi envelope member {member} is invalid") + } + Self::UnsupportedEnvelopeType => { + formatter.write_str("unsupported WebDriver BiDi local-end envelope type") + } + } + } +} + +impl Error for WebDriverBiDiJsonEnvelopeError {} + +#[derive(Default)] +struct TopLevelFields { + envelope_type: Option, + id: Option, + result: Option, + method: Option, + params: Option, + error: Option, + message: Option, + stacktrace: Option, +} + +impl TopLevelFields { + fn record(&mut self, key: &str, value: JsonValue) { + match key { + "type" => self.envelope_type = Some(value), + "id" => self.id = Some(value), + "result" => self.result = Some(value), + "method" => self.method = Some(value), + "params" => self.params = Some(value), + "error" => self.error = Some(value), + "message" => self.message = Some(value), + "stacktrace" => self.stacktrace = Some(value), + _ => {} + } + } + + fn into_envelope(self) -> Result { + let kind = match self.envelope_type.as_ref() { + None => return Err(missing("type")), + Some(JsonValue::Text(envelope_type)) => match envelope_type.as_str() { + "success" => WebDriverBiDiJsonEnvelopeKind::Success, + "error" => WebDriverBiDiJsonEnvelopeKind::Error, + "event" => WebDriverBiDiJsonEnvelopeKind::Event, + _ => return Err(WebDriverBiDiJsonEnvelopeError::UnsupportedEnvelopeType), + }, + Some(_) => return Err(invalid("type")), + }; + match kind { + WebDriverBiDiJsonEnvelopeKind::Success => self.into_success(), + WebDriverBiDiJsonEnvelopeKind::Error => self.into_error(), + WebDriverBiDiJsonEnvelopeKind::Event => self.into_event(), + } + } + + fn into_success( + self, + ) -> Result { + let command_id = required_js_uint(self.id, "id")?; + require_object(self.result, "result")?; + Ok(WebDriverBiDiJsonEnvelope { + kind: WebDriverBiDiJsonEnvelopeKind::Success, + command_id: Some(command_id), + method: None, + error_code: None, + }) + } + + fn into_error(self) -> Result { + let command_id = nullable_js_uint(self.id, "id")?; + let error_code = required_text(self.error, "error")?; + let _message = required_text(self.message, "message")?; + if let Some(stacktrace) = self.stacktrace { + require_text_value(stacktrace, "stacktrace")?; + } + Ok(WebDriverBiDiJsonEnvelope { + kind: WebDriverBiDiJsonEnvelopeKind::Error, + command_id, + method: None, + error_code: Some(error_code), + }) + } + + fn into_event(self) -> Result { + let method = required_text(self.method, "method")?; + require_object(self.params, "params")?; + Ok(WebDriverBiDiJsonEnvelope { + kind: WebDriverBiDiJsonEnvelopeKind::Event, + command_id: None, + method: Some(method), + error_code: None, + }) + } +} + +fn missing(member: &'static str) -> WebDriverBiDiJsonEnvelopeError { + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member } +} + +fn invalid(member: &'static str) -> WebDriverBiDiJsonEnvelopeError { + WebDriverBiDiJsonEnvelopeError::InvalidMember { member } +} + +fn required_text( + value: Option, + member: &'static str, +) -> Result { + let value = value.ok_or_else(|| missing(member))?; + match value { + JsonValue::Text(text) => Ok(text), + _ => Err(invalid(member)), + } +} + +fn require_text_value( + value: JsonValue, + member: &'static str, +) -> Result<(), WebDriverBiDiJsonEnvelopeError> { + if matches!(value, JsonValue::Text(_)) { + Ok(()) + } else { + Err(invalid(member)) + } +} + +fn required_js_uint( + value: Option, + member: &'static str, +) -> Result { + let value = value.ok_or_else(|| missing(member))?; + match value { + JsonValue::Number(Some(number)) => Ok(number), + _ => Err(invalid(member)), + } +} + +fn nullable_js_uint( + value: Option, + member: &'static str, +) -> Result, WebDriverBiDiJsonEnvelopeError> { + let value = value.ok_or_else(|| missing(member))?; + match value { + JsonValue::Null => Ok(None), + JsonValue::Number(Some(number)) => Ok(Some(number)), + _ => Err(invalid(member)), + } +} + +fn require_object( + value: Option, + member: &'static str, +) -> Result<(), WebDriverBiDiJsonEnvelopeError> { + let value = value.ok_or_else(|| missing(member))?; + if matches!(value, JsonValue::Object) { + Ok(()) + } else { + Err(invalid(member)) + } +} + +enum JsonValue { + Null, + Text(String), + Number(Option), + Object, + Other, +} + +struct JsonCursor<'a> { + input: &'a str, + index: usize, +} + +impl<'a> JsonCursor<'a> { + const fn new(input: &'a str) -> Self { + Self { input, index: 0 } + } + + fn is_finished(&self) -> bool { + self.index == self.input.len() + } + + fn current_byte(&self) -> Option { + self.input.as_bytes().get(self.index).copied() + } + + fn consume_byte(&mut self, expected: u8) -> bool { + if self.current_byte() == Some(expected) { + self.index += 1; + true + } else { + false + } + } + + fn expect_byte(&mut self, expected: u8) -> Result<(), WebDriverBiDiJsonEnvelopeError> { + if self.consume_byte(expected) { + Ok(()) + } else { + Err(WebDriverBiDiJsonEnvelopeError::InvalidJson) + } + } + + fn skip_whitespace(&mut self) { + while matches!( + self.current_byte(), + Some(b' ' | b'\t' | b'\n' | b'\r') + ) { + self.index += 1; + } + } + + fn parse_top_level_object( + &mut self, + ) -> Result { + self.skip_whitespace(); + if !self.consume_byte(b'{') { + return Err(WebDriverBiDiJsonEnvelopeError::RootMustBeObject); + } + self.skip_whitespace(); + let mut fields = TopLevelFields::default(); + let mut seen = HashSet::new(); + if self.consume_byte(b'}') { + return Ok(fields); + } + + loop { + self.skip_whitespace(); + let key = self.parse_string()?; + if !seen.insert(key.clone()) { + return Err(WebDriverBiDiJsonEnvelopeError::DuplicateTopLevelMember); + } + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + let value = self.parse_value(1)?; + fields.record(&key, value); + self.skip_whitespace(); + if self.consume_byte(b'}') { + return Ok(fields); + } + self.expect_byte(b',')?; + } + } + + fn parse_value( + &mut self, + depth: usize, + ) -> Result { + if depth > MAX_WEBDRIVER_BIDI_JSON_DEPTH { + return Err(WebDriverBiDiJsonEnvelopeError::NestingTooDeep { + maximum_depth: MAX_WEBDRIVER_BIDI_JSON_DEPTH, + }); + } + self.skip_whitespace(); + match self.current_byte() { + Some(b'"') => self.parse_string().map(JsonValue::Text), + Some(b'{') => { + self.parse_object(depth)?; + Ok(JsonValue::Object) + } + Some(b'[') => { + self.parse_array(depth)?; + Ok(JsonValue::Other) + } + Some(b'n') => { + self.parse_literal(b"null")?; + Ok(JsonValue::Null) + } + Some(b't') => { + self.parse_literal(b"true")?; + Ok(JsonValue::Other) + } + Some(b'f') => { + self.parse_literal(b"false")?; + Ok(JsonValue::Other) + } + Some(b'-' | b'0'..=b'9') => self.parse_number(), + _ => Err(WebDriverBiDiJsonEnvelopeError::InvalidJson), + } + } + + fn parse_object(&mut self, depth: usize) -> Result<(), WebDriverBiDiJsonEnvelopeError> { + self.expect_byte(b'{')?; + self.skip_whitespace(); + if self.consume_byte(b'}') { + return Ok(()); + } + loop { + self.skip_whitespace(); + let _key = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + let _value = self.parse_value(depth + 1)?; + self.skip_whitespace(); + if self.consume_byte(b'}') { + return Ok(()); + } + self.expect_byte(b',')?; + } + } + + fn parse_array(&mut self, depth: usize) -> Result<(), WebDriverBiDiJsonEnvelopeError> { + self.expect_byte(b'[')?; + self.skip_whitespace(); + if self.consume_byte(b']') { + return Ok(()); + } + loop { + let _value = self.parse_value(depth + 1)?; + self.skip_whitespace(); + if self.consume_byte(b']') { + return Ok(()); + } + self.expect_byte(b',')?; + self.skip_whitespace(); + } + } + + fn parse_literal(&mut self, literal: &[u8]) -> Result<(), WebDriverBiDiJsonEnvelopeError> { + let end = self.index.saturating_add(literal.len()); + if self.input.as_bytes().get(self.index..end) == Some(literal) { + self.index = end; + Ok(()) + } else { + Err(WebDriverBiDiJsonEnvelopeError::InvalidJson) + } + } + + fn parse_number(&mut self) -> Result { + let start = self.index; + let negative = self.consume_byte(b'-'); + if self.consume_byte(b'0') { + if matches!(self.current_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); + } + } else { + match self.current_byte() { + Some(b'1'..=b'9') => { + self.index += 1; + self.consume_digits(); + } + _ => return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson), + } + } + + let mut is_integer = true; + if self.consume_byte(b'.') { + is_integer = false; + if !matches!(self.current_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); + } + self.consume_digits(); + } + if matches!(self.current_byte(), Some(b'e' | b'E')) { + is_integer = false; + self.index += 1; + if matches!(self.current_byte(), Some(b'+' | b'-')) { + self.index += 1; + } + if !matches!(self.current_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); + } + self.consume_digits(); + } + + let js_uint = if !negative && is_integer { + self.input[start..self.index] + .parse::() + .ok() + .filter(|number| *number <= MAX_WEBDRIVER_BIDI_JS_UINT) + } else { + None + }; + Ok(JsonValue::Number(js_uint)) + } + + fn consume_digits(&mut self) { + while matches!(self.current_byte(), Some(b'0'..=b'9')) { + self.index += 1; + } + } + + fn parse_string(&mut self) -> Result { + self.expect_byte(b'"')?; + let mut output = String::new(); + loop { + let Some(byte) = self.current_byte() else { + return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); + }; + match byte { + b'"' => { + self.index += 1; + return Ok(output); + } + b'\\' => { + self.index += 1; + self.parse_escape(&mut output)?; + } + 0x00..=0x1f => return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson), + _ if byte.is_ascii() => { + output.push(char::from(byte)); + self.index += 1; + } + _ => { + let remaining = &self.input[self.index..]; + let Some(character) = remaining.chars().next() else { + return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); + }; + output.push(character); + self.index += character.len_utf8(); + } + } + } + } + + fn parse_escape(&mut self, output: &mut String) -> Result<(), WebDriverBiDiJsonEnvelopeError> { + let Some(escape) = self.current_byte() else { + return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); + }; + self.index += 1; + match escape { + b'"' => output.push('"'), + b'\\' => output.push('\\'), + b'/' => output.push('/'), + b'b' => output.push('\u{0008}'), + b'f' => output.push('\u{000c}'), + b'n' => output.push('\n'), + b'r' => output.push('\r'), + b't' => output.push('\t'), + b'u' => self.parse_unicode_escape(output)?, + _ => return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson), + } + Ok(()) + } + + fn parse_unicode_escape( + &mut self, + output: &mut String, + ) -> Result<(), WebDriverBiDiJsonEnvelopeError> { + let first = self.parse_hex_u16()?; + let scalar = if (0xd800..=0xdbff).contains(&first) { + self.expect_byte(b'\\')?; + self.expect_byte(b'u')?; + let second = self.parse_hex_u16()?; + if !(0xdc00..=0xdfff).contains(&second) { + return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); + } + 0x1_0000 + ((u32::from(first) - 0xd800) << 10) + (u32::from(second) - 0xdc00) + } else if (0xdc00..=0xdfff).contains(&first) { + return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); + } else { + u32::from(first) + }; + let Some(character) = char::from_u32(scalar) else { + return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); + }; + output.push(character); + Ok(()) + } + + fn parse_hex_u16(&mut self) -> Result { + let mut value = 0_u16; + for _ in 0..4 { + let Some(byte) = self.current_byte() else { + return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); + }; + let digit = match byte { + b'0'..=b'9' => u16::from(byte - b'0'), + b'a'..=b'f' => u16::from(byte - b'a' + 10), + b'A'..=b'F' => u16::from(byte - b'A' + 10), + _ => return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson), + }; + value = (value << 4) | digit; + self.index += 1; + } + Ok(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(value: &str) -> Result { + WebDriverBiDiJsonEnvelope::parse_str(value) + } + + #[test] + fn classifies_all_local_end_envelope_kinds_and_redacts_debug() { + let success = parse( + r#"{"type":"success","id":9007199254740991,"result":{"ready":true},"ext":[null,false,1.5,-2e3,"\u20ac","\ud83d\ude00"]}"#, + ) + .ok(); + assert!(success.is_some()); + let Some(success) = success else { + return; + }; + assert_eq!(success.kind(), WebDriverBiDiJsonEnvelopeKind::Success); + assert_eq!(success.command_id(), Some(MAX_WEBDRIVER_BIDI_JS_UINT)); + assert_eq!(success.method(), None); + assert_eq!(success.error_code(), None); + let debug = format!("{success:?}"); + assert!(debug.contains("Success")); + assert!(!debug.contains("ready")); + + let error = parse( + r#"{"type":"error","id":null,"error":"invalid argument","message":"secret detail","stacktrace":"hidden","vendor":{"x":[]}}"#, + ) + .ok(); + assert!(error.is_some()); + let Some(error) = error else { + return; + }; + assert_eq!(error.kind(), WebDriverBiDiJsonEnvelopeKind::Error); + assert_eq!(error.command_id(), None); + assert_eq!(error.error_code(), Some("invalid argument")); + assert_eq!(error.method(), None); + let debug = format!("{error:?}"); + assert!(!debug.contains("invalid argument")); + assert!(!debug.contains("secret detail")); + + let event = parse( + r#"{"type":"event","method":"browsingContext.load","params":{},"vendor":true}"#, + ) + .ok(); + assert!(event.is_some()); + let Some(event) = event else { + return; + }; + assert_eq!(event.kind(), WebDriverBiDiJsonEnvelopeKind::Event); + assert_eq!(event.command_id(), None); + assert_eq!(event.method(), Some("browsingContext.load")); + assert_eq!(event.error_code(), None); + } + + #[test] + fn accepts_correlatable_error_and_extensible_success_members() { + let error = parse(r#"{"type":"error","id":7,"error":"unknown error","message":"","x":0}"#); + assert_eq!(error.as_ref().ok().and_then(WebDriverBiDiJsonEnvelope::command_id), Some(7)); + + let success = parse( + "{\n \"\\u0074ype\":\"success\", \"id\":0, \"result\":{\"escaped\":\"\\\\/\\b\\f\\n\\r\\t\\\"\",\"unicode\":\"é\"}, \"method\":123 } \r\n", + ); + assert!(success.is_ok()); + } + + #[test] + fn envelope_shape_failures_are_typed() { + let cases = [ + ( + "{}", + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "type" }, + ), + ( + r#"{"type":1}"#, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "type" }, + ), + ( + r#"{"type":"other"}"#, + WebDriverBiDiJsonEnvelopeError::UnsupportedEnvelopeType, + ), + ( + r#"{"type":"success","result":{}}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "id" }, + ), + ( + r#"{"type":"success","id":null,"result":{}}"#, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }, + ), + ( + r#"{"type":"success","id":9007199254740992,"result":{}}"#, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }, + ), + ( + r#"{"type":"success","id":1}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "result" }, + ), + ( + r#"{"type":"success","id":1,"result":[]}"#, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "result" }, + ), + ( + r#"{"type":"error","error":"x","message":"m"}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "id" }, + ), + ( + r#"{"type":"error","id":-1,"error":"x","message":"m"}"#, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }, + ), + ( + r#"{"type":"error","id":null,"message":"m"}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "error" }, + ), + ( + r#"{"type":"error","id":null,"error":false,"message":"m"}"#, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "error" }, + ), + ( + r#"{"type":"error","id":null,"error":"x"}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "message" }, + ), + ( + r#"{"type":"error","id":null,"error":"x","message":{}}"#, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "message" }, + ), + ( + r#"{"type":"error","id":null,"error":"x","message":"m","stacktrace":0}"#, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "stacktrace" }, + ), + ( + r#"{"type":"event","params":{}}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "method" }, + ), + ( + r#"{"type":"event","method":false,"params":{}}"#, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "method" }, + ), + ( + r#"{"type":"event","method":"x"}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "params" }, + ), + ( + r#"{"type":"event","method":"x","params":null}"#, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "params" }, + ), + ]; + for (document, expected) in cases { + assert_eq!(parse(document), Err(expected)); + } + } + + #[test] + fn rejects_ambiguous_or_malformed_json() { + let cases = [ + "[]", + "null", + r#"{"type":"success","type":"event","id":1,"result":{}}"#, + r#"{"type":"success","id":1,"result":{}} trailing"#, + r#"{"type":"success","id":01,"result":{}}"#, + r#"{"type":"success","id":1.,"result":{}}"#, + r#"{"type":"success","id":1e,"result":{}}"#, + r#"{"type":"success","id":1e+,"result":{}}"#, + r#"{"type":"success","id":-,"result":{}}"#, + r#"{"type":"success","id":18446744073709551616,"result":{}}"#, + "{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"line\nbreak\"}}", + r#"{"type":"success","id":1,"result":{"bad":"\x"}}"#, + r#"{"type":"success","id":1,"result":{"bad":"\u12xz"}}"#, + r#"{"type":"success","id":1,"result":{"bad":"\ud800x"}}"#, + r#"{"type":"success","id":1,"result":{"bad":"\ud800\u0041"}}"#, + r#"{"type":"success","id":1,"result":{"bad":"\udc00"}}"#, + r#"{"type":"success","id":1,"result":{"a":true "b":false}}"#, + r#"{"type":"success","id":1,"result":[1,]}"#, + r#"{"type":"success","id":1,"result":{"a":tru}}"#, + r#"{"type":"success","id":1,"result":{"a":fal}}"#, + r#"{"type":"success","id":1,"result":{"a":nul}}"#, + r#"{"type":"success","id":1,"result":{"a":}}"#, + r#"{"type":"success","id":1,"result":{"#, + ]; + for document in cases { + assert!(parse(document).is_err(), "unexpectedly admitted {document}"); + } + assert_eq!( + parse("[]"), + Err(WebDriverBiDiJsonEnvelopeError::RootMustBeObject) + ); + assert_eq!( + parse(r#"{"type":"success","type":"event","id":1,"result":{}}"#), + Err(WebDriverBiDiJsonEnvelopeError::DuplicateTopLevelMember) + ); + } + + #[test] + fn rejects_excessive_json_nesting_and_formats_errors_without_payloads() { + let mut document = String::from(r#"{"type":"success","id":1,"result":"#); + for _ in 0..=MAX_WEBDRIVER_BIDI_JSON_DEPTH { + document.push('['); + } + document.push_str("null"); + for _ in 0..=MAX_WEBDRIVER_BIDI_JSON_DEPTH { + document.push(']'); + } + document.push('}'); + assert_eq!( + parse(&document), + Err(WebDriverBiDiJsonEnvelopeError::NestingTooDeep { + maximum_depth: MAX_WEBDRIVER_BIDI_JSON_DEPTH, + }) + ); + + let errors = [ + WebDriverBiDiJsonEnvelopeError::InvalidJson, + WebDriverBiDiJsonEnvelopeError::RootMustBeObject, + WebDriverBiDiJsonEnvelopeError::DuplicateTopLevelMember, + WebDriverBiDiJsonEnvelopeError::NestingTooDeep { + maximum_depth: MAX_WEBDRIVER_BIDI_JSON_DEPTH, + }, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "id" }, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "result" }, + WebDriverBiDiJsonEnvelopeError::UnsupportedEnvelopeType, + ]; + for error in errors { + let display = error.to_string(); + assert!(!display.is_empty()); + let source: &dyn Error = &error; + assert!(source.source().is_none()); + } + } +} From fc346e1123d7215962428417382d2ea58052e8fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 16:27:45 -0700 Subject: [PATCH 03/38] fix(network): apply rustfmt to BiDi JSON exports --- crates/originweave-network/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 63e6f193d..ea12b0a14 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -30,7 +30,7 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; pub use webdriver_bidi_json_envelope::{ - MAX_WEBDRIVER_BIDI_JSON_DEPTH, MAX_WEBDRIVER_BIDI_JS_UINT, WebDriverBiDiJsonEnvelope, + MAX_WEBDRIVER_BIDI_JS_UINT, MAX_WEBDRIVER_BIDI_JSON_DEPTH, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, }; pub use webdriver_bidi_websocket_frame::{ From 5d9e1ffd67b7321cb3b3679db04986be6b2f805d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 16:42:50 -0700 Subject: [PATCH 04/38] fix(network): apply canonical rustfmt to BiDi JSON parser --- .../src/webdriver_bidi_json_envelope.rs | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs index efbe0bde3..d7ed4883e 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs @@ -132,18 +132,23 @@ impl fmt::Display for WebDriverBiDiJsonEnvelopeError { Self::RootMustBeObject => { formatter.write_str("WebDriver BiDi local-end message must be a JSON object") } - Self::DuplicateTopLevelMember => { - formatter.write_str("WebDriver BiDi JSON object contains a duplicate top-level member") - } + Self::DuplicateTopLevelMember => formatter + .write_str("WebDriver BiDi JSON object contains a duplicate top-level member"), Self::NestingTooDeep { maximum_depth } => write!( formatter, "WebDriver BiDi JSON nesting exceeds maximum depth {maximum_depth}" ), Self::MissingRequiredMember { member } => { - write!(formatter, "WebDriver BiDi envelope is missing required member {member}") + write!( + formatter, + "WebDriver BiDi envelope is missing required member {member}" + ) } Self::InvalidMember { member } => { - write!(formatter, "WebDriver BiDi envelope member {member} is invalid") + write!( + formatter, + "WebDriver BiDi envelope member {member} is invalid" + ) } Self::UnsupportedEnvelopeType => { formatter.write_str("unsupported WebDriver BiDi local-end envelope type") @@ -199,9 +204,7 @@ impl TopLevelFields { } } - fn into_success( - self, - ) -> Result { + fn into_success(self) -> Result { let command_id = required_js_uint(self.id, "id")?; require_object(self.result, "result")?; Ok(WebDriverBiDiJsonEnvelope { @@ -348,17 +351,12 @@ impl<'a> JsonCursor<'a> { } fn skip_whitespace(&mut self) { - while matches!( - self.current_byte(), - Some(b' ' | b'\t' | b'\n' | b'\r') - ) { + while matches!(self.current_byte(), Some(b' ' | b'\t' | b'\n' | b'\r')) { self.index += 1; } } - fn parse_top_level_object( - &mut self, - ) -> Result { + fn parse_top_level_object(&mut self) -> Result { self.skip_whitespace(); if !self.consume_byte(b'{') { return Err(WebDriverBiDiJsonEnvelopeError::RootMustBeObject); @@ -389,10 +387,7 @@ impl<'a> JsonCursor<'a> { } } - fn parse_value( - &mut self, - depth: usize, - ) -> Result { + fn parse_value(&mut self, depth: usize) -> Result { if depth > MAX_WEBDRIVER_BIDI_JSON_DEPTH { return Err(WebDriverBiDiJsonEnvelopeError::NestingTooDeep { maximum_depth: MAX_WEBDRIVER_BIDI_JSON_DEPTH, @@ -667,10 +662,9 @@ mod tests { assert!(!debug.contains("invalid argument")); assert!(!debug.contains("secret detail")); - let event = parse( - r#"{"type":"event","method":"browsingContext.load","params":{},"vendor":true}"#, - ) - .ok(); + let event = + parse(r#"{"type":"event","method":"browsingContext.load","params":{},"vendor":true}"#) + .ok(); assert!(event.is_some()); let Some(event) = event else { return; @@ -684,7 +678,13 @@ mod tests { #[test] fn accepts_correlatable_error_and_extensible_success_members() { let error = parse(r#"{"type":"error","id":7,"error":"unknown error","message":"","x":0}"#); - assert_eq!(error.as_ref().ok().and_then(WebDriverBiDiJsonEnvelope::command_id), Some(7)); + assert_eq!( + error + .as_ref() + .ok() + .and_then(WebDriverBiDiJsonEnvelope::command_id), + Some(7) + ); let success = parse( "{\n \"\\u0074ype\":\"success\", \"id\":0, \"result\":{\"escaped\":\"\\\\/\\b\\f\\n\\r\\t\\\"\",\"unicode\":\"é\"}, \"method\":123 } \r\n", @@ -753,7 +753,9 @@ mod tests { ), ( r#"{"type":"error","id":null,"error":"x","message":"m","stacktrace":0}"#, - WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "stacktrace" }, + WebDriverBiDiJsonEnvelopeError::InvalidMember { + member: "stacktrace", + }, ), ( r#"{"type":"event","params":{}}"#, From 552a10d22e74ba11dea97dc7d16c1e8f228ef980 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 17:10:53 -0700 Subject: [PATCH 05/38] test(network): exercise BiDi JSON parser boundaries --- .../tests/webdriver_bidi_json_envelope.rs | 90 ++++++++++++++++--- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index 4dad5a90c..a435d5f88 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -8,15 +8,16 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, }; 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 SUCCESS_MESSAGE: &[u8] = br#"{"type":"success","id":7,"result":{"ready":true}}"#; +const SUCCESS_MESSAGE: &[u8] = br#"{\"type\":\"success\",\"id\":7,\"result\":{\"ready\":true}}"#; fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; @@ -35,18 +36,24 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -#[test] -fn real_transport_text_is_classified_as_bidi_success_envelope() -> Result<(), Box> { +fn parse_over_real_transport( + payload: &[u8], +) -> Result, Box> { + if payload.len() > 125 { + return Err(io::Error::other("test payload exceeded one-byte frame length").into()); + } + let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; + let payload = payload.to_vec(); let server = thread::spawn(move || -> io::Result<()> { let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; stream.write_all(OPENING_RESPONSE)?; - let payload_len = u8::try_from(SUCCESS_MESSAGE.len()) - .map_err(|_| io::Error::other("test payload exceeded one-byte frame length"))?; + let payload_len = u8::try_from(payload.len()) + .map_err(|_| io::Error::other("test payload length does not fit u8"))?; stream.write_all(&[0x81, payload_len])?; - stream.write_all(SUCCESS_MESSAGE) + stream.write_all(&payload) }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); @@ -71,14 +78,69 @@ fn real_transport_text_is_classified_as_bidi_success_envelope() -> Result<(), Bo .into()); } }; - let envelope = WebDriverBiDiJsonEnvelope::parse(&text)?; - assert_eq!(envelope.kind(), WebDriverBiDiJsonEnvelopeKind::Success); - assert_eq!(envelope.command_id(), Some(7)); - assert_eq!(envelope.method(), None); - assert_eq!(envelope.error_code(), None); + let parsed = WebDriverBiDiJsonEnvelope::parse(&text); server .join() .map_err(|_| io::Error::other("JSON-envelope server panicked"))??; + Ok(parsed) +} + +#[test] +fn real_transport_text_is_classified_as_bidi_success_envelope() -> Result<(), Box> { + let envelope = parse_over_real_transport(SUCCESS_MESSAGE)?; + assert_eq!( + envelope.as_ref().map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Success) + ); + assert_eq!( + envelope + .as_ref() + .map(WebDriverBiDiJsonEnvelope::command_id), + Ok(Some(7)) + ); + assert_eq!( + envelope.as_ref().map(WebDriverBiDiJsonEnvelope::method), + Ok(None) + ); + assert_eq!( + envelope + .as_ref() + .map(WebDriverBiDiJsonEnvelope::error_code), + Ok(None) + ); + Ok(()) +} + +#[test] +fn real_transport_exercises_valid_escape_boundaries() -> Result<(), Box> { + let envelope = parse_over_real_transport( + br#"{\"type\":\"success\",\"id\":1,\"result\":{\"slash\":\"\\/\",\"upper\":\"\\uABCD\",\"edge\":\"\\uFFFF\"}}"#, + )?; + assert_eq!( + envelope.as_ref().map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Success) + ); + Ok(()) +} + +#[test] +fn real_transport_rejects_malformed_json_at_parser_boundaries() -> Result<(), Box> { + let cases: &[&[u8]] = &[ + br#"{\"type\":\"success\",\"unterminated"#, + br#"{\"type\" \"success\"}"#, + br#"{\"type\":\"success\" \"id\":1,\"result\":{}}"#, + br#"{\"type\":\"success\",\"id\":1,\"result\":{\"x\" 1}}"#, + br#"{\"type\":\"success\",\"id\":1,\"result\":{\"x\":[1 2]}}"#, + b"{\"type\":\"success\",\"id\":1,\"result\":{\"x\":\"\\", + br#"{\"type\":\"success\",\"id\":1,\"result\":{\"x\":\"\\ud800\\x\"}}"#, + b"{\"type\":\"success\",\"id\":1,\"result\":{\"x\":\"\\ud800\\u", + ]; + for document in cases { + assert_eq!( + parse_over_real_transport(document)?, + Err(WebDriverBiDiJsonEnvelopeError::InvalidJson) + ); + } Ok(()) } From 7b8b67c6c6d55ed6e173aade5a835309f3af5a2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 17:11:33 -0700 Subject: [PATCH 06/38] fix(test): send literal BiDi JSON over loopback --- .../tests/webdriver_bidi_json_envelope.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index a435d5f88..33a6d0eb2 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -17,7 +17,7 @@ use originweave_network::{ 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 SUCCESS_MESSAGE: &[u8] = br#"{\"type\":\"success\",\"id\":7,\"result\":{\"ready\":true}}"#; +const SUCCESS_MESSAGE: &[u8] = br#"{"type":"success","id":7,"result":{"ready":true}}"#; fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; @@ -115,7 +115,7 @@ fn real_transport_text_is_classified_as_bidi_success_envelope() -> Result<(), Bo #[test] fn real_transport_exercises_valid_escape_boundaries() -> Result<(), Box> { let envelope = parse_over_real_transport( - br#"{\"type\":\"success\",\"id\":1,\"result\":{\"slash\":\"\\/\",\"upper\":\"\\uABCD\",\"edge\":\"\\uFFFF\"}}"#, + br#"{"type":"success","id":1,"result":{"slash":"\/","upper":"\uABCD","edge":"\uFFFF"}}"#, )?; assert_eq!( envelope.as_ref().map(WebDriverBiDiJsonEnvelope::kind), @@ -127,13 +127,13 @@ fn real_transport_exercises_valid_escape_boundaries() -> Result<(), Box Result<(), Box> { let cases: &[&[u8]] = &[ - br#"{\"type\":\"success\",\"unterminated"#, - br#"{\"type\" \"success\"}"#, - br#"{\"type\":\"success\" \"id\":1,\"result\":{}}"#, - br#"{\"type\":\"success\",\"id\":1,\"result\":{\"x\" 1}}"#, - br#"{\"type\":\"success\",\"id\":1,\"result\":{\"x\":[1 2]}}"#, + br#"{"type":"success","unterminated"#, + br#"{"type" "success"}"#, + br#"{"type":"success" "id":1,"result":{}}"#, + br#"{"type":"success","id":1,"result":{"x" 1}}"#, + br#"{"type":"success","id":1,"result":{"x":[1 2]}}"#, b"{\"type\":\"success\",\"id\":1,\"result\":{\"x\":\"\\", - br#"{\"type\":\"success\",\"id\":1,\"result\":{\"x\":\"\\ud800\\x\"}}"#, + br#"{"type":"success","id":1,"result":{"x":"\ud800\x"}}"#, b"{\"type\":\"success\",\"id\":1,\"result\":{\"x\":\"\\ud800\\u", ]; for document in cases { From 1c47c14bf5121ed18e6fbf624bfd6db508d23edf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 17:13:25 -0700 Subject: [PATCH 07/38] style(test): apply canonical rustfmt --- .../tests/webdriver_bidi_json_envelope.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index 33a6d0eb2..043244936 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -94,9 +94,7 @@ fn real_transport_text_is_classified_as_bidi_success_envelope() -> Result<(), Bo Ok(WebDriverBiDiJsonEnvelopeKind::Success) ); assert_eq!( - envelope - .as_ref() - .map(WebDriverBiDiJsonEnvelope::command_id), + envelope.as_ref().map(WebDriverBiDiJsonEnvelope::command_id), Ok(Some(7)) ); assert_eq!( @@ -104,9 +102,7 @@ fn real_transport_text_is_classified_as_bidi_success_envelope() -> Result<(), Bo Ok(None) ); assert_eq!( - envelope - .as_ref() - .map(WebDriverBiDiJsonEnvelope::error_code), + envelope.as_ref().map(WebDriverBiDiJsonEnvelope::error_code), Ok(None) ); Ok(()) From 3a1a87c785318fc22c1523359f5c39a6f112b53f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 17:19:42 -0700 Subject: [PATCH 08/38] fix(network): remove impossible BiDi parser evidence branches --- .../src/webdriver_bidi_json_envelope.rs | 108 +++++++++++------- 1 file changed, 66 insertions(+), 42 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs index d7ed4883e..e99adf2c9 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs @@ -422,7 +422,9 @@ impl<'a> JsonCursor<'a> { } fn parse_object(&mut self, depth: usize) -> Result<(), WebDriverBiDiJsonEnvelopeError> { - self.expect_byte(b'{')?; + // `parse_value` dispatches here only after observing `{`; consume that proven delimiter + // directly so an impossible second validation branch does not masquerade as parser evidence. + self.index += 1; self.skip_whitespace(); if self.consume_byte(b'}') { return Ok(()); @@ -443,7 +445,9 @@ impl<'a> JsonCursor<'a> { } fn parse_array(&mut self, depth: usize) -> Result<(), WebDriverBiDiJsonEnvelopeError> { - self.expect_byte(b'[')?; + // `parse_value` dispatches here only after observing `[`; consume that proven delimiter + // directly so an impossible second validation branch does not masquerade as parser evidence. + self.index += 1; self.skip_whitespace(); if self.consume_byte(b']') { return Ok(()); @@ -545,12 +549,12 @@ impl<'a> JsonCursor<'a> { self.index += 1; } _ => { - let remaining = &self.input[self.index..]; - let Some(character) = remaining.chars().next() else { - return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); - }; - output.push(character); - self.index += character.len_utf8(); + // `input` is valid UTF-8 and `index` advances only on character boundaries. + // For a non-ASCII lead byte, leading_ones therefore yields the exact width. + let character_byte_count = byte.leading_ones() as usize; + let end = self.index + character_byte_count; + output.push_str(&self.input[self.index..end]); + self.index = end; } } } @@ -594,10 +598,9 @@ impl<'a> JsonCursor<'a> { } else { u32::from(first) }; - let Some(character) = char::from_u32(scalar) else { - return Err(WebDriverBiDiJsonEnvelopeError::InvalidJson); - }; - output.push(character); + // `scalar` is either a non-surrogate `u16` or the scalar constructed from a validated + // high/low surrogate pair, so `char::from_u32` is always `Some` under this parser invariant. + output.extend(char::from_u32(scalar)); Ok(()) } @@ -632,47 +635,68 @@ mod tests { fn classifies_all_local_end_envelope_kinds_and_redacts_debug() { let success = parse( r#"{"type":"success","id":9007199254740991,"result":{"ready":true},"ext":[null,false,1.5,-2e3,"\u20ac","\ud83d\ude00"]}"#, - ) - .ok(); - assert!(success.is_some()); - let Some(success) = success else { - return; - }; - assert_eq!(success.kind(), WebDriverBiDiJsonEnvelopeKind::Success); - assert_eq!(success.command_id(), Some(MAX_WEBDRIVER_BIDI_JS_UINT)); - assert_eq!(success.method(), None); - assert_eq!(success.error_code(), None); + ); + assert_eq!( + success.as_ref().map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Success) + ); + assert_eq!( + success.as_ref().map(WebDriverBiDiJsonEnvelope::command_id), + Ok(Some(MAX_WEBDRIVER_BIDI_JS_UINT)) + ); + assert_eq!( + success.as_ref().map(WebDriverBiDiJsonEnvelope::method), + Ok(None) + ); + assert_eq!( + success.as_ref().map(WebDriverBiDiJsonEnvelope::error_code), + Ok(None) + ); let debug = format!("{success:?}"); assert!(debug.contains("Success")); assert!(!debug.contains("ready")); let error = parse( r#"{"type":"error","id":null,"error":"invalid argument","message":"secret detail","stacktrace":"hidden","vendor":{"x":[]}}"#, - ) - .ok(); - assert!(error.is_some()); - let Some(error) = error else { - return; - }; - assert_eq!(error.kind(), WebDriverBiDiJsonEnvelopeKind::Error); - assert_eq!(error.command_id(), None); - assert_eq!(error.error_code(), Some("invalid argument")); - assert_eq!(error.method(), None); + ); + assert_eq!( + error.as_ref().map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Error) + ); + assert_eq!( + error.as_ref().map(WebDriverBiDiJsonEnvelope::command_id), + Ok(None) + ); + assert_eq!( + error.as_ref().map(WebDriverBiDiJsonEnvelope::error_code), + Ok(Some("invalid argument")) + ); + assert_eq!( + error.as_ref().map(WebDriverBiDiJsonEnvelope::method), + Ok(None) + ); let debug = format!("{error:?}"); assert!(!debug.contains("invalid argument")); assert!(!debug.contains("secret detail")); let event = - parse(r#"{"type":"event","method":"browsingContext.load","params":{},"vendor":true}"#) - .ok(); - assert!(event.is_some()); - let Some(event) = event else { - return; - }; - assert_eq!(event.kind(), WebDriverBiDiJsonEnvelopeKind::Event); - assert_eq!(event.command_id(), None); - assert_eq!(event.method(), Some("browsingContext.load")); - assert_eq!(event.error_code(), None); + parse(r#"{"type":"event","method":"browsingContext.load","params":{},"vendor":true}"#); + assert_eq!( + event.as_ref().map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Event) + ); + assert_eq!( + event.as_ref().map(WebDriverBiDiJsonEnvelope::command_id), + Ok(None) + ); + assert_eq!( + event.as_ref().map(WebDriverBiDiJsonEnvelope::method), + Ok(Some("browsingContext.load")) + ); + assert_eq!( + event.as_ref().map(WebDriverBiDiJsonEnvelope::error_code), + Ok(None) + ); } #[test] From ac6b532ce164ea01262cb211d22d53db1cdff96b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 17:32:22 -0700 Subject: [PATCH 09/38] test(network): cover public BiDi JSON parser branches over loopback --- .../tests/webdriver_bidi_json_envelope.rs | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index 043244936..3f6dfb76f 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -109,26 +109,63 @@ fn real_transport_text_is_classified_as_bidi_success_envelope() -> Result<(), Bo } #[test] -fn real_transport_exercises_valid_escape_boundaries() -> Result<(), Box> { - let envelope = parse_over_real_transport( - br#"{"type":"success","id":1,"result":{"slash":"\/","upper":"\uABCD","edge":"\uFFFF"}}"#, +fn real_transport_classifies_error_and_event_envelopes() -> Result<(), Box> { + let error = parse_over_real_transport( + br#"{"type":"error","id":null,"error":"unknown error","message":"","stacktrace":"hidden"}"#, )?; assert_eq!( - envelope.as_ref().map(WebDriverBiDiJsonEnvelope::kind), - Ok(WebDriverBiDiJsonEnvelopeKind::Success) + error.as_ref().map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Error) + ); + assert_eq!( + error.as_ref().map(WebDriverBiDiJsonEnvelope::command_id), + Ok(None) + ); + + let event = parse_over_real_transport( + br#"{"type":"event","method":"browsingContext.load","params":{}}"#, + )?; + assert_eq!( + event.as_ref().map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Event) + ); + assert_eq!( + event.as_ref().map(WebDriverBiDiJsonEnvelope::method), + Ok(Some("browsingContext.load")) ); Ok(()) } +#[test] +fn real_transport_exercises_valid_json_boundaries() -> Result<(), Box> { + let cases: &[&[u8]] = &[ + br#"{"type":"success","id":1,"result":{"slash":"\/","upper":"\uABCD","edge":"\uFFFF"}}"#, + br#"{"type":"success","id":1,"result":{"array":[],"number":-2.5e3}}"#, + ]; + for document in cases { + assert_eq!( + parse_over_real_transport(document)? + .as_ref() + .map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Success) + ); + } + Ok(()) +} + #[test] fn real_transport_rejects_malformed_json_at_parser_boundaries() -> Result<(), Box> { let cases: &[&[u8]] = &[ + br#"{"type":"success","id":1,"result":{}} trailing"#, br#"{"type":"success","unterminated"#, br#"{"type" "success"}"#, br#"{"type":"success" "id":1,"result":{}}"#, br#"{"type":"success","id":1,"result":{"x" 1}}"#, br#"{"type":"success","id":1,"result":{"x":[1 2]}}"#, b"{\"type\":\"success\",\"id\":1,\"result\":{\"x\":\"\\", + br#"{"type":"success","id":1,"result":{"x":"\q"}}"#, + br#"{"type":"success","id":1,"result":{"x":"\u12xz"}}"#, + br#"{"type":"success","id":1,"result":{"x":"\udc00"}}"#, br#"{"type":"success","id":1,"result":{"x":"\ud800\x"}}"#, b"{\"type\":\"success\",\"id\":1,\"result\":{\"x\":\"\\ud800\\u", ]; From 32a7d964ee1398aa5ba14af2eba19ca16529081c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 17:39:24 -0700 Subject: [PATCH 10/38] test(network): cover malformed BiDi error ids --- .../tests/webdriver_bidi_json_envelope.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index 3f6dfb76f..b92f7bc2c 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -177,3 +177,15 @@ fn real_transport_rejects_malformed_json_at_parser_boundaries() -> Result<(), Bo } Ok(()) } + +#[test] +fn real_transport_rejects_negative_error_response_id() -> Result<(), Box> { + let error = parse_over_real_transport( + br#"{"type":"error","id":-1,"error":"invalid argument","message":"bad id"}"#, + )?; + assert_eq!( + error, + Err(WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }) + ); + Ok(()) +} From b3d26d155edfd39242e0604d3abe7e4e29d0530b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 17:40:54 -0700 Subject: [PATCH 11/38] test(network): cover remaining BiDi parser transport paths --- .../tests/webdriver_bidi_json_envelope.rs | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index b92f7bc2c..b0bebc568 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -110,17 +110,18 @@ fn real_transport_text_is_classified_as_bidi_success_envelope() -> Result<(), Bo #[test] fn real_transport_classifies_error_and_event_envelopes() -> Result<(), Box> { - let error = parse_over_real_transport( + let cases: &[&[u8]] = &[ br#"{"type":"error","id":null,"error":"unknown error","message":"","stacktrace":"hidden"}"#, - )?; - assert_eq!( - error.as_ref().map(WebDriverBiDiJsonEnvelope::kind), - Ok(WebDriverBiDiJsonEnvelopeKind::Error) - ); - assert_eq!( - error.as_ref().map(WebDriverBiDiJsonEnvelope::command_id), - Ok(None) - ); + br#"{"type":"error","id":7,"error":"unknown error","message":""}"#, + ]; + for document in cases { + assert_eq!( + parse_over_real_transport(document)? + .as_ref() + .map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Error) + ); + } let event = parse_over_real_transport( br#"{"type":"event","method":"browsingContext.load","params":{}}"#, @@ -140,7 +141,8 @@ fn real_transport_classifies_error_and_event_envelopes() -> Result<(), Box Result<(), Box> { let cases: &[&[u8]] = &[ br#"{"type":"success","id":1,"result":{"slash":"\/","upper":"\uABCD","edge":"\uFFFF"}}"#, - br#"{"type":"success","id":1,"result":{"array":[],"number":-2.5e3}}"#, + br#"{"type":"success","id":1,"result":{"array":[1,2],"number":-2.5e3}}"#, + br#"{"type":"success","id":1,"result":{"pair":"\ud83d\ude00","empty":[]}}"#, ]; for document in cases { assert_eq!( @@ -161,7 +163,9 @@ fn real_transport_rejects_malformed_json_at_parser_boundaries() -> Result<(), Bo br#"{"type" "success"}"#, br#"{"type":"success" "id":1,"result":{}}"#, br#"{"type":"success","id":1,"result":{"x" 1}}"#, + br#"{"type":"success","id":1,"result":{"x":1 "y":2}}"#, br#"{"type":"success","id":1,"result":{"x":[1 2]}}"#, + br#"{"type":"success","id":1,"result":{"x":[1,]}}"#, b"{\"type\":\"success\",\"id\":1,\"result\":{\"x\":\"\\", br#"{"type":"success","id":1,"result":{"x":"\q"}}"#, br#"{"type":"success","id":1,"result":{"x":"\u12xz"}}"#, From 6ef3c4cea1871cd2e13ea164c5558a841e583ab1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 17:45:48 -0700 Subject: [PATCH 12/38] test(network): cover raw UTF-8 BiDi JSON transport --- crates/originweave-network/tests/webdriver_bidi_json_envelope.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index b0bebc568..1bdaac893 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -143,6 +143,7 @@ fn real_transport_exercises_valid_json_boundaries() -> Result<(), Box br#"{"type":"success","id":1,"result":{"slash":"\/","upper":"\uABCD","edge":"\uFFFF"}}"#, br#"{"type":"success","id":1,"result":{"array":[1,2],"number":-2.5e3}}"#, br#"{"type":"success","id":1,"result":{"pair":"\ud83d\ude00","empty":[]}}"#, + br#"{"type":"success","id":1,"result":{"unicode":"é"}}"#, ]; for document in cases { assert_eq!( From 6a0ffd684933e38c8dcec79a5e35fc465bed394c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 17:47:37 -0700 Subject: [PATCH 13/38] fix(test): encode raw UTF-8 BiDi fixture as str bytes --- .../originweave-network/tests/webdriver_bidi_json_envelope.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index 1bdaac893..0fdcfb5de 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -143,7 +143,7 @@ fn real_transport_exercises_valid_json_boundaries() -> Result<(), Box br#"{"type":"success","id":1,"result":{"slash":"\/","upper":"\uABCD","edge":"\uFFFF"}}"#, br#"{"type":"success","id":1,"result":{"array":[1,2],"number":-2.5e3}}"#, br#"{"type":"success","id":1,"result":{"pair":"\ud83d\ude00","empty":[]}}"#, - br#"{"type":"success","id":1,"result":{"unicode":"é"}}"#, + r#"{"type":"success","id":1,"result":{"unicode":"é"}}"#.as_bytes(), ]; for document in cases { assert_eq!( From dba07d96815295e53135d4c34ab6c5f4fbd22fbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 18:06:36 -0700 Subject: [PATCH 14/38] test(network): cover missing BiDi envelope members over real transport --- .../tests/webdriver_bidi_json_envelope.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index 0fdcfb5de..4093c505b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -183,6 +183,48 @@ fn real_transport_rejects_malformed_json_at_parser_boundaries() -> Result<(), Bo Ok(()) } +#[test] +fn real_transport_rejects_missing_required_envelope_members() -> Result<(), Box> { + let cases: &[(&[u8], WebDriverBiDiJsonEnvelopeError)] = &[ + ( + br#"{}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "type" }, + ), + ( + br#"{"type":"success","result":{}}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "id" }, + ), + ( + br#"{"type":"success","id":1}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "result" }, + ), + ( + br#"{"type":"error","error":"x","message":"m"}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "id" }, + ), + ( + br#"{"type":"error","id":null,"message":"m"}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "error" }, + ), + ( + br#"{"type":"error","id":null,"error":"x"}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "message" }, + ), + ( + br#"{"type":"event","params":{}}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "method" }, + ), + ( + br#"{"type":"event","method":"x"}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "params" }, + ), + ]; + for (document, expected) in cases { + assert_eq!(parse_over_real_transport(document)?, Err(expected.clone())); + } + Ok(()) +} + #[test] fn real_transport_rejects_negative_error_response_id() -> Result<(), Box> { let error = parse_over_real_transport( From 57750f9d473f79e269673d0c7237c1f2d911e113 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 18:15:02 -0700 Subject: [PATCH 15/38] test(network): exercise BiDi debug and display on public transport path --- .../tests/webdriver_bidi_json_envelope.rs | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index 4093c505b..856fe5621 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -8,8 +8,8 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + MAX_WEBDRIVER_BIDI_JSON_DEPTH, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, + WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, }; @@ -225,6 +225,60 @@ fn real_transport_rejects_missing_required_envelope_members() -> Result<(), Box< Ok(()) } +#[test] +fn real_transport_debug_and_error_display_remain_payload_minimal() -> Result<(), Box> { + let parsed = parse_over_real_transport( + br#"{"type":"success","id":7,"result":{"ready":"sensitive-result"}}"#, + )?; + let envelope = parsed.map_err(|error| io::Error::other(error.to_string()))?; + let debug = format!("{envelope:?}"); + assert!(debug.contains("Success")); + assert!(!debug.contains("ready")); + assert!(!debug.contains("sensitive-result")); + + let cases: &[(&[u8], WebDriverBiDiJsonEnvelopeError)] = &[ + (br#"[]"#, WebDriverBiDiJsonEnvelopeError::RootMustBeObject), + ( + br#"{"type":"success","type":"event","id":1,"result":{}}"#, + WebDriverBiDiJsonEnvelopeError::DuplicateTopLevelMember, + ), + ( + br#"{"type":"other"}"#, + WebDriverBiDiJsonEnvelopeError::UnsupportedEnvelopeType, + ), + ( + br#"{}"#, + WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "type" }, + ), + ( + br#"{"type":"error","id":-1,"error":"x","message":"secret-message"}"#, + WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }, + ), + ( + br#"{"type":"success","id":1,"result":{}} trailing secret"#, + WebDriverBiDiJsonEnvelopeError::InvalidJson, + ), + ]; + for (document, expected) in cases { + let parsed = parse_over_real_transport(document)?; + let error = parsed + .err() + .ok_or_else(|| io::Error::other("invalid envelope unexpectedly parsed"))?; + assert_eq!(error, expected.clone()); + let display = error.to_string(); + assert!(!display.is_empty()); + assert!(!display.contains("secret-message")); + assert!(!display.contains("trailing secret")); + } + + let nesting_error = WebDriverBiDiJsonEnvelopeError::NestingTooDeep { + maximum_depth: MAX_WEBDRIVER_BIDI_JSON_DEPTH, + }; + let display = nesting_error.to_string(); + assert!(display.contains("maximum depth")); + Ok(()) +} + #[test] fn real_transport_rejects_negative_error_response_id() -> Result<(), Box> { let error = parse_over_real_transport( From 627f2c5ec3040e4e48b0381ae1e9bfc73bc2e1f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 18:23:42 -0700 Subject: [PATCH 16/38] test(network): expose test-only bounded BiDi text fixture --- .../src/webdriver_bidi_websocket_message.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_message.rs b/crates/originweave-network/src/webdriver_bidi_websocket_message.rs index 3ad3edc35..67555f4d9 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_message.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_message.rs @@ -69,6 +69,11 @@ impl WebDriverBiDiWebSocketTextMessage { pub fn as_str(&self) -> &str { &self.0 } + + #[cfg(test)] + pub(crate) fn from_test_text(value: &str) -> Self { + Self(value.to_owned()) + } } /// Result of admitting one RFC 6455 frame into the bounded BiDi message assembler. From 36466c8326ad7a965bbec54c22c3fc8050462ff0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 18:31:18 -0700 Subject: [PATCH 17/38] fix(network): remove unused BiDi text test fixture --- .../src/webdriver_bidi_websocket_message.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_message.rs b/crates/originweave-network/src/webdriver_bidi_websocket_message.rs index 67555f4d9..3ad3edc35 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_message.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_message.rs @@ -69,11 +69,6 @@ impl WebDriverBiDiWebSocketTextMessage { pub fn as_str(&self) -> &str { &self.0 } - - #[cfg(test)] - pub(crate) fn from_test_text(value: &str) -> Self { - Self(value.to_owned()) - } } /// Result of admitting one RFC 6455 frame into the bounded BiDi message assembler. From 0a364e56883462e09d48ab4915a5fbedc7762472 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 18:45:32 -0700 Subject: [PATCH 18/38] test(network): exercise public BiDi JSON boundary in unit build --- crates/originweave-network/src/lib.rs | 3 + ...idi_json_envelope_public_boundary_tests.rs | 87 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index ea12b0a14..79dd97492 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -21,6 +21,9 @@ mod webdriver_bidi_websocket_handshake; mod webdriver_bidi_websocket_message; mod webdriver_bidi_websocket_opening_recovery; +#[cfg(test)] +mod webdriver_bidi_json_envelope_public_boundary_tests; + pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, NetworkError, SocketConnectionEvidence, diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs new file mode 100644 index 000000000..f9c9fe318 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs @@ -0,0 +1,87 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; + +use crate::{ + WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, +}; + +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 SUCCESS_MESSAGE: &[u8] = br#"{"type":"success","id":7,"result":{"ready":true}}"#; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +#[test] +fn public_json_envelope_boundary_is_exercised_from_unit_build() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + stream.write_all(&[0x81, SUCCESS_MESSAGE.len() as u8])?; + stream.write_all(SUCCESS_MESSAGE) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let (_established, frame) = established.read_frame(Duration::from_millis(500))?; + + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + let text = match assembler.push_frame(frame)? { + WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + other => { + return Err(io::Error::other(format!( + "validated text frame produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let parsed = WebDriverBiDiJsonEnvelope::parse(&text); + + server + .join() + .map_err(|_| io::Error::other("JSON-envelope unit server panicked"))??; + assert_eq!( + parsed.as_ref().map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Success) + ); + assert_eq!( + parsed.as_ref().map(WebDriverBiDiJsonEnvelope::command_id), + Ok(Some(7)) + ); + Ok(()) +} From 51ae91835e537dff21f0b1da80be2eeb19a9db12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 19:13:37 -0700 Subject: [PATCH 19/38] test(network): cover BiDi JSON parser residual paths --- ...idi_json_envelope_public_boundary_tests.rs | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs index f9c9fe318..5688bdcb4 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs @@ -9,15 +9,17 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use crate::{ - WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeKind, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, }; 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 SUCCESS_MESSAGE: &[u8] = br#"{"type":"success","id":7,"result":{"ready":true}}"#; +const SUCCESS_MESSAGE: &[u8] = + br#"{"type":"success","id":7,"result":{"ready":true,"upper":"\uABCD"}}"#; fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; @@ -36,16 +38,21 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -#[test] -fn public_json_envelope_boundary_is_exercised_from_unit_build() -> Result<(), Box> { +fn parse_over_loopback( + document: &'static [u8], +) -> Result, Box> { + if document.len() > 125 { + return Err(io::Error::other("unit JSON document exceeded one-byte frame length").into()); + } + let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; stream.write_all(OPENING_RESPONSE)?; - stream.write_all(&[0x81, SUCCESS_MESSAGE.len() as u8])?; - stream.write_all(SUCCESS_MESSAGE) + stream.write_all(&[0x81, document.len() as u8])?; + stream.write_all(document) }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); @@ -75,6 +82,12 @@ fn public_json_envelope_boundary_is_exercised_from_unit_build() -> Result<(), Bo server .join() .map_err(|_| io::Error::other("JSON-envelope unit server panicked"))??; + Ok(parsed) +} + +#[test] +fn public_json_envelope_boundary_is_exercised_from_unit_build() -> Result<(), Box> { + let parsed = parse_over_loopback(SUCCESS_MESSAGE)?; assert_eq!( parsed.as_ref().map(WebDriverBiDiJsonEnvelope::kind), Ok(WebDriverBiDiJsonEnvelopeKind::Success) @@ -85,3 +98,25 @@ fn public_json_envelope_boundary_is_exercised_from_unit_build() -> Result<(), Bo ); Ok(()) } + +#[test] +fn public_json_envelope_unit_build_covers_fail_closed_json_edges() -> Result<(), Box> { + let malformed_documents: [&'static [u8]; 8] = [ + br#"{"unterminated"#, + br#"{"type" "success"}"#, + br#"{"type":"success" "id":1}"#, + br#"{"type":"success","id":1,"result":{"a" 1}}"#, + br#"{"type":"success","id":1,"result":[1 2]}"#, + br##"{"type":"success","id":1,"result":{"bad":"\"##, + br#"{"type":"success","id":1,"result":{"bad":"\ud800\0041"}}"#, + br##"{"type":"success","id":1,"result":{"bad":"\ud800\u"##, + ]; + + for document in malformed_documents { + assert_eq!( + parse_over_loopback(document)?, + Err(WebDriverBiDiJsonEnvelopeError::InvalidJson) + ); + } + Ok(()) +} From e6d5166825d0b2e7fea95b7adb6880ee64e6a6c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 19:17:45 -0700 Subject: [PATCH 20/38] test(network): cover escaped slash in BiDi JSON unit path --- .../src/webdriver_bidi_json_envelope_public_boundary_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs index 5688bdcb4..27377f08d 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs @@ -19,7 +19,7 @@ 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 SUCCESS_MESSAGE: &[u8] = - br#"{"type":"success","id":7,"result":{"ready":true,"upper":"\uABCD"}}"#; + br#"{"type":"success","id":7,"result":{"ready":true,"slash":"\/","upper":"\uABCD"}}"#; fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; From 1bdfb42b35f1bda118e4b4b1a4d331db2190c3bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 18:41:05 +0900 Subject: [PATCH 21/38] test(network): reject non-protocol BiDi error codes --- .../tests/webdriver_bidi_json_envelope.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index 856fe5621..79ea85da2 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -290,3 +290,15 @@ fn real_transport_rejects_negative_error_response_id() -> Result<(), Box Result<(), Box> { + let error = parse_over_real_transport( + br#"{"type":"error","id":7,"error":"attacker-defined-code","message":"bad code"}"#, + )?; + assert_eq!( + error, + Err(WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "error" }) + ); + Ok(()) +} From 87854fdc3ef84a7c0fc4d6a8baf61fa1532c1c8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 20:08:44 +0900 Subject: [PATCH 22/38] fix(network): validate WebDriver BiDi error codes --- .../src/webdriver_bidi_json_envelope.rs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs index e99adf2c9..048f03117 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs @@ -12,6 +12,39 @@ pub const MAX_WEBDRIVER_BIDI_JSON_DEPTH: usize = 64; /// Largest integer admitted by WebDriver BiDi's `js-uint` production. pub const MAX_WEBDRIVER_BIDI_JS_UINT: u64 = 9_007_199_254_740_991; +const WEBDRIVER_BIDI_ERROR_CODES: [&str; 30] = [ + "invalid argument", + "invalid selector", + "invalid session id", + "invalid web extension", + "move target out of bounds", + "no such alert", + "no such network collector", + "no such element", + "no such frame", + "no such handle", + "no such history entry", + "no such intercept", + "no such network data", + "no such node", + "no such request", + "no such screencast", + "no such script", + "no such storage partition", + "no such user context", + "no such web extension", + "session not created", + "unable to capture screen", + "unable to close browser", + "unable to set cookie", + "unable to set file input", + "unavailable network data", + "underspecified storage partition", + "unknown command", + "unknown error", + "unsupported operation", +]; + /// Local-end WebDriver BiDi envelope kind after complete JSON syntax validation. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WebDriverBiDiJsonEnvelopeKind { @@ -222,6 +255,9 @@ impl TopLevelFields { if let Some(stacktrace) = self.stacktrace { require_text_value(stacktrace, "stacktrace")?; } + if !is_webdriver_bidi_error_code(&error_code) { + return Err(invalid("error")); + } Ok(WebDriverBiDiJsonEnvelope { kind: WebDriverBiDiJsonEnvelopeKind::Error, command_id, @@ -250,6 +286,10 @@ fn invalid(member: &'static str) -> WebDriverBiDiJsonEnvelopeError { WebDriverBiDiJsonEnvelopeError::InvalidMember { member } } +fn is_webdriver_bidi_error_code(value: &str) -> bool { + WEBDRIVER_BIDI_ERROR_CODES.contains(&value) +} + fn required_text( value: Option, member: &'static str, @@ -699,6 +739,28 @@ mod tests { ); } + #[test] + fn accepts_current_protocol_error_code_vocabulary() { + for error_code in WEBDRIVER_BIDI_ERROR_CODES { + let document = format!( + r#"{{"type":"error","id":7,"error":"{error_code}","message":""}}"# + ); + assert_eq!( + parse(&document) + .as_ref() + .ok() + .and_then(WebDriverBiDiJsonEnvelope::error_code), + Some(error_code) + ); + } + assert_eq!( + parse( + r#"{"type":"error","id":7,"error":"attacker-defined-code","message":"bad code"}"# + ), + Err(WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "error" }) + ); + } + #[test] fn accepts_correlatable_error_and_extensible_success_members() { let error = parse(r#"{"type":"error","id":7,"error":"unknown error","message":"","x":0}"#); From 53acd599d85b63f78d1756a118ce0af2ad8aa1be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 20:14:37 +0900 Subject: [PATCH 23/38] docs(doctoring): correct WebDriver BiDi ErrorCode evidence --- docs/doctoring/browser-agent-protocols.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index dbf3ef731..da48f1055 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -1,6 +1,6 @@ # Browser and Agent Protocol Standards Evidence -- **Reviewed:** 2026-08-18 +- **Reviewed:** 2026-09-04 - **Purpose:** primary-source evidence for OriginWeave browser compatibility and adapter boundaries - **Canonical research index:** [`../doctoring.md`](../doctoring.md) @@ -8,13 +8,13 @@ This addendum complements the main doctoring record. The main record already car ## WebDriver BiDi -The latest published W3C technical-report baseline reviewed here remains the 1 June 2026 **Working Draft**, not a Recommendation. The current Editor’s Draft reviewed on 18 August 2026 identifies itself as the 20 July 2026 draft. OriginWeave therefore treats BiDi as a versioned browser-automation adapter rather than product-internal authority. Raw BiDi session/context/node identifiers do not become durable OriginWeave identities. +The latest published W3C technical-report baseline reviewed here is the 29 June 2026 **Working Draft**, not a Recommendation. The live Editor’s Draft retrieved on 4 September 2026 identifies itself as the 5 August 2026 draft and explicitly remains work in progress. OriginWeave therefore treats BiDi as a versioned browser-automation adapter rather than product-internal authority. Raw BiDi session/context/node identifiers do not become durable OriginWeave identities. For the bounded `browsingContext.locateNodes` command-serialization boundary, the reviewed Editor’s Draft defines a command envelope with `id: js-uint`, defines `js-uint` as `0..9007199254740991`, and defines `browsingContext.locateNodes` parameters containing a browsing context, locator, optional positive `maxNodeCount`, optional `serializationOptions`, and optional `startNodes`. OriginWeave serializes only its separately reviewed accessibility-locator subset and fixed minimal serialization options; this deterministic JSON value is not transport authentication or browser/Agent authority. WebDriver BiDi commands may execute concurrently and finish out of order. The Editor’s Draft defines the command id as the local end’s correlation identifier and sets a successful `CommandResponse.id` to that exact command id; an `ErrorResponse.id` may be `null` when no valid command id can be recovered. OriginWeave therefore fails closed unless a non-null protocol-range response id exactly matches the consumed command before later payload admission. Parsing success/error envelopes, handling nullable malformed-command errors, and authenticating the browser transport remain separate adapter boundaries. -The same reviewed Editor’s Draft defines a closed `ErrorCode` vocabulary that currently includes `no such client window`. OriginWeave admits only the reviewed vocabulary at its bounded response-envelope parser and rejects unknown error-code text fail closed; adding a newly reviewed protocol code changes compatibility only and grants no browser, transport, node, policy, or Agent authority. +The same reviewed Editor’s Draft defines `ErrorResponse.error` as `ErrorCode` and its local-end CDDL enumerates a finite 30-value vocabulary from `invalid argument` through `unsupported operation`. The CDDL does **not** include `no such client window`; the specification may define additional error concepts elsewhere, but their presence outside the `ErrorCode` production is not authority to admit them in a local-end error response. OriginWeave therefore admits only the reviewed CDDL vocabulary at its bounded response-envelope parser and rejects unknown error-code text fail closed. Adding a newly reviewed protocol code changes compatibility only and grants no browser, transport, node, policy, or Agent authority. Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). @@ -55,7 +55,7 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re 1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. Admit a BiDi `script.NodeRemoteValue` only as an untrusted transport handle when its type is exactly `node` and a usable control-free `sharedId` is present; do not treat a realm-local `handle`, a missing shared identifier, or control/whitespace-bearing protocol text as OriginWeave node authority. Treat an accessibility-query role as one exact WAI-ARIA token, not a whitespace-separated fallback list. 2. Serialize reviewed BiDi commands from already validated bounded values only, then correlate each non-null response id to the exact consumed command before payload admission; a protocol-shaped JSON envelope or matching id never substitutes for authenticated browser transport, current session/context/origin/document authority, policy authorization, or post-condition evidence. 3. Pin exact Chromium/CDP compatibility evidence at release time. -4. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. +4. Keep WebDriver BiDi's Working Draft/Editor’s Draft status visible in compatibility claims and re-review finite protocol vocabularies when the pinned adapter revision moves. 5. Keep WebMCP experimental/optional and propagate untrusted-content semantics. 6. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. 7. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. @@ -83,10 +83,10 @@ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.o World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/2023/REC-wai-aria-1.2-20230606/ -World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +World Wide Web Consortium. (2026, June 29). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260629/ -World Wide Web Consortium. (2026, July 20). *WebDriver BiDi* (Editor’s Draft). https://w3c.github.io/webdriver-bidi/ +World Wide Web Consortium. (2026, August 5). *WebDriver BiDi* (Editor’s Draft). Retrieved September 4, 2026, from https://w3c.github.io/webdriver-bidi/ World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ -International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html +International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html \ No newline at end of file From 7560fa106dc1eae8d43e030537d7ecb2ae90175d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 20:15:35 +0900 Subject: [PATCH 24/38] docs(changelog): record BiDi error-code hardening --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e3f7a56..ab15f5791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security +- WebDriver BiDi local-end error envelopes now admit only the finite `ErrorCode` vocabulary reviewed from the current W3C CDDL; arbitrary error strings fail closed before becoming typed response metadata, and realistic loopback coverage exercises the hostile unknown-code path. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. @@ -80,7 +81,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Resolver answers are rejected when empty or larger than 256 addresses, preventing an unbounded resolver response from entering policy state. - `localhost` may approve only loopback addresses, while literal IPv4 and IPv6 origins may approve only the exact canonical address encoded in the origin. - Resolver answers must remain a non-empty subset of the origin-bound approved address set; any newly introduced address fails closed as a possible DNS-rebinding event. -- Every redirect rechecks target-origin authority, target-bound resolution, HTTPS downgrade, complete-target cycle state, and hop capacity before policy state changes. +- Every redirect rechecks target-origin authority, target-bound resolution, HTTPS downgrade, complete-target digest, and hop capacity before policy state changes. - Direct TCP plans reject port zero, zero or excessive timeouts, excessive attempts, unapproved IPs, non-canonical IPv4-mapped IPv6 sockets, and IPv6 flow or scope metadata not represented in destination authority before connection I/O. - Direct connection code accepts only an explicit `SocketAddr`, never a hostname, and does not read proxy environment variables. - Established streams are discarded when peer inspection fails or the observed remote IP or port differs from the approved socket. From 7f31e50c901138bd586c5a8f06dbcc295273779f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 20:17:13 +0900 Subject: [PATCH 25/38] docs(changelog): preserve redirect-cycle evidence wording --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab15f5791..90f642a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,7 +81,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Resolver answers are rejected when empty or larger than 256 addresses, preventing an unbounded resolver response from entering policy state. - `localhost` may approve only loopback addresses, while literal IPv4 and IPv6 origins may approve only the exact canonical address encoded in the origin. - Resolver answers must remain a non-empty subset of the origin-bound approved address set; any newly introduced address fails closed as a possible DNS-rebinding event. -- Every redirect rechecks target-origin authority, target-bound resolution, HTTPS downgrade, complete-target digest, and hop capacity before policy state changes. +- Every redirect rechecks target-origin authority, target-bound resolution, HTTPS downgrade, complete-target cycle state, and hop capacity before policy state changes. - Direct TCP plans reject port zero, zero or excessive timeouts, excessive attempts, unapproved IPs, non-canonical IPv4-mapped IPv6 sockets, and IPv6 flow or scope metadata not represented in destination authority before connection I/O. - Direct connection code accepts only an explicit `SocketAddr`, never a hostname, and does not read proxy environment variables. - Established streams are discarded when peer inspection fails or the observed remote IP or port differs from the approved socket. From 89140129de304e31f241ae3e77b8b82fe01a6bfa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 21:06:12 +0900 Subject: [PATCH 26/38] test(network): reject invalid BiDi event method names --- ...bidi_json_envelope_public_boundary_tests.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs index 27377f08d..593fa41ae 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs @@ -99,6 +99,24 @@ fn public_json_envelope_boundary_is_exercised_from_unit_build() -> Result<(), Bo Ok(()) } +#[test] +fn public_json_envelope_rejects_non_event_name_methods() -> Result<(), Box> { + let malformed_methods: [&'static [u8]; 4] = [ + br#"{"type":"event","method":"","params":{}}"#, + br#"{"type":"event","method":"load","params":{}}"#, + br#"{"type":"event","method":".load","params":{}}"#, + br#"{"type":"event","method":"browsingContext.","params":{}}"#, + ]; + + for document in malformed_methods { + assert_eq!( + parse_over_loopback(document)?, + Err(WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "method" }) + ); + } + Ok(()) +} + #[test] fn public_json_envelope_unit_build_covers_fail_closed_json_edges() -> Result<(), Box> { let malformed_documents: [&'static [u8]; 8] = [ From 977c4c3adf3618cfd637bc3987b10dffe155588f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:30:44 +0900 Subject: [PATCH 27/38] fix(network): validate WebDriver BiDi event names --- .../src/webdriver_bidi_json_envelope.rs | 105 ++++++++++-------- 1 file changed, 57 insertions(+), 48 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs index 048f03117..3dcc1a693 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs @@ -269,6 +269,9 @@ impl TopLevelFields { fn into_event(self) -> Result { let method = required_text(self.method, "method")?; require_object(self.params, "params")?; + if !is_webdriver_bidi_event_name(&method) { + return Err(invalid("method")); + } Ok(WebDriverBiDiJsonEnvelope { kind: WebDriverBiDiJsonEnvelopeKind::Event, command_id: None, @@ -290,6 +293,13 @@ fn is_webdriver_bidi_error_code(value: &str) -> bool { WEBDRIVER_BIDI_ERROR_CODES.contains(&value) } +fn is_webdriver_bidi_event_name(value: &str) -> bool { + match value.split_once('.') { + Some((module_name, event_name)) => !module_name.is_empty() && !event_name.is_empty(), + None => false, + } +} + fn required_text( value: Option, member: &'static str, @@ -674,7 +684,7 @@ mod tests { #[test] fn classifies_all_local_end_envelope_kinds_and_redacts_debug() { let success = parse( - r#"{"type":"success","id":9007199254740991,"result":{"ready":true},"ext":[null,false,1.5,-2e3,"\u20ac","\ud83d\ude00"]}"#, + r#"{\"type\":\"success\",\"id\":9007199254740991,\"result\":{\"ready\":true},\"ext\":[null,false,1.5,-2e3,\"\\u20ac\",\"\\ud83d\\ude00\"]}"#, ); assert_eq!( success.as_ref().map(WebDriverBiDiJsonEnvelope::kind), @@ -697,7 +707,7 @@ mod tests { assert!(!debug.contains("ready")); let error = parse( - r#"{"type":"error","id":null,"error":"invalid argument","message":"secret detail","stacktrace":"hidden","vendor":{"x":[]}}"#, + r#"{\"type\":\"error\",\"id\":null,\"error\":\"invalid argument\",\"message\":\"secret detail\",\"stacktrace\":\"hidden\",\"vendor\":{\"x\":[]}}"#, ); assert_eq!( error.as_ref().map(WebDriverBiDiJsonEnvelope::kind), @@ -720,7 +730,7 @@ mod tests { assert!(!debug.contains("secret detail")); let event = - parse(r#"{"type":"event","method":"browsingContext.load","params":{},"vendor":true}"#); + parse(r#"{\"type\":\"event\",\"method\":\"browsingContext.load\",\"params\":{},\"vendor\":true}"#); assert_eq!( event.as_ref().map(WebDriverBiDiJsonEnvelope::kind), Ok(WebDriverBiDiJsonEnvelopeKind::Event) @@ -742,9 +752,8 @@ mod tests { #[test] fn accepts_current_protocol_error_code_vocabulary() { for error_code in WEBDRIVER_BIDI_ERROR_CODES { - let document = format!( - r#"{{"type":"error","id":7,"error":"{error_code}","message":""}}"# - ); + let document = + format!(r#"{{\"type\":\"error\",\"id\":7,\"error\":\"{error_code}\",\"message\":\"\"}}"#); assert_eq!( parse(&document) .as_ref() @@ -755,7 +764,7 @@ mod tests { } assert_eq!( parse( - r#"{"type":"error","id":7,"error":"attacker-defined-code","message":"bad code"}"# + r#"{\"type\":\"error\",\"id\":7,\"error\":\"attacker-defined-code\",\"message\":\"bad code\"}"# ), Err(WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "error" }) ); @@ -763,7 +772,7 @@ mod tests { #[test] fn accepts_correlatable_error_and_extensible_success_members() { - let error = parse(r#"{"type":"error","id":7,"error":"unknown error","message":"","x":0}"#); + let error = parse(r#"{\"type\":\"error\",\"id\":7,\"error\":\"unknown error\",\"message\":\"\",\"x\":0}"#); assert_eq!( error .as_ref() @@ -786,77 +795,77 @@ mod tests { WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "type" }, ), ( - r#"{"type":1}"#, + r#"{\"type\":1}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "type" }, ), ( - r#"{"type":"other"}"#, + r#"{\"type\":\"other\"}"#, WebDriverBiDiJsonEnvelopeError::UnsupportedEnvelopeType, ), ( - r#"{"type":"success","result":{}}"#, + r#"{\"type\":\"success\",\"result\":{}}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "id" }, ), ( - r#"{"type":"success","id":null,"result":{}}"#, + r#"{\"type\":\"success\",\"id\":null,\"result\":{}}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }, ), ( - r#"{"type":"success","id":9007199254740992,"result":{}}"#, + r#"{\"type\":\"success\",\"id\":9007199254740992,\"result\":{}}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }, ), ( - r#"{"type":"success","id":1}"#, + r#"{\"type\":\"success\",\"id\":1}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "result" }, ), ( - r#"{"type":"success","id":1,"result":[]}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":[]}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "result" }, ), ( - r#"{"type":"error","error":"x","message":"m"}"#, + r#"{\"type\":\"error\",\"error\":\"x\",\"message\":\"m\"}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "id" }, ), ( - r#"{"type":"error","id":-1,"error":"x","message":"m"}"#, + r#"{\"type\":\"error\",\"id\":-1,\"error\":\"x\",\"message\":\"m\"}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }, ), ( - r#"{"type":"error","id":null,"message":"m"}"#, + r#"{\"type\":\"error\",\"id\":null,\"message\":\"m\"}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "error" }, ), ( - r#"{"type":"error","id":null,"error":false,"message":"m"}"#, + r#"{\"type\":\"error\",\"id\":null,\"error\":false,\"message\":\"m\"}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "error" }, ), ( - r#"{"type":"error","id":null,"error":"x"}"#, + r#"{\"type\":\"error\",\"id\":null,\"error\":\"x\"}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "message" }, ), ( - r#"{"type":"error","id":null,"error":"x","message":{}}"#, + r#"{\"type\":\"error\",\"id\":null,\"error\":\"x\",\"message\":{}}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "message" }, ), ( - r#"{"type":"error","id":null,"error":"x","message":"m","stacktrace":0}"#, + r#"{\"type\":\"error\",\"id\":null,\"error\":\"x\",\"message\":\"m\",\"stacktrace\":0}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "stacktrace", }, ), ( - r#"{"type":"event","params":{}}"#, + r#"{\"type\":\"event\",\"params\":{}}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "method" }, ), ( - r#"{"type":"event","method":false,"params":{}}"#, + r#"{\"type\":\"event\",\"method\":false,\"params\":{}}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "method" }, ), ( - r#"{"type":"event","method":"x"}"#, + r#"{\"type\":\"event\",\"method\":\"x\"}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "params" }, ), ( - r#"{"type":"event","method":"x","params":null}"#, + r#"{\"type\":\"event\",\"method\":\"x\",\"params\":null}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "params" }, ), ]; @@ -870,27 +879,27 @@ mod tests { let cases = [ "[]", "null", - r#"{"type":"success","type":"event","id":1,"result":{}}"#, - r#"{"type":"success","id":1,"result":{}} trailing"#, - r#"{"type":"success","id":01,"result":{}}"#, - r#"{"type":"success","id":1.,"result":{}}"#, - r#"{"type":"success","id":1e,"result":{}}"#, - r#"{"type":"success","id":1e+,"result":{}}"#, - r#"{"type":"success","id":-,"result":{}}"#, - r#"{"type":"success","id":18446744073709551616,"result":{}}"#, + r#"{\"type\":\"success\",\"type\":\"event\",\"id\":1,\"result\":{}}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{}} trailing"#, + r#"{\"type\":\"success\",\"id\":01,\"result\":{}}"#, + r#"{\"type\":\"success\",\"id\":1.,\"result\":{}}"#, + r#"{\"type\":\"success\",\"id\":1e,\"result\":{}}"#, + r#"{\"type\":\"success\",\"id\":1e+,\"result\":{}}"#, + r#"{\"type\":\"success\",\"id\":-,\"result\":{}}"#, + r#"{\"type\":\"success\",\"id\":18446744073709551616,\"result\":{}}"#, "{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"line\nbreak\"}}", - r#"{"type":"success","id":1,"result":{"bad":"\x"}}"#, - r#"{"type":"success","id":1,"result":{"bad":"\u12xz"}}"#, - r#"{"type":"success","id":1,"result":{"bad":"\ud800x"}}"#, - r#"{"type":"success","id":1,"result":{"bad":"\ud800\u0041"}}"#, - r#"{"type":"success","id":1,"result":{"bad":"\udc00"}}"#, - r#"{"type":"success","id":1,"result":{"a":true "b":false}}"#, - r#"{"type":"success","id":1,"result":[1,]}"#, - r#"{"type":"success","id":1,"result":{"a":tru}}"#, - r#"{"type":"success","id":1,"result":{"a":fal}}"#, - r#"{"type":"success","id":1,"result":{"a":nul}}"#, - r#"{"type":"success","id":1,"result":{"a":}}"#, - r#"{"type":"success","id":1,"result":{"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"\\x\"}}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"\\u12xz\"}}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"\\ud800x\"}}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"\\ud800\\u0041\"}}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"\\udc00\"}}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{\"a\":true \"b\":false}}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":[1,]}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{\"a\":tru}}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{\"a\":fal}}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{\"a\":nul}}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{\"a\":}}"#, + r#"{\"type\":\"success\",\"id\":1,\"result\":{"#, ]; for document in cases { assert!(parse(document).is_err(), "unexpectedly admitted {document}"); @@ -900,14 +909,14 @@ mod tests { Err(WebDriverBiDiJsonEnvelopeError::RootMustBeObject) ); assert_eq!( - parse(r#"{"type":"success","type":"event","id":1,"result":{}}"#), + parse(r#"{\"type\":\"success\",\"type\":\"event\",\"id\":1,\"result\":{}}"#), Err(WebDriverBiDiJsonEnvelopeError::DuplicateTopLevelMember) ); } #[test] fn rejects_excessive_json_nesting_and_formats_errors_without_payloads() { - let mut document = String::from(r#"{"type":"success","id":1,"result":"#); + let mut document = String::from(r#"{\"type\":\"success\",\"id\":1,\"result\":"#); for _ in 0..=MAX_WEBDRIVER_BIDI_JSON_DEPTH { document.push('['); } From c6ab0c597b625c5dcb36f63ed9e82c53176d0492 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:37:15 +0900 Subject: [PATCH 28/38] fix(network): restore JSON envelope test fixtures --- .../src/webdriver_bidi_json_envelope.rs | 105 ++++++++---------- 1 file changed, 48 insertions(+), 57 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs index 3dcc1a693..048f03117 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs @@ -269,9 +269,6 @@ impl TopLevelFields { fn into_event(self) -> Result { let method = required_text(self.method, "method")?; require_object(self.params, "params")?; - if !is_webdriver_bidi_event_name(&method) { - return Err(invalid("method")); - } Ok(WebDriverBiDiJsonEnvelope { kind: WebDriverBiDiJsonEnvelopeKind::Event, command_id: None, @@ -293,13 +290,6 @@ fn is_webdriver_bidi_error_code(value: &str) -> bool { WEBDRIVER_BIDI_ERROR_CODES.contains(&value) } -fn is_webdriver_bidi_event_name(value: &str) -> bool { - match value.split_once('.') { - Some((module_name, event_name)) => !module_name.is_empty() && !event_name.is_empty(), - None => false, - } -} - fn required_text( value: Option, member: &'static str, @@ -684,7 +674,7 @@ mod tests { #[test] fn classifies_all_local_end_envelope_kinds_and_redacts_debug() { let success = parse( - r#"{\"type\":\"success\",\"id\":9007199254740991,\"result\":{\"ready\":true},\"ext\":[null,false,1.5,-2e3,\"\\u20ac\",\"\\ud83d\\ude00\"]}"#, + r#"{"type":"success","id":9007199254740991,"result":{"ready":true},"ext":[null,false,1.5,-2e3,"\u20ac","\ud83d\ude00"]}"#, ); assert_eq!( success.as_ref().map(WebDriverBiDiJsonEnvelope::kind), @@ -707,7 +697,7 @@ mod tests { assert!(!debug.contains("ready")); let error = parse( - r#"{\"type\":\"error\",\"id\":null,\"error\":\"invalid argument\",\"message\":\"secret detail\",\"stacktrace\":\"hidden\",\"vendor\":{\"x\":[]}}"#, + r#"{"type":"error","id":null,"error":"invalid argument","message":"secret detail","stacktrace":"hidden","vendor":{"x":[]}}"#, ); assert_eq!( error.as_ref().map(WebDriverBiDiJsonEnvelope::kind), @@ -730,7 +720,7 @@ mod tests { assert!(!debug.contains("secret detail")); let event = - parse(r#"{\"type\":\"event\",\"method\":\"browsingContext.load\",\"params\":{},\"vendor\":true}"#); + parse(r#"{"type":"event","method":"browsingContext.load","params":{},"vendor":true}"#); assert_eq!( event.as_ref().map(WebDriverBiDiJsonEnvelope::kind), Ok(WebDriverBiDiJsonEnvelopeKind::Event) @@ -752,8 +742,9 @@ mod tests { #[test] fn accepts_current_protocol_error_code_vocabulary() { for error_code in WEBDRIVER_BIDI_ERROR_CODES { - let document = - format!(r#"{{\"type\":\"error\",\"id\":7,\"error\":\"{error_code}\",\"message\":\"\"}}"#); + let document = format!( + r#"{{"type":"error","id":7,"error":"{error_code}","message":""}}"# + ); assert_eq!( parse(&document) .as_ref() @@ -764,7 +755,7 @@ mod tests { } assert_eq!( parse( - r#"{\"type\":\"error\",\"id\":7,\"error\":\"attacker-defined-code\",\"message\":\"bad code\"}"# + r#"{"type":"error","id":7,"error":"attacker-defined-code","message":"bad code"}"# ), Err(WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "error" }) ); @@ -772,7 +763,7 @@ mod tests { #[test] fn accepts_correlatable_error_and_extensible_success_members() { - let error = parse(r#"{\"type\":\"error\",\"id\":7,\"error\":\"unknown error\",\"message\":\"\",\"x\":0}"#); + let error = parse(r#"{"type":"error","id":7,"error":"unknown error","message":"","x":0}"#); assert_eq!( error .as_ref() @@ -795,77 +786,77 @@ mod tests { WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "type" }, ), ( - r#"{\"type\":1}"#, + r#"{"type":1}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "type" }, ), ( - r#"{\"type\":\"other\"}"#, + r#"{"type":"other"}"#, WebDriverBiDiJsonEnvelopeError::UnsupportedEnvelopeType, ), ( - r#"{\"type\":\"success\",\"result\":{}}"#, + r#"{"type":"success","result":{}}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "id" }, ), ( - r#"{\"type\":\"success\",\"id\":null,\"result\":{}}"#, + r#"{"type":"success","id":null,"result":{}}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }, ), ( - r#"{\"type\":\"success\",\"id\":9007199254740992,\"result\":{}}"#, + r#"{"type":"success","id":9007199254740992,"result":{}}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }, ), ( - r#"{\"type\":\"success\",\"id\":1}"#, + r#"{"type":"success","id":1}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "result" }, ), ( - r#"{\"type\":\"success\",\"id\":1,\"result\":[]}"#, + r#"{"type":"success","id":1,"result":[]}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "result" }, ), ( - r#"{\"type\":\"error\",\"error\":\"x\",\"message\":\"m\"}"#, + r#"{"type":"error","error":"x","message":"m"}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "id" }, ), ( - r#"{\"type\":\"error\",\"id\":-1,\"error\":\"x\",\"message\":\"m\"}"#, + r#"{"type":"error","id":-1,"error":"x","message":"m"}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }, ), ( - r#"{\"type\":\"error\",\"id\":null,\"message\":\"m\"}"#, + r#"{"type":"error","id":null,"message":"m"}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "error" }, ), ( - r#"{\"type\":\"error\",\"id\":null,\"error\":false,\"message\":\"m\"}"#, + r#"{"type":"error","id":null,"error":false,"message":"m"}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "error" }, ), ( - r#"{\"type\":\"error\",\"id\":null,\"error\":\"x\"}"#, + r#"{"type":"error","id":null,"error":"x"}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "message" }, ), ( - r#"{\"type\":\"error\",\"id\":null,\"error\":\"x\",\"message\":{}}"#, + r#"{"type":"error","id":null,"error":"x","message":{}}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "message" }, ), ( - r#"{\"type\":\"error\",\"id\":null,\"error\":\"x\",\"message\":\"m\",\"stacktrace\":0}"#, + r#"{"type":"error","id":null,"error":"x","message":"m","stacktrace":0}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "stacktrace", }, ), ( - r#"{\"type\":\"event\",\"params\":{}}"#, + r#"{"type":"event","params":{}}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "method" }, ), ( - r#"{\"type\":\"event\",\"method\":false,\"params\":{}}"#, + r#"{"type":"event","method":false,"params":{}}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "method" }, ), ( - r#"{\"type\":\"event\",\"method\":\"x\"}"#, + r#"{"type":"event","method":"x"}"#, WebDriverBiDiJsonEnvelopeError::MissingRequiredMember { member: "params" }, ), ( - r#"{\"type\":\"event\",\"method\":\"x\",\"params\":null}"#, + r#"{"type":"event","method":"x","params":null}"#, WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "params" }, ), ]; @@ -879,27 +870,27 @@ mod tests { let cases = [ "[]", "null", - r#"{\"type\":\"success\",\"type\":\"event\",\"id\":1,\"result\":{}}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":{}} trailing"#, - r#"{\"type\":\"success\",\"id\":01,\"result\":{}}"#, - r#"{\"type\":\"success\",\"id\":1.,\"result\":{}}"#, - r#"{\"type\":\"success\",\"id\":1e,\"result\":{}}"#, - r#"{\"type\":\"success\",\"id\":1e+,\"result\":{}}"#, - r#"{\"type\":\"success\",\"id\":-,\"result\":{}}"#, - r#"{\"type\":\"success\",\"id\":18446744073709551616,\"result\":{}}"#, + r#"{"type":"success","type":"event","id":1,"result":{}}"#, + r#"{"type":"success","id":1,"result":{}} trailing"#, + r#"{"type":"success","id":01,"result":{}}"#, + r#"{"type":"success","id":1.,"result":{}}"#, + r#"{"type":"success","id":1e,"result":{}}"#, + r#"{"type":"success","id":1e+,"result":{}}"#, + r#"{"type":"success","id":-,"result":{}}"#, + r#"{"type":"success","id":18446744073709551616,"result":{}}"#, "{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"line\nbreak\"}}", - r#"{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"\\x\"}}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"\\u12xz\"}}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"\\ud800x\"}}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"\\ud800\\u0041\"}}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":{\"bad\":\"\\udc00\"}}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":{\"a\":true \"b\":false}}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":[1,]}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":{\"a\":tru}}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":{\"a\":fal}}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":{\"a\":nul}}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":{\"a\":}}"#, - r#"{\"type\":\"success\",\"id\":1,\"result\":{"#, + r#"{"type":"success","id":1,"result":{"bad":"\x"}}"#, + r#"{"type":"success","id":1,"result":{"bad":"\u12xz"}}"#, + r#"{"type":"success","id":1,"result":{"bad":"\ud800x"}}"#, + r#"{"type":"success","id":1,"result":{"bad":"\ud800\u0041"}}"#, + r#"{"type":"success","id":1,"result":{"bad":"\udc00"}}"#, + r#"{"type":"success","id":1,"result":{"a":true "b":false}}"#, + r#"{"type":"success","id":1,"result":[1,]}"#, + r#"{"type":"success","id":1,"result":{"a":tru}}"#, + r#"{"type":"success","id":1,"result":{"a":fal}}"#, + r#"{"type":"success","id":1,"result":{"a":nul}}"#, + r#"{"type":"success","id":1,"result":{"a":}}"#, + r#"{"type":"success","id":1,"result":{"#, ]; for document in cases { assert!(parse(document).is_err(), "unexpectedly admitted {document}"); @@ -909,14 +900,14 @@ mod tests { Err(WebDriverBiDiJsonEnvelopeError::RootMustBeObject) ); assert_eq!( - parse(r#"{\"type\":\"success\",\"type\":\"event\",\"id\":1,\"result\":{}}"#), + parse(r#"{"type":"success","type":"event","id":1,"result":{}}"#), Err(WebDriverBiDiJsonEnvelopeError::DuplicateTopLevelMember) ); } #[test] fn rejects_excessive_json_nesting_and_formats_errors_without_payloads() { - let mut document = String::from(r#"{\"type\":\"success\",\"id\":1,\"result\":"#); + let mut document = String::from(r#"{"type":"success","id":1,"result":"#); for _ in 0..=MAX_WEBDRIVER_BIDI_JSON_DEPTH { document.push('['); } From 4a9d7fa4cb8d6e31604d42a4a006a374f473d42a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:14:53 +0900 Subject: [PATCH 29/38] fix(network): reject malformed BiDi event names Signed-off-by: Seongho Bae --- .../src/webdriver_bidi_json_envelope.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs index 048f03117..f86d2317c 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs @@ -269,6 +269,12 @@ impl TopLevelFields { fn into_event(self) -> Result { let method = required_text(self.method, "method")?; require_object(self.params, "params")?; + if !matches!( + method.split_once('.'), + Some((module, event)) if !module.is_empty() && !event.is_empty() + ) { + return Err(invalid("method")); + } Ok(WebDriverBiDiJsonEnvelope { kind: WebDriverBiDiJsonEnvelopeKind::Event, command_id: None, @@ -742,9 +748,8 @@ mod tests { #[test] fn accepts_current_protocol_error_code_vocabulary() { for error_code in WEBDRIVER_BIDI_ERROR_CODES { - let document = format!( - r#"{{"type":"error","id":7,"error":"{error_code}","message":""}}"# - ); + let document = + format!(r#"{{"type":"error","id":7,"error":"{error_code}","message":""}}"#); assert_eq!( parse(&document) .as_ref() From 9b704036bfbbace93fd2cea6ffadec88d3908d42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:05:55 +0900 Subject: [PATCH 30/38] docs(doctoring): refresh WebDriver BiDi 2026-09-03 evidence --- docs/doctoring/browser-agent-protocols.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index da48f1055..37d0b88ac 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -1,6 +1,6 @@ # Browser and Agent Protocol Standards Evidence -- **Reviewed:** 2026-09-04 +- **Reviewed:** 2026-09-05 - **Purpose:** primary-source evidence for OriginWeave browser compatibility and adapter boundaries - **Canonical research index:** [`../doctoring.md`](../doctoring.md) @@ -8,7 +8,7 @@ This addendum complements the main doctoring record. The main record already car ## WebDriver BiDi -The latest published W3C technical-report baseline reviewed here is the 29 June 2026 **Working Draft**, not a Recommendation. The live Editor’s Draft retrieved on 4 September 2026 identifies itself as the 5 August 2026 draft and explicitly remains work in progress. OriginWeave therefore treats BiDi as a versioned browser-automation adapter rather than product-internal authority. Raw BiDi session/context/node identifiers do not become durable OriginWeave identities. +The latest published W3C technical-report baseline reviewed here is the 3 September 2026 **Working Draft**, not a Recommendation. The live Editor’s Draft retrieved on 5 September 2026 also identifies itself as the 3 September 2026 draft and explicitly remains work in progress. OriginWeave therefore treats BiDi as a versioned browser-automation adapter rather than product-internal authority. Raw BiDi session/context/node identifiers do not become durable OriginWeave identities. For the bounded `browsingContext.locateNodes` command-serialization boundary, the reviewed Editor’s Draft defines a command envelope with `id: js-uint`, defines `js-uint` as `0..9007199254740991`, and defines `browsingContext.locateNodes` parameters containing a browsing context, locator, optional positive `maxNodeCount`, optional `serializationOptions`, and optional `startNodes`. OriginWeave serializes only its separately reviewed accessibility-locator subset and fixed minimal serialization options; this deterministic JSON value is not transport authentication or browser/Agent authority. @@ -83,9 +83,9 @@ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.o World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/2023/REC-wai-aria-1.2-20230606/ -World Wide Web Consortium. (2026, June 29). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260629/ +World Wide Web Consortium. (2026, September 3). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/ -World Wide Web Consortium. (2026, August 5). *WebDriver BiDi* (Editor’s Draft). Retrieved September 4, 2026, from https://w3c.github.io/webdriver-bidi/ +World Wide Web Consortium. (2026, September 3). *WebDriver BiDi* (Editor’s Draft). Retrieved September 5, 2026, from https://w3c.github.io/webdriver-bidi/ World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ From 6cade8968c25a10a29bd9437e1622595fc37918d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:33:10 +0900 Subject: [PATCH 31/38] test(network): require spec-defined client-window error --- .../webdriver_bidi_error_code_interop.rs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_error_code_interop.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_error_code_interop.rs b/crates/originweave-network/tests/webdriver_bidi_error_code_interop.rs new file mode 100644 index 000000000..69b668b78 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_error_code_interop.rs @@ -0,0 +1,106 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, +}; + +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 parse_over_real_transport( + payload: &[u8], +) -> Result, Box> { + if payload.len() > 125 { + return Err(io::Error::other("test payload exceeded one-byte frame length").into()); + } + + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let payload = payload.to_vec(); + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let payload_len = u8::try_from(payload.len()) + .map_err(|_| io::Error::other("test payload length does not fit u8"))?; + stream.write_all(&[0x81, payload_len])?; + stream.write_all(&payload) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let (_established, frame) = established.read_frame(Duration::from_millis(500))?; + + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + let text = match assembler.push_frame(frame)? { + WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + other => { + return Err(io::Error::other(format!( + "validated text frame produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + let parsed = WebDriverBiDiJsonEnvelope::parse(&text); + + server + .join() + .map_err(|_| io::Error::other("JSON-envelope server panicked"))??; + Ok(parsed) +} + +#[test] +fn real_transport_accepts_spec_defined_client_window_error() -> Result<(), Box> { + let envelope = parse_over_real_transport( + br#"{"type":"error","id":7,"error":"no such client window","message":"unknown client window"}"#, + )?; + assert_eq!( + envelope.as_ref().map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Error) + ); + assert_eq!( + envelope.as_ref().map(WebDriverBiDiJsonEnvelope::command_id), + Ok(Some(7)) + ); + assert_eq!( + envelope.as_ref().map(WebDriverBiDiJsonEnvelope::error_code), + Ok(Some("no such client window")) + ); + Ok(()) +} From 56e70a2cd54e3ea4f85595ce7b5a00f05e0364bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:41:08 +0900 Subject: [PATCH 32/38] test(network): colocate client-window interop regression --- .../tests/webdriver_bidi_json_envelope.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs index 79ea85da2..41bb90524 100644 --- a/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -302,3 +302,23 @@ fn real_transport_rejects_non_protocol_error_code() -> Result<(), Box ); Ok(()) } + +#[test] +fn real_transport_accepts_spec_defined_client_window_error() -> Result<(), Box> { + let envelope = parse_over_real_transport( + br#"{"type":"error","id":7,"error":"no such client window","message":"unknown client window"}"#, + )?; + assert_eq!( + envelope.as_ref().map(WebDriverBiDiJsonEnvelope::kind), + Ok(WebDriverBiDiJsonEnvelopeKind::Error) + ); + assert_eq!( + envelope.as_ref().map(WebDriverBiDiJsonEnvelope::command_id), + Ok(Some(7)) + ); + assert_eq!( + envelope.as_ref().map(WebDriverBiDiJsonEnvelope::error_code), + Ok(Some("no such client window")) + ); + Ok(()) +} From 75780785d6a6a9cc3659103e0653880ae6222fd4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:41:22 +0900 Subject: [PATCH 33/38] test(network): remove duplicate interop harness --- .../webdriver_bidi_error_code_interop.rs | 106 ------------------ 1 file changed, 106 deletions(-) delete mode 100644 crates/originweave-network/tests/webdriver_bidi_error_code_interop.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_error_code_interop.rs b/crates/originweave-network/tests/webdriver_bidi_error_code_interop.rs deleted file mode 100644 index 69b668b78..000000000 --- a/crates/originweave-network/tests/webdriver_bidi_error_code_interop.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::{ - error::Error, - io::{self, Read, Write}, - net::{TcpListener, TcpStream}, - thread, - time::Duration, -}; - -use originweave_core::WebDriverBiDiWebSocketEndpoint; -use originweave_network::{ - WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, - WebDriverBiDiWebSocketMessageAssembly, -}; - -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 parse_over_real_transport( - payload: &[u8], -) -> Result, Box> { - if payload.len() > 125 { - return Err(io::Error::other("test payload exceeded one-byte frame length").into()); - } - - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let local_addr = listener.local_addr()?; - let payload = payload.to_vec(); - let server = thread::spawn(move || -> io::Result<()> { - let (mut stream, _) = listener.accept()?; - read_opening_request(&mut stream)?; - stream.write_all(OPENING_RESPONSE)?; - let payload_len = u8::try_from(payload.len()) - .map_err(|_| io::Error::other("test payload length does not fit u8"))?; - stream.write_all(&[0x81, payload_len])?; - stream.write_all(&payload) - }); - - let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); - let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; - let correlated = admitted.correlate_session_id(SESSION_ID)?; - let target = correlated.into_explicit_connect_target()?; - let connection = - WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; - let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? - .write_opening_request(Duration::from_millis(500))? - .read_opening_response(Duration::from_millis(500))?; - let (_established, frame) = established.read_frame(Duration::from_millis(500))?; - - let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); - let text = match assembler.push_frame(frame)? { - WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, - other => { - return Err(io::Error::other(format!( - "validated text frame produced unexpected assembly state: {other:?}" - )) - .into()); - } - }; - let parsed = WebDriverBiDiJsonEnvelope::parse(&text); - - server - .join() - .map_err(|_| io::Error::other("JSON-envelope server panicked"))??; - Ok(parsed) -} - -#[test] -fn real_transport_accepts_spec_defined_client_window_error() -> Result<(), Box> { - let envelope = parse_over_real_transport( - br#"{"type":"error","id":7,"error":"no such client window","message":"unknown client window"}"#, - )?; - assert_eq!( - envelope.as_ref().map(WebDriverBiDiJsonEnvelope::kind), - Ok(WebDriverBiDiJsonEnvelopeKind::Error) - ); - assert_eq!( - envelope.as_ref().map(WebDriverBiDiJsonEnvelope::command_id), - Ok(Some(7)) - ); - assert_eq!( - envelope.as_ref().map(WebDriverBiDiJsonEnvelope::error_code), - Ok(Some("no such client window")) - ); - Ok(()) -} From 01ef4296e099d7cae5dc27c5a896cd26443c720b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:46:22 +0900 Subject: [PATCH 34/38] fix(network): admit client-window BiDi errors Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + crates/originweave-network/src/webdriver_bidi_json_envelope.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f642a93..9689df7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Aligned the bounded WebDriver BiDi error-envelope vocabulary with the current specification by admitting the defined `no such client window` response while retaining fail-closed rejection of unknown error codes. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs index f86d2317c..e8d8e01d1 100644 --- a/crates/originweave-network/src/webdriver_bidi_json_envelope.rs +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs @@ -12,13 +12,14 @@ pub const MAX_WEBDRIVER_BIDI_JSON_DEPTH: usize = 64; /// Largest integer admitted by WebDriver BiDi's `js-uint` production. pub const MAX_WEBDRIVER_BIDI_JS_UINT: u64 = 9_007_199_254_740_991; -const WEBDRIVER_BIDI_ERROR_CODES: [&str; 30] = [ +const WEBDRIVER_BIDI_ERROR_CODES: [&str; 31] = [ "invalid argument", "invalid selector", "invalid session id", "invalid web extension", "move target out of bounds", "no such alert", + "no such client window", "no such network collector", "no such element", "no such frame", From 3df49e60c27b423c9218ed772d34db61fd5f5305 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:47:17 +0900 Subject: [PATCH 35/38] docs(network): align BiDi error vocabulary evidence Signed-off-by: Seongho Bae --- docs/doctoring/browser-agent-protocols.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 37d0b88ac..e206c72ac 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -12,9 +12,9 @@ The latest published W3C technical-report baseline reviewed here is the 3 Septem For the bounded `browsingContext.locateNodes` command-serialization boundary, the reviewed Editor’s Draft defines a command envelope with `id: js-uint`, defines `js-uint` as `0..9007199254740991`, and defines `browsingContext.locateNodes` parameters containing a browsing context, locator, optional positive `maxNodeCount`, optional `serializationOptions`, and optional `startNodes`. OriginWeave serializes only its separately reviewed accessibility-locator subset and fixed minimal serialization options; this deterministic JSON value is not transport authentication or browser/Agent authority. -WebDriver BiDi commands may execute concurrently and finish out of order. The Editor’s Draft defines the command id as the local end’s correlation identifier and sets a successful `CommandResponse.id` to that exact command id; an `ErrorResponse.id` may be `null` when no valid command id can be recovered. OriginWeave therefore fails closed unless a non-null protocol-range response id exactly matches the consumed command before later payload admission. Parsing success/error envelopes, handling nullable malformed-command errors, and authenticating the browser transport remain separate adapter boundaries. +WebDriver BiDi commands may execute concurrently and finish out of order. The 3 September 2026 Working Draft defines the command id as the local end’s correlation identifier; its local-end `CommandResponse` production requires `id: js-uint`, while `ErrorResponse.id` is `js-uint / null`. OriginWeave therefore represents a validated success response with a structurally present command id, retains nullable ids only for protocol errors, and fails closed unless a non-null protocol-range response id exactly matches the consumed command before later payload admission. Parsing success/error envelopes, handling nullable malformed-command errors, and authenticating the browser transport remain separate adapter boundaries. -The same reviewed Editor’s Draft defines `ErrorResponse.error` as `ErrorCode` and its local-end CDDL enumerates a finite 30-value vocabulary from `invalid argument` through `unsupported operation`. The CDDL does **not** include `no such client window`; the specification may define additional error concepts elsewhere, but their presence outside the `ErrorCode` production is not authority to admit them in a local-end error response. OriginWeave therefore admits only the reviewed CDDL vocabulary at its bounded response-envelope parser and rejects unknown error-code text fail closed. Adding a newly reviewed protocol code changes compatibility only and grants no browser, transport, node, policy, or Agent authority. +The same Working Draft defines `ErrorResponse.error` as `ErrorCode` and its local-end CDDL enumerates a finite 31-value vocabulary from `invalid argument` through `unsupported operation`, including `no such client window`. OriginWeave admits only that reviewed vocabulary at its bounded response-envelope parser and rejects unknown error-code text fail closed. Adding a newly reviewed protocol code changes compatibility only and grants no browser, transport, node, policy, or Agent authority. Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). @@ -89,4 +89,4 @@ World Wide Web Consortium. (2026, September 3). *WebDriver BiDi* (Editor’s Dra World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ -International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html \ No newline at end of file +International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html From 7a4594317cccbd08678e3f3fbc7d62b905851109 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:10:06 +0900 Subject: [PATCH 36/38] docs(network): record BiDi ErrorCode inconsistency accurately --- docs/doctoring/browser-agent-protocols.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index e206c72ac..0795642a4 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -14,7 +14,7 @@ For the bounded `browsingContext.locateNodes` command-serialization boundary, th WebDriver BiDi commands may execute concurrently and finish out of order. The 3 September 2026 Working Draft defines the command id as the local end’s correlation identifier; its local-end `CommandResponse` production requires `id: js-uint`, while `ErrorResponse.id` is `js-uint / null`. OriginWeave therefore represents a validated success response with a structurally present command id, retains nullable ids only for protocol errors, and fails closed unless a non-null protocol-range response id exactly matches the consumed command before later payload admission. Parsing success/error envelopes, handling nullable malformed-command errors, and authenticating the browser transport remain separate adapter boundaries. -The same Working Draft defines `ErrorResponse.error` as `ErrorCode` and its local-end CDDL enumerates a finite 31-value vocabulary from `invalid argument` through `unsupported operation`, including `no such client window`. OriginWeave admits only that reviewed vocabulary at its bounded response-envelope parser and rejects unknown error-code text fail closed. Adding a newly reviewed protocol code changes compatibility only and grants no browser, transport, node, policy, or Agent authority. +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). From 9fa4f37f1bbe6b835c157633cdebd8c3c2d3e799 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:12:11 +0900 Subject: [PATCH 37/38] docs(changelog): preserve BiDi error-code discrepancy --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9689df7b0..9a137ee95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security -- WebDriver BiDi local-end error envelopes now admit only the finite `ErrorCode` vocabulary reviewed from the current W3C CDDL; arbitrary error strings fail closed before becoming typed response metadata, and realistic loopback coverage exercises the hostile unknown-code path. +- WebDriver BiDi local-end error envelopes now admit the 30-value rendered W3C `ErrorCode` CDDL plus the separately defined normative `no such client window` error; arbitrary strings still fail closed, and realistic loopback coverage exercises both that compatibility exception and the hostile unknown-code path. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. From 6407895f4db4bee640074cb9c9d3cbe8b0e9e13a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:14:48 +0900 Subject: [PATCH 38/38] test(docs): lock BiDi error-vocabulary evidence --- ...bdriver_bidi_error_vocabulary_doctoring.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/test_webdriver_bidi_error_vocabulary_doctoring.py diff --git a/tests/test_webdriver_bidi_error_vocabulary_doctoring.py b/tests/test_webdriver_bidi_error_vocabulary_doctoring.py new file mode 100644 index 000000000..7c35ff14a --- /dev/null +++ b/tests/test_webdriver_bidi_error_vocabulary_doctoring.py @@ -0,0 +1,46 @@ +"""Lock the reviewed WebDriver BiDi error-vocabulary evidence to the shipped adapter contract.""" + +from __future__ import annotations + +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DOCTORING = ROOT / "docs" / "doctoring" / "browser-agent-protocols.md" +CHANGELOG = ROOT / "CHANGELOG.md" + + +class WebDriverBiDiErrorVocabularyDoctoringTests(unittest.TestCase): + """Prevent the W3C prose/CDDL discrepancy from being rewritten as false conformance evidence.""" + + def test_doctoring_records_the_reviewed_cddl_exception(self) -> None: + """The adapter's 30+1 admission rule must stay explicit and fail-closed.""" + + doctoring = DOCTORING.read_text(encoding="utf-8") + self.assertIn( + "rendered local-end CDDL enumerates 30 values and omits `no such client window`", + doctoring, + ) + self.assertIn( + "§3.5 separately defines `no such client window` and normative client-window algorithms return that error code", + doctoring, + ) + self.assertIn( + "explicit interoperability exception for a specification-internal inconsistency", + doctoring, + ) + + def test_changelog_does_not_misstate_the_exception_as_cddl_membership(self) -> None: + """Release evidence must distinguish rendered CDDL values from the normative exception.""" + + changelog = CHANGELOG.read_text(encoding="utf-8") + self.assertIn( + "30-value rendered W3C `ErrorCode` CDDL plus the separately defined normative `no such client window` error", + changelog, + ) + self.assertIn("arbitrary strings still fail closed", changelog) + + +if __name__ == "__main__": + unittest.main()