diff --git a/CHANGELOG.md b/CHANGELOG.md index 8afb9ec7c..5598829e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. +- Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. +- Explicit no-DNS WebDriver BiDi loopback connection targets that derive exact IPv4/IPv6 loopback `SocketAddr` metadata from a session-correlated endpoint, reject `localhost` as requiring separately trusted name resolution, preserve the TLS requirement and exact session id, perform no socket I/O, and grant no Agent authority. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Versioned browser-protocol adapter metadata that distinguishes WebDriver BiDi from pinned CDP, binds bounded adapter/browser revision tokens to an explicit duplicate-free capability set, normalizes capability-set identity independently of caller ordering, and exposes typed fail-closed capability requirements without granting browser, action, network, or secret authority by protocol kind alone. - Canonical OriginWeave protocol-version parsing for exact `originweave/.` syntax, with typed fail-closed rejection of malformed, ambiguous, overflowed, or noncanonical serialized generations; parsing does not negotiate compatibility or grant adapter authority. @@ -19,6 +22,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - Fail-closed WebDriver BiDi `locateNodes` response correlation that consumes the exact serialized command, rejects out-of-range or mismatched response `id` values, and returns non-cloneable correlation evidence carrying only the matched command identifier and browsing context without parsing the response or granting browser/Agent authority. - Typed WebDriver BiDi response-envelope correlation that preserves success/error classification, rejects success responses with absent ids, treats nullable error ids as explicitly uncorrelatable, and prevents correlated error envelopes from becoming success correlation evidence without raw JSON parsing or browser/Agent authority grants. - Bounded raw WebDriver BiDi response-document admission before JSON parsing, with a 65,536-byte product safety budget, exact wire-text retention, JSON-whitespace-aware top-level object-boundary checks, and typed fail-closed errors; this coarse boundary deliberately does not claim JSON validity, response correlation, browser authenticity, or Agent authority. +- One consuming bounded WebDriver BiDi response-document correlation boundary that parses the exact admitted JSON document and feeds only its typed response kind and protocol-range id into the existing exact command-correlation gate, preserving nested parser/correlation error sources without authenticating transport or granting browser/Agent authority. +- Consuming WebDriver BiDi WebSocket endpoint/session correlation that validates one caller-supplied canonical session UUID and rejects exact session mismatches before later transport use; the correlated type preserves only bounded endpoint metadata and does not authenticate Chromium, ChromeDriver, the caller, or the socket peer. - Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, rejecting control and whitespace so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. - Same-call `locateNodes` result admission that revalidates the exact current session, browsing context, canonical origin, and document epoch, rejects an over-budget or non-node result, and translates each admitted `sharedId` through the authority registry into an `ObservedNodeHandle` without performing browser I/O. - Same-call QueryNodes admission that transfers a non-cloneable SemanticObservation protocol-use proof by ownership into `bind_current_nodes` before an untrusted `locateNodes` result can become current `ObservedNodeHandle` values, so Navigation-only or TypedInput-only proofs cannot mint observation handles. diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index fb43a48b9..b9d69c8b0 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -32,8 +32,13 @@ mod browser_registry; mod browser_registry_coverage; mod contracts; mod webdriver_bidi_command; +mod webdriver_bidi_error_code; mod webdriver_bidi_response_document; +mod webdriver_bidi_response_document_correlation; +mod webdriver_bidi_response_envelope; mod webdriver_bidi_result; +mod webdriver_bidi_websocket_connect_target; +mod webdriver_bidi_websocket_endpoint; pub use browser_authority_registry::BrowserAuthorityRegistry; pub use browser_protocol::{ @@ -71,10 +76,25 @@ pub use webdriver_bidi_command::{ WebDriverBiDiLocateNodesResponseCorrelationError, WebDriverBiDiLocateNodesResponseEnvelopeError, }; +pub use webdriver_bidi_error_code::WebDriverBiDiErrorCode; pub use webdriver_bidi_response_document::{ BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, WebDriverBiDiResponseDocumentAdmissionError, }; +pub use webdriver_bidi_response_document_correlation::WebDriverBiDiLocateNodesResponseDocumentError; +pub use webdriver_bidi_response_envelope::{ + MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS, + ParsedWebDriverBiDiCommandResponseEnvelope, WebDriverBiDiResponseEnvelopeParseError, +}; pub use webdriver_bidi_result::{ ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; +pub use webdriver_bidi_websocket_connect_target::{ + VerifiedWebDriverBiDiSocketPeer, WebDriverBiDiSocketPeerVerificationError, + WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketConnectTargetError, +}; +pub use webdriver_bidi_websocket_endpoint::{ + CorrelatedWebDriverBiDiWebSocketEndpoint, MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES, + WebDriverBiDiWebSocketEndpoint, WebDriverBiDiWebSocketEndpointAdmissionError, + WebDriverBiDiWebSocketEndpointCorrelationError, +}; diff --git a/crates/originweave-core/src/webdriver_bidi_error_code.rs b/crates/originweave-core/src/webdriver_bidi_error_code.rs new file mode 100644 index 000000000..bd336cf2d --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_error_code.rs @@ -0,0 +1,170 @@ +/// Typed current WebDriver BiDi protocol error code retained from one validated error response. +/// +/// This vocabulary is deliberately closed over the protocol error codes reviewed by OriginWeave. +/// Unknown wire text remains fail-closed and cannot become typed protocol evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiErrorCode { + /// The command or one of its arguments is invalid. + InvalidArgument, + /// A selector argument is invalid. + InvalidSelector, + /// The referenced browser session does not exist. + InvalidSessionId, + /// The referenced web extension is invalid. + InvalidWebExtension, + /// A requested pointer move target is outside the allowed bounds. + MoveTargetOutOfBounds, + /// The referenced user prompt does not exist. + NoSuchAlert, + /// The referenced client window does not exist. + NoSuchClientWindow, + /// The referenced network collector does not exist. + NoSuchNetworkCollector, + /// The referenced element does not exist. + NoSuchElement, + /// The referenced frame does not exist. + NoSuchFrame, + /// The referenced handle does not exist. + NoSuchHandle, + /// The referenced history entry does not exist. + NoSuchHistoryEntry, + /// The referenced network intercept does not exist. + NoSuchIntercept, + /// The requested network data does not exist. + NoSuchNetworkData, + /// The referenced node does not exist. + NoSuchNode, + /// The referenced network request does not exist. + NoSuchRequest, + /// The referenced screencast does not exist. + NoSuchScreencast, + /// The referenced script does not exist. + NoSuchScript, + /// The referenced storage partition does not exist. + NoSuchStoragePartition, + /// The referenced user context does not exist. + NoSuchUserContext, + /// The referenced web extension does not exist. + NoSuchWebExtension, + /// A browser session could not be created. + SessionNotCreated, + /// The browser could not capture the requested screen image. + UnableToCaptureScreen, + /// The browser could not close as requested. + UnableToCloseBrowser, + /// The browser could not set the requested cookie. + UnableToSetCookie, + /// The browser could not set the requested file input. + UnableToSetFileInput, + /// Requested network data is temporarily unavailable. + UnavailableNetworkData, + /// The supplied storage-partition descriptor is underspecified. + UnderspecifiedStoragePartition, + /// The command is unknown to the remote end. + UnknownCommand, + /// The remote end reported an otherwise unclassified protocol error. + UnknownError, + /// The requested operation is unsupported by the remote end. + UnsupportedOperation, +} + +/// Parse one exact decoded WebDriver BiDi `ErrorCode` value into typed protocol evidence. +pub(crate) fn parse_webdriver_bidi_error_code(value: &[u8]) -> Option { + const ERROR_CODES: &[(&[u8], WebDriverBiDiErrorCode)] = &[ + (b"invalid argument", WebDriverBiDiErrorCode::InvalidArgument), + (b"invalid selector", WebDriverBiDiErrorCode::InvalidSelector), + ( + b"invalid session id", + WebDriverBiDiErrorCode::InvalidSessionId, + ), + ( + b"invalid web extension", + WebDriverBiDiErrorCode::InvalidWebExtension, + ), + ( + b"move target out of bounds", + WebDriverBiDiErrorCode::MoveTargetOutOfBounds, + ), + (b"no such alert", WebDriverBiDiErrorCode::NoSuchAlert), + ( + b"no such client window", + WebDriverBiDiErrorCode::NoSuchClientWindow, + ), + ( + b"no such network collector", + WebDriverBiDiErrorCode::NoSuchNetworkCollector, + ), + (b"no such element", WebDriverBiDiErrorCode::NoSuchElement), + (b"no such frame", WebDriverBiDiErrorCode::NoSuchFrame), + (b"no such handle", WebDriverBiDiErrorCode::NoSuchHandle), + ( + b"no such history entry", + WebDriverBiDiErrorCode::NoSuchHistoryEntry, + ), + ( + b"no such intercept", + WebDriverBiDiErrorCode::NoSuchIntercept, + ), + ( + b"no such network data", + WebDriverBiDiErrorCode::NoSuchNetworkData, + ), + (b"no such node", WebDriverBiDiErrorCode::NoSuchNode), + (b"no such request", WebDriverBiDiErrorCode::NoSuchRequest), + ( + b"no such screencast", + WebDriverBiDiErrorCode::NoSuchScreencast, + ), + (b"no such script", WebDriverBiDiErrorCode::NoSuchScript), + ( + b"no such storage partition", + WebDriverBiDiErrorCode::NoSuchStoragePartition, + ), + ( + b"no such user context", + WebDriverBiDiErrorCode::NoSuchUserContext, + ), + ( + b"no such web extension", + WebDriverBiDiErrorCode::NoSuchWebExtension, + ), + ( + b"session not created", + WebDriverBiDiErrorCode::SessionNotCreated, + ), + ( + b"unable to capture screen", + WebDriverBiDiErrorCode::UnableToCaptureScreen, + ), + ( + b"unable to close browser", + WebDriverBiDiErrorCode::UnableToCloseBrowser, + ), + ( + b"unable to set cookie", + WebDriverBiDiErrorCode::UnableToSetCookie, + ), + ( + b"unable to set file input", + WebDriverBiDiErrorCode::UnableToSetFileInput, + ), + ( + b"unavailable network data", + WebDriverBiDiErrorCode::UnavailableNetworkData, + ), + ( + b"underspecified storage partition", + WebDriverBiDiErrorCode::UnderspecifiedStoragePartition, + ), + (b"unknown command", WebDriverBiDiErrorCode::UnknownCommand), + (b"unknown error", WebDriverBiDiErrorCode::UnknownError), + ( + b"unsupported operation", + WebDriverBiDiErrorCode::UnsupportedOperation, + ), + ]; + + ERROR_CODES + .iter() + .find_map(|(raw, code)| (*raw == value).then_some(*code)) +} diff --git a/crates/originweave-core/src/webdriver_bidi_response_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document.rs index 4eacf84cb..3bb7a42c4 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document.rs @@ -15,6 +15,8 @@ pub enum WebDriverBiDiResponseDocumentAdmissionError { EmptyDocument, /// The raw response exceeds the OriginWeave pre-parser byte budget. DocumentTooLarge, + /// The raw response is not valid UTF-8. + InvalidUtf8, /// The first and last non-whitespace bytes do not delimit a JSON object. InvalidObjectBoundary, } @@ -27,6 +29,9 @@ impl fmt::Display for WebDriverBiDiResponseDocumentAdmissionError { formatter, "WebDriver BiDi response document exceeds {MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES} bytes" ), + Self::InvalidUtf8 => { + formatter.write_str("WebDriver BiDi response document is not valid UTF-8") + } Self::InvalidObjectBoundary => formatter.write_str( "WebDriver BiDi response document must have a top-level JSON object boundary", ), @@ -68,6 +73,23 @@ impl BoundedWebDriverBiDiResponseDocument { }) } + /// Admits raw transport bytes after bounding them and validating UTF-8. + /// + /// The byte budget is checked before UTF-8 validation or owned-text + /// allocation. This keeps hostile transport payloads outside the parser + /// boundary until both the resource and text-encoding contracts hold. + pub fn from_utf8_bytes( + raw: &[u8], + ) -> Result { + if raw.len() > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES { + return Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge); + } + + let raw = std::str::from_utf8(raw) + .map_err(|_| WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8)?; + Self::new(raw) + } + /// Returns the exact admitted response text, including surrounding JSON whitespace. #[must_use] pub fn as_str(&self) -> &str { diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs new file mode 100644 index 000000000..068bdced5 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -0,0 +1,269 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +mod locate_nodes_result_document; + +use crate::webdriver_bidi_command::{ + CorrelatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseEnvelopeError, +}; +use crate::webdriver_bidi_response_document::BoundedWebDriverBiDiResponseDocument; +use crate::webdriver_bidi_response_envelope::WebDriverBiDiResponseEnvelopeParseError; +use crate::webdriver_bidi_result::{ + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, +}; +use crate::{ + BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, + ValidatedBrowserProtocolUse, WebDriverBiDiErrorCode, WebDriverBiDiLocateNodesAdmissionError, +}; + +/// Fail-closed errors while parsing, correlating, classifying, and admitting one bounded +/// WebDriver BiDi `locateNodes` response document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResponseDocumentError { + /// The bounded document failed complete WebDriver BiDi response-envelope parsing. + Parse(WebDriverBiDiResponseEnvelopeParseError), + /// The parsed envelope failed exact command correlation or success-only conversion. + Envelope(WebDriverBiDiLocateNodesResponseEnvelopeError), + /// The exactly correlated remote end returned a typed WebDriver BiDi protocol error. + ProtocolError(WebDriverBiDiErrorCode), + /// The correlated success result omitted its required `nodes` field. + MissingResultNodes, + /// The correlated success result's `nodes` field was not a JSON array. + InvalidResultNodes, + /// The correlated success result repeated the decoded `nodes` field. + DuplicateResultNodes, + /// One in-budget `nodes` array item was not a JSON object. + InvalidResultNode, + /// One in-budget node object repeated decoded `type` or `sharedId` authority-relevant metadata. + DuplicateResultNodeField, + /// One in-budget node object omitted its required WebDriver BiDi remote-value `type` field. + MissingResultNodeType, + /// One in-budget node object's `type` field was not a JSON string. + InvalidResultNodeType, + /// One present in-budget node `sharedId` field was not a JSON string. + InvalidResultNodeSharedId, + /// Exact command-budget or remote-node admission rejected the wire-derived node batch. + ResultAdmission(WebDriverBiDiLocateNodesResultAdmissionError), + /// Exact current browser authority rejected the wire-derived node batch. + NodeBinding(WebDriverBiDiLocateNodesAdmissionError), + /// A second-pass result parser invariant failed after complete envelope parsing succeeded. + ResultParserInvariant, +} + +impl Display for WebDriverBiDiLocateNodesResponseDocumentError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Parse(error) => write!( + formatter, + "WebDriver BiDi response document rejected envelope parsing: {error}" + ), + Self::Envelope(error) => write!( + formatter, + "WebDriver BiDi response document rejected command correlation: {error}" + ), + Self::ProtocolError(_) => formatter.write_str( + "WebDriver BiDi response document contains a correlated typed protocol error", + ), + Self::MissingResultNodes => { + formatter.write_str("WebDriver BiDi locateNodes result is missing its nodes field") + } + Self::InvalidResultNodes => formatter + .write_str("WebDriver BiDi locateNodes result nodes field is not a JSON array"), + Self::DuplicateResultNodes => formatter.write_str( + "WebDriver BiDi locateNodes result contains duplicate decoded nodes fields", + ), + Self::InvalidResultNode => formatter + .write_str("WebDriver BiDi locateNodes result contains a non-object node item"), + Self::DuplicateResultNodeField => formatter.write_str( + "WebDriver BiDi locateNodes node contains duplicate authority-relevant fields", + ), + Self::MissingResultNodeType => formatter + .write_str("WebDriver BiDi locateNodes node is missing its remote-value type"), + Self::InvalidResultNodeType => formatter + .write_str("WebDriver BiDi locateNodes node type is not a JSON string"), + Self::InvalidResultNodeSharedId => formatter + .write_str("WebDriver BiDi locateNodes node sharedId is not a JSON string"), + Self::ResultAdmission(error) => write!( + formatter, + "WebDriver BiDi locateNodes wire result rejected node admission: {error}" + ), + Self::NodeBinding(error) => write!( + formatter, + "WebDriver BiDi locateNodes wire result rejected current browser authority: {error}" + ), + Self::ResultParserInvariant => formatter.write_str( + "WebDriver BiDi locateNodes result parser invariant failed after envelope validation", + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResponseDocumentError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Parse(error) => Some(error), + Self::Envelope(error) => Some(error), + Self::ResultAdmission(error) => Some(error), + Self::NodeBinding(error) => Some(error), + Self::ProtocolError(_) + | Self::MissingResultNodes + | Self::InvalidResultNodes + | Self::DuplicateResultNodes + | Self::InvalidResultNode + | Self::DuplicateResultNodeField + | Self::MissingResultNodeType + | Self::InvalidResultNodeType + | Self::InvalidResultNodeSharedId + | Self::ResultParserInvariant => None, + } + } +} + +impl WebDriverBiDiLocateNodesCommand { + /// Consume this command and one bounded raw response through parsing and exact correlation. + /// + /// The document must first pass complete response-envelope parsing. Only the resulting typed + /// response kind and protocol-range response id are then admitted to the existing exact command + /// correlation boundary. Parser and correlation failures remain distinguishable and preserve + /// their causal error sources. This boundary does not authenticate Chromium, ChromeDriver, or + /// WebSocket transport provenance, validate `locateNodes` result nodes, mint node authority, + /// authorize an Agent action, execute browser input, or prove a post-condition. + pub fn correlate_response_document( + self, + document: BoundedWebDriverBiDiResponseDocument, + ) -> Result< + CorrelatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseDocumentError, + > { + let parsed = document + .parse_command_response() + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Parse)?; + self.correlate_response_envelope(parsed.kind(), parsed.response_id()) + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope) + } + + /// Consume one bounded raw `locateNodes` response through exact wire-derived node admission. + /// + /// The same bounded document first passes the complete response-envelope parser and exact + /// command-id correlation. A parsed error with JSON `null` id remains uncorrelatable and fails + /// closed before its typed error code can influence recovery. An exactly correlated protocol + /// error retains its reviewed typed error code and stops before result admission; unknown + /// error-code text still fails closed during complete envelope parsing. Only a correlated + /// success proceeds to the result parser, which derives the exact `result.nodes` array from that + /// already-validated wire document. The command's exact `maxNodeCount` is carried into this + /// parser so overflow items are consumed only as generic JSON and produce the existing + /// result-budget failure before authority-relevant node metadata is decoded or normalized. + /// Decoded duplicate `nodes`, and duplicate or malformed in-budget `type`/`sharedId` fields, + /// fail closed. JSON-escaped protocol metadata is decoded before admission, and callers cannot + /// supply replacement node metadata to this method. + /// + /// Success remains untrusted transport evidence. It does not authenticate Chromium, + /// ChromeDriver, WebSocket/TLS provenance, or an adapter process; prove current + /// session/context/origin/document authority; mint OriginWeave node handles; authorize policy + /// or typed input; execute browser I/O; or prove a post-condition. + pub fn admit_response_document_nodes( + self, + document: BoundedWebDriverBiDiResponseDocument, + ) -> Result< + ValidatedWebDriverBiDiLocateNodesResult, + WebDriverBiDiLocateNodesResponseDocumentError, + > { + let parsed = document + .parse_command_response() + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Parse)?; + let response_id = match parsed.response_id() { + Some(response_id) => response_id, + None => { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + )); + } + }; + let validated = self.correlate_response_id(response_id).map_err(|error| { + WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation(error), + ) + })?; + if let Some(error_code) = parsed.error_code() { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError(error_code)); + } + let wire_nodes = locate_nodes_result_document::parse_wire_locate_nodes_result_bounded( + parsed.as_str(), + validated.max_node_count(), + )?; + let admission_parts = wire_nodes + .iter() + .map(locate_nodes_result_document::WireLocateNodesNode::as_admission_parts) + .collect::>(); + validated + .admit_result_nodes(&admission_parts) + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission) + } + + /// Consume one bounded raw `locateNodes` response through wire admission and current authority. + /// + /// This is the direct composition boundary from the exact parsed wire document to current + /// OriginWeave node authority. The caller cannot replace the response kind, response id, result + /// nodes, browsing-context identifier, or command result budget between wire parsing and node + /// binding. After wire-derived admission succeeds, the supplied WebDriver BiDi + /// `SemanticObservation` proof and exact current session/context/origin/document epoch are + /// revalidated by [`ValidatedWebDriverBiDiLocateNodesResult::bind_current_nodes`]. + /// + /// Success mints only [`ObservedNodeHandle`] values. It still does not authenticate Chromium, + /// ChromeDriver, WebSocket/TLS provenance, or the adapter process; authorize policy or typed + /// input; execute browser I/O; or prove an action post-condition. + pub fn bind_response_document_nodes( + self, + document: BoundedWebDriverBiDiResponseDocument, + validated: ValidatedBrowserProtocolUse, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + self.admit_response_document_nodes(document)? + .bind_current_nodes(validated, authority_registry, target) + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding) + } +} + +#[cfg(test)] +mod tests { + use super::WebDriverBiDiLocateNodesResponseDocumentError; + use super::locate_nodes_result_document::{ + parse_wire_locate_nodes_result, parse_wire_locate_nodes_result_bounded, + }; + + #[test] + fn wire_node_admission_parts_preserve_wire_derived_metadata() { + let nodes = parse_wire_locate_nodes_result(concat!( + "{\"result\":{\"nodes\":[", + "{\"type\":\"node\",\"sharedId\":\"shared-1\"},", + "{\"type\":\"window\"}", + "]}}" + )) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].as_admission_parts(), ("node", Some("shared-1"))); + assert_eq!(nodes[1].as_admission_parts(), ("window", None)); + } + + #[test] + fn bounded_overflow_parser_preserves_invalid_generic_json_invariant() { + let result = parse_wire_locate_nodes_result_bounded( + concat!( + "{\"result\":{\"nodes\":[", + "{\"type\":\"node\",\"sharedId\":\"shared-1\"},", + "?]}}" + ), + 1, + ); + + assert_eq!( + result.err(), + Some(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant) + ); + } +} diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs new file mode 100644 index 000000000..409d0f6f8 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs @@ -0,0 +1,714 @@ +use super::WebDriverBiDiLocateNodesResponseDocumentError; + +pub(super) struct WireLocateNodesNode { + remote_type: String, + shared_id: Option, +} + +impl WireLocateNodesNode { + pub(super) fn as_admission_parts(&self) -> (&str, Option<&str>) { + (self.remote_type.as_str(), self.shared_id.as_deref()) + } + + fn overflow_count_marker() -> Self { + Self { + remote_type: String::new(), + shared_id: None, + } + } +} + +#[cfg(test)] +pub(super) fn parse_wire_locate_nodes_result( + input: &str, +) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + ResultParser::new(input).parse() +} + +pub(super) fn parse_wire_locate_nodes_result_bounded( + input: &str, + max_node_count: u16, +) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + ResultParser::with_node_budget(input, usize::from(max_node_count)).parse() +} + +struct ResultParser<'input> { + input: &'input str, + position: usize, + max_node_count: Option, +} + +impl<'input> ResultParser<'input> { + #[cfg(test)] + const fn new(input: &'input str) -> Self { + Self { + input, + position: 0, + max_node_count: None, + } + } + + const fn with_node_budget(input: &'input str, max_node_count: usize) -> Self { + Self { + input, + position: 0, + max_node_count: Some(max_node_count), + } + } + + fn parse( + mut self, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + self.skip_whitespace(); + self.expect_byte(b'{')?; + self.skip_whitespace(); + + loop { + if self.peek_byte() == Some(b'}') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + let field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + if field_name == "result" { + return self.parse_result_object(); + } + self.skip_value()?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn parse_result_object( + &mut self, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + if self.peek_byte() != Some(b'{') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes); + } + self.position += 1; + self.skip_whitespace(); + let mut nodes = None; + + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Err(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes); + } + + loop { + let field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + if field_name == "nodes" { + if nodes.is_some() { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes, + ); + } + nodes = Some(self.parse_nodes_array()?); + } else { + self.skip_value()?; + } + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + break; + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + + nodes.ok_or(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes) + } + + fn parse_nodes_array( + &mut self, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + if self.peek_byte() != Some(b'[') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes); + } + self.position += 1; + self.skip_whitespace(); + let mut nodes = Vec::new(); + let mut over_budget = false; + if self.peek_byte() == Some(b']') { + self.position += 1; + return Ok(nodes); + } + + loop { + let at_node_budget = self + .max_node_count + .is_some_and(|max_node_count| nodes.len() >= max_node_count); + if over_budget || at_node_budget { + self.skip_value()?; + if !over_budget { + nodes.push(WireLocateNodesNode::overflow_count_marker()); + over_budget = true; + } + } else { + nodes.push(self.parse_node()?); + } + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b']') => { + self.position += 1; + return Ok(nodes); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn parse_node( + &mut self, + ) -> Result { + if self.peek_byte() != Some(b'{') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNode); + } + self.position += 1; + self.skip_whitespace(); + let mut remote_type = None; + let mut shared_id = None; + let mut shared_id_seen = false; + + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Err(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType); + } + + loop { + let field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + match field_name.as_str() { + "type" => { + if remote_type.is_some() { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ); + } + if self.peek_byte() != Some(b'"') { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeType, + ); + } + remote_type = Some(self.parse_string()?); + } + "sharedId" => { + if shared_id_seen { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ); + } + shared_id_seen = true; + if self.peek_byte() != Some(b'"') { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeSharedId, + ); + } + shared_id = Some(self.parse_string()?); + } + _ => self.skip_value()?, + } + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + break; + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + + Ok(WireLocateNodesNode { + remote_type: remote_type + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType)?, + shared_id, + }) + } + + fn skip_value(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + match self.peek_byte() { + Some(b'{') => self.skip_object(), + Some(b'[') => self.skip_array(), + Some(b'"') => { + let _value = self.parse_string()?; + Ok(()) + } + Some(b'-' | b'0'..=b'9') => self.skip_number(), + Some(b't') => self.skip_literal(b"true"), + Some(b'f') => self.skip_literal(b"false"), + Some(b'n') => self.skip_literal(b"null"), + _ => Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant), + } + } + + fn skip_object(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + self.expect_byte(b'{')?; + self.skip_whitespace(); + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Ok(()); + } + loop { + let _field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + self.skip_value()?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + return Ok(()); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn skip_array(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + self.expect_byte(b'[')?; + self.skip_whitespace(); + if self.peek_byte() == Some(b']') { + self.position += 1; + return Ok(()); + } + loop { + self.skip_value()?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b']') => { + self.position += 1; + return Ok(()); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn skip_number(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + let start = self.position; + while let Some(byte) = self.peek_byte() { + if matches!(byte, b',' | b']' | b'}' | b' ' | b'\t' | b'\r' | b'\n') { + break; + } + self.position += 1; + } + if self.position == start { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + Ok(()) + } + + fn skip_literal( + &mut self, + literal: &[u8], + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + for expected in literal { + self.expect_byte(*expected)?; + } + Ok(()) + } + + fn parse_string(&mut self) -> Result { + self.expect_byte(b'"')?; + let mut decoded = String::new(); + let mut literal_start = self.position; + loop { + let byte = self + .peek_byte() + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + match byte { + b'"' => { + decoded.push_str(&self.input[literal_start..self.position]); + self.position += 1; + return Ok(decoded); + } + b'\\' => { + decoded.push_str(&self.input[literal_start..self.position]); + self.position += 1; + self.parse_escape(&mut decoded)?; + literal_start = self.position; + } + 0x00..=0x1f => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + _ => { + self.position += 1; + } + } + } + } + + fn parse_escape( + &mut self, + decoded: &mut String, + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + let escaped = self + .peek_byte() + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + self.position += 1; + match escaped { + b'"' => decoded.push('"'), + b'\\' => decoded.push('\\'), + b'/' => decoded.push('/'), + b'b' => decoded.push('\u{0008}'), + b'f' => decoded.push('\u{000c}'), + b'n' => decoded.push('\n'), + b'r' => decoded.push('\r'), + b't' => decoded.push('\t'), + b'u' => self.parse_unicode_escape(decoded)?, + _ => return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant), + } + Ok(()) + } + + fn parse_unicode_escape( + &mut self, + decoded: &mut String, + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + let first = self.parse_hex_quad()?; + let scalar = if (0xd800..=0xdbff).contains(&first) { + self.expect_byte(b'\\')?; + self.expect_byte(b'u')?; + let second = self.parse_hex_quad()?; + if !(0xdc00..=0xdfff).contains(&second) { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + 0x1_0000 + (((u32::from(first) - 0xd800) << 10) | (u32::from(second) - 0xdc00)) + } else { + u32::from(first) + }; + let character = char::from_u32(scalar) + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + decoded.push(character); + Ok(()) + } + + fn parse_hex_quad(&mut self) -> Result { + let mut value = 0_u16; + for _ in 0..4 { + let byte = self + .peek_byte() + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + self.position += 1; + 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( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + }; + value = (value << 4) | digit; + } + Ok(value) + } + + fn expect_byte( + &mut self, + expected: u8, + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + if self.peek_byte() != Some(expected) { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + self.position += 1; + Ok(()) + } + + fn skip_whitespace(&mut self) { + while matches!(self.peek_byte(), Some(b' ' | b'\t' | b'\r' | b'\n')) { + self.position += 1; + } + } + + fn peek_byte(&self) -> Option { + self.input.as_bytes().get(self.position).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const INVARIANT: WebDriverBiDiLocateNodesResponseDocumentError = + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant; + + #[test] + fn second_pass_parser_covers_valid_skipped_value_and_escape_shapes() { + let raw = concat!( + " \n{\t\"metadata\": [true,false,null,{\"a\":1,\"b\":2}],", + "\"result\":{", + "\"emptyObject\":{},\"emptyArray\":[],", + "\"object\":{\"first\":1,\"second\":2},", + "\"array\":[true,false,null],", + "\"escaped\":\"\\\"\\\\\\/\\b\\f\\n\\r\\t\",", + "\"utf8\":\"é\",", + "\"number\":-1.25e+2,\"truth\":true,\"falsehood\":false,\"nothing\":null,", + "\"nodes\":[{", + "\"ignored\":{\"nested\":[1,2]},", + "\"type\":\"no\\u0064e\",", + "\"sharedId\":\"node-\\u0041-\\u00E9-\\u263A-\\uD83D\\uDE00-\\u00af-\\u00AF\"", + "}]}}" + ); + let nodes = parse_wire_locate_nodes_result(raw) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].remote_type, "node"); + assert_eq!(nodes[0].shared_id.as_deref(), Some("node-A-é-☺-😀-¯-¯")); + } + + #[test] + fn second_pass_parser_rejects_structural_and_typed_result_faults() { + let cases = [ + ("", INVARIANT), + ("{}", INVARIANT), + (r#"{"metadata":0}"#, INVARIANT), + (r#"{"metadata":0]"#, INVARIANT), + (r#"{"metadata" 0,"result":{"nodes":[]}}"#, INVARIANT), + (r#"{metadata:0,"result":{"nodes":[]}}"#, INVARIANT), + ( + r#"{"result":[]}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes, + ), + ( + r#"{"result":{}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ), + ( + r#"{"result":{"other":0}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ), + ( + r#"{"result":{"nodes":0}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes, + ), + ( + r#"{"result":{"nodes":[0]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNode, + ), + ( + r#"{"result":{"nodes":[{}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType, + ), + ( + r#"{"result":{"nodes":[{"sharedId":"node-a"}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType, + ), + ( + r#"{"result":{"nodes":[{"type":0}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeType, + ), + ( + r#"{"result":{"nodes":[{"type":"node","sharedId":0}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeSharedId, + ), + ( + r#"{"result":{"nodes":[],"nodes":[]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes, + ), + ( + r#"{"result":{"nodes":[{"type":"node","type":"node"}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ), + ( + r#"{"result":{"nodes":[{"type":"node","sharedId":"a","sharedId":"b"}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ), + (r#"{"result":{"nodes":[] "other":0}}"#, INVARIANT), + (r#"{"result":{"nodes":[{"type":"node"} 0]}}"#, INVARIANT), + ( + r#"{"result":{"nodes":[{"type":"node" "sharedId":"node-a"}]}}"#, + INVARIANT, + ), + (r#"{"metadata":?,"result":{"nodes":[]}}"#, INVARIANT), + (r#"{"result":{?}}"#, INVARIANT), + (r#"{"result":{"nodes" []}}"#, INVARIANT), + (r#"{"result":{"other":?,"nodes":[]}}"#, INVARIANT), + (r#"{"result":{"nodes":[{?}]}}"#, INVARIANT), + (r#"{"result":{"nodes":[{"type" "node"}]}}"#, INVARIANT), + (r#"{"result":{"nodes":[{"type":"\x"}]}}"#, INVARIANT), + ( + r#"{"result":{"nodes":[{"type":"node","sharedId":"\x"}]}}"#, + INVARIANT, + ), + ( + r#"{"result":{"nodes":[{"ignored":?,"type":"node"}]}}"#, + INVARIANT, + ), + ]; + + for (raw, expected) in cases { + assert_eq!(parse_wire_locate_nodes_result(raw).err(), Some(expected)); + } + } + + #[test] + fn bounded_parser_uses_one_count_marker_and_skips_overflow_node_shapes() { + let nodes = parse_wire_locate_nodes_result_bounded( + r#"{"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":1},{"type":2}]}}"#, + 1, + ) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].remote_type, "node"); + assert_eq!(nodes[1].as_admission_parts(), ("", None)); + } + + #[test] + fn skip_helpers_cover_empty_nonempty_and_malformed_containers() { + let mut empty_object = ResultParser::new("{}"); + assert_eq!(empty_object.skip_object(), Ok(())); + + let mut object = ResultParser::new(r#"{"a":0,"b":1}"#); + assert_eq!(object.skip_object(), Ok(())); + + let mut malformed_object = ResultParser::new(r#"{"a":0 "b":1}"#); + assert_eq!(malformed_object.skip_object(), Err(INVARIANT)); + + let mut empty_array = ResultParser::new("[]"); + assert_eq!(empty_array.skip_array(), Ok(())); + + let mut array = ResultParser::new("[0,1]"); + assert_eq!(array.skip_array(), Ok(())); + + let mut malformed_array = ResultParser::new("[0 1]"); + assert_eq!(malformed_array.skip_array(), Err(INVARIANT)); + + let mut unknown = ResultParser::new("?"); + assert_eq!(unknown.skip_value(), Err(INVARIANT)); + + let mut empty_number = ResultParser::new(""); + assert_eq!(empty_number.skip_number(), Err(INVARIANT)); + + let mut terminal_number = ResultParser::new("123"); + assert_eq!(terminal_number.skip_number(), Ok(())); + assert_eq!(terminal_number.peek_byte(), None); + + let mut malformed_string_value = ResultParser::new(r#""\x""#); + assert_eq!(malformed_string_value.skip_value(), Err(INVARIANT)); + + let mut wrong_object_opener = ResultParser::new("[]"); + assert_eq!(wrong_object_opener.skip_object(), Err(INVARIANT)); + + let mut malformed_object_key = ResultParser::new("{?}"); + assert_eq!(malformed_object_key.skip_object(), Err(INVARIANT)); + + let mut missing_object_colon = ResultParser::new(r#"{"a" 0}"#); + assert_eq!(missing_object_colon.skip_object(), Err(INVARIANT)); + + let mut malformed_object_value = ResultParser::new(r#"{"a":?}"#); + assert_eq!(malformed_object_value.skip_object(), Err(INVARIANT)); + + let mut wrong_array_opener = ResultParser::new("{}"); + assert_eq!(wrong_array_opener.skip_array(), Err(INVARIANT)); + + let mut malformed_array_value = ResultParser::new("[?]"); + assert_eq!(malformed_array_value.skip_array(), Err(INVARIANT)); + + let mut truncated_literal = ResultParser::new("tru"); + assert_eq!(truncated_literal.skip_literal(b"true"), Err(INVARIANT)); + } + + #[test] + fn string_decoder_rejects_all_second_pass_escape_invariants() { + let mut missing_open_quote = ResultParser::new("plain"); + assert_eq!(missing_open_quote.parse_string(), Err(INVARIANT)); + + let mut unterminated = ResultParser::new("\"plain"); + assert_eq!(unterminated.parse_string(), Err(INVARIANT)); + + let mut control = ResultParser::new("\"\u{0001}\""); + assert_eq!(control.parse_string(), Err(INVARIANT)); + + let mut missing_escape = ResultParser::new("\"\\"); + assert_eq!(missing_escape.parse_string(), Err(INVARIANT)); + + let mut invalid_escape = ResultParser::new(r#""\x""#); + assert_eq!(invalid_escape.parse_string(), Err(INVARIANT)); + + for raw in [ + r#""\uD83D""#, + r#""\uD83D\x0000""#, + r#""\uD83D\u0041""#, + "\"\\uD83D\\u12", + r#""\uDE00""#, + r#""\u12""#, + "\"\\u12", + r#""\u00G0""#, + ] { + let mut parser = ResultParser::new(raw); + assert_eq!(parser.parse_string(), Err(INVARIANT)); + } + } +} diff --git a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs new file mode 100644 index 000000000..cf22c762c --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs @@ -0,0 +1,625 @@ +use std::{error::Error, fmt}; + +use crate::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_COMMAND_ID, + WebDriverBiDiCommandResponseKind, + webdriver_bidi_error_code::{WebDriverBiDiErrorCode, parse_webdriver_bidi_error_code}, +}; + +/// Maximum accepted JSON container nesting depth for one WebDriver BiDi response document. +/// +/// The top-level response object is depth 1. The limit is an OriginWeave resource-safety +/// budget, not a WebDriver BiDi protocol maximum. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH: usize = 64; + +/// Maximum accepted number of fields in one top-level WebDriver BiDi response object. +/// +/// The limit is an OriginWeave resource-safety budget, not a WebDriver BiDi protocol maximum. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS: usize = 64; + +/// Fail-closed reasons a bounded WebDriver BiDi response document cannot become typed envelope +/// evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiResponseEnvelopeParseError { + /// The document is not syntactically valid JSON with one complete top-level object. + InvalidJson, + /// JSON object/array nesting exceeded the configured parser safety budget. + JsonDepthExceeded, + /// The top-level response object contains more fields than the configured safety budget. + TopLevelFieldCountExceeded, + /// The top-level response object repeats a field after JSON string escape decoding. + DuplicateTopLevelField, + /// The response object omits the required `type` discriminator. + MissingResponseType, + /// The response `type` is not exactly `success` or `error`. + UnexpectedResponseType, + /// The response object omits the required `id` field. + MissingResponseId, + /// The response `id` is not a protocol-range JSON integer, or is `null` where forbidden. + InvalidResponseId, + /// The selected response kind omits one of its required payload fields. + MissingRequiredPayload, + /// A required response payload field has the wrong JSON value type. + InvalidRequiredPayloadType, + /// The error response uses a string outside the current WebDriver BiDi `ErrorCode` vocabulary. + UnexpectedErrorCode, +} + +impl fmt::Display for WebDriverBiDiResponseEnvelopeParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidJson => "WebDriver BiDi response document is not valid JSON", + Self::JsonDepthExceeded => { + "WebDriver BiDi response JSON depth exceeds the safety budget" + } + Self::TopLevelFieldCountExceeded => { + "WebDriver BiDi response top-level field count exceeds the safety budget" + } + Self::DuplicateTopLevelField => { + "WebDriver BiDi response contains a duplicate top-level field" + } + Self::MissingResponseType => "WebDriver BiDi response is missing its type field", + Self::UnexpectedResponseType => "WebDriver BiDi response type is not success or error", + Self::MissingResponseId => "WebDriver BiDi response is missing its id field", + Self::InvalidResponseId => "WebDriver BiDi response id is invalid", + Self::MissingRequiredPayload => { + "WebDriver BiDi response is missing a required payload field" + } + Self::InvalidRequiredPayloadType => { + "WebDriver BiDi response payload field has an invalid JSON type" + } + Self::UnexpectedErrorCode => "WebDriver BiDi response error code is not recognized", + }) + } +} + +impl Error for WebDriverBiDiResponseEnvelopeParseError {} + +/// Typed evidence that one bounded raw document is a syntactically valid WebDriver BiDi command +/// response envelope. +/// +/// The value retains the exact admitted wire text, command-response kind, parsed response +/// identifier, and the typed protocol error code for an error response. Parsing does not +/// authenticate a browser or transport and does not grant browser, node, policy, or Agent +/// authority. +#[derive(Debug, PartialEq, Eq)] +pub struct ParsedWebDriverBiDiCommandResponseEnvelope { + document: BoundedWebDriverBiDiResponseDocument, + kind: WebDriverBiDiCommandResponseKind, + response_id: Option, + error_code: Option, +} + +impl ParsedWebDriverBiDiCommandResponseEnvelope { + /// Returns whether the parsed command response is a success or error envelope. + #[must_use] + pub const fn kind(&self) -> WebDriverBiDiCommandResponseKind { + self.kind + } + + /// Returns the parsed command identifier, or `None` only for an error response whose required + /// `id` field was explicitly JSON `null`. + #[must_use] + pub const fn response_id(&self) -> Option { + self.response_id + } + + /// Returns the typed protocol error code, or `None` for a success response. + /// + /// The value is derived from the same decoded top-level `error` field that passed complete + /// envelope validation; callers therefore do not need to reparse untrusted wire text merely to + /// classify a recoverable protocol failure. + #[must_use] + pub const fn error_code(&self) -> Option { + self.error_code + } + + /// Returns the exact bounded wire text from which this envelope evidence was parsed. + #[must_use] + pub fn as_str(&self) -> &str { + self.document.as_str() + } +} + +impl BoundedWebDriverBiDiResponseDocument { + /// Parses this already-bounded raw document into typed command-response envelope evidence. + /// + /// Complete JSON syntax, decoded top-level field uniqueness, response-kind requirements, + /// protocol-range response identifiers, typed current error-code classification, and explicit + /// parser resource budgets are enforced before the value can be used by a later correlation + /// boundary. + pub fn parse_command_response( + self, + ) -> Result + { + let parsed = ResponseEnvelopeParser::new(self.as_str()).parse()?; + Ok(ParsedWebDriverBiDiCommandResponseEnvelope { + document: self, + kind: parsed.kind, + response_id: parsed.response_id, + error_code: parsed.error_code, + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +enum ParsedJsonValue { + Object, + Array, + String(Vec), + Number(String), + Boolean, + Null, +} + +struct ParsedEnvelopeFields { + kind: WebDriverBiDiCommandResponseKind, + response_id: Option, + error_code: Option, +} + +struct ResponseEnvelopeParser<'input> { + input: &'input str, + position: usize, +} + +impl<'input> ResponseEnvelopeParser<'input> { + const fn new(input: &'input str) -> Self { + Self { input, position: 0 } + } + + fn parse(mut self) -> Result { + self.skip_whitespace(); + // The bounded-document constructor proves the first non-whitespace byte is `{`. + self.position += 1; + self.skip_whitespace(); + + let mut seen_fields: Vec> = Vec::new(); + let mut response_type = None; + let mut response_id = None; + let mut result = None; + let mut error_code = None; + let mut message = None; + let mut stacktrace = None; + + if self.peek_byte() != Some(b'}') { + loop { + if seen_fields.len() >= MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS { + return Err( + WebDriverBiDiResponseEnvelopeParseError::TopLevelFieldCountExceeded, + ); + } + + let field_name = self.parse_string()?; + if seen_fields.contains(&field_name) { + return Err(WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField); + } + seen_fields.push(field_name.clone()); + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + let value = self.parse_value(2)?; + + match field_name.as_slice() { + b"type" => response_type = Some(value), + b"id" => response_id = Some(value), + b"result" => result = Some(value), + b"error" => error_code = Some(value), + b"message" => message = Some(value), + b"stacktrace" => stacktrace = Some(value), + _ => {} + } + + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + break; + } + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + } else { + self.position += 1; + } + + self.skip_whitespace(); + if self.position != self.input.len() { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + + let kind = Self::parse_response_type(response_type)?; + let response_id = Self::parse_response_id(response_id, kind)?; + let error_code = + Self::validate_required_payload(kind, result, error_code, message, stacktrace)?; + + Ok(ParsedEnvelopeFields { + kind, + response_id, + error_code, + }) + } + + fn parse_response_type( + value: Option, + ) -> Result { + let value = value.ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingResponseType)?; + match value { + ParsedJsonValue::String(value) if value == b"success" => { + Ok(WebDriverBiDiCommandResponseKind::Success) + } + ParsedJsonValue::String(value) if value == b"error" => { + Ok(WebDriverBiDiCommandResponseKind::Error) + } + _ => Err(WebDriverBiDiResponseEnvelopeParseError::UnexpectedResponseType), + } + } + + fn parse_response_id( + value: Option, + kind: WebDriverBiDiCommandResponseKind, + ) -> Result, WebDriverBiDiResponseEnvelopeParseError> { + let value = value.ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingResponseId)?; + let raw = match value { + ParsedJsonValue::Null if kind == WebDriverBiDiCommandResponseKind::Error => { + return Ok(None); + } + ParsedJsonValue::Number(raw) => raw, + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId), + }; + if !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId); + } + let parsed = raw + .parse::() + .map_err(|_| WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId)?; + if parsed > MAX_WEBDRIVER_BIDI_COMMAND_ID { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId); + } + Ok(Some(parsed)) + } + + fn validate_required_payload( + kind: WebDriverBiDiCommandResponseKind, + result: Option, + error_code: Option, + message: Option, + stacktrace: Option, + ) -> Result, WebDriverBiDiResponseEnvelopeParseError> { + match kind { + WebDriverBiDiCommandResponseKind::Success => { + let result = result + .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; + if !matches!(result, ParsedJsonValue::Object) { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + } + Ok(None) + } + WebDriverBiDiCommandResponseKind::Error => { + let error_code = error_code + .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; + let message = message + .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; + let ParsedJsonValue::String(error_code) = error_code else { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + }; + if !matches!(message, ParsedJsonValue::String(_)) { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + } + let error_code = parse_webdriver_bidi_error_code(&error_code) + .ok_or(WebDriverBiDiResponseEnvelopeParseError::UnexpectedErrorCode)?; + if let Some(stacktrace) = stacktrace + && !matches!(stacktrace, ParsedJsonValue::String(_)) + { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + } + Ok(Some(error_code)) + } + } + } + + fn parse_value( + &mut self, + container_depth: usize, + ) -> Result { + match self.peek_byte() { + Some(b'{') => { + self.parse_object(container_depth)?; + Ok(ParsedJsonValue::Object) + } + Some(b'[') => { + self.parse_array(container_depth)?; + Ok(ParsedJsonValue::Array) + } + Some(b'"') => Ok(ParsedJsonValue::String(self.parse_string()?)), + Some(b'-' | b'0'..=b'9') => Ok(ParsedJsonValue::Number(self.parse_number()?)), + Some(b't') => { + self.parse_literal(b"true")?; + Ok(ParsedJsonValue::Boolean) + } + Some(b'f') => { + self.parse_literal(b"false")?; + Ok(ParsedJsonValue::Boolean) + } + Some(b'n') => { + self.parse_literal(b"null")?; + Ok(ParsedJsonValue::Null) + } + _ => Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + + fn parse_object( + &mut self, + depth: usize, + ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + Self::require_depth(depth)?; + self.position += 1; + self.skip_whitespace(); + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Ok(()); + } + + loop { + self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + self.parse_value(depth + 1)?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + return Ok(()); + } + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + } + + fn parse_array(&mut self, depth: usize) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + Self::require_depth(depth)?; + self.position += 1; + self.skip_whitespace(); + if self.peek_byte() == Some(b']') { + self.position += 1; + return Ok(()); + } + + loop { + self.parse_value(depth + 1)?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b']') => { + self.position += 1; + return Ok(()); + } + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + } + + fn require_depth(depth: usize) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + if depth > MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH { + Err(WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded) + } else { + Ok(()) + } + } + + fn parse_string(&mut self) -> Result, WebDriverBiDiResponseEnvelopeParseError> { + self.expect_byte(b'"')?; + let mut decoded = Vec::new(); + loop { + let byte = self + .peek_byte() + .ok_or(WebDriverBiDiResponseEnvelopeParseError::InvalidJson)?; + match byte { + b'"' => { + self.position += 1; + return Ok(decoded); + } + b'\\' => { + self.position += 1; + self.parse_escape(&mut decoded)?; + } + 0x00..=0x1f => { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + _ => { + decoded.push(byte); + self.position += 1; + } + } + } + } + + fn parse_escape( + &mut self, + decoded: &mut Vec, + ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + // The bounded top-level object guarantees a following byte; map any violated internal + // invariant to the existing fail-closed invalid-JSON path without a second unreachable branch. + let escaped = self.peek_byte().unwrap_or_default(); + self.position += 1; + match escaped { + b'"' => decoded.push(b'"'), + b'\\' => decoded.push(b'\\'), + b'/' => decoded.push(b'/'), + b'b' => decoded.push(0x08), + b'f' => decoded.push(0x0c), + b'n' => decoded.push(b'\n'), + b'r' => decoded.push(b'\r'), + b't' => decoded.push(b'\t'), + b'u' => { + let scalar = self.parse_unicode_escape()?; + Self::push_utf8(decoded, scalar); + } + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + Ok(()) + } + + fn parse_unicode_escape(&mut self) -> Result { + let first = self.parse_hex_code_unit()?; + if (0xd800..=0xdbff).contains(&first) { + if self.peek_byte() != Some(b'\\') { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.position += 1; + if self.peek_byte() != Some(b'u') { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.position += 1; + let second = self.parse_hex_code_unit()?; + if !(0xdc00..=0xdfff).contains(&second) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + return Ok(0x1_0000 + + ((u32::from(first) - 0xd800) << 10) + + (u32::from(second) - 0xdc00)); + } + if (0xdc00..=0xdfff).contains(&first) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + Ok(u32::from(first)) + } + + fn push_utf8(decoded: &mut Vec, scalar: u32) { + if scalar <= 0x7f { + decoded.push(scalar as u8); + } else if scalar <= 0x7ff { + decoded.push((0xc0 | (scalar >> 6)) as u8); + decoded.push((0x80 | (scalar & 0x3f)) as u8); + } else if scalar <= 0xffff { + decoded.push((0xe0 | (scalar >> 12)) as u8); + decoded.push((0x80 | ((scalar >> 6) & 0x3f)) as u8); + decoded.push((0x80 | (scalar & 0x3f)) as u8); + } else { + decoded.push((0xf0 | (scalar >> 18)) as u8); + decoded.push((0x80 | ((scalar >> 12) & 0x3f)) as u8); + decoded.push((0x80 | ((scalar >> 6) & 0x3f)) as u8); + decoded.push((0x80 | (scalar & 0x3f)) as u8); + } + } + + fn parse_hex_code_unit(&mut self) -> Result { + let mut value = 0_u16; + for _ in 0..4 { + // A truncated escape cannot run past the admitted top-level closing `}`. If an + // internal invariant is ever violated, zero still deterministically fails hex decoding. + let byte = self.peek_byte().unwrap_or_default(); + let digit = Self::hex_value(byte) + .ok_or(WebDriverBiDiResponseEnvelopeParseError::InvalidJson)?; + value = (value << 4) | u16::from(digit); + self.position += 1; + } + Ok(value) + } + + const fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } + } + + fn parse_number(&mut self) -> Result { + let start = self.position; + if self.peek_byte() == Some(b'-') { + self.position += 1; + } + + match self.peek_byte() { + Some(b'0') => { + self.position += 1; + if matches!(self.peek_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + } + Some(b'1'..=b'9') => self.consume_digits(), + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + + if self.peek_byte() == Some(b'.') { + self.position += 1; + if !matches!(self.peek_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.consume_digits(); + } + + if matches!(self.peek_byte(), Some(b'e' | b'E')) { + self.position += 1; + if matches!(self.peek_byte(), Some(b'+' | b'-')) { + self.position += 1; + } + if !matches!(self.peek_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.consume_digits(); + } + + Ok(self.input[start..self.position].to_owned()) + } + + fn consume_digits(&mut self) { + while matches!(self.peek_byte(), Some(b'0'..=b'9')) { + self.position += 1; + } + } + + fn parse_literal( + &mut self, + literal: &[u8], + ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + let end = self.position + literal.len(); + if self.input.as_bytes().get(self.position..end) != Some(literal) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.position = end; + Ok(()) + } + + fn skip_whitespace(&mut self) { + while matches!(self.peek_byte(), Some(b' ' | b'\t' | b'\r' | b'\n')) { + self.position += 1; + } + } + + fn expect_byte(&mut self, expected: u8) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + if self.peek_byte() == Some(expected) { + self.position += 1; + Ok(()) + } else { + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + } + } + + fn peek_byte(&self) -> Option { + self.input.as_bytes().get(self.position).copied() + } +} diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs new file mode 100644 index 000000000..5002731db --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs @@ -0,0 +1,206 @@ +//! Explicit no-DNS connection targets for correlated WebDriver BiDi endpoints. +//! +//! This boundary converts only literal loopback listener identities into exact socket metadata. +//! It deliberately refuses `localhost` so a later connector cannot silently inherit ambient DNS +//! authority from an admitted WebDriver endpoint. When explicit trusted name resolution is needed, +//! the typed error preserves the correlated endpoint instead of discarding its session evidence. +//! A separately observed connected peer must also match the approved socket destination exactly +//! before it becomes verified transport metadata. These values do not open a socket, authenticate +//! a process, negotiate TLS, perform a WebSocket handshake, or grant Agent authority. + +use std::{ + fmt, + net::{Ipv4Addr, Ipv6Addr, SocketAddr}, +}; + +use crate::CorrelatedWebDriverBiDiWebSocketEndpoint; + +/// An exact loopback socket destination derived from one correlated WebDriver BiDi endpoint. +/// +/// The destination is inert connection metadata. It proves only that the already-admitted endpoint +/// named a literal loopback IP address, retained an explicit nonzero port, and was correlated to the +/// expected WebDriver session id. A runtime connector must independently establish a connection and +/// verify its observed peer before treating that transport as the approved destination. TLS, +/// WebSocket, process, policy, and browser authority remain separate boundaries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBiDiWebSocketConnectTarget { + socket_addr: SocketAddr, + requires_tls: bool, + session_id: String, +} + +impl WebDriverBiDiWebSocketConnectTarget { + /// Return the exact loopback socket destination without performing name resolution. + #[must_use] + pub const fn socket_addr(&self) -> SocketAddr { + self.socket_addr + } + + /// Return whether the admitted endpoint requires a TLS-protected WebSocket transport. + #[must_use] + pub const fn requires_tls(&self) -> bool { + self.requires_tls + } + + /// Return the exact WebDriver session id established by the preceding correlation boundary. + #[must_use] + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// Consume this approved destination and verify one observed connected socket peer exactly. + /// + /// Matching requires the complete [`SocketAddr`]—IP address and port—to equal the approved + /// no-DNS destination. A mismatch consumes the target and fails closed, preventing a connector + /// from accidentally reusing the same authority after observing a different peer. Success + /// produces inert verified-peer metadata only; it does not authenticate an OS process, + /// negotiate TLS, perform a WebSocket handshake, or grant browser/Agent authority. + pub fn verify_connected_peer( + self, + observed_peer: SocketAddr, + ) -> Result { + let expected = self.socket_addr; + if observed_peer != expected { + return Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { + expected, + actual: observed_peer, + }); + } + + Ok(VerifiedWebDriverBiDiSocketPeer { + connect_target: self, + }) + } +} + +/// Inert metadata proving that a connected peer exactly matched the approved BiDi destination. +/// +/// This value carries only the destination, TLS requirement, and correlated WebDriver session id +/// already established by preceding boundaries. It does not prove process identity, TLS peer +/// identity, WebSocket protocol state, browser authenticity, policy authorization, or Agent action +/// authority. +#[derive(Debug, PartialEq, Eq)] +pub struct VerifiedWebDriverBiDiSocketPeer { + connect_target: WebDriverBiDiWebSocketConnectTarget, +} + +impl VerifiedWebDriverBiDiSocketPeer { + /// Return the exact approved and observed socket peer address. + #[must_use] + pub const fn socket_addr(&self) -> SocketAddr { + self.connect_target.socket_addr() + } + + /// Return whether the correlated endpoint still requires TLS before WebSocket use. + #[must_use] + pub const fn requires_tls(&self) -> bool { + self.connect_target.requires_tls() + } + + /// Return the exact correlated WebDriver session id. + #[must_use] + pub fn session_id(&self) -> &str { + self.connect_target.session_id() + } +} + +/// Fail-closed errors while verifying an observed BiDi socket peer. +#[derive(Debug, PartialEq, Eq)] +pub enum WebDriverBiDiSocketPeerVerificationError { + /// The connected peer differed from the exact destination approved before connection. + PeerMismatch { + /// Exact socket address that the connector was authorized to reach. + expected: SocketAddr, + /// Socket peer address observed after connection. + actual: SocketAddr, + }, +} + +impl fmt::Display for WebDriverBiDiSocketPeerVerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PeerMismatch { .. } => formatter.write_str( + "connected WebDriver BiDi socket peer does not match the approved destination", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiSocketPeerVerificationError {} + +impl CorrelatedWebDriverBiDiWebSocketEndpoint { + /// Consume this correlated endpoint and derive one exact no-DNS loopback socket destination. + /// + /// Literal IPv4 and IPv6 loopback hosts become an exact [`SocketAddr`]. Any admitted host that + /// is not an IP literal—including `localhost`—fails closed so the caller must perform an + /// explicit, separately trusted name-resolution step rather than inheriting ambient resolver + /// authority. The name-resolution-required error retains this correlated endpoint so that + /// trusted resolver handoff does not require reconstructing or recorrelation of session evidence. + /// This method performs no DNS lookup, socket I/O, peer authentication, TLS, or WebSocket + /// handshake. + pub fn into_explicit_connect_target( + self, + ) -> Result { + let socket_addr = if let Ok(ipv4) = self.host().parse::() { + SocketAddr::from((ipv4, self.port())) + } else if let Ok(ipv6) = self.host().parse::() { + SocketAddr::from((ipv6, self.port())) + } else { + return Err( + WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired { + correlated_endpoint: self, + }, + ); + }; + + Ok(WebDriverBiDiWebSocketConnectTarget { + socket_addr, + requires_tls: self.is_secure(), + session_id: self.session_id().to_owned(), + }) + } +} + +/// Fail-closed errors while deriving an explicit WebDriver BiDi socket destination. +#[derive(Debug, PartialEq, Eq)] +pub enum WebDriverBiDiWebSocketConnectTargetError { + /// The admitted endpoint used a host name and therefore requires explicit trusted resolution. + NameResolutionRequired { + /// The still-correlated endpoint that must be handed to a separately trusted resolver. + correlated_endpoint: CorrelatedWebDriverBiDiWebSocketEndpoint, + }, +} + +impl WebDriverBiDiWebSocketConnectTargetError { + /// Borrow the correlated endpoint preserved for an explicit trusted resolver handoff. + #[must_use] + pub const fn correlated_endpoint(&self) -> &CorrelatedWebDriverBiDiWebSocketEndpoint { + match self { + Self::NameResolutionRequired { + correlated_endpoint, + } => correlated_endpoint, + } + } + + /// Recover the correlated endpoint for an explicit trusted resolver handoff. + #[must_use] + pub fn into_correlated_endpoint(self) -> CorrelatedWebDriverBiDiWebSocketEndpoint { + match self { + Self::NameResolutionRequired { + correlated_endpoint, + } => correlated_endpoint, + } + } +} + +impl fmt::Display for WebDriverBiDiWebSocketConnectTargetError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NameResolutionRequired { .. } => formatter.write_str( + "WebDriver BiDi WebSocket endpoint requires explicit trusted name resolution", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiWebSocketConnectTargetError {} diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs new file mode 100644 index 000000000..83d2e9b04 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs @@ -0,0 +1,327 @@ +use std::fmt; +use std::net::{Ipv4Addr, Ipv6Addr}; + +/// Maximum admitted bytes for one WebDriver BiDi WebSocket endpoint. +/// +/// This is an OriginWeave first-Chromium-fixture safety budget, not a +/// WebDriver BiDi protocol maximum. +pub const MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES: usize = 2_048; + +/// One bounded canonical WebDriver BiDi session WebSocket endpoint. +/// +/// This value is transport metadata only. Construction does not authenticate +/// Chromium, ChromeDriver, the operating-system peer, TLS, policy, or Agent +/// authority. The first real-Chromium fixture intentionally admits only +/// loopback listener identities; the connection boundary must still verify the +/// actual peer before exposing transport I/O. +#[derive(Debug, PartialEq, Eq)] +pub struct WebDriverBiDiWebSocketEndpoint { + endpoint: String, + secure: bool, + host: String, + port: u16, + session_id: String, +} + +/// One admitted WebDriver BiDi WebSocket endpoint correlated to an expected session id. +/// +/// Correlation proves only that the endpoint resource and the caller-supplied expected session id +/// contain the same canonical admitted session text. The expected session id must itself come from +/// a trusted session-creation boundary. This value does not authenticate Chromium, ChromeDriver, +/// the caller, the operating-system peer, TLS, policy, or Agent authority, and it does not establish +/// a socket. +#[derive(Debug, PartialEq, Eq)] +pub struct CorrelatedWebDriverBiDiWebSocketEndpoint { + endpoint: WebDriverBiDiWebSocketEndpoint, +} + +impl WebDriverBiDiWebSocketEndpoint { + /// Admit one bounded canonical first-fixture WebDriver BiDi endpoint. + pub fn new(value: &str) -> Result { + if value.is_empty() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint); + } + if value.len() > MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::EndpointTooLong); + } + if value.bytes().any(|byte| !byte.is_ascii_graphic()) { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText); + } + if value.bytes().any(|byte| matches!(byte, b'?' | b'#')) { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden); + } + + let (secure, remainder) = if let Some(remainder) = value.strip_prefix("ws://") { + (false, remainder) + } else if let Some(remainder) = value.strip_prefix("wss://") { + (true, remainder) + } else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme); + }; + + let Some(path_start) = remainder.find('/') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource); + }; + let authority = &remainder[..path_start]; + let resource = &remainder[path_start..]; + if authority.is_empty() || authority.contains('@') { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + + let (host, port_text) = if let Some(bracketed) = authority.strip_prefix('[') { + let Some(close) = bracketed.find(']') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + let host_text = &bracketed[..close]; + let suffix = &bracketed[close + 1..]; + let Some(port_text) = suffix.strip_prefix(':') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + if port_text.is_empty() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + let Ok(ip) = host_text.parse::() else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + if !ip.is_loopback() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost); + } + if ip.to_string() != host_text { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + (host_text.to_owned(), port_text) + } else { + let Some((host_text, port_text)) = authority.rsplit_once(':') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + if host_text.is_empty() || port_text.is_empty() || host_text.contains(':') { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + if host_text == "localhost" { + (host_text.to_owned(), port_text) + } else if let Ok(ip) = host_text.parse::() { + if !ip.is_loopback() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost); + } + (host_text.to_owned(), port_text) + } else if host_text + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) + { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost); + } else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + }; + + let Ok(port) = port_text.parse::() else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort); + }; + if port == 0 || port.to_string() != port_text { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort); + } + + let Some(session_id) = resource.strip_prefix("/session/") else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource); + }; + if session_id.is_empty() || session_id.contains('/') { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource); + } + if !is_canonical_session_id(session_id) { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId); + } + + Ok(Self { + endpoint: value.to_owned(), + secure, + host, + port, + session_id: session_id.to_owned(), + }) + } + + /// Correlate this endpoint resource to one exact expected WebDriver session id. + /// + /// The endpoint is consumed so downstream connection code can require the correlated type and + /// cannot accidentally retain an uncorrelated copy. This comparison does not establish that the + /// caller-supplied expected id is authentic; the caller must obtain that id from its trusted + /// session-creation boundary. + pub fn correlate_session_id( + self, + expected_session_id: &str, + ) -> Result< + CorrelatedWebDriverBiDiWebSocketEndpoint, + WebDriverBiDiWebSocketEndpointCorrelationError, + > { + if !is_canonical_session_id(expected_session_id) { + return Err(WebDriverBiDiWebSocketEndpointCorrelationError::InvalidExpectedSessionId); + } + if self.session_id != expected_session_id { + return Err(WebDriverBiDiWebSocketEndpointCorrelationError::SessionIdMismatch); + } + Ok(CorrelatedWebDriverBiDiWebSocketEndpoint { endpoint: self }) + } + + /// Return the exact admitted endpoint text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.endpoint + } + + /// Return whether the endpoint uses `wss` rather than `ws`. + #[must_use] + pub const fn is_secure(&self) -> bool { + self.secure + } + + /// Return the canonical loopback listener host without IPv6 brackets. + #[must_use] + pub fn host(&self) -> &str { + &self.host + } + + /// Return the explicit nonzero listener port. + #[must_use] + pub const fn port(&self) -> u16 { + self.port + } + + /// Return the exact canonical session identifier admitted from the WebDriver endpoint. + #[must_use] + pub fn session_id(&self) -> &str { + &self.session_id + } +} + +impl CorrelatedWebDriverBiDiWebSocketEndpoint { + /// Return the exact admitted endpoint text. + #[must_use] + pub fn as_str(&self) -> &str { + self.endpoint.as_str() + } + + /// Return whether the endpoint uses `wss` rather than `ws`. + #[must_use] + pub const fn is_secure(&self) -> bool { + self.endpoint.is_secure() + } + + /// Return the canonical loopback listener host without IPv6 brackets. + #[must_use] + pub fn host(&self) -> &str { + self.endpoint.host() + } + + /// Return the explicit nonzero listener port. + #[must_use] + pub const fn port(&self) -> u16 { + self.endpoint.port() + } + + /// Return the exact session id proven equal to the caller-supplied expected session id. + #[must_use] + pub fn session_id(&self) -> &str { + self.endpoint.session_id() + } +} + +fn is_lowercase_hex(byte: u8) -> bool { + byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') +} + +fn is_canonical_session_id(value: &str) -> bool { + let bytes = value.as_bytes(); + match bytes.len() { + 32 => bytes.iter().copied().all(is_lowercase_hex), + 36 => { + for (index, byte) in bytes.iter().copied().enumerate() { + let valid = if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + is_lowercase_hex(byte) + }; + if !valid { + return false; + } + } + true + } + _ => false, + } +} + +/// Fail-closed admission errors for WebDriver BiDi WebSocket endpoint metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiWebSocketEndpointAdmissionError { + /// The endpoint text is empty. + EmptyEndpoint, + /// The endpoint text exceeds the OriginWeave safety budget. + EndpointTooLong, + /// The endpoint contains non-ASCII, whitespace, or control text. + InvalidEndpointText, + /// The endpoint does not use the exact `ws` or `wss` scheme. + InvalidScheme, + /// Query or fragment data is present and therefore not part of the admitted session resource. + QueryOrFragmentForbidden, + /// The authority is absent, credential-bearing, ambiguous, or malformed. + InvalidAuthority, + /// The authority identifies a non-loopback host. + NonLoopbackHost, + /// The port is absent, zero, out of range, or not canonically serialized. + InvalidPort, + /// The path is not exactly one `/session/` resource. + InvalidSessionResource, + /// The session id is not an admitted canonical W3C/ChromeDriver representation. + InvalidSessionId, +} + +impl fmt::Display for WebDriverBiDiWebSocketEndpointAdmissionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::EmptyEndpoint => "WebDriver BiDi WebSocket endpoint is empty", + Self::EndpointTooLong => "WebDriver BiDi WebSocket endpoint exceeds the safety budget", + Self::InvalidEndpointText => { + "WebDriver BiDi WebSocket endpoint text is not canonical ASCII" + } + Self::InvalidScheme => "WebDriver BiDi WebSocket endpoint scheme is not ws or wss", + Self::QueryOrFragmentForbidden => { + "WebDriver BiDi WebSocket endpoint query or fragment is forbidden" + } + Self::InvalidAuthority => "WebDriver BiDi WebSocket endpoint authority is invalid", + Self::NonLoopbackHost => "WebDriver BiDi WebSocket endpoint host is not loopback", + Self::InvalidPort => "WebDriver BiDi WebSocket endpoint port is invalid", + Self::InvalidSessionResource => { + "WebDriver BiDi WebSocket endpoint session resource is invalid" + } + Self::InvalidSessionId => "WebDriver BiDi WebSocket endpoint session id is invalid", + }; + f.write_str(message) + } +} + +impl std::error::Error for WebDriverBiDiWebSocketEndpointAdmissionError {} + +/// Fail-closed errors while correlating an admitted endpoint with an expected session id. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiWebSocketEndpointCorrelationError { + /// The expected session id is not an admitted canonical W3C/ChromeDriver representation. + InvalidExpectedSessionId, + /// The endpoint resource belongs to a different canonical session id. + SessionIdMismatch, +} + +impl fmt::Display for WebDriverBiDiWebSocketEndpointCorrelationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidExpectedSessionId => { + "expected WebDriver session id is not an admitted canonical representation" + } + Self::SessionIdMismatch => { + "WebDriver BiDi WebSocket endpoint session id does not match the expected session" + } + }; + f.write_str(message) + } +} + +impl std::error::Error for WebDriverBiDiWebSocketEndpointCorrelationError {} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs new file mode 100644 index 000000000..811def1f3 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs @@ -0,0 +1,100 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiLocateNodesResponseEnvelopeError, + WebDriverBiDiResponseEnvelopeParseError, +}; + +fn locate_nodes_command( + command_id: u64, +) -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + command_id, + "context-a", + &query, + )?) +} + +#[test] +fn bounded_success_document_is_parsed_and_correlated_in_one_consuming_boundary() +-> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[]}}"#, + )?; + let correlated = locate_nodes_command(42)?.correlate_response_document(document)?; + + assert_eq!(correlated.kind(), WebDriverBiDiCommandResponseKind::Success); + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn malformed_bounded_document_preserves_the_parser_failure() -> Result<(), Box> { + let document = + BoundedWebDriverBiDiResponseDocument::new(r#"{"type":"success","id":42,"result":{},}"#)?; + let result = locate_nodes_command(42)?.correlate_response_document(document); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Parse( + WebDriverBiDiResponseEnvelopeParseError::InvalidJson, + )) + ); + Ok(()) +} + +#[test] +fn parsed_response_id_mismatch_preserves_exact_correlation_failure() -> Result<(), Box> { + let document = + BoundedWebDriverBiDiResponseDocument::new(r#"{"type":"success","id":41,"result":{}}"#)?; + let result = locate_nodes_command(42)?.correlate_response_document(document); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 41, + }, + ), + )) + ); + Ok(()) +} + +#[test] +fn nullable_error_document_remains_explicitly_uncorrelatable() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":null,"error":"invalid argument","message":"bad request"}"#, + )?; + let result = locate_nodes_command(42)?.correlate_response_document(document); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + )) + ); + Ok(()) +} + +#[test] +fn document_correlation_error_preserves_nested_error_sources() { + let parse = WebDriverBiDiLocateNodesResponseDocumentError::Parse( + WebDriverBiDiResponseEnvelopeParseError::InvalidJson, + ); + assert!(parse.source().is_some()); + assert!(!parse.to_string().is_empty()); + + let envelope = WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId, + ); + assert!(envelope.source().is_some()); + assert!(!envelope.to_string().is_empty()); +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs new file mode 100644 index 000000000..aba667210 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs @@ -0,0 +1,209 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, WebDriverBiDiErrorCode, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, +}; + +fn locate_nodes_command( + command_id: u64, + max_node_count: u16, +) -> Result> { + let query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), max_node_count)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + command_id, + "context-a", + &query, + )?) +} + +#[test] +fn bounded_locate_nodes_document_admits_exact_wire_nodes_without_caller_selected_payload() +-> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"ignored":[true,false,null],"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b","value":{"nodeType":1}}]}}"#, + )?; + let admitted = locate_nodes_command(42, 2)?.admit_response_document_nodes(document)?; + + assert_eq!(admitted.command_id(), 42); + assert_eq!(admitted.browsing_context(), "context-a"); + assert_eq!(admitted.max_node_count(), 2); + assert_eq!(admitted.nodes().len(), 2); + assert_eq!(admitted.nodes()[0].remote_type(), "node"); + assert_eq!(admitted.nodes()[0].shared_id(), "node-a"); + assert_eq!(admitted.nodes()[1].shared_id(), "node-b"); + Ok(()) +} + +#[test] +fn wire_result_preserves_exact_command_node_budget() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b"}]}}"#, + )?; + + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + Ok(()) +} + +#[test] +fn wire_result_checks_node_budget_before_parsing_overflow_items() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":1,"sharedId":"malformed-overflow-item"}]}}"#, + )?; + + assert!(matches!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(document), + Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission(_)) + )); + Ok(()) +} + +#[test] +fn wire_result_rejects_missing_shared_id() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node"}]}}"#, + )?; + + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + Ok(()) +} + +#[test] +fn wire_result_rejects_non_node_remote_value() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"window","sharedId":"node-a"}]}}"#, + )?; + + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + Ok(()) +} + +#[test] +fn wire_result_requires_nodes_array() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":42,"result":{}}"#, + r#"{"type":"success","id":42,"result":{"nodes":{}}}"#, + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + } + Ok(()) +} + +#[test] +fn wire_result_rejects_ambiguous_duplicate_result_or_node_fields() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":42,"result":{"nodes":[],"nodes":[]}}"#, + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","type":"node","sharedId":"node-a"}]}}"#, + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a","sharedId":"node-a"}]}}"#, + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + } + Ok(()) +} + +#[test] +fn wire_result_decodes_json_escaped_protocol_fields_before_admission() -> Result<(), Box> +{ + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"res\u0075lt":{"no\u0064es":[{"ty\u0070e":"no\u0064e","shared\u0049d":"node-\u03b1"}]}}"#, + )?; + let admitted = locate_nodes_command(42, 1)?.admit_response_document_nodes(document)?; + + assert_eq!(admitted.nodes().len(), 1); + assert_eq!(admitted.nodes()[0].shared_id(), "node-α"); + Ok(()) +} + +#[test] +fn wire_result_boundary_preserves_parse_correlation_and_protocol_failures() +-> Result<(), Box> { + let malformed = + BoundedWebDriverBiDiResponseDocument::new(r#"{"type":"success","id":42,"result":{},}"#)?; + assert!(matches!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(malformed), + Err(WebDriverBiDiLocateNodesResponseDocumentError::Parse(_)) + )); + + let mismatched = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[]}}"#, + )?; + assert!(matches!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(mismatched), + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope(_)) + )); + + let error_response = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":42,"error":"invalid argument","message":"bad request"}"#, + )?; + assert_eq!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(error_response), + Err( + WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + WebDriverBiDiErrorCode::InvalidArgument, + ) + ) + ); + Ok(()) +} + +#[test] +fn wire_result_document_error_display_and_sources_cover_result_failure_variants() +-> Result<(), Box> { + let source_free = [ + WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + WebDriverBiDiErrorCode::InvalidArgument, + ), + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNode, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeType, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeSharedId, + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ]; + for error in source_free { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + + let over_budget = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b"}]}}"#, + )?; + let result = locate_nodes_command(42, 1)?.admit_response_document_nodes(over_budget); + let error = match result { + Err(error @ WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission(_)) => error, + _ => { + return Err( + "over-budget wire result must preserve result-admission error evidence".into(), + ); + } + }; + assert!(!error.to_string().is_empty()); + assert!(error.source().is_some()); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs new file mode 100644 index 000000000..a138cab4e --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs @@ -0,0 +1,44 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, WebDriverBiDiErrorCode, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiLocateNodesResponseEnvelopeError, +}; + +#[test] +fn correlated_wire_error_remains_typed_through_node_admission() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + let command = WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":42,"error":"unavailable network data","message":"retry later"}"#, + )?; + + assert_eq!( + command.admit_response_document_nodes(document), + Err( + WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + WebDriverBiDiErrorCode::UnavailableNetworkData, + ) + ) + ); + Ok(()) +} + +#[test] +fn nullable_wire_error_remains_uncorrelatable_before_protocol_error_admission() +-> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + let command = WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":null,"error":"invalid argument","message":"bad request"}"#, + )?; + + assert_eq!( + command.admit_response_document_nodes(document), + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + )) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs index 7909c4f28..8d440272e 100644 --- a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs +++ b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs @@ -14,6 +14,28 @@ fn bounded_response_document_retains_exact_wire_text() -> Result<(), Box Result<(), Box> { + let raw = b" \r\n{\"id\":42,\"type\":\"success\",\"result\":{}}\t"; + let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(raw)?; + + assert_eq!(document.as_str(), std::str::from_utf8(raw)?); + + let invalid_utf8 = [b'{', 0xff, b'}']; + assert_eq!( + BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(&invalid_utf8), + Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8) + ); + + let oversized_invalid_utf8 = vec![0xff; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1]; + assert_eq!( + BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(&oversized_invalid_utf8), + Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge) + ); + Ok(()) +} + #[test] fn empty_or_json_whitespace_only_response_document_fails_closed() { for raw in ["", " ", "\t\r\n"] { @@ -67,6 +89,7 @@ fn response_document_errors_are_deterministic_and_source_free() { for error in [ WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument, WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge, + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8, WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary, ] { assert!(!error.to_string().is_empty()); diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs new file mode 100644 index 000000000..692e7ea68 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs @@ -0,0 +1,72 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, + WebDriverBiDiResponseDocumentAdmissionError, WebDriverBiDiResponseEnvelopeParseError, +}; + +fn assert_invalid_json(raw: &str) -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + ); + Ok(()) +} + +#[test] +fn non_object_response_stops_at_document_admission_before_parser() { + assert!(matches!( + BoundedWebDriverBiDiResponseDocument::new("[]"), + Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary) + )); +} + +#[test] +fn parser_rejects_truncated_literal_documents() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"x":falsX}"#, + r#"{"type":"success","id":1,"result":{},"x":nulX}"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_rejects_malformed_escape_and_unicode_code_units() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"x":"\q"}"#, + r#"{"type":"success","id":1,"result":{},"x":"\u12G4"}"#, + r#"{"type":"success","id":1,"result":{},"x":"\uD83D\u12G4"}"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_rejects_nested_object_key_and_colon_faults() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"x":{1:2}}"#, + r#"{"type":"success","id":1,"result":{},"x":{"a" 1}}"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_enforces_depth_budget_for_object_nesting() -> Result<(), Box> { + let over_nested = format!( + "{{\"type\":\"success\",\"id\":1,\"result\":{{\"x\":{}{}}}}}", + "{\"k\":".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1), + "}".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1) + ); + let document = BoundedWebDriverBiDiResponseDocument::new(&over_nested)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs new file mode 100644 index 000000000..5b56399be --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs @@ -0,0 +1,107 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiResponseEnvelopeParseError, +}; + +fn assert_invalid_json(raw: &str) -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + ); + Ok(()) +} + +#[test] +fn parser_rejects_top_level_separator_trailing_document_and_missing_colon_faults() +-> Result<(), Box> { + for raw in [ + "{\"type\":\"success\" \"id\":1,\"result\":{}}", + "{\"type\" \"success\",\"id\":1,\"result\":{}}", + "{\"type\":\"success\",\"id\":1,\"result\":{}} {}", + ] { + assert_invalid_json(raw)?; + } + + let empty = BoundedWebDriverBiDiResponseDocument::new("{}")?; + assert_eq!( + empty.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::MissingResponseType) + ); + Ok(()) +} + +#[test] +fn parser_rejects_malformed_nested_object_array_string_and_literal_values() +-> Result<(), Box> { + for raw in [ + "{\"type\":\"success\",\"id\":1,\"result\":{\"a\":1 \"b\":2}}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":[1 2]}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":tru}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":-}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":1.}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":1e}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":\"unterminated}", + ] { + assert_invalid_json(raw)?; + } + + let raw_control = "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":\"bad\u{0001}text\"}"; + assert_invalid_json(raw_control)?; + Ok(()) +} + +#[test] +fn parser_accepts_complete_json_escape_number_and_nested_container_forms() +-> Result<(), Box> { + let raw = concat!( + r#"{"type":"success","id":1,"result":{"a":1,"b":2},"esc":""#, + r#"\"\\\/\b\f\n\r\t","zero":0,"signed_exponent":1e+2,"nested":[1,2,{"ok":true}]}"#, + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(raw)?.parse_command_response()?; + assert_eq!(parsed.response_id(), Some(1)); + Ok(()) +} + +#[test] +fn parser_accepts_all_utf8_widths_from_json_unicode_escapes() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"text":"\u0041"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u00E9"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u263A"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uD83D\uDE00"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u00AF"}"#, + ] { + let parsed = BoundedWebDriverBiDiResponseDocument::new(raw)?.parse_command_response()?; + assert_eq!(parsed.response_id(), Some(1)); + } + Ok(()) +} + +#[test] +fn parser_rejects_invalid_unicode_escape_sequences() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"text":"\uD83D"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uD83D\x"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uD83D\u0041"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uDE00"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u12"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u00G0"}"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_rejects_integer_overflow_even_before_protocol_range_validation() +-> Result<(), Box> { + let raw = "{\"type\":\"success\",\"id\":18446744073709551616,\"result\":{}}"; + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs new file mode 100644 index 000000000..d15a525b0 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs @@ -0,0 +1,254 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_COMMAND_ID, + MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS, + ParsedWebDriverBiDiCommandResponseEnvelope, WebDriverBiDiCommandResponseKind, + WebDriverBiDiResponseEnvelopeParseError, +}; + +#[test] +fn parser_classifies_exact_success_and_nullable_error_envelopes() -> Result<(), Box> { + let success_raw = " {\"type\":\"success\",\"id\":42,\"result\":{\"nodes\":[]}}\r\n"; + let success = + BoundedWebDriverBiDiResponseDocument::new(success_raw)?.parse_command_response()?; + assert_eq!(success.kind(), WebDriverBiDiCommandResponseKind::Success); + assert_eq!(success.response_id(), Some(42)); + assert_eq!(success.as_str(), success_raw); + + let error_raw = "{\"type\":\"error\",\"id\":null,\"error\":\"invalid argument\",\"message\":\"bad request\"}"; + let error = BoundedWebDriverBiDiResponseDocument::new(error_raw)?.parse_command_response()?; + assert_eq!(error.kind(), WebDriverBiDiCommandResponseKind::Error); + assert_eq!(error.response_id(), None); + assert_eq!(error.as_str(), error_raw); + Ok(()) +} + +#[test] +fn parser_accepts_extensible_fields_only_when_the_complete_json_is_valid() +-> Result<(), Box> { + let raw = concat!( + "{\"vendor\":{\"nested\":[true,false,null,{\"text\":\"a\\\\b\\\"c\\u263a\"}]},", + "\"id\":7,\"result\":{},\"type\":\"success\"}" + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(raw)?.parse_command_response()?; + assert_eq!(parsed.response_id(), Some(7)); + + for malformed in [ + "{\"type\":\"success\",\"id\":7,\"result\":{},}", + "{\"type\":\"success\",\"id\":7,\"result\":{},\"x\":01}", + "{\"type\":\"success\",\"id\":7,\"result\":{},\"x\":\"\\q\"}", + "{\"type\":\"success\",\"id\":7,\"result\":{},\"x\":[1,]}", + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(malformed)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + ); + } + Ok(()) +} + +#[test] +fn parser_rejects_missing_duplicate_or_unexpected_response_discriminators() +-> Result<(), Box> { + for (raw, expected) in [ + ( + "{\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::MissingResponseType, + ), + ( + "{\"type\":\"event\",\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::UnexpectedResponseType, + ), + ( + "{\"type\":\"success\",\"type\":\"error\",\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField, + ), + ( + "{\"type\":\"success\",\"id\":1,\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField, + ), + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!(document.parse_command_response(), Err(expected)); + } + Ok(()) +} + +#[test] +fn parser_requires_a_present_protocol_range_id_and_success_result() -> Result<(), Box> { + for (raw, expected) in [ + ( + "{\"type\":\"success\",\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::MissingResponseId, + ), + ( + "{\"type\":\"error\",\"error\":\"invalid argument\",\"message\":\"bad\"}", + WebDriverBiDiResponseEnvelopeParseError::MissingResponseId, + ), + ( + "{\"type\":\"success\",\"id\":null,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":-1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":1.0,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":1e0,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":9007199254740992,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":1}", + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + ), + ( + "{\"type\":\"success\",\"id\":1,\"result\":[]}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!(document.parse_command_response(), Err(expected)); + } + + let maximum = + format!("{{\"type\":\"success\",\"id\":{MAX_WEBDRIVER_BIDI_COMMAND_ID},\"result\":{{}}}}"); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&maximum)? + .parse_command_response()? + .response_id(), + Some(MAX_WEBDRIVER_BIDI_COMMAND_ID) + ); + Ok(()) +} + +#[test] +fn parser_requires_error_code_message_and_string_stacktrace() -> Result<(), Box> { + for (raw, expected) in [ + ( + "{\"type\":\"error\",\"id\":1,\"message\":\"bad\"}", + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":\"invalid argument\"}", + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":1,\"message\":\"bad\"}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":\"invalid argument\",\"message\":false}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":\"invalid argument\",\"message\":\"bad\",\"stacktrace\":[]}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!(document.parse_command_response(), Err(expected)); + } + + let valid = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"error\",\"id\":7,\"error\":\"invalid argument\",\"message\":\"bad\",\"stacktrace\":\"frame\"}", + )? + .parse_command_response()?; + assert_eq!(valid.response_id(), Some(7)); + Ok(()) +} + +#[test] +fn parser_enforces_top_level_field_and_json_depth_budgets() -> Result<(), Box> { + let mut fields = vec![ + "\"type\":\"success\"".to_owned(), + "\"id\":1".to_owned(), + "\"result\":{}".to_owned(), + ]; + while fields.len() < MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS { + fields.push(format!("\"x{}\":null", fields.len())); + } + let exact_fields = format!("{{{}}}", fields.join(",")); + assert!( + BoundedWebDriverBiDiResponseDocument::new(&exact_fields)? + .parse_command_response() + .is_ok() + ); + fields.push("\"overflow\":null".to_owned()); + let over_fields = format!("{{{}}}", fields.join(",")); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&over_fields)?.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::TopLevelFieldCountExceeded) + ); + + let exact_nested = format!( + "{{\"type\":\"success\",\"id\":1,\"result\":{{\"x\":{}{}}}}}", + "[".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 2), + "]".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 2) + ); + assert!( + BoundedWebDriverBiDiResponseDocument::new(&exact_nested)? + .parse_command_response() + .is_ok() + ); + + let over_nested = format!( + "{{\"type\":\"success\",\"id\":1,\"result\":{{\"x\":{}{}}}}}", + "[".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1), + "]".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1) + ); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&over_nested)?.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded) + ); + Ok(()) +} + +#[test] +fn parser_normalizes_escaped_top_level_names_before_duplicate_detection() +-> Result<(), Box> { + let duplicate = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"success\",\"\\u0069d\":1,\"id\":1,\"result\":{}}", + )?; + assert_eq!( + duplicate.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField) + ); + + let unicode_extension = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"success\",\"id\":1,\"result\":{},\"메타\":\"값\"}", + )? + .parse_command_response()?; + assert_eq!(unicode_extension.response_id(), Some(1)); + Ok(()) +} + +#[test] +fn response_envelope_parse_errors_are_deterministic_and_source_free() { + for error in [ + WebDriverBiDiResponseEnvelopeParseError::InvalidJson, + WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded, + WebDriverBiDiResponseEnvelopeParseError::TopLevelFieldCountExceeded, + WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField, + WebDriverBiDiResponseEnvelopeParseError::MissingResponseType, + WebDriverBiDiResponseEnvelopeParseError::UnexpectedResponseType, + WebDriverBiDiResponseEnvelopeParseError::MissingResponseId, + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} + +fn _parsed_type_is_public(_parsed: ParsedWebDriverBiDiCommandResponseEnvelope) {} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs new file mode 100644 index 000000000..92b5c6611 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs @@ -0,0 +1,130 @@ +use std::error::Error; + +use originweave_core::{BoundedWebDriverBiDiResponseDocument, WebDriverBiDiErrorCode}; + +const CURRENT_WEBDRIVER_BIDI_ERROR_CODES: &[(&str, WebDriverBiDiErrorCode)] = &[ + ("invalid argument", WebDriverBiDiErrorCode::InvalidArgument), + ("invalid selector", WebDriverBiDiErrorCode::InvalidSelector), + ( + "invalid session id", + WebDriverBiDiErrorCode::InvalidSessionId, + ), + ( + "invalid web extension", + WebDriverBiDiErrorCode::InvalidWebExtension, + ), + ( + "move target out of bounds", + WebDriverBiDiErrorCode::MoveTargetOutOfBounds, + ), + ("no such alert", WebDriverBiDiErrorCode::NoSuchAlert), + ( + "no such client window", + WebDriverBiDiErrorCode::NoSuchClientWindow, + ), + ( + "no such network collector", + WebDriverBiDiErrorCode::NoSuchNetworkCollector, + ), + ("no such element", WebDriverBiDiErrorCode::NoSuchElement), + ("no such frame", WebDriverBiDiErrorCode::NoSuchFrame), + ("no such handle", WebDriverBiDiErrorCode::NoSuchHandle), + ( + "no such history entry", + WebDriverBiDiErrorCode::NoSuchHistoryEntry, + ), + ("no such intercept", WebDriverBiDiErrorCode::NoSuchIntercept), + ( + "no such network data", + WebDriverBiDiErrorCode::NoSuchNetworkData, + ), + ("no such node", WebDriverBiDiErrorCode::NoSuchNode), + ("no such request", WebDriverBiDiErrorCode::NoSuchRequest), + ( + "no such screencast", + WebDriverBiDiErrorCode::NoSuchScreencast, + ), + ("no such script", WebDriverBiDiErrorCode::NoSuchScript), + ( + "no such storage partition", + WebDriverBiDiErrorCode::NoSuchStoragePartition, + ), + ( + "no such user context", + WebDriverBiDiErrorCode::NoSuchUserContext, + ), + ( + "no such web extension", + WebDriverBiDiErrorCode::NoSuchWebExtension, + ), + ( + "session not created", + WebDriverBiDiErrorCode::SessionNotCreated, + ), + ( + "unable to capture screen", + WebDriverBiDiErrorCode::UnableToCaptureScreen, + ), + ( + "unable to close browser", + WebDriverBiDiErrorCode::UnableToCloseBrowser, + ), + ( + "unable to set cookie", + WebDriverBiDiErrorCode::UnableToSetCookie, + ), + ( + "unable to set file input", + WebDriverBiDiErrorCode::UnableToSetFileInput, + ), + ( + "unavailable network data", + WebDriverBiDiErrorCode::UnavailableNetworkData, + ), + ( + "underspecified storage partition", + WebDriverBiDiErrorCode::UnderspecifiedStoragePartition, + ), + ("unknown command", WebDriverBiDiErrorCode::UnknownCommand), + ("unknown error", WebDriverBiDiErrorCode::UnknownError), + ( + "unsupported operation", + WebDriverBiDiErrorCode::UnsupportedOperation, + ), +]; + +#[test] +fn parser_retains_every_current_webdriver_bidi_error_code() -> Result<(), Box> { + for &(raw_code, expected) in CURRENT_WEBDRIVER_BIDI_ERROR_CODES { + let raw = format!( + "{{\"type\":\"error\",\"id\":7,\"error\":\"{raw_code}\",\"message\":\"browser rejected command\"}}" + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(&raw)?.parse_command_response()?; + assert_eq!( + parsed.error_code(), + Some(expected), + "current WebDriver BiDi error code must retain its typed mapping: {raw_code}" + ); + } + Ok(()) +} + +#[test] +fn parser_rejects_unknown_webdriver_bidi_error_code() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"error\",\"id\":7,\"error\":\"made up browser failure\",\"message\":\"untrusted adapter text\"}", + )?; + + let error = match document.parse_command_response() { + Ok(_) => { + return Err(std::io::Error::other( + "unknown WebDriver BiDi error code was unexpectedly accepted", + ) + .into()); + } + Err(error) => error, + }; + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs new file mode 100644 index 000000000..88bed1649 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs @@ -0,0 +1,35 @@ +use std::error::Error; + +use originweave_core::{BoundedWebDriverBiDiResponseDocument, WebDriverBiDiErrorCode}; + +#[test] +fn parsed_error_envelope_retains_typed_error_code() -> Result<(), Box> { + for (raw_code, expected) in [ + ("invalid argument", WebDriverBiDiErrorCode::InvalidArgument), + ( + "no such client window", + WebDriverBiDiErrorCode::NoSuchClientWindow, + ), + ( + "unavailable network data", + WebDriverBiDiErrorCode::UnavailableNetworkData, + ), + ] { + let raw = format!( + "{{\"type\":\"error\",\"id\":7,\"error\":\"{raw_code}\",\"message\":\"remote failure\"}}" + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(&raw)?.parse_command_response()?; + assert_eq!(parsed.error_code(), Some(expected)); + } + Ok(()) +} + +#[test] +fn parsed_success_envelope_has_no_error_code() -> Result<(), Box> { + let parsed = + BoundedWebDriverBiDiResponseDocument::new("{\"type\":\"success\",\"id\":7,\"result\":{}}")? + .parse_command_response()?; + + assert_eq!(parsed.error_code(), None); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs b/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs new file mode 100644 index 000000000..dd20a4ef1 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs @@ -0,0 +1,107 @@ +use std::{error::Error, net::SocketAddr}; + +use originweave_core::{ + CorrelatedWebDriverBiDiWebSocketEndpoint, WebDriverBiDiSocketPeerVerificationError, + WebDriverBiDiWebSocketEndpoint, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn connect_target(endpoint: &str) -> originweave_core::WebDriverBiDiWebSocketConnectTarget { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + + let correlated: Result = + admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted literal loopback target") + }; + target +} + +#[test] +fn exact_connected_peer_becomes_verified_transport_metadata() { + let endpoint = format!("wss://127.0.0.1:9443/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let peer = SocketAddr::from(([127, 0, 0, 1], 9443)); + + let verified = target.verify_connected_peer(peer); + assert!(verified.is_ok(), "{verified:?}"); + let Ok(verified) = verified else { + return; + }; + + assert_eq!(verified.socket_addr(), peer); + assert!(verified.requires_tls()); + assert_eq!(verified.session_id(), SESSION_ID); +} + +#[test] +fn connected_peer_with_wrong_port_fails_closed() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([127, 0, 0, 1], 9516)); + + let result = target.verify_connected_peer(actual); + assert_eq!( + result, + Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { + expected: SocketAddr::from(([127, 0, 0, 1], 9515)), + actual, + }) + ); +} + +#[test] +fn connected_peer_with_different_address_fails_closed() { + let endpoint = format!("ws://[::1]:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([127, 0, 0, 1], 9515)); + + let result = target.verify_connected_peer(actual); + assert!(matches!( + result, + Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { .. }) + )); +} + +#[test] +fn non_loopback_observed_peer_cannot_inherit_approved_loopback_authority() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([192, 0, 2, 10], 9515)); + + let result = target.verify_connected_peer(actual); + assert!(matches!( + result, + Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { .. }) + )); +} + +#[test] +fn peer_mismatch_error_is_deterministic_and_source_free() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([127, 0, 0, 1], 9516)); + + let result = target.verify_connected_peer(actual); + let Err(error) = result else { + return; + }; + + assert_eq!( + error.to_string(), + "connected WebDriver BiDi socket peer does not match the approved destination" + ); + assert!(error.source().is_none()); +} diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs new file mode 100644 index 000000000..85f26f658 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs @@ -0,0 +1,99 @@ +use std::{error::Error, net::SocketAddr}; + +use originweave_core::{ + CorrelatedWebDriverBiDiWebSocketEndpoint, WebDriverBiDiWebSocketConnectTargetError, + WebDriverBiDiWebSocketEndpoint, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn correlated(endpoint: &str) -> CorrelatedWebDriverBiDiWebSocketEndpoint { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + correlated +} + +#[test] +fn explicit_ipv4_loopback_becomes_exact_no_dns_connect_target() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + assert!(result.is_ok(), "{result:?}"); + let Ok(target) = result else { + return; + }; + + assert_eq!( + target.socket_addr(), + SocketAddr::from(([127, 0, 0, 1], 9515)) + ); + assert!(!target.requires_tls()); + assert_eq!(target.session_id(), SESSION_ID); +} + +#[test] +fn explicit_ipv6_loopback_preserves_exact_destination_and_tls_requirement() { + let endpoint = format!("wss://[::1]:9443/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + assert!(result.is_ok(), "{result:?}"); + let Ok(target) = result else { + return; + }; + + assert_eq!( + target.socket_addr(), + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 9443)) + ); + assert!(target.requires_tls()); + assert_eq!(target.session_id(), SESSION_ID); +} + +#[test] +fn localhost_name_never_silently_inherits_ambient_dns_authority() { + let endpoint = format!("ws://localhost:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + assert!(matches!( + &result, + Err(WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired { .. }) + )); +} + +#[test] +fn name_resolution_failure_preserves_correlated_endpoint_for_trusted_resolver() { + let endpoint = format!("ws://localhost:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + let Err(error) = result else { + return; + }; + + assert_eq!(error.correlated_endpoint().as_str(), endpoint); + assert_eq!(error.correlated_endpoint().session_id(), SESSION_ID); + assert!(!error.correlated_endpoint().is_secure()); + assert_eq!(error.correlated_endpoint().port(), 9515); + + let recovered = error.into_correlated_endpoint(); + assert_eq!(recovered.as_str(), endpoint); + assert_eq!(recovered.session_id(), SESSION_ID); +} + +#[test] +fn connect_target_errors_are_deterministic_and_source_free() { + let endpoint = format!("ws://localhost:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + let Err(error) = result else { + return; + }; + + assert_eq!( + error.to_string(), + "WebDriver BiDi WebSocket endpoint requires explicit trusted name resolution" + ); + assert!(error.source().is_none()); +} diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs new file mode 100644 index 000000000..9440dff76 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs @@ -0,0 +1,206 @@ +use std::error::Error; + +use originweave_core::{ + MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES, WebDriverBiDiWebSocketEndpoint, + WebDriverBiDiWebSocketEndpointAdmissionError, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CHROMEDRIVER_SESSION_ID: &str = "0123456789abcdef0123456789abcdef"; + +#[test] +fn canonical_loopback_session_endpoints_are_admitted_without_granting_authority() { + let ipv4_result = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://127.0.0.1:9515/session/{SESSION_ID}")); + assert!(ipv4_result.is_ok(), "{ipv4_result:?}"); + let Ok(ipv4) = ipv4_result else { + return; + }; + assert!(!ipv4.is_secure()); + assert_eq!(ipv4.host(), "127.0.0.1"); + assert_eq!(ipv4.port(), 9515); + assert_eq!(ipv4.session_id(), SESSION_ID); + assert_eq!( + ipv4.as_str(), + format!("ws://127.0.0.1:9515/session/{SESSION_ID}") + ); + + let localhost_result = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://localhost:4444/session/{SESSION_ID}")); + assert!(localhost_result.is_ok(), "{localhost_result:?}"); + let Ok(localhost) = localhost_result else { + return; + }; + assert_eq!(localhost.host(), "localhost"); + + let ipv6_result = + WebDriverBiDiWebSocketEndpoint::new(&format!("wss://[::1]:9222/session/{SESSION_ID}")); + assert!(ipv6_result.is_ok(), "{ipv6_result:?}"); + let Ok(ipv6) = ipv6_result else { + return; + }; + assert!(ipv6.is_secure()); + assert_eq!(ipv6.host(), "::1"); + assert_eq!(ipv6.port(), 9222); +} + +#[test] +fn chromedriver_generated_session_identifier_is_admitted_for_real_chromium_fixture() { + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://127.0.0.1:9515/session/{CHROMEDRIVER_SESSION_ID}" + )); + assert!(endpoint.is_ok(), "{endpoint:?}"); + let Ok(endpoint) = endpoint else { + return; + }; + assert_eq!(endpoint.session_id(), CHROMEDRIVER_SESSION_ID); +} + +#[test] +fn remote_or_ambiguous_authorities_fail_closed() { + for endpoint in [ + format!("ws://example.com:9515/session/{SESSION_ID}"), + format!("ws://192.0.2.1:9515/session/{SESSION_ID}"), + format!("ws://[2001:db8::1]:9515/session/{SESSION_ID}"), + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost) + )); + } + + for endpoint in [ + format!("ws:///session/{SESSION_ID}"), + format!("ws://user@localhost:9515/session/{SESSION_ID}"), + format!("ws://localhost/session/{SESSION_ID}"), + format!("ws://::1:9515/session/{SESSION_ID}"), + format!("ws://[::1]9515/session/{SESSION_ID}"), + format!("ws://[::zz]:9515/session/{SESSION_ID}"), + format!("ws://:9515/session/{SESSION_ID}"), + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority) + )); + } +} + +#[test] +fn malformed_loopback_authority_edge_cases_fail_closed() { + for endpoint in [ + format!("ws://[::1:9515/session/{SESSION_ID}"), + format!("ws://[::1]:/session/{SESSION_ID}"), + format!("ws://[0:0:0:0:0:0:0:1]:9515/session/{SESSION_ID}"), + format!("ws://localhost:/session/{SESSION_ID}"), + format!("ws://local_host:9515/session/{SESSION_ID}"), + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority) + )); + } +} + +#[test] +fn port_and_session_resource_are_canonical_and_bounded() { + for endpoint in [ + format!("ws://localhost:0/session/{SESSION_ID}"), + format!("ws://localhost:09515/session/{SESSION_ID}"), + format!("ws://localhost:65536/session/{SESSION_ID}"), + format!("ws://localhost:+9515/session/{SESSION_ID}"), + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort) + )); + } + + for endpoint in [ + format!("ws://localhost:9515/other/{SESSION_ID}"), + format!("ws://localhost:9515/session/{SESSION_ID}/extra"), + "ws://localhost:9515/session/".to_owned(), + "ws://localhost:9515".to_owned(), + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource) + )); + } + + for session_id in [ + "01234567-89ab-cdef-0123-456789abcdeF", + "0123456789ab-cdef-0123-456789abcdef", + "01234567-89ab-cdef-0123-456789abcdeg", + "01234567_89ab-cdef-0123-456789abcdef", + "0123456789abcdef0123456789abcdeF", + "0123456789abcdef0123456789abcdeg", + "0123456789abcdef0123456789abcde_", + "0123456789abcdef0123456789abcde", + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://localhost:9515/session/{session_id}" + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId) + )); + } +} + +#[test] +fn endpoint_text_rejects_noncanonical_or_unbounded_inputs_before_transport_use() { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(""), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint) + )); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!("http://localhost:9515/session/{SESSION_ID}")), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme) + )); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://local host:9515/session/{SESSION_ID}")), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText) + )); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://locálhost:9515/session/{SESSION_ID}")), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText) + )); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://localhost:9515/session/{SESSION_ID}?token=secret" + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden) + )); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://localhost:9515/session/{SESSION_ID}#fragment" + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden) + )); + + let oversized = "x".repeat(MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES + 1); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&oversized), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::EndpointTooLong) + )); +} + +#[test] +fn endpoint_error_contract_is_deterministic_and_source_free() { + let errors = [ + WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint, + WebDriverBiDiWebSocketEndpointAdmissionError::EndpointTooLong, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme, + WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority, + WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId, + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs new file mode 100644 index 000000000..e522d7c55 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs @@ -0,0 +1,92 @@ +use std::error::Error; + +use originweave_core::{ + WebDriverBiDiWebSocketEndpoint, WebDriverBiDiWebSocketEndpointCorrelationError, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const OTHER_SESSION_ID: &str = "11234567-89ab-cdef-0123-456789abcdef"; +const CHROMEDRIVER_SESSION_ID: &str = "0123456789abcdef0123456789abcdef"; + +fn endpoint() -> WebDriverBiDiWebSocketEndpoint { + let result = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://127.0.0.1:9515/session/{SESSION_ID}")); + assert!(result.is_ok(), "{result:?}"); + let Ok(endpoint) = result else { + unreachable!("asserted valid endpoint") + }; + endpoint +} + +#[test] +fn exact_session_identity_correlation_preserves_bounded_endpoint_metadata() { + let result = endpoint().correlate_session_id(SESSION_ID); + assert!(result.is_ok(), "{result:?}"); + let Ok(correlated) = result else { + return; + }; + + assert_eq!( + correlated.as_str(), + format!("ws://127.0.0.1:9515/session/{SESSION_ID}") + ); + assert!(!correlated.is_secure()); + assert_eq!(correlated.host(), "127.0.0.1"); + assert_eq!(correlated.port(), 9515); + assert_eq!(correlated.session_id(), SESSION_ID); +} + +#[test] +fn chromedriver_session_identity_correlation_preserves_exact_session_evidence() { + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://127.0.0.1:9515/session/{CHROMEDRIVER_SESSION_ID}" + )); + assert!(endpoint.is_ok(), "{endpoint:?}"); + let Ok(endpoint) = endpoint else { + return; + }; + + let result = endpoint.correlate_session_id(CHROMEDRIVER_SESSION_ID); + assert!(result.is_ok(), "{result:?}"); + let Ok(correlated) = result else { + return; + }; + assert_eq!(correlated.session_id(), CHROMEDRIVER_SESSION_ID); +} + +#[test] +fn a_different_canonical_session_identity_fails_closed() { + assert!(matches!( + endpoint().correlate_session_id(OTHER_SESSION_ID), + Err(WebDriverBiDiWebSocketEndpointCorrelationError::SessionIdMismatch) + )); +} + +#[test] +fn malformed_expected_session_identity_is_rejected_before_comparison() { + for expected in [ + "", + "01234567-89ab-cdef-0123-456789abcdeF", + "0123456789ab-cdef-0123-456789abcdef", + "01234567-89ab-cdef-0123-456789abcdeg", + "01234567_89ab-cdef-0123-456789abcdef", + "0123456789abcdef0123456789abcdeF", + "0123456789abcdef0123456789abcdeg", + ] { + assert!(matches!( + endpoint().correlate_session_id(expected), + Err(WebDriverBiDiWebSocketEndpointCorrelationError::InvalidExpectedSessionId) + )); + } +} + +#[test] +fn session_correlation_errors_are_deterministic_and_source_free() { + for error in [ + WebDriverBiDiWebSocketEndpointCorrelationError::InvalidExpectedSessionId, + WebDriverBiDiWebSocketEndpointCorrelationError::SessionIdMismatch, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs b/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs new file mode 100644 index 000000000..70802e89c --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs @@ -0,0 +1,156 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextDispatchTarget, + BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserRegistryError, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiLocateNodesResponseEnvelopeError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 1)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 42, + "context-a", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + origin: &'a Origin, + external_context: &str, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, external_context)?; + let epoch = registry.bind_context_origin(session, context, origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + origin, + ), + epoch, + )) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +fn successful_wire_document() -> Result> { + Ok(BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"}]}}"#, + )?) +} + +fn mismatched_wire_document() -> Result> { + Ok(BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":43,"result":{"nodes":[{"type":"node","sharedId":"node-a"}]}}"#, + )?) +} + +#[test] +fn wire_response_binds_nodes_to_exact_current_authority_without_caller_selected_intermediate_result() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + + let handles = locate_nodes_command()?.bind_response_document_nodes( + successful_wire_document()?, + semantic_observation_proof()?, + &mut registry, + target, + )?; + + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].origin(), &origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + Ok(()) +} + +#[test] +fn wire_response_binding_preserves_wire_correlation_failure_before_authority() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + + let error = locate_nodes_command()?.bind_response_document_nodes( + mismatched_wire_document()?, + semantic_observation_proof()?, + &mut registry, + target, + ); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 43, + }, + ), + )) + ); + Ok(()) +} + +#[test] +fn wire_response_binding_preserves_current_context_authority_failure() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-b")?; + + let error = locate_nodes_command()?.bind_response_document_nodes( + successful_wire_document()?, + semantic_observation_proof()?, + &mut registry, + target, + ); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding( + WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::ContextExternalIdentifierMismatch, + ), + )) + ); + let error = error + .err() + .ok_or("expected exact current context failure")?; + assert!(error.source().is_some()); + assert!(!error.to_string().is_empty()); + Ok(()) +} diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index d5b26c1c3..a77d9b794 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -1,15 +1,29 @@ //! Direct-only policy-bound TCP connection authority for OriginWeave. //! -//! The crate consumes a validated connection plan, opens one exact socket -//! address without hostname resolution or proxy inheritance, verifies the -//! operating-system peer, and emits credential-free evidence. +//! The crate consumes validated connection plans, opens exact socket addresses +//! without hostname resolution or proxy inheritance, verifies operating-system +//! 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 and can bind an inert +//! RFC 6455 opening request to an already-verified plain BiDi stream without +//! granting browser, WebSocket, TLS, policy, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] mod connection; +mod webdriver_bidi_connection; +mod webdriver_bidi_websocket_handshake; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, NetworkError, SocketConnectionEvidence, }; +pub use webdriver_bidi_connection::{ + WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, + WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, +}; +pub use webdriver_bidi_websocket_handshake::{ + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, + WebDriverBiDiWebSocketHandshakePlan, +}; diff --git a/crates/originweave-network/src/webdriver_bidi_connection.rs b/crates/originweave-network/src/webdriver_bidi_connection.rs new file mode 100644 index 000000000..5d39bb5e3 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection.rs @@ -0,0 +1,254 @@ +use std::{ + io, + net::{SocketAddr, TcpStream}, + time::Duration, +}; + +use originweave_core::{VerifiedWebDriverBiDiSocketPeer, WebDriverBiDiWebSocketConnectTarget}; + +use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; + +mod error; + +pub use error::WebDriverBiDiTcpConnectionError; + +#[cfg(test)] +mod tests; + +fn is_retryable_connect_error(kind: io::ErrorKind) -> bool { + matches!( + kind, + io::ErrorKind::TimedOut + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::Interrupted + ) +} + +/// Single-use authority to open one exact WebDriver BiDi loopback TCP destination. +/// +/// The plan consumes a session-correlated, no-DNS [`WebDriverBiDiWebSocketConnectTarget`] +/// produced by `originweave-core`. It applies the same bounded per-attempt timeout and retry +/// ceilings as the general direct-network connector, opens only the exact [`SocketAddr`] carried by +/// that target, and does not expose the stream until the operating system's observed peer has been +/// verified by the consumed target. +/// +/// This boundary performs no DNS lookup, proxy or PAC routing, Chromium/ChromeDriver process +/// authentication, TLS negotiation, WebSocket upgrade, BiDi framing, browser policy decision, or +/// Agent-authority grant. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnectionPlan { + target: WebDriverBiDiWebSocketConnectTarget, + connect_timeout: Duration, + maximum_attempts: u8, +} + +impl WebDriverBiDiTcpConnectionPlan { + /// Validate one bounded exact-loopback connection plan without performing network I/O. + pub fn new( + target: WebDriverBiDiWebSocketConnectTarget, + connect_timeout: Duration, + maximum_attempts: u8, + ) -> Result { + if connect_timeout.is_zero() || connect_timeout > MAX_CONNECT_TIMEOUT { + return Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { + connect_timeout, + maximum_timeout: MAX_CONNECT_TIMEOUT, + }); + } + if maximum_attempts == 0 || maximum_attempts > MAX_CONNECTION_ATTEMPTS { + return Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { + attempt_count: maximum_attempts, + maximum_attempts: MAX_CONNECTION_ATTEMPTS, + }); + } + + Ok(Self { + target, + connect_timeout, + maximum_attempts, + }) + } + + /// Open the exact approved loopback socket and expose it only after peer verification. + /// + /// Retry is limited to transport errors that can occur transiently while a local browser driver + /// listener is becoming ready. Peer-inspection and peer-mismatch failures are integrity failures + /// and therefore fail closed without retry or fallback. + pub fn connect(self) -> Result { + self.connect_with(&SystemWebDriverBiDiConnector) + } + + fn connect_with( + self, + connector: &dyn WebDriverBiDiSocketConnector, + ) -> Result { + let socket_address = self.target.socket_addr(); + let connect_timeout = self.connect_timeout; + let maximum_attempts = self.maximum_attempts; + let target = self.target; + let mut attempt_number = 1; + + loop { + match connector.connect_timeout(&socket_address, connect_timeout) { + Ok(stream) => { + let observed_peer = connector.peer_addr(&stream).map_err(|source| { + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + socket_address, + attempt_number, + source, + } + })?; + let verified_peer = + target + .verify_connected_peer(observed_peer) + .map_err(|source| WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number, + source, + })?; + return Ok(WebDriverBiDiTcpConnection { + stream, + verified_peer, + attempt_number, + connect_timeout, + }); + } + Err(source) + if is_retryable_connect_error(source.kind()) + && attempt_number < maximum_attempts => + { + attempt_number += 1; + } + Err(source) => { + if source.kind() == io::ErrorKind::TimedOut { + return Err(WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + socket_address, + attempt_count: attempt_number, + connect_timeout, + source, + }); + } + return Err(WebDriverBiDiTcpConnectionError::ConnectionFailed { + socket_address, + attempt_count: attempt_number, + source, + }); + } + } + } + } +} + +trait WebDriverBiDiSocketConnector { + fn connect_timeout( + &self, + socket_address: &SocketAddr, + timeout: Duration, + ) -> io::Result; + + fn peer_addr(&self, stream: &TcpStream) -> io::Result; +} + +struct SystemWebDriverBiDiConnector; + +impl WebDriverBiDiSocketConnector for SystemWebDriverBiDiConnector { + fn connect_timeout( + &self, + socket_address: &SocketAddr, + timeout: Duration, + ) -> io::Result { + TcpStream::connect_timeout(socket_address, timeout) + } + + fn peer_addr(&self, stream: &TcpStream) -> io::Result { + stream.peer_addr() + } +} + +/// Established WebDriver BiDi TCP stream whose observed peer matched the approved target exactly. +/// +/// This wrapper proves only exact transport-destination equality for one bounded connection. The +/// caller must still establish any required TLS channel, complete a WebSocket handshake, bind the +/// transport to the expected browser process/session, and pass separate action-policy checks. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnection { + stream: TcpStream, + verified_peer: VerifiedWebDriverBiDiSocketPeer, + attempt_number: u8, + connect_timeout: Duration, +} + +impl WebDriverBiDiTcpConnection { + /// Borrow the verified TCP stream. + #[must_use] + pub const fn stream(&self) -> &TcpStream { + &self.stream + } + + /// Borrow the session-correlated exact peer evidence consumed by this connection. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + &self.verified_peer + } + + /// Return the one-based bounded attempt on which the connection succeeded. + #[must_use] + pub const fn attempt_number(&self) -> u8 { + self.attempt_number + } + + /// Return the per-attempt timeout applied while establishing this connection. + #[must_use] + pub const fn connect_timeout(&self) -> Duration { + self.connect_timeout + } + + /// Consume the wrapper into the original verified stream and credential-free transport evidence. + /// + /// This handoff does not clone the socket or create reusable connection authority. The returned + /// evidence records only the already-verified peer plus bounded connection-attempt metadata; it + /// does not authenticate a browser process, establish TLS, complete WebSocket framing, or grant + /// browser or Agent authority. + #[must_use] + pub fn into_parts(self) -> (TcpStream, WebDriverBiDiTcpConnectionEvidence) { + let evidence = WebDriverBiDiTcpConnectionEvidence { + verified_peer: self.verified_peer, + attempt_number: self.attempt_number, + connect_timeout: self.connect_timeout, + }; + (self.stream, evidence) + } +} + +/// Credential-free evidence retained when a verified WebDriver BiDi TCP stream is consumed. +/// +/// This value records exact peer/session/TLS-requirement metadata inherited from the consumed +/// no-DNS target together with the successful bounded attempt and per-attempt timeout. It is +/// transport evidence only and grants no process, TLS, WebSocket, browser-action, or Agent authority. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnectionEvidence { + verified_peer: VerifiedWebDriverBiDiSocketPeer, + attempt_number: u8, + connect_timeout: Duration, +} + +impl WebDriverBiDiTcpConnectionEvidence { + /// Borrow the exact session-correlated peer verified before stream exposure. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + &self.verified_peer + } + + /// Return the one-based bounded attempt on which the connection succeeded. + #[must_use] + pub const fn attempt_number(&self) -> u8 { + self.attempt_number + } + + /// Return the per-attempt timeout applied while establishing the connection. + #[must_use] + pub const fn connect_timeout(&self) -> Duration { + self.connect_timeout + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_connection/error.rs b/crates/originweave-network/src/webdriver_bidi_connection/error.rs new file mode 100644 index 000000000..226cd1d2b --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection/error.rs @@ -0,0 +1,134 @@ +use std::{fmt, io, net::SocketAddr, time::Duration}; + +use originweave_core::WebDriverBiDiSocketPeerVerificationError; + +/// Deterministic failures while establishing one bounded WebDriver BiDi TCP transport. +#[derive(Debug)] +pub enum WebDriverBiDiTcpConnectionError { + /// The requested timeout was zero or exceeded [`crate::MAX_CONNECT_TIMEOUT`]. + InvalidConnectTimeout { + /// The rejected timeout. + connect_timeout: Duration, + /// The largest accepted per-attempt timeout. + maximum_timeout: Duration, + }, + /// The requested attempt count was outside `1..=MAX_CONNECTION_ATTEMPTS`. + InvalidAttemptCount { + /// The rejected attempt count. + attempt_count: u8, + /// The largest accepted attempt count. + maximum_attempts: u8, + }, + /// The final bounded connection attempt timed out. + ConnectionTimedOut { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// Number of attempts completed before failure. + attempt_count: u8, + /// Per-attempt timeout used by the plan. + connect_timeout: Duration, + /// Final operating-system timeout error. + source: io::Error, + }, + /// The final bounded connection attempt failed without a timeout. + ConnectionFailed { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// Number of attempts completed before failure. + attempt_count: u8, + /// Final operating-system connection error. + source: io::Error, + }, + /// The established stream did not reveal an operating-system peer address. + PeerInspectionFailed { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// One-based attempt that established the stream. + attempt_number: u8, + /// Operating-system peer-inspection error. + source: io::Error, + }, + /// The established stream reported a peer other than the exact approved BiDi target. + PeerMismatch { + /// One-based attempt that established the stream. + attempt_number: u8, + /// Typed core peer-verification failure preserving expected and actual socket addresses. + source: WebDriverBiDiSocketPeerVerificationError, + }, +} + +impl WebDriverBiDiTcpConnectionError { + /// Return the number of transport attempts associated with this failure, when applicable. + #[must_use] + pub const fn attempt_count(&self) -> Option { + match self { + Self::ConnectionTimedOut { attempt_count, .. } + | Self::ConnectionFailed { attempt_count, .. } => Some(*attempt_count), + Self::PeerInspectionFailed { attempt_number, .. } + | Self::PeerMismatch { attempt_number, .. } => Some(*attempt_number), + Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + } + } +} + +impl fmt::Display for WebDriverBiDiTcpConnectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConnectTimeout { + connect_timeout, + maximum_timeout, + } => write!( + formatter, + "WebDriver BiDi connect timeout {connect_timeout:?} is outside 1ns..={maximum_timeout:?}", + ), + Self::InvalidAttemptCount { + attempt_count, + maximum_attempts, + } => write!( + formatter, + "WebDriver BiDi connection attempt count {attempt_count} is outside 1..={maximum_attempts}", + ), + Self::ConnectionTimedOut { + socket_address, + attempt_count, + connect_timeout, + .. + } => write!( + formatter, + "WebDriver BiDi TCP connection to {socket_address} timed out after {attempt_count} attempts with per-attempt timeout {connect_timeout:?}", + ), + Self::ConnectionFailed { + socket_address, + attempt_count, + .. + } => write!( + formatter, + "WebDriver BiDi TCP connection to {socket_address} failed after {attempt_count} attempts", + ), + Self::PeerInspectionFailed { + socket_address, + attempt_number, + .. + } => write!( + formatter, + "WebDriver BiDi TCP peer inspection failed for {socket_address} on attempt {attempt_number}", + ), + Self::PeerMismatch { attempt_number, .. } => write!( + formatter, + "WebDriver BiDi TCP peer did not match the approved target on attempt {attempt_number}", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiTcpConnectionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ConnectionTimedOut { source, .. } + | Self::ConnectionFailed { source, .. } + | Self::PeerInspectionFailed { source, .. } => Some(source), + Self::PeerMismatch { source, .. } => Some(source), + Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + } + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs new file mode 100644 index 000000000..e2d287ca1 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs @@ -0,0 +1,361 @@ +#![allow(clippy::expect_used)] + +use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + error::Error, + io, + net::{SocketAddr, TcpListener, TcpStream}, + time::Duration, +}; + +use originweave_core::{WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketEndpoint}; + +use super::{ + WebDriverBiDiSocketConnector, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, + is_retryable_connect_error, +}; +use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn socket_address() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 9515)) +} + +enum ConnectOutcome { + Success(TcpStream), + Error(io::ErrorKind), +} + +enum PeerOutcome { + Address(SocketAddr), + Error(io::ErrorKind), +} + +struct FakeConnector { + connect_outcomes: RefCell>, + peer_outcomes: RefCell>, + connect_calls: Cell, + peer_calls: Cell, +} + +impl FakeConnector { + fn new(connect_outcomes: Vec, peer_outcomes: Vec) -> Self { + Self { + connect_outcomes: RefCell::new(connect_outcomes.into()), + peer_outcomes: RefCell::new(peer_outcomes.into()), + connect_calls: Cell::new(0), + peer_calls: Cell::new(0), + } + } +} + +impl WebDriverBiDiSocketConnector for FakeConnector { + fn connect_timeout( + &self, + _socket_address: &SocketAddr, + _timeout: Duration, + ) -> io::Result { + self.connect_calls.set(self.connect_calls.get() + 1); + match self + .connect_outcomes + .borrow_mut() + .pop_front() + .expect("test must provide a connection outcome") + { + ConnectOutcome::Success(stream) => Ok(stream), + ConnectOutcome::Error(kind) => Err(io::Error::from(kind)), + } + } + + fn peer_addr(&self, _stream: &TcpStream) -> io::Result { + self.peer_calls.set(self.peer_calls.get() + 1); + match self + .peer_outcomes + .borrow_mut() + .pop_front() + .expect("test must provide a peer outcome") + { + PeerOutcome::Address(address) => Ok(address), + PeerOutcome::Error(kind) => Err(io::Error::from(kind)), + } + } +} + +fn loopback_stream() -> TcpStream { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback listener"); + let address = listener + .local_addr() + .expect("read loopback listener address"); + let client = TcpStream::connect(address).expect("connect loopback client"); + let (server, _) = listener.accept().expect("accept loopback client"); + drop(server); + client +} + +fn connect_target(secure: bool) -> WebDriverBiDiWebSocketConnectTarget { + let scheme = if secure { "wss" } else { "ws" }; + let endpoint = format!("{scheme}://127.0.0.1:9515/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint).expect("admit endpoint"); + let correlated = admitted + .correlate_session_id(SESSION_ID) + .expect("correlate endpoint"); + correlated + .into_explicit_connect_target() + .expect("derive explicit connect target") +} + +fn plan(maximum_attempts: u8) -> WebDriverBiDiTcpConnectionPlan { + WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + maximum_attempts, + ) + .expect("valid test plan") +} + +#[test] +fn validates_timeout_and_attempt_bounds_before_io() { + let zero_timeout = + WebDriverBiDiTcpConnectionPlan::new(connect_target(false), Duration::ZERO, 1); + assert!(matches!( + zero_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let excessive_timeout = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + MAX_CONNECT_TIMEOUT + Duration::from_nanos(1), + 1, + ); + assert!(matches!( + excessive_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let zero_attempts = + WebDriverBiDiTcpConnectionPlan::new(connect_target(false), Duration::from_millis(250), 0); + assert!(matches!( + zero_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); + + let excessive_attempts = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + MAX_CONNECTION_ATTEMPTS + 1, + ); + assert!(matches!( + excessive_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); +} + +#[test] +fn verified_peer_is_required_before_stream_exposure() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Address(socket_address())], + ); + let connection = + WebDriverBiDiTcpConnectionPlan::new(connect_target(true), Duration::from_millis(250), 1) + .expect("valid plan") + .connect_with(&connector) + .expect("verified connection"); + + assert!(connection.stream().peer_addr().is_ok()); + assert_eq!(connection.verified_peer().socket_addr(), socket_address()); + assert!(connection.verified_peer().requires_tls()); + assert_eq!(connection.verified_peer().session_id(), SESSION_ID); + assert_eq!(connection.attempt_number(), 1); + assert_eq!(connection.connect_timeout(), Duration::from_millis(250)); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); +} + +#[test] +fn all_recoverable_connect_kinds_can_retry_once() { + for kind in [ + io::ErrorKind::TimedOut, + io::ErrorKind::ConnectionRefused, + io::ErrorKind::ConnectionReset, + io::ErrorKind::ConnectionAborted, + io::ErrorKind::Interrupted, + ] { + assert!(is_retryable_connect_error(kind)); + let connector = FakeConnector::new( + vec![ + ConnectOutcome::Error(kind), + ConnectOutcome::Success(loopback_stream()), + ], + vec![PeerOutcome::Address(socket_address())], + ); + let connection = plan(2) + .connect_with(&connector) + .expect("second bounded attempt succeeds"); + assert_eq!(connection.attempt_number(), 2); + assert_eq!(connector.connect_calls.get(), 2); + assert_eq!(connector.peer_calls.get(), 1); + } + assert!(!is_retryable_connect_error(io::ErrorKind::PermissionDenied)); +} + +#[test] +fn final_timeout_preserves_source_and_attempt_count() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::TimedOut)], + Vec::new(), + ); + let error = plan(1) + .connect_with(&connector) + .expect_err("timeout must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + attempt_count: 1, + .. + } + )); + assert!(error.source().is_some()); + assert_eq!(error.attempt_count(), Some(1)); +} + +#[test] +fn exhausted_retryable_non_timeout_error_is_connection_failure() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::ConnectionRefused)], + Vec::new(), + ); + let error = plan(1) + .connect_with(&connector) + .expect_err("refusal must fail after the bounded final attempt"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + attempt_count: 1, + .. + } + )); + assert!(error.source().is_some()); +} + +#[test] +fn non_retryable_connection_error_fails_without_retry() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::PermissionDenied)], + Vec::new(), + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("permission failure must not retry"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + attempt_count: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); +} + +#[test] +fn peer_inspection_failure_is_not_retried() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Error(io::ErrorKind::NotConnected)], + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("peer inspection failure must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + attempt_number: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); + assert!(error.source().is_some()); +} + +#[test] +fn peer_mismatch_is_not_retried_or_converted_to_success() { + let wrong_peer = SocketAddr::from(([127, 0, 0, 1], 9516)); + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Address(wrong_peer)], + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("peer mismatch must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); + assert!(error.source().is_some()); +} + +#[test] +fn error_display_source_and_attempt_contracts_cover_every_variant() { + let mismatch = connect_target(false) + .verify_connected_peer(SocketAddr::from(([127, 0, 0, 1], 9516))) + .expect_err("wrong peer must fail"); + let errors = [ + WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { + connect_timeout: Duration::ZERO, + maximum_timeout: MAX_CONNECT_TIMEOUT, + }, + WebDriverBiDiTcpConnectionError::InvalidAttemptCount { + attempt_count: 0, + maximum_attempts: MAX_CONNECTION_ATTEMPTS, + }, + WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + socket_address: socket_address(), + attempt_count: 2, + connect_timeout: Duration::from_millis(250), + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + socket_address: socket_address(), + attempt_count: 3, + source: io::Error::from(io::ErrorKind::ConnectionRefused), + }, + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + socket_address: socket_address(), + attempt_number: 1, + source: io::Error::from(io::ErrorKind::NotConnected), + }, + WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number: 1, + source: mismatch, + }, + ]; + + let messages: Vec = errors.iter().map(ToString::to_string).collect(); + assert!(messages[0].contains("outside 1ns")); + assert!(messages[1].contains("attempt count 0")); + assert!(messages[2].contains("timed out after 2 attempts")); + assert!(messages[3].contains("failed after 3 attempts")); + assert!(messages[4].contains("peer inspection failed")); + assert!(messages[5].contains("did not match the approved target")); + + assert_eq!(errors[0].attempt_count(), None); + assert_eq!(errors[1].attempt_count(), None); + assert_eq!(errors[2].attempt_count(), Some(2)); + assert_eq!(errors[3].attempt_count(), Some(3)); + assert_eq!(errors[4].attempt_count(), Some(1)); + assert_eq!(errors[5].attempt_count(), Some(1)); + assert!(errors[0].source().is_none()); + assert!(errors[1].source().is_none()); + assert!(errors[2].source().is_some()); + assert!(errors[3].source().is_some()); + assert!(errors[4].source().is_some()); + assert!(errors[5].source().is_some()); +} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs new file mode 100644 index 000000000..026fa390b --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -0,0 +1,155 @@ +use std::fmt; + +use originweave_core::VerifiedWebDriverBiDiSocketPeer; + +use crate::WebDriverBiDiTcpConnection; + +const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +const REDACTED_WEBSOCKET_CLIENT_NONCE: &str = ""; + +fn is_base64_data_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') +} + +fn is_canonical_16_byte_base64(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH + && bytes[..22].iter().copied().all(is_base64_data_byte) + && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') + && bytes[22] == b'=' + && bytes[23] == b'=' +} + +/// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. +#[derive(Debug, Eq, PartialEq)] +pub enum WebDriverBiDiWebSocketHandshakeError { + /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. + InvalidClientKey, + /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. + TlsRequired, +} + +impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidClientKey => formatter.write_str( + "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", + ), + Self::TlsRequired => formatter.write_str( + "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiWebSocketHandshakeError {} + +/// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. +/// +/// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type +/// validates only the canonical wire representation, including zero padding bits. It does not +/// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce +/// for each connection attempt. Its [`fmt::Debug`] representation deliberately redacts the nonce so +/// diagnostic output cannot disclose handshake material. +#[derive(Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketClientKey(String); + +impl fmt::Debug for WebDriverBiDiWebSocketClientKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("WebDriverBiDiWebSocketClientKey") + .field(&REDACTED_WEBSOCKET_CLIENT_NONCE) + .finish() + } +} + +impl WebDriverBiDiWebSocketClientKey { + /// Admit one canonical base64 client key representing exactly 16 bytes. + pub fn new(value: &str) -> Result { + if !is_canonical_16_byte_base64(value) { + return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); + } + Ok(Self(value.to_owned())) + } + + /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. +/// +/// The plan consumes the verified TCP connection so the opening request cannot be detached from the +/// socket peer/session evidence that authorized its exact loopback destination. It serializes only +/// the fixed WebSocket version-13 request required for the admitted `/session/` resource +/// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. +/// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary +/// before any WebSocket bytes may be written. Its [`fmt::Debug`] representation omits the serialized +/// request and redacts the client nonce because the request embeds that nonce in `Sec-WebSocket-Key`. +/// +/// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` +/// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or +/// Agent-authority grant. +pub struct WebDriverBiDiWebSocketHandshakePlan { + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + request: Vec, +} + +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketHandshakePlan") + .field("verified_peer", self.connection.verified_peer()) + .field("client_nonce", &REDACTED_WEBSOCKET_CLIENT_NONCE) + .field("request_byte_count", &self.request.len()) + .finish() + } +} + +impl WebDriverBiDiWebSocketHandshakePlan { + /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. + pub fn new( + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + ) -> Result { + if connection.verified_peer().requires_tls() { + return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); + } + + let peer = connection.verified_peer(); + let request = format!( + "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", + peer.session_id(), + peer.socket_addr(), + client_key.as_str(), + ) + .into_bytes(); + + Ok(Self { + connection, + client_key, + request, + }) + } + + /// Borrow the exact serialized RFC 6455 opening-request bytes. + #[must_use] + pub fn request_bytes(&self) -> &[u8] { + &self.request + } + + /// Borrow the exact client key that a later server-handshake validator must correlate. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + + /// Borrow the exact peer/session evidence already verified before request construction. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + self.connection.verified_peer() + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs new file mode 100644 index 000000000..fac15b6a5 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs @@ -0,0 +1,94 @@ +use std::{net::TcpListener, thread, time::Duration}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn connect_target(endpoint: &str) -> originweave_core::WebDriverBiDiWebSocketConnectTarget { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted literal loopback target") + }; + target +} + +#[test] +fn exact_loopback_target_opens_one_verified_bidi_tcp_stream() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + + let server = thread::spawn(move || listener.accept().map(|_| ())); + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + return; + }; + + assert_eq!(connection.verified_peer().socket_addr(), local_addr); + assert!(!connection.verified_peer().requires_tls()); + assert_eq!(connection.verified_peer().session_id(), SESSION_ID); + assert_eq!(connection.attempt_number(), 1); + assert_eq!(connection.connect_timeout(), Duration::from_secs(1)); + + let (stream, evidence) = connection.into_parts(); + assert_eq!(stream.peer_addr().ok(), Some(local_addr)); + assert_eq!(evidence.verified_peer().socket_addr(), local_addr); + assert!(!evidence.verified_peer().requires_tls()); + assert_eq!(evidence.verified_peer().session_id(), SESSION_ID); + assert_eq!(evidence.attempt_number(), 1); + assert_eq!(evidence.connect_timeout(), Duration::from_secs(1)); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + +#[test] +fn bidi_tcp_plan_rejects_invalid_retry_settings_before_io() { + let endpoint = format!("wss://127.0.0.1:9443/session/{SESSION_ID}"); + let zero_timeout = + WebDriverBiDiTcpConnectionPlan::new(connect_target(&endpoint), Duration::ZERO, 1); + assert!(matches!( + zero_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let zero_attempts = + WebDriverBiDiTcpConnectionPlan::new(connect_target(&endpoint), Duration::from_secs(1), 0); + assert!(matches!( + zero_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs new file mode 100644 index 000000000..93550245a --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -0,0 +1,202 @@ +use std::{net::TcpListener, thread, time::Duration}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REDACTED_CLIENT_KEY: &str = ""; + +fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted explicit target") + }; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + unreachable!("asserted connection plan") + }; + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + unreachable!("asserted loopback connection") + }; + connection +} + +#[test] +fn client_key_debug_redacts_websocket_nonce() { + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + + let debug = format!("{key:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); +} + +#[test] +fn handshake_plan_debug_redacts_websocket_nonce() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let debug = format!("{plan:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + +#[test] +fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let expected = format!( + "GET /session/{SESSION_ID} HTTP/1.1\r\nHost: {local_addr}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {RFC6455_SAMPLE_KEY}\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + assert_eq!(plan.request_bytes(), expected.as_bytes()); + assert_eq!(plan.client_key().as_str(), RFC6455_SAMPLE_KEY); + assert_eq!(plan.verified_peer().socket_addr(), local_addr); + assert_eq!(plan.verified_peer().session_id(), SESSION_ID); + assert!(!plan.verified_peer().requires_tls()); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + +#[test] +fn handshake_errors_render_actionable_fail_closed_messages() { + assert_eq!( + WebDriverBiDiWebSocketHandshakeError::InvalidClientKey.to_string(), + "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes" + ); + assert_eq!( + WebDriverBiDiWebSocketHandshakeError::TlsRequired.to_string(), + "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request" + ); +} + +#[test] +fn handshake_plan_rejects_tls_required_stream_and_noncanonical_client_keys() { + let invalid_length = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ="); + assert!(matches!( + invalid_length, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + let invalid_character = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZ!=="); + assert!(matches!( + invalid_character, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + let invalid_padding_bits = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZR=="); + assert!(matches!( + invalid_padding_bits, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + let invalid_padding_character = + WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQA="); + assert!(matches!( + invalid_padding_character, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("wss://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(matches!( + plan, + Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired) + )); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 9d35587d3..dbf3ef731 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -14,6 +14,8 @@ For the bounded `browsingContext.locateNodes` command-serialization boundary, th 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. + Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). ## Chrome Manifest V3 diff --git a/tests/test_webdriver_bidi_connect_target_governance.py b/tests/test_webdriver_bidi_connect_target_governance.py new file mode 100644 index 000000000..de7949478 --- /dev/null +++ b/tests/test_webdriver_bidi_connect_target_governance.py @@ -0,0 +1,35 @@ +"""Governance regression for explicit WebDriver BiDi socket destinations.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +CHANGELOG = ROOT / "CHANGELOG.md" + + +class WebDriverBiDiConnectTargetGovernanceTests(unittest.TestCase): + """Keep the active no-DNS transport boundary visible in release evidence.""" + + def test_changelog_records_explicit_no_dns_connect_target_boundary(self) -> None: + """The production connect-target slice must have a truthful Unreleased record.""" + changelog = CHANGELOG.read_text(encoding="utf-8") + self.assertIn( + "Explicit no-DNS WebDriver BiDi loopback connection targets", + changelog, + ) + self.assertIn("localhost", changelog) + self.assertIn("no socket I/O", changelog) + self.assertIn("no Agent authority", changelog) + + def test_changelog_records_exact_connected_peer_verification_boundary(self) -> None: + """Verified socket-peer metadata must be visible without overstating transport trust.""" + changelog = CHANGELOG.read_text(encoding="utf-8") + self.assertIn("Exact WebDriver BiDi socket-peer verification", changelog) + self.assertIn("IP address and port", changelog) + self.assertIn("does not authenticate an OS process", changelog) + self.assertIn("does not negotiate TLS", changelog) + + +if __name__ == "__main__": + unittest.main()