diff --git a/CHANGELOG.md b/CHANGELOG.md index 829fba083..1c8a3162f 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. @@ -69,10 +70,12 @@ All notable changes to OriginWeave are documented in this file. The format follo - Kept the revoked-stream fixture peer alive until local shutdown and fail-closed write classification complete, removing a macOS `ENOTCONN` race from the coverage path. - Carried the existing connection-lifetime test repairs into the frame-transport stack so inherited tests no longer close the peer before the behavior under test completes; production cleanup errors remain fail-closed. - Made opening-exchange tests wait for the complete client request and retain the peer until each client assertion finishes, avoiding premature connection closure in both successful and rejected handshakes without changing production error handling. +- Carried the verified connection-lifetime fixture repairs into the message-assembly stack while preserving its message and JSON validation behavior. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. ### Security +- 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. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index efea5b801..79dd97492 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -5,19 +5,25 @@ //! peers before exposing transport I/O, and emits credential-free evidence. //! 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, and provides bounded masked client writes and -//! unmasked server-frame reads without granting browser, TLS, policy, or Agent -//! authority. +//! the RFC 6455 opening exchange, provides bounded masked client writes and +//! 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; 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, @@ -26,6 +32,10 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; +pub use webdriver_bidi_json_envelope::{ + MAX_WEBDRIVER_BIDI_JS_UINT, MAX_WEBDRIVER_BIDI_JSON_DEPTH, WebDriverBiDiJsonEnvelope, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiJsonEnvelopeKind, +}; pub use webdriver_bidi_websocket_frame::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrame, @@ -38,4 +48,10 @@ pub use webdriver_bidi_websocket_handshake::{ WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketOpeningWriteError, }; +pub use webdriver_bidi_websocket_message::{ + MAX_WEBDRIVER_BIDI_MESSAGE_SIZE, WebDriverBiDiWebSocketControlKind, + WebDriverBiDiWebSocketControlMessage, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketMessageError, + WebDriverBiDiWebSocketTextMessage, +}; pub use webdriver_bidi_websocket_opening_recovery::WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition; 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..e8d8e01d1 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope.rs @@ -0,0 +1,950 @@ +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; + +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", + "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 { + /// 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")?; + } + if !is_webdriver_bidi_error_code(&error_code) { + return Err(invalid("error")); + } + 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")?; + 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, + 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 is_webdriver_bidi_error_code(value: &str) -> bool { + WEBDRIVER_BIDI_ERROR_CODES.contains(&value) +} + +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> { + // `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(()); + } + 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> { + // `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(()); + } + 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; + } + _ => { + // `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; + } + } + } + } + + 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) + }; + // `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(()) + } + + 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"]}"#, + ); + 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":[]}}"#, + ); + 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}"#); + 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] + 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}"#); + 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()); + } + } +} 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..593fa41ae --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_json_envelope_public_boundary_tests.rs @@ -0,0 +1,140 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; + +use crate::{ + 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,"slash":"\/","upper":"\uABCD"}}"#; + +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_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, document.len() as u8])?; + stream.write_all(document) + }); + + 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"))??; + 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) + ); + assert_eq!( + parsed.as_ref().map(WebDriverBiDiJsonEnvelope::command_id), + Ok(Some(7)) + ); + 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] = [ + 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(()) +} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_message.rs b/crates/originweave-network/src/webdriver_bidi_websocket_message.rs new file mode 100644 index 000000000..3ad3edc35 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_message.rs @@ -0,0 +1,554 @@ +use std::{error::Error, fmt}; + +use crate::{MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, WebDriverBiDiWebSocketFrame}; + +/// Maximum UTF-8 payload bytes admitted for one assembled WebDriver BiDi WebSocket message. +/// +/// The aggregate message bound intentionally matches the existing per-frame data bound so +/// fragmentation cannot be used to bypass the reviewed 1 MiB transport resource budget. +pub const MAX_WEBDRIVER_BIDI_MESSAGE_SIZE: usize = MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE; + +/// Semantic kind of one bounded RFC 6455 control frame observed between BiDi message fragments. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebDriverBiDiWebSocketControlKind { + /// The peer sent a Close control frame. + Close, + /// The peer sent a Ping control frame. + Ping, + /// The peer sent a Pong control frame. + Pong, +} + +/// One bounded WebSocket control message retained without exposing application text. +#[derive(Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketControlMessage { + kind: WebDriverBiDiWebSocketControlKind, + payload: Vec, +} + +impl fmt::Debug for WebDriverBiDiWebSocketControlMessage { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketControlMessage") + .field("kind", &self.kind) + .field("payload_bytes", &self.payload.len()) + .finish() + } +} + +impl WebDriverBiDiWebSocketControlMessage { + /// Return the exact control-frame kind. + #[must_use] + pub const fn kind(&self) -> WebDriverBiDiWebSocketControlKind { + self.kind + } + + /// Borrow the bounded control payload. + #[must_use] + pub fn payload(&self) -> &[u8] { + &self.payload + } +} + +/// One complete validated UTF-8 WebDriver BiDi WebSocket text message. +#[derive(Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketTextMessage(String); + +impl fmt::Debug for WebDriverBiDiWebSocketTextMessage { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketTextMessage") + .field("payload_bytes", &self.0.len()) + .finish() + } +} + +impl WebDriverBiDiWebSocketTextMessage { + /// Borrow the complete validated UTF-8 message text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Result of admitting one RFC 6455 frame into the bounded BiDi message assembler. +#[derive(Debug, Eq, PartialEq)] +pub enum WebDriverBiDiWebSocketMessageAssembly { + /// A fragmented text message is still incomplete. + Pending, + /// One complete UTF-8 text message is ready for the later BiDi JSON layer. + Text(WebDriverBiDiWebSocketTextMessage), + /// One RFC 6455 control message was observed without disturbing partial text state. + Control(WebDriverBiDiWebSocketControlMessage), +} + +/// Fail-closed semantic failures while assembling WebDriver BiDi WebSocket text messages. +#[derive(Debug, Eq, PartialEq)] +pub enum WebDriverBiDiWebSocketMessageError { + /// The assembler is terminal after a prior semantic failure or peer Close frame. + AssemblerPoisoned, + /// A continuation frame arrived without an active fragmented text message. + UnexpectedContinuation, + /// WebDriver BiDi requires text WebSocket messages; binary data is not admitted. + UnexpectedBinaryMessage, + /// A new text frame began before the active fragmented text message completed. + InterruptedFragmentedText, + /// Aggregate fragmented message bytes exceeded the reviewed resource bound. + MessageTooLarge { + /// Aggregate payload bytes that the attempted append would produce. + payload_bytes: usize, + /// Maximum aggregate payload bytes admitted by this boundary. + maximum_bytes: usize, + }, + /// The complete text message was not valid UTF-8. + InvalidTextUtf8, + /// A frame opcode escaped the lower RFC 6455 validation layer unexpectedly. + UnsupportedFrameOpcode { + /// Unexpected RFC 6455 opcode. + opcode: u8, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketMessageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AssemblerPoisoned => formatter.write_str( + "WebDriver BiDi WebSocket message assembly is terminal after failure or Close", + ), + Self::UnexpectedContinuation => formatter + .write_str("WebDriver BiDi WebSocket continuation arrived without fragmented text"), + Self::UnexpectedBinaryMessage => formatter.write_str( + "WebDriver BiDi requires WebSocket text messages; binary message rejected", + ), + Self::InterruptedFragmentedText => formatter + .write_str("WebDriver BiDi fragmented text was interrupted by a new data message"), + Self::MessageTooLarge { + payload_bytes, + maximum_bytes, + } => write!( + formatter, + "WebDriver BiDi WebSocket message has {payload_bytes} bytes; maximum is {maximum_bytes}" + ), + Self::InvalidTextUtf8 => { + formatter.write_str("WebDriver BiDi WebSocket text message is not valid UTF-8") + } + Self::UnsupportedFrameOpcode { opcode } => write!( + formatter, + "unexpected RFC 6455 opcode escaped frame validation: {opcode:#04x}" + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketMessageError {} + +/// Stateful bounded assembler for the text-message semantics required by WebDriver BiDi. +/// +/// The lower frame layer owns RFC 6455 framing and wire validation. This layer only joins text and +/// continuation payloads, preserves interleaved control frames, rejects binary messages, validates +/// UTF-8 after the complete message exists, and enforces an aggregate 1 MiB message budget. Any +/// semantic protocol failure makes the assembler terminal so callers cannot accidentally recover +/// authority from a corrupted message sequence. A peer Close frame is returned once and likewise +/// makes subsequent message assembly terminal. +pub struct WebDriverBiDiWebSocketMessageAssembler { + fragmented_text: Option>, + poisoned: bool, +} + +impl fmt::Debug for WebDriverBiDiWebSocketMessageAssembler { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketMessageAssembler") + .field( + "fragmented_payload_bytes", + &self.fragmented_text.as_ref().map_or(0, Vec::len), + ) + .field("terminal", &self.poisoned) + .finish() + } +} + +impl WebDriverBiDiWebSocketMessageAssembler { + /// Create one empty assembler with no inherited message state. + #[must_use] + pub const fn new() -> Self { + Self { + fragmented_text: None, + poisoned: false, + } + } + + /// Admit one already-validated RFC 6455 frame into the BiDi text-message state machine. + pub fn push_frame( + &mut self, + frame: WebDriverBiDiWebSocketFrame, + ) -> Result { + self.push_parts(frame.fin(), frame.opcode(), frame.payload()) + } + + fn push_parts( + &mut self, + fin: bool, + opcode: u8, + payload: &[u8], + ) -> Result { + if self.poisoned { + return Err(WebDriverBiDiWebSocketMessageError::AssemblerPoisoned); + } + match opcode { + 0x0 => self.push_continuation(fin, payload), + 0x1 => self.push_text(fin, payload), + 0x2 => self.reject(WebDriverBiDiWebSocketMessageError::UnexpectedBinaryMessage), + 0x8 => { + self.fragmented_text = None; + self.poisoned = true; + Ok(WebDriverBiDiWebSocketMessageAssembly::Control( + WebDriverBiDiWebSocketControlMessage { + kind: WebDriverBiDiWebSocketControlKind::Close, + payload: payload.to_vec(), + }, + )) + } + 0x9 => Ok(Self::control( + WebDriverBiDiWebSocketControlKind::Ping, + payload, + )), + 0xa => Ok(Self::control( + WebDriverBiDiWebSocketControlKind::Pong, + payload, + )), + _ => self.reject(WebDriverBiDiWebSocketMessageError::UnsupportedFrameOpcode { opcode }), + } + } + + fn push_text( + &mut self, + fin: bool, + payload: &[u8], + ) -> Result { + if self.fragmented_text.is_some() { + return self.reject(WebDriverBiDiWebSocketMessageError::InterruptedFragmentedText); + } + if payload.len() > MAX_WEBDRIVER_BIDI_MESSAGE_SIZE { + return self.reject(WebDriverBiDiWebSocketMessageError::MessageTooLarge { + payload_bytes: payload.len(), + maximum_bytes: MAX_WEBDRIVER_BIDI_MESSAGE_SIZE, + }); + } + if fin { + return Self::complete_text(payload.to_vec()) + .map(WebDriverBiDiWebSocketMessageAssembly::Text) + .inspect_err(|_| { + self.fragmented_text = None; + self.poisoned = true; + }); + } + self.fragmented_text = Some(payload.to_vec()); + Ok(WebDriverBiDiWebSocketMessageAssembly::Pending) + } + + fn push_continuation( + &mut self, + fin: bool, + payload: &[u8], + ) -> Result { + let Some(mut buffer) = self.fragmented_text.take() else { + return self.reject(WebDriverBiDiWebSocketMessageError::UnexpectedContinuation); + }; + let current_len = buffer.len(); + if payload.len() > MAX_WEBDRIVER_BIDI_MESSAGE_SIZE - current_len { + return self.reject(WebDriverBiDiWebSocketMessageError::MessageTooLarge { + payload_bytes: current_len.saturating_add(payload.len()), + maximum_bytes: MAX_WEBDRIVER_BIDI_MESSAGE_SIZE, + }); + } + buffer.extend_from_slice(payload); + if !fin { + self.fragmented_text = Some(buffer); + return Ok(WebDriverBiDiWebSocketMessageAssembly::Pending); + } + Self::complete_text(buffer) + .map(WebDriverBiDiWebSocketMessageAssembly::Text) + .inspect_err(|_| { + self.poisoned = true; + }) + } + + fn complete_text( + payload: Vec, + ) -> Result { + String::from_utf8(payload) + .map(WebDriverBiDiWebSocketTextMessage) + .map_err(|_| WebDriverBiDiWebSocketMessageError::InvalidTextUtf8) + } + + fn control( + kind: WebDriverBiDiWebSocketControlKind, + payload: &[u8], + ) -> WebDriverBiDiWebSocketMessageAssembly { + WebDriverBiDiWebSocketMessageAssembly::Control(WebDriverBiDiWebSocketControlMessage { + kind, + payload: payload.to_vec(), + }) + } + + fn reject( + &mut self, + error: WebDriverBiDiWebSocketMessageError, + ) -> Result { + self.fragmented_text = None; + self.poisoned = true; + Err(error) + } +} + +impl Default for WebDriverBiDiWebSocketMessageAssembler { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_message(value: &str) -> WebDriverBiDiWebSocketTextMessage { + WebDriverBiDiWebSocketTextMessage(value.to_owned()) + } + + fn control_message( + kind: WebDriverBiDiWebSocketControlKind, + payload: &[u8], + ) -> WebDriverBiDiWebSocketControlMessage { + WebDriverBiDiWebSocketControlMessage { + kind, + payload: payload.to_vec(), + } + } + + #[test] + fn complete_text_and_debug_are_payload_redacted() { + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::default(); + assert_eq!( + assembler.push_parts(true, 0x1, b"secret-text"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Text(text_message( + "secret-text" + ))) + ); + let message = text_message("secret-text"); + assert_eq!(message.as_str(), "secret-text"); + let message_debug = format!("{message:?}"); + assert!(message_debug.contains("payload_bytes: 11")); + assert!(!message_debug.contains("secret-text")); + let assembler_debug = format!("{assembler:?}"); + assert!(assembler_debug.contains("fragmented_payload_bytes: 0")); + assert!(assembler_debug.contains("terminal: false")); + } + + #[test] + fn fragments_reassemble_only_after_final_continuation() { + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + assert_eq!( + assembler.push_parts(false, 0x1, b"A\xe2"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Pending) + ); + assert_eq!( + assembler.push_parts(false, 0x0, b"\x82"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Pending) + ); + assert_eq!( + assembler.push_parts(true, 0x0, b"\xacB"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Text(text_message( + "A€B" + ))) + ); + } + + #[test] + fn ping_and_pong_preserve_fragmented_text_state() { + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + assert_eq!( + assembler.push_parts(false, 0x1, b"left-"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Pending) + ); + assert_eq!( + assembler.push_parts(true, 0x9, b"ping-data"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Control( + control_message(WebDriverBiDiWebSocketControlKind::Ping, b"ping-data") + )) + ); + let ping = control_message(WebDriverBiDiWebSocketControlKind::Ping, b"ping-data"); + assert_eq!(ping.kind(), WebDriverBiDiWebSocketControlKind::Ping); + assert_eq!(ping.payload(), b"ping-data"); + let ping_debug = format!("{ping:?}"); + assert!(ping_debug.contains("payload_bytes: 9")); + assert!(!ping_debug.contains("ping-data")); + + assert_eq!( + assembler.push_parts(true, 0xa, b"pong"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Control( + control_message(WebDriverBiDiWebSocketControlKind::Pong, b"pong") + )) + ); + let pong = control_message(WebDriverBiDiWebSocketControlKind::Pong, b"pong"); + assert_eq!(pong.kind(), WebDriverBiDiWebSocketControlKind::Pong); + assert_eq!(pong.payload(), b"pong"); + assert_eq!( + assembler.push_parts(true, 0x0, b"right"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Text(text_message( + "left-right" + ))) + ); + } + + #[test] + fn close_is_returned_once_and_makes_assembler_terminal() { + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + assert_eq!( + assembler.push_parts(false, 0x1, b"discarded"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Pending) + ); + assert_eq!( + assembler.push_parts(true, 0x8, b"bye"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Control( + control_message(WebDriverBiDiWebSocketControlKind::Close, b"bye") + )) + ); + let close = control_message(WebDriverBiDiWebSocketControlKind::Close, b"bye"); + assert_eq!(close.kind(), WebDriverBiDiWebSocketControlKind::Close); + assert_eq!(close.payload(), b"bye"); + assert_eq!( + assembler.push_parts(true, 0x1, b"later"), + Err(WebDriverBiDiWebSocketMessageError::AssemblerPoisoned) + ); + } + + #[test] + fn semantic_data_sequence_errors_fail_closed() { + let cases = [ + ( + 0x0, + WebDriverBiDiWebSocketMessageError::UnexpectedContinuation, + ), + ( + 0x2, + WebDriverBiDiWebSocketMessageError::UnexpectedBinaryMessage, + ), + ( + 0x3, + WebDriverBiDiWebSocketMessageError::UnsupportedFrameOpcode { opcode: 0x3 }, + ), + ]; + for (opcode, expected) in cases { + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + assert_eq!(assembler.push_parts(true, opcode, b"x"), Err(expected)); + assert_eq!( + assembler.push_parts(true, 0x1, b"later"), + Err(WebDriverBiDiWebSocketMessageError::AssemblerPoisoned) + ); + } + + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + assert_eq!( + assembler.push_parts(false, 0x1, b"partial"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Pending) + ); + assert_eq!( + assembler.push_parts(true, 0x1, b"new-message"), + Err(WebDriverBiDiWebSocketMessageError::InterruptedFragmentedText) + ); + } + + #[test] + fn aggregate_message_bound_rejects_fragmentation_bypass() { + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + let maximum = vec![b'x'; MAX_WEBDRIVER_BIDI_MESSAGE_SIZE]; + assert_eq!( + assembler.push_parts(false, 0x1, &maximum), + Ok(WebDriverBiDiWebSocketMessageAssembly::Pending) + ); + assert_eq!( + assembler.push_parts(true, 0x0, b"y"), + Err(WebDriverBiDiWebSocketMessageError::MessageTooLarge { + payload_bytes: MAX_WEBDRIVER_BIDI_MESSAGE_SIZE + 1, + maximum_bytes: MAX_WEBDRIVER_BIDI_MESSAGE_SIZE, + }) + ); + + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + let oversized = vec![b'x'; MAX_WEBDRIVER_BIDI_MESSAGE_SIZE + 1]; + assert_eq!( + assembler.push_parts(false, 0x1, &oversized), + Err(WebDriverBiDiWebSocketMessageError::MessageTooLarge { + payload_bytes: MAX_WEBDRIVER_BIDI_MESSAGE_SIZE + 1, + maximum_bytes: MAX_WEBDRIVER_BIDI_MESSAGE_SIZE, + }) + ); + } + + #[test] + fn utf8_validation_waits_for_complete_message_and_then_fails_closed() { + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + assert_eq!( + assembler.push_parts(true, 0x1, b"\xff"), + Err(WebDriverBiDiWebSocketMessageError::InvalidTextUtf8) + ); + assert_eq!( + assembler.push_parts(true, 0x1, b"later"), + Err(WebDriverBiDiWebSocketMessageError::AssemblerPoisoned) + ); + + let mut fragmented = WebDriverBiDiWebSocketMessageAssembler::new(); + assert_eq!( + fragmented.push_parts(false, 0x1, b"\xe2"), + Ok(WebDriverBiDiWebSocketMessageAssembly::Pending) + ); + assert_eq!( + fragmented.push_parts(true, 0x0, b"x"), + Err(WebDriverBiDiWebSocketMessageError::InvalidTextUtf8) + ); + } + + #[test] + fn public_error_contract_is_stable_and_source_free() { + let cases = [ + ( + WebDriverBiDiWebSocketMessageError::AssemblerPoisoned, + "WebDriver BiDi WebSocket message assembly is terminal after failure or Close", + ), + ( + WebDriverBiDiWebSocketMessageError::UnexpectedContinuation, + "WebDriver BiDi WebSocket continuation arrived without fragmented text", + ), + ( + WebDriverBiDiWebSocketMessageError::UnexpectedBinaryMessage, + "WebDriver BiDi requires WebSocket text messages; binary message rejected", + ), + ( + WebDriverBiDiWebSocketMessageError::InterruptedFragmentedText, + "WebDriver BiDi fragmented text was interrupted by a new data message", + ), + ( + WebDriverBiDiWebSocketMessageError::InvalidTextUtf8, + "WebDriver BiDi WebSocket text message is not valid UTF-8", + ), + ( + WebDriverBiDiWebSocketMessageError::UnsupportedFrameOpcode { opcode: 0x3 }, + "unexpected RFC 6455 opcode escaped frame validation: 0x03", + ), + ]; + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } + let too_large = WebDriverBiDiWebSocketMessageError::MessageTooLarge { + payload_bytes: 10, + maximum_bytes: 9, + }; + assert_eq!( + too_large.to_string(), + "WebDriver BiDi WebSocket message has 10 bytes; maximum is 9" + ); + assert!(too_large.source().is_none()); + } +} 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..41bb90524 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_json_envelope.rs @@ -0,0 +1,324 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + MAX_WEBDRIVER_BIDI_JSON_DEPTH, 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}}"#; + +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_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_classifies_error_and_event_envelopes() -> Result<(), Box> { + let cases: &[&[u8]] = &[ + br#"{"type":"error","id":null,"error":"unknown error","message":"","stacktrace":"hidden"}"#, + 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":{}}"#, + )?; + 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":[1,2],"number":-2.5e3}}"#, + br#"{"type":"success","id":1,"result":{"pair":"\ud83d\ude00","empty":[]}}"#, + r#"{"type":"success","id":1,"result":{"unicode":"é"}}"#.as_bytes(), + ]; + 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 "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"}}"#, + 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", + ]; + for document in cases { + assert_eq!( + parse_over_real_transport(document)?, + Err(WebDriverBiDiJsonEnvelopeError::InvalidJson) + ); + } + 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_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( + br#"{"type":"error","id":-1,"error":"invalid argument","message":"bad id"}"#, + )?; + assert_eq!( + error, + Err(WebDriverBiDiJsonEnvelopeError::InvalidMember { member: "id" }) + ); + Ok(()) +} + +#[test] +fn real_transport_rejects_non_protocol_error_code() -> 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(()) +} + +#[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(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_message_assembly.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_message_assembly.rs new file mode 100644 index 000000000..fed062dd3 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_message_assembly.rs @@ -0,0 +1,192 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketControlKind, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiWebSocketMessageError, +}; + +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 connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn establish( + endpoint: &str, +) -> Result> { + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + Ok(written.read_opening_response(Duration::from_millis(500))?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn write_server_frame( + stream: &mut TcpStream, + fin: bool, + opcode: u8, + payload: &[u8], +) -> io::Result<()> { + let first = if fin { 0x80 | opcode } else { opcode }; + stream.write_all(&[first])?; + match payload.len() { + 0..=125 => stream.write_all(&[payload.len() as u8])?, + 126..=65_535 => { + stream.write_all(&[126])?; + stream.write_all(&(payload.len() as u16).to_be_bytes())?; + } + _ => { + stream.write_all(&[127])?; + stream.write_all(&(payload.len() as u64).to_be_bytes())?; + } + } + stream.write_all(payload) +} + +#[test] +fn message_assembler_reassembles_split_utf8_around_interleaved_ping() -> 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)?; + write_server_frame(&mut stream, false, 0x1, b"A\xe2")?; + write_server_frame(&mut stream, true, 0x9, b"probe")?; + write_server_frame(&mut stream, true, 0x0, b"\x82\xacB") + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let established = establish(&endpoint)?; + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + + let (established, first) = established.read_frame(Duration::from_millis(500))?; + assert!(matches!( + assembler.push_frame(first)?, + WebDriverBiDiWebSocketMessageAssembly::Pending + )); + + let (established, ping) = established.read_frame(Duration::from_millis(500))?; + let control = match assembler.push_frame(ping)? { + WebDriverBiDiWebSocketMessageAssembly::Control(control) => control, + other => { + return Err(io::Error::other(format!( + "interleaved Ping produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + assert_eq!(control.kind(), WebDriverBiDiWebSocketControlKind::Ping); + assert_eq!(control.payload(), b"probe"); + + let (_established, continuation) = established.read_frame(Duration::from_millis(500))?; + let text = match assembler.push_frame(continuation)? { + WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + other => { + return Err(io::Error::other(format!( + "final continuation produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + assert_eq!(text.as_str(), "A€B"); + + server + .join() + .map_err(|_| io::Error::other("fragmented-message server panicked"))??; + Ok(()) +} + +#[test] +fn message_assembler_fails_closed_after_binary_message() -> 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)?; + write_server_frame(&mut stream, true, 0x2, b"binary")?; + write_server_frame(&mut stream, true, 0x1, b"later-text") + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let established = establish(&endpoint)?; + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + + let (established, binary) = established.read_frame(Duration::from_millis(500))?; + assert!(matches!( + assembler.push_frame(binary), + Err(WebDriverBiDiWebSocketMessageError::UnexpectedBinaryMessage) + )); + + let (_established, later_text) = established.read_frame(Duration::from_millis(500))?; + assert!(matches!( + assembler.push_frame(later_text), + Err(WebDriverBiDiWebSocketMessageError::AssemblerPoisoned) + )); + + server + .join() + .map_err(|_| io::Error::other("binary-message server panicked"))??; + Ok(()) +} + +#[test] +fn message_assembler_rejects_continuation_without_text_start() -> 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)?; + write_server_frame(&mut stream, true, 0x0, b"orphan") + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let established = establish(&endpoint)?; + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + let (_established, continuation) = established.read_frame(Duration::from_millis(500))?; + assert!(matches!( + assembler.push_frame(continuation), + Err(WebDriverBiDiWebSocketMessageError::UnexpectedContinuation) + )); + + server + .join() + .map_err(|_| io::Error::other("orphan-continuation server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_message_public_contract.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_message_public_contract.rs new file mode 100644 index 000000000..78b8b671a --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_message_public_contract.rs @@ -0,0 +1,76 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + 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(()) +} + +#[test] +fn public_message_admission_consumes_real_validated_frame() -> 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, 0x02, b'o', b'k']) + }); + + 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()); + } + }; + assert_eq!(text.as_str(), "ok"); + + server + .join() + .map_err(|_| io::Error::other("validated-frame server panicked"))??; + Ok(()) +} diff --git a/docs/doctoring.md b/docs/doctoring.md index 23fa97613..f31d37605 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -106,6 +106,8 @@ The owning opening-response PR #242 already repaired that fixture at `17754d717b ### Opening-exchange fixture lifetime +PR #246 adopts frame-transport parent `97fab641ed9d76e6c515eadcef0629edfc8064a3` by ordinary merge. Its message-assembly and JSON-envelope implementation/test blobs remain identical to predecessor `b87191bcb6a95dfd7e0ed234e600639a1093c43a`, while the inherited opening-exchange fixtures match the corrected parent. Fifty pre-integration fixture-suite runs passed in this invocation, so no fresh failure rate or new reproduction is claimed; the missing parent ancestry and earlier recorded failures establish why the existing repair must propagate. The raw assembler remains a bounded protocol-data boundary, not received-connection provenance or browser authority. + On 5 September 2026, a complete Rust run after integrating PR #242 head `55fef0c3fae1724eddada53e52c4a0311f509aa3` into #243 reproduced `WriteTimeoutCleanupFailed` with macOS `EINVAL` after 198 request bytes in `opening_response_rejects_a_mismatched_accept_value`. That fixture returned its invalid response and closed immediately, before the client could finish opening-write cleanup. The successful-handshake fixture's one-byte close probe could consume the first request byte rather than observe closure, and the request-only fixture also closed immediately after reading the request. All three paths therefore shared a premature peer-lifetime assumption; the previously repaired invalid-deadline fixture did not cover them. The test-only `serve_opening_exchange` helper reuses the bounded request reader, reads the complete request before sending the configured response, and retains the accepted stream until the client explicitly releases it after its assertions. Successful, mismatched-accept, and request-only tests all use that helper. No sleep, retry-based acceptance, production timeout change, ignored cleanup error, dependency, or coverage exclusion is introduced. Existing real-socket assertions remain the regression checks, including the requirement that an invalid accept value reaches `AcceptMismatch` rather than an earlier fixture-induced transport failure. Descendant stacks must adopt the owner fix and rerun their own gates; the reproduced failure and this fixture repair do not imply protected-main or browser-runtime delivery. diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index dbf3ef731..0795642a4 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-05 - **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 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. -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 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 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). @@ -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,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 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +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, July 20). *WebDriver BiDi* (Editor’s Draft). 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/ 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()