diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9b23ef9f0..be1bd6096 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,7 +166,7 @@ Observation should prefer the most structured trustworthy source available: 4. accessibility tree combined with DOM and layout; 5. screenshot or vision fallback for canvas and inaccessible custom interfaces. -Raw HTML is not the default model input. Full snapshots are followed by incremental semantic diffs, versioned by document epoch. Node references become invalid after navigation or epoch change. +Raw HTML is not the default model input. Full snapshots are followed by incremental semantic diffs, versioned by document epoch. Node references become invalid after navigation or epoch change. An untrusted WebDriver BiDi `locateNodes` result becomes an `ObservedNodeHandle` only after the adapter transfers a non-cloneable `QueryNodes` / `SemanticObservation` protocol-use proof into `bind_current_nodes` and the exact current session, browsing context, canonical origin, and document epoch still match. Navigation or TypedInput proofs fail closed. That control-plane composition does not perform browser I/O or authorize typed input. ## 8. Action lifecycle diff --git a/CHANGELOG.md b/CHANGELOG.md index d5558e982..c8e40e7c2 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. - Bound explicit extension-to-Agent grants to a nonzero host-assigned Agent Task identity, so a grant that otherwise matches extension, session, browsing context, origin, expiry, and capability fails closed when reused by a different task. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. @@ -17,8 +20,23 @@ All notable changes to OriginWeave are documented in this file. The format follo - Runtime browser-adapter version binding at the atomic protocol-use boundary: the caller-supplied bounded adapter-version token must exactly match the reviewed descriptor version before runtime revision or capability checks can succeed, preventing adapter-build drift from silently reusing otherwise matching protocol/browser metadata without authenticating or attesting the adapter process. - Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority. - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. +- Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. +- Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. +- Deterministic WebDriver BiDi `browsingContext.locateNodes` command serialization that accepts only protocol-range `js-uint` identifiers and bounded injection-safe browsing-context identifiers, JSON-escapes reviewed query text, and emits the exact accessibility locator, finite node budget, and minimal serialization options without performing transport I/O or granting browser or Agent authority. +- 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. +- Correlated WebDriver BiDi `locateNodes` result admission that consumes exact response-correlation evidence, revalidates the serialized browsing-context identifier against the registered browsing-context identity, enforces the command's node budget, and binds admitted nodes atomically without granting browser or Agent authority. +- 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. +- Fail-closed rejection of reviewed Unicode format and bidirectional-override characters in accessibility roles, accessible names, BiDi `sharedId` values, and registry external identifiers, while ordinary spaces in accessible names remain valid. - Explicit `BrowserAuthorityRegistry::bind_context_origin` registration that binds one canonical origin to the exact current browser session, browsing context, and document epoch before origin-sensitive protocol use, rejecting cross-session ownership and same-epoch origin changes without granting navigation or action authority. - Explicit `BrowserAuthorityRegistry::require_context_origin` revalidation that requires the exact registered canonical origin for the current browser session, browsing context, and document epoch before origin-sensitive protocol use, failing closed when binding is absent or mismatched without granting navigation or action authority. +- Origin-gated `dispatch_if_context_origin_current` composition that revalidates the exact registered session, browsing context, and canonical origin, carries the current document epoch into the same synchronous callback, and then validates protocol metadata and capability without claiming destination, network, TLS, adapter-authentication, action, or post-condition authority. +- Epoch-gated `dispatch_if_context_origin_epoch_current` composition that additionally requires the registry's current document epoch to equal the observed epoch before protocol validation or callback execution, failing closed on same-origin navigation without granting browser, action, or post-condition authority. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs new file mode 100644 index 000000000..3af93cb07 --- /dev/null +++ b/crates/originweave-core/src/browser_authority_registry.rs @@ -0,0 +1,163 @@ +use crate::browser_registry::BrowserAuthorityRegistry as RawBrowserAuthorityRegistry; +use crate::{ + BrowserRegistryError, BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, + Origin, +}; + +/// Public browser-authority registry with raw node minting kept inside the crate. +/// +/// Browser-session, browsing-context, document-epoch, and canonical-origin lifecycle operations are +/// public because trusted adapters need them to maintain current authority. Converting untrusted +/// browser-protocol node identifiers into [`ObservedNodeHandle`] values is deliberately +/// crate-private: external callers must use a reviewed semantic-observation admission boundary such +/// as [`crate::WebDriverBiDiAccessibilityQuery::bind_current_nodes`], which consumes the required +/// protocol-use proof, validates the complete batch, and revalidates the exact current document +/// before atomically minting handles. +pub struct BrowserAuthorityRegistry { + inner: RawBrowserAuthorityRegistry, +} + +impl BrowserAuthorityRegistry { + /// Create an empty registry with the reviewed default per-namespace identifier capacity. + #[must_use] + pub fn new() -> Self { + Self { + inner: RawBrowserAuthorityRegistry::new(), + } + } + + /// Create an empty registry with a caller-selected per-namespace identifier capacity. + /// + /// Session, browsing-context, and node identifiers retain independent monotonic namespaces. + /// The node namespace is still reachable only through crate-owned semantic admission. + #[must_use] + pub fn with_identifier_limit(maximum_identifier: u64) -> Self { + Self { + inner: RawBrowserAuthorityRegistry::with_identifier_limit(maximum_identifier), + } + } + + /// Register one opaque external browser-session identifier. + pub fn register_session( + &mut self, + external_identifier: &str, + ) -> Result { + self.inner.register_session(external_identifier) + } + + /// Register one opaque external browsing-context identifier inside a known browser session. + pub fn register_context( + &mut self, + browser_session: BrowserSessionId, + external_identifier: &str, + ) -> Result { + self.inner + .register_context(browser_session, external_identifier) + } + + /// Retire one browsing context and all registry-local authority derived from it. + pub fn remove_context( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result<(), BrowserRegistryError> { + self.inner.remove_context(browsing_context) + } + + /// Retire one browser session and every registered context and node binding beneath it. + pub fn remove_session( + &mut self, + browser_session: BrowserSessionId, + ) -> Result<(), BrowserRegistryError> { + self.inner.remove_session(browser_session) + } + + /// Return the currently active document epoch for a known browsing context. + pub fn current_epoch( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.inner.current_epoch(browsing_context) + } + + /// Return the current document epoch only when the supplied session owns the context. + pub fn current_context_epoch( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + ) -> Result { + self.inner + .current_context_epoch(browser_session, browsing_context) + } + + /// Require an opaque external browsing-context identifier to name this exact context. + pub(crate) fn require_context_external_identifier( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_identifier: &str, + ) -> Result<(), BrowserRegistryError> { + self.inner.require_context_external_identifier( + browser_session, + browsing_context, + external_identifier, + ) + } + + /// Bind the canonical origin observed for the exact current browser document. + pub fn bind_context_origin( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + self.inner + .bind_context_origin(browser_session, browsing_context, origin) + } + + /// Revalidate the canonical origin bound to the exact current browser document. + pub fn require_context_origin( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + self.inner + .require_context_origin(browser_session, browsing_context, origin) + } + + /// Advance a browsing context to the next document epoch and invalidate old node bindings. + pub fn advance_document( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + self.inner.advance_document(browsing_context) + } + + /// Bind one admitted batch of adapter-local node identifiers to current browser authority. + /// + /// This operation is intentionally crate-private. The raw registry commits the batch only when + /// every identifier can be bound; a later failure rolls back node identifiers and any origin + /// binding created by the batch before the error is returned. Production callers outside this + /// crate therefore cannot bypass semantic admission or observe partial authority from a failed + /// `locateNodes` result. + pub(crate) fn bind_nodes( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifiers: &[&str], + ) -> Result, BrowserRegistryError> { + self.inner.bind_nodes( + browser_session, + browsing_context, + origin, + external_identifiers, + ) + } +} + +impl Default for BrowserAuthorityRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs index 97c7777da..14bafb3d6 100644 --- a/crates/originweave-core/src/browser_protocol_dispatch.rs +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -3,7 +3,8 @@ use std::fmt; use crate::{ BrowserAuthorityRegistry, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolUseValidationError, BrowserRegistryError, BrowserSessionId, - BrowsingContextId, DocumentEpoch, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + BrowsingContextId, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, }; /// Current runtime metadata sampled from the browser-protocol adapter about to perform I/O. @@ -78,6 +79,79 @@ impl BrowserContextDispatchTarget { } } +/// Exact browser context plus the canonical origin expected immediately before protocol dispatch. +/// +/// Grouping these values keeps one authority target explicit while avoiding a long positional +/// argument list at the dispatch boundary. Construction does not prove that the context is current +/// or that the origin is bound; [`BrowserProtocolAdapterDescriptor::dispatch_if_context_origin_current`] +/// performs those fail-closed checks immediately before protocol validation and callback execution. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserContextOriginDispatchTarget<'a> { + context: BrowserContextDispatchTarget, + expected_origin: &'a Origin, +} + +impl<'a> BrowserContextOriginDispatchTarget<'a> { + /// Group one browser context target with its freshly sampled canonical origin. + #[must_use] + pub const fn new(context: BrowserContextDispatchTarget, expected_origin: &'a Origin) -> Self { + Self { + context, + expected_origin, + } + } + + /// Return the exact browser session/context pair requested for dispatch. + #[must_use] + pub const fn context(self) -> BrowserContextDispatchTarget { + self.context + } + + /// Return the canonical origin expected to remain current for the dispatch. + #[must_use] + pub const fn expected_origin(self) -> &'a Origin { + self.expected_origin + } +} + +/// Exact browser context, canonical origin, and observed document epoch for one protocol dispatch. +/// +/// This target is intended for actions whose authority was derived from a prior structured browser +/// observation. Construction grants no authority. The dispatch boundary must revalidate the +/// session/context/origin and prove that the registry is still at `expected_epoch` immediately +/// before protocol metadata validation and callback execution. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserContextOriginEpochDispatchTarget<'a> { + context_origin: BrowserContextOriginDispatchTarget<'a>, + expected_epoch: DocumentEpoch, +} + +impl<'a> BrowserContextOriginEpochDispatchTarget<'a> { + /// Bind one immediate-use context/origin target to the document epoch that was observed. + #[must_use] + pub const fn new( + context_origin: BrowserContextOriginDispatchTarget<'a>, + expected_epoch: DocumentEpoch, + ) -> Self { + Self { + context_origin, + expected_epoch, + } + } + + /// Return the exact browser context and canonical origin requested for dispatch. + #[must_use] + pub const fn context_origin(self) -> BrowserContextOriginDispatchTarget<'a> { + self.context_origin + } + + /// Return the exact document epoch whose observation authorized the requested action. + #[must_use] + pub const fn expected_epoch(self) -> DocumentEpoch { + self.expected_epoch + } +} + impl BrowserProtocolAdapterDescriptor { /// Validate current browser-protocol metadata and immediately invoke one dispatch callback. /// @@ -145,6 +219,98 @@ impl BrowserProtocolAdapterDescriptor { ) .map_err(BrowserContextProtocolDispatchError::ProtocolValidation) } + + /// Revalidate exact browser session/context/origin authority and protocol metadata before I/O. + /// + /// The registry first proves that `target.expected_origin()` is the canonical origin currently + /// bound to the supplied browser session and browsing context and returns that document's + /// current epoch. Only then are the exact protocol generation, runtime protocol family, adapter + /// version, upstream/browser revisions, and required capability validated. `dispatch` receives + /// both the non-cloneable protocol-use proof and the epoch sampled by that origin revalidation. + /// + /// This method does not derive the origin from Chromium, authenticate the adapter process, + /// authorize a destination/network/TLS/HTTP operation, grant Agent capability or approval, or + /// prove a post-condition. The caller must construct `target` from the origin freshly sampled + /// from the trusted adapter about to perform I/O, sample `runtime_metadata` from that same + /// adapter, and prevent intervening registry mutation across its larger execution transaction. + pub fn dispatch_if_context_origin_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextOriginDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + required_capability: BrowserProtocolCapability, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse, DocumentEpoch) -> R, + { + let context = target.context(); + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + target.expected_origin(), + ) + .map_err(BrowserContextProtocolDispatchError::BrowserAuthority)?; + self.dispatch_if_runtime_matches( + required_originweave_protocol_version, + runtime_metadata, + required_capability, + |validated| dispatch(validated, current_epoch), + ) + .map_err(BrowserContextProtocolDispatchError::ProtocolValidation) + } + + /// Revalidate exact browser session/context/origin/document authority before protocol I/O. + /// + /// This stronger action boundary first proves the exact current session/context/origin through + /// the authority registry, then compares the registry's current document epoch with the epoch + /// that produced the caller's observation. A same-origin navigation therefore fails closed + /// before protocol validation or callback execution even when the canonical origin is rebound. + /// Exact protocol generation, family, adapter version, protocol/browser revisions and required + /// capability are validated only after the document remains current. + /// + /// The caller remains responsible for deriving the origin and observed epoch from the trusted + /// adapter/observation that produced the action, sampling runtime protocol metadata from the + /// adapter about to perform I/O, and preventing intervening mutation across the larger + /// transaction. This method does not authenticate Chromium, authorize destination/network + /// authority or policy approval, validate semantic node state, perform I/O, or prove success. + pub fn dispatch_if_context_origin_epoch_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + required_capability: BrowserProtocolCapability, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse, DocumentEpoch) -> R, + { + let context_origin = target.context_origin(); + let context = context_origin.context(); + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + ) + .map_err(BrowserContextProtocolDispatchError::BrowserAuthority)?; + if current_epoch != target.expected_epoch() { + return Err(BrowserContextProtocolDispatchError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + }); + } + self.dispatch_if_runtime_matches( + required_originweave_protocol_version, + runtime_metadata, + required_capability, + |validated| dispatch(validated, current_epoch), + ) + .map_err(BrowserContextProtocolDispatchError::ProtocolValidation) + } } /// Failure to compose current browser context ownership with protocol validation before dispatch. @@ -152,6 +318,13 @@ impl BrowserProtocolAdapterDescriptor { pub enum BrowserContextProtocolDispatchError { /// The supplied browser session/context pair is not current in the authority registry. BrowserAuthority(BrowserRegistryError), + /// The observed document epoch no longer matches the registry's current document. + DocumentEpochMismatch { + /// The document epoch that produced the action's observation. + expected: DocumentEpoch, + /// The document epoch currently active in the registry. + current: DocumentEpoch, + }, /// The current browser-protocol metadata or required capability failed validation. ProtocolValidation(BrowserProtocolUseValidationError), } @@ -165,6 +338,12 @@ impl fmt::Display for BrowserContextProtocolDispatchError { "browser context authority denied protocol dispatch: {error}" ) } + Self::DocumentEpochMismatch { expected, current } => write!( + formatter, + "browser document epoch {} no longer matches observed epoch {}", + current.value(), + expected.value() + ), Self::ProtocolValidation(error) => { write!( formatter, @@ -179,6 +358,7 @@ impl std::error::Error for BrowserContextProtocolDispatchError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::BrowserAuthority(error) => Some(error), + Self::DocumentEpochMismatch { .. } => None, Self::ProtocolValidation(error) => Some(error), } } diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs new file mode 100644 index 000000000..280be51c1 --- /dev/null +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -0,0 +1,610 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::{ + BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, + BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, + BrowserRegistryError, DocumentEpoch, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, ObservedNodeHandle, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +/// Exact WebDriver BiDi method used by the bounded accessibility-query contract. +pub const WEBDRIVER_BIDI_LOCATE_NODES_METHOD: &str = "browsingContext.locateNodes"; +/// Maximum UTF-8 bytes accepted for one accessibility-role query value. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES: usize = 64; +/// Maximum UTF-8 bytes accepted for one accessibility-name query value. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES: usize = 512; +/// Maximum number of nodes one bounded accessibility query may request. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT: u16 = 128; +/// Fixed DOM serialization depth for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH: u16 = 0; +/// Fixed object serialization depth for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH: u16 = 0; +/// Fixed shadow-tree serialization mode for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE: &str = "none"; + +/// Fail-closed validation errors for one bounded WebDriver BiDi accessibility query. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiAccessibilityQueryError { + /// Neither an accessibility role nor an accessible name was supplied. + MissingLocatorValue, + /// An explicitly supplied accessibility role was empty. + EmptyRole, + /// The accessibility role exceeded the local UTF-8 byte budget. + RoleTooLong, + /// An explicitly supplied accessible name was empty. + EmptyName, + /// The accessibility role contained whitespace, a control, or a Unicode format character. + InvalidRole, + /// The accessible name contained a control, Unicode format character, or only whitespace. + InvalidName, + /// The accessible name exceeded the local UTF-8 byte budget. + NameTooLong, + /// The requested node count was zero or exceeded the local result budget. + InvalidNodeCount, + /// The untrusted adapter returned more nodes than the reviewed request budget allowed. + ResultNodeCountExceeded, +} + +impl Display for WebDriverBiDiAccessibilityQueryError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::MissingLocatorValue => "accessibility query requires a role or accessible name", + Self::EmptyRole => "accessibility query role must not be empty", + Self::RoleTooLong => "accessibility query role exceeds the local byte budget", + Self::EmptyName => "accessibility query name must not be empty", + Self::InvalidRole => { + "accessibility query role must not contain whitespace, control, or Unicode format characters" + } + Self::InvalidName => { + "accessibility query name must not contain control or Unicode format characters or only whitespace" + } + Self::NameTooLong => "accessibility query name exceeds the local byte budget", + Self::InvalidNodeCount => "accessibility query node count is outside the local budget", + Self::ResultNodeCountExceeded => { + "accessibility query result exceeds the requested node budget" + } + }; + formatter.write_str(message) + } +} + +impl Error for WebDriverBiDiAccessibilityQueryError {} + +/// Bounded transport parameters for WebDriver BiDi accessibility-node lookup. +/// +/// This value captures only the reviewed `browsingContext.locateNodes` accessibility-locator +/// parameters needed by the first Chromium observation slice. It accepts an exact role, an exact +/// accessible name, or both, together with a finite result count. Roles are exact tokens and +/// therefore reject whitespace, controls, and Unicode format characters. Accessible names may +/// contain ordinary spaces but reject controls, format characters, and whitespace-only values. +/// Text budgets are OriginWeave resource limits rather +/// than claims about upstream protocol maxima. +/// +/// The first slice also fixes WebDriver BiDi serialization to zero DOM depth, zero object depth, +/// and no shadow-tree expansion. Those settings intentionally minimize the remote-value surface a +/// future transport adapter may request. The adapter must additionally revalidate the returned +/// node count against this exact query before it retains or normalizes any returned node data. +/// +/// Construction grants no browser session, context, origin, semantic-node, policy, capability, or +/// network authority and performs no browser I/O. A trusted adapter must still bind the query to an +/// exact current browsing context through the separately reviewed authority and protocol boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBiDiAccessibilityQuery { + role: Option, + name: Option, + max_node_count: u16, +} + +impl WebDriverBiDiAccessibilityQuery { + /// Validate one bounded accessibility lookup request. + /// + /// Explicit empty values fail closed rather than being treated as absent. Role and name limits + /// are measured in UTF-8 bytes so later serialization cannot exceed the reviewed local budget + /// through multi-byte text. Roles are exact WAI-ARIA tokens, so whitespace, control, and + /// Unicode format characters fail closed instead of becoming fallback-role lists. Accessible + /// names may contain ordinary spaces but not controls, Unicode format characters, or + /// whitespace-only values. At least one selector value and one result slot are required. + pub fn new( + role: Option<&str>, + name: Option<&str>, + max_node_count: u16, + ) -> Result { + if role.is_some_and(str::is_empty) { + return Err(WebDriverBiDiAccessibilityQueryError::EmptyRole); + } + if role.is_some_and(|value| crate::contains_disallowed_protocol_text(value, false)) { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidRole); + } + if role.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES) { + return Err(WebDriverBiDiAccessibilityQueryError::RoleTooLong); + } + if name.is_some_and(str::is_empty) { + return Err(WebDriverBiDiAccessibilityQueryError::EmptyName); + } + if name.is_some_and(|value| { + crate::contains_disallowed_protocol_text(value, true) + || value.chars().all(char::is_whitespace) + }) { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidName); + } + if name.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES) { + return Err(WebDriverBiDiAccessibilityQueryError::NameTooLong); + } + if role.is_none() && name.is_none() { + return Err(WebDriverBiDiAccessibilityQueryError::MissingLocatorValue); + } + if max_node_count == 0 || max_node_count > MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount); + } + + Ok(Self { + role: role.map(str::to_owned), + name: name.map(str::to_owned), + max_node_count, + }) + } + + /// Return the exact upstream method associated with this query contract. + #[must_use] + pub const fn method(&self) -> &'static str { + WEBDRIVER_BIDI_LOCATE_NODES_METHOD + } + + /// Return the exact WebDriver BiDi locator type represented by this value. + #[must_use] + pub const fn locator_type(&self) -> &'static str { + "accessibility" + } + + /// Return the fixed maximum DOM serialization depth for returned remote nodes. + #[must_use] + pub const fn serialization_max_dom_depth(&self) -> u16 { + WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH + } + + /// Return the fixed maximum object serialization depth for returned remote nodes. + #[must_use] + pub const fn serialization_max_object_depth(&self) -> u16 { + WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH + } + + /// Return the fixed shadow-tree serialization mode for returned remote nodes. + #[must_use] + pub const fn serialization_include_shadow_tree(&self) -> &'static str { + WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE + } + + /// Return the exact optional accessibility role requested by the caller. + #[must_use] + pub fn role(&self) -> Option<&str> { + self.role.as_deref() + } + + /// Return the exact optional accessible name requested by the caller. + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Return the finite maximum number of nodes requested from the adapter. + #[must_use] + pub const fn max_node_count(&self) -> u16 { + self.max_node_count + } + + /// Revalidate an untrusted `locateNodes` result count against this exact request budget. + /// + /// A conforming browser is expected to honor `maxNodeCount`, but an adapter boundary must not + /// treat that expectation as resource authority. Zero through the requested maximum are valid; + /// any larger returned array fails closed before later node normalization or retention. + pub fn validate_result_count( + &self, + returned_node_count: usize, + ) -> Result<(), WebDriverBiDiAccessibilityQueryError> { + if returned_node_count > usize::from(self.max_node_count) { + return Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded); + } + Ok(()) + } + + /// Admit one untrusted `locateNodes` result against the exact current document authority. + /// + /// The caller must transfer a non-cloneable [`ValidatedBrowserProtocolUse`] whose protocol + /// family is exactly [`BrowserProtocolKind::WebDriverBiDi`] and whose capability is exactly + /// [`BrowserProtocolCapability::SemanticObservation`]. A CDP proof or a Navigation/TypedInput + /// proof fails closed before the registry is consulted, so another protocol surface cannot + /// mint WebDriver BiDi observation handles. The proof is consumed by ownership and cannot be + /// reused for a later bind. + /// + /// The registry then proves that `target` still names the current session, browsing context, + /// canonical origin, and document epoch. Only then is the returned item count checked against + /// this query's budget. Each item must be an exact `node` remote value with a usable shared + /// identifier. The complete admitted batch is then translated atomically into + /// [`ObservedNodeHandle`] values bound to that same current authority: if any registry binding + /// fails, no partial node authority from this call is retained. + /// + /// This method performs no browser I/O, does not authenticate Chromium, and does not grant + /// policy, destination, or typed-input authority. A later action must still revalidate the + /// returned handles immediately before use. + pub fn bind_current_nodes( + &self, + validated: ValidatedBrowserProtocolUse, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + items: &[(&str, Option<&str>)], + ) -> Result, WebDriverBiDiLocateNodesAdmissionError> { + if validated.kind() != BrowserProtocolKind::WebDriverBiDi { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind(validated.kind()), + ); + } + if validated.capability() != BrowserProtocolCapability::SemanticObservation { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + validated.capability(), + ), + ); + } + let _consumed_query_nodes_proof = validated; + let context_origin = target.context_origin(); + let context = context_origin.context(); + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + if current_epoch != target.expected_epoch() { + return Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + }, + ); + } + self.validate_result_count(items.len()) + .map_err(WebDriverBiDiLocateNodesAdmissionError::Query)?; + + let mut references = Vec::new(); + for (remote_type, shared_id) in items { + references.push( + WebDriverBiDiRemoteNodeReference::new(remote_type, *shared_id) + .map_err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode)?, + ); + } + + let shared_ids = references + .iter() + .map(WebDriverBiDiRemoteNodeReference::shared_id) + .collect::>(); + authority_registry + .bind_nodes( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + &shared_ids, + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority) + } +} + +/// Fail-closed errors for QueryNodes admission that requires SemanticObservation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiQueryNodesAdmissionError { + /// Protocol metadata or the QueryNodes capability failed before node admission. + ProtocolDispatch(BrowserContextProtocolDispatchError), + /// The untrusted `locateNodes` result failed current-authority admission. + LocateNodes(WebDriverBiDiLocateNodesAdmissionError), +} + +impl Display for WebDriverBiDiQueryNodesAdmissionError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ProtocolDispatch(error) => { + write!( + formatter, + "QueryNodes protocol dispatch rejected locateNodes admission: {error}" + ) + } + Self::LocateNodes(error) => { + write!( + formatter, + "QueryNodes current-authority admission rejected locateNodes result: {error}" + ) + } + } + } +} + +impl Error for WebDriverBiDiQueryNodesAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::ProtocolDispatch(error) => Some(error), + Self::LocateNodes(error) => Some(error), + } + } +} + +/// Fail-closed errors for admitting an untrusted `locateNodes` result into current node authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesAdmissionError { + /// The untrusted result violated this query's reviewed locator or result-count contract. + Query(WebDriverBiDiAccessibilityQueryError), + /// One result item was not an admissible node remote value. + RemoteNode(WebDriverBiDiRemoteNodeReferenceError), + /// The observed document epoch no longer matches the registry's current document. + DocumentEpochMismatch { + /// The document epoch that produced the observation being bound. + expected: DocumentEpoch, + /// The document epoch currently active in the registry. + current: DocumentEpoch, + }, + /// The supplied browser session, context, or origin is not current in the registry. + BrowserAuthority(BrowserRegistryError), + /// The consumed protocol-use proof came from a different browser protocol family. + UnsupportedProtocolKind(BrowserProtocolKind), + /// The consumed protocol-use proof was not SemanticObservation. + UnsupportedCapability(BrowserProtocolCapability), +} + +impl Display for WebDriverBiDiLocateNodesAdmissionError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Query(error) => { + write!( + formatter, + "accessibility query rejected locateNodes admission: {error}" + ) + } + Self::RemoteNode(error) => { + write!( + formatter, + "remote node reference rejected locateNodes admission: {error}" + ) + } + Self::DocumentEpochMismatch { expected, current } => write!( + formatter, + "browser document epoch {} no longer matches observed epoch {}", + current.value(), + expected.value() + ), + Self::BrowserAuthority(error) => { + write!( + formatter, + "browser authority denied locateNodes admission: {error}" + ) + } + Self::UnsupportedProtocolKind(kind) => write!( + formatter, + "locateNodes admission requires a WebDriverBiDi protocol-use proof, not {kind:?}" + ), + Self::UnsupportedCapability(capability) => { + let name = match capability { + BrowserProtocolCapability::Navigation => "Navigation", + BrowserProtocolCapability::SemanticObservation => "SemanticObservation", + BrowserProtocolCapability::TypedInput => "TypedInput", + BrowserProtocolCapability::NetworkObservation => "NetworkObservation", + }; + write!( + formatter, + "locateNodes admission requires a SemanticObservation protocol-use proof, not {name}" + ) + } + } + } +} + +impl Error for WebDriverBiDiLocateNodesAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Query(error) => Some(error), + Self::RemoteNode(error) => Some(error), + Self::DocumentEpochMismatch { .. } => None, + Self::BrowserAuthority(error) => Some(error), + Self::UnsupportedProtocolKind(_) | Self::UnsupportedCapability(_) => None, + } + } +} + +/// Exact WebDriver BiDi remote-value type admitted as a later node handle. +pub const WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE: &str = "node"; + +/// Fail-closed validation errors for one untrusted WebDriver BiDi node remote value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiRemoteNodeReferenceError { + /// The remote value type was not the exact `node` type. + UnexpectedRemoteType, + /// The remote value omitted `sharedId`. + MissingSharedId, + /// The shared identifier was empty, contained control, whitespace, or Unicode format text, or exceeded the local budget. + InvalidSharedId, +} + +impl Display for WebDriverBiDiRemoteNodeReferenceError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::UnexpectedRemoteType => { + "remote node reference type must be the exact node remote value" + } + Self::MissingSharedId => "remote node reference requires a shared id", + Self::InvalidSharedId => { + "remote node reference shared id is empty, contains control, whitespace, or Unicode format characters, or exceeds the local byte budget" + } + }; + formatter.write_str(message) + } +} + +impl Error for WebDriverBiDiRemoteNodeReferenceError {} + +/// Bounded admission of one untrusted WebDriver BiDi `script.NodeRemoteValue`. +/// +/// The 1 June 2026 WebDriver BiDi Working Draft returns `script.NodeRemoteValue` items from +/// `browsingContext.locateNodes`. Those values have a required `type` of `node` and an optional +/// `sharedId`. OriginWeave admits a result item only when the type is exactly `node` and a +/// non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context +/// identifiers and contains no control, whitespace, or Unicode format characters. +/// +/// Requiring `sharedId` is a local fail-closed policy: the Working Draft permits omitting it, but a +/// later typed-input adapter cannot refer to the same node across realms without that shared +/// identity. Construction grants no session, context, origin, document-epoch, semantic-node, +/// policy, or network authority and performs no browser I/O. The admitted value remains an +/// untrusted transport handle until a separately reviewed authority boundary binds it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBiDiRemoteNodeReference { + shared_id: String, +} + +impl WebDriverBiDiRemoteNodeReference { + /// Admit one untrusted locateNodes remote value as a later node handle. + /// + /// The remote type is checked first so a non-node value cannot be retained even when it carries + /// a well-formed shared identifier. A missing shared identifier is distinct from an empty or + /// over-budget identifier so callers can distinguish protocol omission from local + /// resource-budget rejection. + pub fn new( + remote_type: &str, + shared_id: Option<&str>, + ) -> Result { + if remote_type != WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE { + return Err(WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType); + } + let Some(shared_id) = shared_id else { + return Err(WebDriverBiDiRemoteNodeReferenceError::MissingSharedId); + }; + if shared_id.is_empty() + || shared_id.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || crate::contains_disallowed_protocol_text(shared_id, false) + { + return Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId); + } + Ok(Self { + shared_id: shared_id.to_owned(), + }) + } + + /// Return the exact admitted WebDriver BiDi remote-value type. + #[must_use] + pub const fn remote_type(&self) -> &'static str { + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE + } + + /// Return the exact shared node identifier admitted from the remote value. + #[must_use] + pub fn shared_id(&self) -> &str { + &self.shared_id + } +} + +/// One typed buyer-visible browser operation whose transport prerequisite is derived internally. +/// +/// This value carries operation semantics only. It grants no browser session, context, origin, +/// semantic-node, policy, approval, secret, network, or adapter authority and does not perform +/// browser I/O. The vocabulary intentionally mirrors the bounded first Chromium vertical slice so +/// callers cannot hide materially different actions behind a coarse transport capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolOperation { + /// Navigate one controlled browser context. + Navigate, + /// Query bounded semantic nodes from the current document. + QueryNodes, + /// Dispatch one policy-authorized click to a separately validated semantic node. + ClickNode, + /// Dispatch policy-authorized text input to a separately validated semantic node. + TypeText, + /// Observe bounded semantic state until a separately specified condition is satisfied. + WaitForState, + /// Produce bounded browser-network evidence. + ObserveNetwork, +} + +impl BrowserProtocolOperation { + /// Return the exact adapter capability required for this typed operation. + /// + /// This mapping is a transport prerequisite only. A matching capability does not authorize the + /// operation itself: node freshness, policy, approval, destination, and post-condition checks + /// remain independent authority boundaries. + #[must_use] + pub const fn required_capability(self) -> BrowserProtocolCapability { + match self { + Self::Navigate => BrowserProtocolCapability::Navigation, + Self::QueryNodes | Self::WaitForState => BrowserProtocolCapability::SemanticObservation, + Self::ClickNode | Self::TypeText => BrowserProtocolCapability::TypedInput, + Self::ObserveNetwork => BrowserProtocolCapability::NetworkObservation, + } + } +} + +impl BrowserProtocolAdapterDescriptor { + /// Revalidate exact browser authority and derive adapter capability from one typed operation. + /// + /// The existing context/origin/document-epoch boundary runs first, followed by exact runtime + /// protocol metadata and the capability derived from `operation`. The callback can run only + /// after all prerequisites pass and receives the same typed operation together with the + /// non-cloneable protocol-use proof and freshly revalidated document epoch. + /// + /// This method does not authenticate Chromium or the adapter, grant policy approval, validate + /// semantic-node state, authorize destination/network activity, perform browser I/O, or prove + /// an action post-condition. The operation value itself grants no authority. + pub fn dispatch_operation_if_context_origin_epoch_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + operation: BrowserProtocolOperation, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse, BrowserProtocolOperation, DocumentEpoch) -> R, + { + self.dispatch_if_context_origin_epoch_current( + authority_registry, + target, + required_originweave_protocol_version, + runtime_metadata, + operation.required_capability(), + |validated, epoch| dispatch(validated, operation, epoch), + ) + } + + /// Admit one untrusted `locateNodes` result only after QueryNodes protocol proof. + /// + /// The same-call boundary first obtains a non-cloneable protocol-use proof for + /// [`BrowserProtocolOperation::QueryNodes`], which derives + /// [`BrowserProtocolCapability::SemanticObservation`]. That proof is transferred by + /// ownership into [`WebDriverBiDiAccessibilityQuery::bind_current_nodes`], which requires + /// the exact [`BrowserProtocolKind::WebDriverBiDi`] family and refuses any other capability + /// before translating admitted `sharedId` values into [`ObservedNodeHandle`] values on the + /// exact current session, browsing context, canonical origin, and document epoch. + /// + /// This method performs no browser I/O, does not authenticate Chromium, and does not grant + /// policy, destination, or typed-input authority. A later action must still revalidate the + /// returned handles immediately before use. + pub fn admit_query_nodes( + &self, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + query: &WebDriverBiDiAccessibilityQuery, + items: &[(&str, Option<&str>)], + ) -> Result, WebDriverBiDiQueryNodesAdmissionError> { + let validated = self + .dispatch_operation_if_context_origin_epoch_current( + authority_registry, + target, + required_originweave_protocol_version, + runtime_metadata, + BrowserProtocolOperation::QueryNodes, + |validated, _operation, _epoch| validated, + ) + .map_err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch)?; + query + .bind_current_nodes(validated, authority_registry, target, items) + .map_err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes) + } +} diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 477117e4e..26a05249f 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -6,6 +6,35 @@ use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHand /// Maximum UTF-8 byte length of an opaque browser-protocol identifier retained by the registry. pub const MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES: usize = 512; +/// Invisible and bidirectional Unicode format characters rejected in protocol text. +/// +/// These code points are Default_Ignorable or bidirectional format controls. They can hide or +/// reorder locator and identifier text without being `char::is_control` or `char::is_whitespace`. +/// The reviewed set is a local fail-closed policy for OriginWeave protocol admission, not a claim +/// that every Unicode format character is forbidden by WebDriver BiDi or WAI-ARIA. +pub const UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS: &[char] = &[ + '\u{00AD}', '\u{061C}', '\u{180E}', '\u{200B}', '\u{200C}', '\u{200D}', '\u{200E}', '\u{200F}', + '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2060}', '\u{2061}', '\u{2062}', + '\u{2063}', '\u{2064}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{206A}', '\u{206B}', + '\u{206C}', '\u{206D}', '\u{206E}', '\u{206F}', '\u{FEFF}', +]; + +/// Return whether protocol text contains a control, whitespace, or reviewed format character. +/// +/// When `allow_ordinary_space` is true, U+0020 may appear so accessible names can keep ordinary +/// spaces. Every other whitespace character, every control, and every reviewed format character +/// still fail closed. +pub(crate) fn contains_disallowed_protocol_text(value: &str, allow_ordinary_space: bool) -> bool { + value.chars().any(|character| { + if allow_ordinary_space && character == ' ' { + return false; + } + character.is_control() + || character.is_whitespace() + || UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS.contains(&character) + }) +} + /// Default maximum number of authority identifiers allocated per registry namespace. const DEFAULT_MAX_BROWSER_AUTHORITY_IDENTIFIERS: u64 = 1_000_000; @@ -198,6 +227,26 @@ impl BrowserAuthorityRegistry { self.current_epoch(browsing_context) } + /// Require an opaque external browsing-context identifier to name this exact context. + /// + /// This read-only check binds transport-level context text back to the already-registered + /// OriginWeave session/context pair. It never registers a new external context as a side effect, + /// so an untrusted result cannot create authority merely by presenting a different identifier. + pub(crate) fn require_context_external_identifier( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_identifier: &str, + ) -> Result<(), BrowserRegistryError> { + validate_external_identifier(external_identifier)?; + self.current_context_epoch(browser_session, browsing_context)?; + let key = (browser_session, external_identifier.to_owned()); + if self.context_by_external.get(&key).copied() != Some(browsing_context) { + return Err(BrowserRegistryError::ContextExternalIdentifierMismatch); + } + Ok(()) + } + /// Bind the canonical origin observed for the exact current browser document. /// /// This boundary lets a trusted browser adapter establish current document-origin state before @@ -327,6 +376,46 @@ impl BrowserAuthorityRegistry { observed_node_handle(browser_session, browsing_context, origin, epoch, node_id) }) } + + /// Bind a batch of node identifiers transactionally to the exact current browser authority. + /// + /// Successful bindings are retained only when every identifier in the batch succeeds. If a + /// later identifier fails validation, authority checks, identifier allocation, or handle + /// construction, node mappings allocated by this batch are removed, the next node identifier + /// is restored, and an origin first established by this batch is removed before the error is + /// returned. Handles created earlier in the failed batch never escape this method, so restoring + /// the local allocation cursor cannot revive externally observable stale authority. + pub(crate) fn bind_nodes( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifiers: &[&str], + ) -> Result, BrowserRegistryError> { + let starting_next_node_id = self.next_node_id; + let had_origin = self.context_origin.contains_key(&browsing_context); + let mut handles = Vec::with_capacity(external_identifiers.len()); + for external_identifier in external_identifiers { + match self.bind_node( + browser_session, + browsing_context, + origin, + external_identifier, + ) { + Ok(handle) => handles.push(handle), + Err(error) => { + self.node_by_external + .retain(|_key, node_id| *node_id < starting_next_node_id); + self.next_node_id = starting_next_node_id; + if !had_origin { + self.context_origin.remove(&browsing_context); + } + return Err(error); + } + } + } + Ok(handles) + } } impl Default for BrowserAuthorityRegistry { @@ -338,7 +427,7 @@ impl Default for BrowserAuthorityRegistry { /// A fail-closed error produced while translating external browser identifiers into local authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An external identifier was empty or exceeded the reviewed byte bound. + /// An external identifier was empty, contained control, whitespace, or Unicode format text, or exceeded the reviewed byte bound. InvalidExternalIdentifier, /// The supplied OriginWeave browser session is not registered in this registry. UnknownBrowserSession, @@ -351,6 +440,8 @@ pub enum BrowserRegistryError { /// Session supplied by the current caller. actual: BrowserSessionId, }, + /// The transport-level browsing-context identifier does not name the supplied registered context. + ContextExternalIdentifierMismatch, /// The current document has no canonical origin bound to the browsing context. ContextOriginNotBound, /// The context origin changed without first rotating the document epoch. @@ -366,9 +457,9 @@ pub enum BrowserRegistryError { impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidExternalIdentifier => { - formatter.write_str("external browser identifier must contain 1 to 512 UTF-8 bytes") - } + Self::InvalidExternalIdentifier => formatter.write_str( + "external browser identifier must contain 1 to 512 UTF-8 bytes without control, whitespace, or Unicode format characters", + ), Self::UnknownBrowserSession => { formatter.write_str("browser session is not registered in this authority registry") } @@ -381,6 +472,9 @@ impl fmt::Display for BrowserRegistryError { expected.value(), actual.value() ), + Self::ContextExternalIdentifierMismatch => formatter.write_str( + "browsing context external identifier does not match the registered context", + ), Self::ContextOriginNotBound => formatter.write_str( "browsing context has no canonical origin bound for the current document", ), @@ -402,7 +496,10 @@ impl fmt::Display for BrowserRegistryError { impl std::error::Error for BrowserRegistryError {} fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { - if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { + if identifier.is_empty() + || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || contains_disallowed_protocol_text(identifier, false) + { return Err(BrowserRegistryError::InvalidExternalIdentifier); } Ok(()) @@ -555,6 +652,18 @@ mod tests { let contexts = values(registry.register_context(session, "context-a")); assert_eq!(contexts.len(), 1); let context = contexts[0]; + assert_eq!( + registry.require_context_external_identifier(session, context, "context-a"), + Ok(()) + ); + assert_eq!( + registry.require_context_external_identifier(session, context, "context-b"), + Err(BrowserRegistryError::ContextExternalIdentifierMismatch) + ); + assert_eq!( + registry.require_context_external_identifier(session, context, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); let maximum_epochs = values(DocumentEpoch::new(u64::MAX)); assert_eq!(maximum_epochs.len(), 1); @@ -569,6 +678,14 @@ mod tests { let unknown_contexts = values(BrowsingContextId::new(999)); assert_eq!(unknown_sessions.len(), 1); assert_eq!(unknown_contexts.len(), 1); + assert_eq!( + registry.require_context_external_identifier(unknown_sessions[0], context, "context-a"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + assert_eq!( + registry.require_context_external_identifier(session, unknown_contexts[0], "context-a"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); assert_eq!( registry.bind_node(unknown_sessions[0], context, origin, "node"), Err(BrowserRegistryError::UnknownBrowserSession) @@ -579,6 +696,64 @@ mod tests { ); } + #[test] + fn batched_node_binding_rolls_back_partial_authority() { + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(2); + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let existing = values(registry.bind_node(session, context, origin, "existing")); + assert_eq!(existing.len(), 1); + assert_eq!(existing[0].node_id(), 1); + assert_eq!( + registry.bind_nodes(session, context, origin, &["existing", "fresh", "overflow"]), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert_eq!(registry.node_by_external.len(), 1); + assert_eq!(registry.next_node_id, 2); + assert!(registry.context_origin.contains_key(&context)); + let recovery = values(registry.bind_node(session, context, origin, "recovery")); + assert_eq!(recovery.len(), 1); + assert_eq!(recovery[0].node_id(), 2); + + let mut unbound_registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let sessions = values(unbound_registry.register_session("unbound-session")); + assert_eq!(sessions.len(), 1); + let unbound_session = sessions[0]; + let contexts = + values(unbound_registry.register_context(unbound_session, "unbound-context")); + assert_eq!(contexts.len(), 1); + let unbound_context = contexts[0]; + assert!( + !unbound_registry + .context_origin + .contains_key(&unbound_context) + ); + assert_eq!( + unbound_registry.bind_nodes( + unbound_session, + unbound_context, + origin, + &["first", "overflow"], + ), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert!( + !unbound_registry + .context_origin + .contains_key(&unbound_context) + ); + assert!(unbound_registry.node_by_external.is_empty()); + assert_eq!(unbound_registry.next_node_id, 1); + } + #[test] fn origin_rotation_and_node_cleanup_are_explicit() { let mut registry = BrowserAuthorityRegistry::new(); @@ -673,6 +848,7 @@ mod tests { expected: expected_values[0], actual: actual_values[0], }, + BrowserRegistryError::ContextExternalIdentifierMismatch, BrowserRegistryError::ContextOriginNotBound, BrowserRegistryError::OriginChangedWithoutDocumentAdvance, BrowserRegistryError::IdentifierSpaceExhausted, diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 1860bc7be..37cedd741 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -19,8 +19,8 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { assert_eq!(origins.len(), 1); let origin = &origins[0]; - let first = values(registry.bind_node(session, context, origin, "unit-node")); - let repeated = values(registry.bind_node(session, context, origin, "unit-node")); + let first = values(registry.bind_nodes(session, context, origin, &["unit-node"])); + let repeated = values(registry.bind_nodes(session, context, origin, &["unit-node"])); assert_eq!(first.len(), 1); assert_eq!(repeated.len(), 1); assert_eq!(first[0], repeated[0]); @@ -51,7 +51,7 @@ fn session_authority_failures_are_exercised_in_the_unit_crate() { let context = contexts[0]; assert_eq!( - registry.bind_node(attacker, context, &origins[0], "unit-node"), + registry.bind_nodes(attacker, context, &origins[0], &["unit-node"]), Err(BrowserRegistryError::ContextSessionMismatch { expected: owner, actual: attacker, diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index d0a40c413..a91d89ba7 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1,22 +1,48 @@ //! Shared security and governance contracts for OriginWeave. //! //! This crate keeps the long-lived value contracts in `contracts`, the -//! browser protocol/identifier boundaries and extension authority in focused -//! modules so browser adapters can evolve without turning raw CDP or WebDriver -//! metadata into OriginWeave authority. +//! browser protocol/identifier boundaries and extension authority in focused modules so browser +//! adapters can evolve without turning raw CDP or WebDriver metadata into +//! OriginWeave authority. +//! +//! Raw adapter-local node identifiers must not be mintable through the public +//! registry API. Public callers must enter through the reviewed semantic-node +//! admission path instead: +//! +//! ```compile_fail +//! use originweave_core::{BrowserAuthorityRegistry, Origin}; +//! +//! let mut registry = BrowserAuthorityRegistry::new(); +//! let session = registry.register_session("webdriver-session")?; +//! let context = registry.register_context(session, "top-level-context")?; +//! let origin = Origin::parse("http://127.0.0.1:43127")?; +//! let _handle = registry.bind_node(session, context, &origin, "backend-node-17")?; +//! # Ok::<(), Box>(()) +//! ``` #![forbid(unsafe_code)] #![deny(missing_docs)] +mod browser_authority_registry; mod browser_protocol; mod browser_protocol_dispatch; +mod browser_protocol_operation; mod browser_registry; #[cfg(test)] mod browser_registry_coverage; mod contract_errors; mod contracts; mod extension_authority; +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::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, @@ -25,11 +51,24 @@ pub use browser_protocol::{ OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, }; pub use browser_protocol_dispatch::{ - BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, }; +pub use browser_protocol_operation::{ + BrowserProtocolOperation, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, + WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, + WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, +}; +pub(crate) use browser_registry::contains_disallowed_protocol_text; pub use browser_registry::{ - BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, }; pub use contracts::{ ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, ApprovalEvidence, @@ -42,3 +81,32 @@ pub use extension_authority::{ AgentTaskId, AgentTaskIdError, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentGrant, evaluate_extension_access, }; +pub use webdriver_bidi_command::{ + CorrelatedWebDriverBiDiLocateNodesResponse, MAX_WEBDRIVER_BIDI_COMMAND_ID, + ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiCommandResponseKind, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesCommandError, + 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_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs new file mode 100644 index 000000000..b73ae90ab --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -0,0 +1,422 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::{ + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + contains_disallowed_protocol_text, +}; + +/// Maximum WebDriver BiDi command identifier representable by the protocol `js-uint` type. +pub const MAX_WEBDRIVER_BIDI_COMMAND_ID: u64 = 9_007_199_254_740_991; + +/// Fail-closed validation errors for one serialized WebDriver BiDi `locateNodes` command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesCommandError { + /// The command identifier exceeds WebDriver BiDi's unsigned safe-integer range. + InvalidCommandId, + /// The browsing-context identifier is empty, over budget, or contains disallowed text. + InvalidBrowsingContext, +} + +impl Display for WebDriverBiDiLocateNodesCommandError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidCommandId => "WebDriver BiDi command id is outside the js-uint range", + Self::InvalidBrowsingContext => { + "WebDriver BiDi browsing context is empty, over budget, or contains disallowed text" + } + }) + } +} + +impl Error for WebDriverBiDiLocateNodesCommandError {} + +/// Fail-closed errors while correlating one WebDriver BiDi response with its exact command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResponseCorrelationError { + /// The returned response identifier exceeds WebDriver BiDi's `js-uint` range. + InvalidResponseId, + /// The returned response identifier belongs to a different in-flight command. + ResponseIdMismatch { + /// Exact command identifier that this response must carry. + expected: u64, + /// Untrusted response identifier returned by the adapter. + actual: u64, + }, +} + +impl Display for WebDriverBiDiLocateNodesResponseCorrelationError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidResponseId => { + formatter.write_str("WebDriver BiDi response id is outside the js-uint range") + } + Self::ResponseIdMismatch { expected, actual } => write!( + formatter, + "WebDriver BiDi response id {actual} does not match command id {expected}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResponseCorrelationError {} + +/// Structured WebDriver BiDi command-response envelope kind retained through correlation. +/// +/// A later trusted parser must derive this classification from the exact wire envelope. This value +/// does not validate raw JSON or grant browser, node, policy, or Agent authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiCommandResponseKind { + /// A WebDriver BiDi command success response. + Success, + /// A WebDriver BiDi command error response. + Error, +} + +/// Fail-closed errors while admitting a structured WebDriver BiDi response envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResponseEnvelopeError { + /// A success envelope did not carry the required command response identifier. + MissingResponseId, + /// An error envelope carried no recoverable command identifier and cannot be correlated. + UncorrelatableErrorResponse, + /// A correlated error envelope cannot be converted into success response evidence. + CorrelatedErrorResponse, + /// The present response identifier failed exact command correlation. + Correlation(WebDriverBiDiLocateNodesResponseCorrelationError), +} + +impl Display for WebDriverBiDiLocateNodesResponseEnvelopeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingResponseId => { + formatter.write_str("WebDriver BiDi success response is missing its command id") + } + Self::UncorrelatableErrorResponse => formatter.write_str( + "WebDriver BiDi error response has no recoverable command id for correlation", + ), + Self::CorrelatedErrorResponse => formatter + .write_str("WebDriver BiDi error response cannot become success response evidence"), + Self::Correlation(error) => write!( + formatter, + "WebDriver BiDi response envelope rejected command correlation: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResponseEnvelopeError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Correlation(error) => Some(error), + Self::MissingResponseId + | Self::UncorrelatableErrorResponse + | Self::CorrelatedErrorResponse => None, + } + } +} + +/// Non-cloneable evidence that one `locateNodes` response matched the exact command id. +/// +/// Only the command's internal exact-id correlation can construct this value. It +/// retains the exact command identifier, bounded browsing-context identifier, and exact serialized +/// result budget so a later trusted transport boundary can carry correlation evidence forward +/// without reconstructing authority from ambient query state. It does not authenticate a browser or +/// adapter, prove current OriginWeave session/context/origin authority, validate response payload +/// shape, admit nodes, or authorize an Agent action. +#[derive(Debug, PartialEq, Eq)] +pub struct ValidatedWebDriverBiDiLocateNodesResponse { + command_id: u64, + browsing_context: String, + max_node_count: u16, +} + +impl ValidatedWebDriverBiDiLocateNodesResponse { + /// Return the exact command identifier proven to match the response. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the bounded browsing-context identifier serialized by the matched command. + #[must_use] + pub fn browsing_context(&self) -> &str { + &self.browsing_context + } + + /// Return the exact `maxNodeCount` serialized by the matched command. + #[must_use] + pub const fn max_node_count(&self) -> u16 { + self.max_node_count + } + + /// Validate a parsed `locateNodes` result count against the matched command's exact budget. + /// + /// This check is intentionally carried by command-correlation evidence rather than by a + /// separately supplied query value, preventing downstream code from validating an untrusted + /// response against a different, more permissive result budget. Zero through the serialized + /// maximum are valid; any larger result fails closed before node normalization or admission. + pub fn validate_result_count( + &self, + returned_node_count: usize, + ) -> Result<(), WebDriverBiDiAccessibilityQueryError> { + if returned_node_count > usize::from(self.max_node_count) { + return Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded); + } + Ok(()) + } +} + +/// Non-cloneable structured-envelope evidence for one correlated `locateNodes` response. +/// +/// This value deliberately keeps success and error envelopes distinguishable after exact response +/// id correlation. The only conversion into [`ValidatedWebDriverBiDiLocateNodesResponse`] is +/// [`Self::into_validated_success`], which fails closed for a correlated error envelope. A later +/// trusted response parser must classify the exact wire envelope before calling +/// [`WebDriverBiDiLocateNodesCommand::correlate_response_envelope`]. This value performs no raw JSON +/// parsing, browser or adapter authentication, node admission, policy authorization, or Agent +/// action authorization. +#[derive(Debug, PartialEq, Eq)] +pub struct CorrelatedWebDriverBiDiLocateNodesResponse { + kind: WebDriverBiDiCommandResponseKind, + correlated: ValidatedWebDriverBiDiLocateNodesResponse, +} + +impl CorrelatedWebDriverBiDiLocateNodesResponse { + /// Return whether the exact correlated envelope was classified as success or error. + #[must_use] + pub const fn kind(&self) -> WebDriverBiDiCommandResponseKind { + self.kind + } + + /// Return the exact command identifier proven to match the response. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.correlated.command_id() + } + + /// Return the bounded browsing-context identifier serialized by the matched command. + #[must_use] + pub fn browsing_context(&self) -> &str { + self.correlated.browsing_context() + } + + /// Consume this envelope and return correlation evidence only when it was a success response. + /// + /// A correlated WebDriver BiDi error envelope remains error evidence and is rejected as + /// [`WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse`]. This explicit + /// fail-closed conversion prevents downstream result/node admission code from accidentally + /// erasing the protocol response kind while reusing exact command correlation evidence. + pub fn into_validated_success( + self, + ) -> Result< + ValidatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseEnvelopeError, + > { + match self.kind { + WebDriverBiDiCommandResponseKind::Success => Ok(self.correlated), + WebDriverBiDiCommandResponseKind::Error => { + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse) + } + } + } +} + +/// Deterministic serialized command envelope for one bounded WebDriver BiDi accessibility query. +/// +/// Direct identifier-only correlation is intentionally unavailable outside this crate; callers +/// must classify the response envelope before success evidence can reach result admission. +/// +/// ```compile_fail +/// use originweave_core::{WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand}; +/// +/// let query = WebDriverBiDiAccessibilityQuery::new(None, None, 1)?; +/// let command = WebDriverBiDiLocateNodesCommand::new(1, "context-a", &query)?; +/// let _ = command.correlate_response_id(1)?; +/// # Ok::<(), Box>(()) +/// ``` +/// +/// Construction accepts only a WebDriver BiDi `js-uint` command identifier, a bounded opaque +/// browsing-context identifier, and an already validated [`WebDriverBiDiAccessibilityQuery`]. The +/// serialized envelope fixes the exact `browsingContext.locateNodes` method, accessibility locator, +/// finite node budget, and minimal serialization options carried by the query. String values are +/// JSON-escaped without interpreting their content. +/// +/// This is an inert transport value. It performs no browser I/O, authenticates no browser or +/// adapter, grants no session/context/origin authority, and cannot authorize policy or typed input. +/// A trusted transport adapter must still bind the command to the exact authenticated browser +/// session and later admit any response through the reviewed current-authority boundary. +#[derive(Debug, PartialEq, Eq)] +pub struct WebDriverBiDiLocateNodesCommand { + command_id: u64, + browsing_context: String, + max_node_count: u16, + json: String, +} + +impl WebDriverBiDiLocateNodesCommand { + /// Validate and serialize one bounded `browsingContext.locateNodes` command envelope. + pub fn new( + command_id: u64, + browsing_context: &str, + query: &WebDriverBiDiAccessibilityQuery, + ) -> Result { + if command_id > MAX_WEBDRIVER_BIDI_COMMAND_ID { + return Err(WebDriverBiDiLocateNodesCommandError::InvalidCommandId); + } + if browsing_context.is_empty() + || browsing_context.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || contains_disallowed_protocol_text(browsing_context, false) + { + return Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext); + } + + let mut json = String::from("{\"id\":"); + json.push_str(&command_id.to_string()); + json.push_str(",\"method\":\""); + json.push_str(WEBDRIVER_BIDI_LOCATE_NODES_METHOD); + json.push_str("\",\"params\":{\"context\":"); + push_json_string(&mut json, browsing_context); + json.push_str(",\"locator\":{\"type\":\""); + json.push_str(query.locator_type()); + json.push_str("\",\"value\":{"); + + if let Some(role) = query.role() { + json.push_str("\"role\":"); + push_json_string(&mut json, role); + } + if let Some(name) = query.name() { + if query.role().is_some() { + json.push(','); + } + json.push_str("\"name\":"); + push_json_string(&mut json, name); + } + + json.push_str("}},\"maxNodeCount\":"); + json.push_str(&query.max_node_count().to_string()); + json.push_str(",\"serializationOptions\":{\"maxDomDepth\":"); + json.push_str(&query.serialization_max_dom_depth().to_string()); + json.push_str(",\"maxObjectDepth\":"); + json.push_str(&query.serialization_max_object_depth().to_string()); + json.push_str(",\"includeShadowTree\":"); + push_json_string(&mut json, query.serialization_include_shadow_tree()); + json.push_str("}}}"); + + Ok(Self { + command_id, + browsing_context: browsing_context.to_owned(), + max_node_count: query.max_node_count(), + json, + }) + } + + /// Return the validated WebDriver BiDi command identifier. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the exact WebDriver BiDi method serialized by this command. + #[must_use] + pub const fn method(&self) -> &'static str { + WEBDRIVER_BIDI_LOCATE_NODES_METHOD + } + + /// Return the exact validated browsing-context identifier. + #[must_use] + pub fn browsing_context(&self) -> &str { + &self.browsing_context + } + + /// Return the deterministic JSON command envelope. + #[must_use] + pub fn as_json(&self) -> &str { + &self.json + } + + /// Consume this command and correlate one untrusted response identifier with it. + /// + /// The response identifier is validated against WebDriver BiDi's `js-uint` range before exact + /// equality is checked. Success consumes the command and returns non-cloneable correlation + /// evidence, preventing this command value from being reused to validate another response. The + /// evidence also retains the exact `maxNodeCount` serialized by this command so later result + /// admission cannot substitute a different query budget. This does not parse a response, + /// authenticate the transport, or grant browser/Agent authority. + pub(crate) fn correlate_response_id( + self, + response_id: u64, + ) -> Result< + ValidatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseCorrelationError, + > { + if response_id > MAX_WEBDRIVER_BIDI_COMMAND_ID { + return Err(WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId); + } + if response_id != self.command_id { + return Err( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: self.command_id, + actual: response_id, + }, + ); + } + + Ok(ValidatedWebDriverBiDiLocateNodesResponse { + command_id: self.command_id, + browsing_context: self.browsing_context, + max_node_count: self.max_node_count, + }) + } + + /// Consume this command and admit one already classified response envelope for correlation. + /// + /// A success envelope must carry a response id. A WebDriver BiDi error envelope may have a null + /// id when no valid command id can be recovered; that case returns + /// [`WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse`] and produces + /// no correlation evidence. When an id is present, the same protocol-range and exact-id checks + /// as the internal exact-id correlation apply. The returned evidence retains whether the envelope + /// was success or error so an error cannot silently become success evidence. + /// + /// The caller must obtain `kind` and `response_id` from a separately reviewed exact response + /// parser. This method does not parse JSON, validate result payload shape, authenticate a browser + /// or adapter, admit nodes, or grant policy, typed-input, secret, or Agent authority. + pub fn correlate_response_envelope( + self, + kind: WebDriverBiDiCommandResponseKind, + response_id: Option, + ) -> Result< + CorrelatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseEnvelopeError, + > { + let response_id = match (kind, response_id) { + (WebDriverBiDiCommandResponseKind::Success, None) => { + return Err(WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId); + } + (WebDriverBiDiCommandResponseKind::Error, None) => { + return Err( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + ); + } + (_, Some(response_id)) => response_id, + }; + let correlated = self + .correlate_response_id(response_id) + .map_err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation)?; + + Ok(CorrelatedWebDriverBiDiLocateNodesResponse { kind, correlated }) + } +} + +fn push_json_string(output: &mut String, value: &str) { + output.push('"'); + for character in value.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + character => output.push(character), + } + } + output.push('"'); +} 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 new file mode 100644 index 000000000..3bb7a42c4 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_document.rs @@ -0,0 +1,98 @@ +use std::fmt; + +/// Maximum raw WebDriver BiDi response-document size admitted before parsing. +/// +/// This is an OriginWeave product safety budget, not a WebDriver BiDi protocol +/// limit. Browser adapters must enforce it before handing raw response text to a +/// JSON parser so an untrusted or malfunctioning peer cannot cause unbounded +/// parser input allocation. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES: usize = 65_536; + +/// Fail-closed reasons for rejecting a raw WebDriver BiDi response document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiResponseDocumentAdmissionError { + /// The response contains no JSON document after removing JSON whitespace. + 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, +} + +impl fmt::Display for WebDriverBiDiResponseDocumentAdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyDocument => formatter.write_str("WebDriver BiDi response document is empty"), + Self::DocumentTooLarge => write!( + 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", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiResponseDocumentAdmissionError {} + +/// Exact raw WebDriver BiDi response text admitted to the parser boundary. +/// +/// Construction proves only the OriginWeave byte budget and an obvious +/// top-level object boundary. It deliberately does not claim JSON validity, +/// response correlation, browser authenticity, or action authority. The exact +/// text is retained so downstream parsing/evidence can remain bound to the +/// admitted bytes. +#[derive(Debug, PartialEq, Eq)] +pub struct BoundedWebDriverBiDiResponseDocument { + raw: String, +} + +impl BoundedWebDriverBiDiResponseDocument { + /// Admits exact raw response text under the pre-parser safety contract. + pub fn new(raw: &str) -> Result { + if raw.len() > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES { + return Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge); + } + + let bounded = raw.trim_matches(|character| matches!(character, ' ' | '\t' | '\r' | '\n')); + if bounded.is_empty() { + return Err(WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument); + } + if !bounded.starts_with('{') || !bounded.ends_with('}') { + return Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary); + } + + Ok(Self { + raw: raw.to_owned(), + }) + } + + /// 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 { + &self.raw + } +} 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_result.rs b/crates/originweave-core/src/webdriver_bidi_result.rs new file mode 100644 index 000000000..ba3288269 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_result.rs @@ -0,0 +1,188 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::{ + BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserProtocolCapability, + BrowserProtocolKind, ObservedNodeHandle, ValidatedBrowserProtocolUse, + ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiAccessibilityQueryError, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, +}; + +/// Fail-closed errors while admitting one correlated `locateNodes` result batch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResultAdmissionError { + /// The returned node count exceeded the exact serialized command budget. + Query(WebDriverBiDiAccessibilityQueryError), + /// One returned item was not an admissible WebDriver BiDi node remote value. + RemoteNode(WebDriverBiDiRemoteNodeReferenceError), +} + +impl Display for WebDriverBiDiLocateNodesResultAdmissionError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Query(error) => write!( + formatter, + "correlated locateNodes result violated the exact command budget: {error}" + ), + Self::RemoteNode(error) => write!( + formatter, + "correlated locateNodes result contained an inadmissible remote node: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResultAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Query(error) => Some(error), + Self::RemoteNode(error) => Some(error), + } + } +} + +/// Non-cloneable evidence for one correlated, bounded, structurally admitted `locateNodes` result. +/// +/// Construction consumes exact command-correlation evidence, validates the returned array length +/// against the exact `maxNodeCount` serialized by that command, and normalizes every returned item +/// through [`WebDriverBiDiRemoteNodeReference`]. The resulting batch therefore cannot be reused +/// with a different ambient query budget and cannot retain non-node remote values or unusable node +/// identifiers. +/// +/// This is still transport evidence, not OriginWeave node authority. It does not parse raw JSON, +/// authenticate Chromium or its adapter, prove current session/context/origin/document authority, +/// mint [`crate::ObservedNodeHandle`] values, authorize policy or typed input, or establish an Agent +/// action. A later reviewed current-authority boundary must consume these normalized references. +#[derive(Debug, PartialEq, Eq)] +pub struct ValidatedWebDriverBiDiLocateNodesResult { + correlated: ValidatedWebDriverBiDiLocateNodesResponse, + nodes: Vec, +} + +impl ValidatedWebDriverBiDiLocateNodesResult { + /// Return the exact command identifier proven to own this result batch. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.correlated.command_id() + } + + /// Return the bounded browsing-context identifier serialized by the correlated command. + #[must_use] + pub fn browsing_context(&self) -> &str { + self.correlated.browsing_context() + } + + /// Return the exact `maxNodeCount` serialized by the correlated command. + #[must_use] + pub const fn max_node_count(&self) -> u16 { + self.correlated.max_node_count() + } + + /// Return the normalized untrusted node references admitted from the result array. + #[must_use] + pub fn nodes(&self) -> &[WebDriverBiDiRemoteNodeReference] { + &self.nodes + } + + /// Consume this correlated result and bind its nodes to exact current browser authority. + /// + /// The consumed protocol-use proof must be WebDriver BiDi SemanticObservation authority. The + /// exact browsing-context identifier serialized by the correlated command must still map to + /// the supplied OriginWeave context; this check is read-only and never registers a missing or + /// different context. The registry then revalidates the exact session, canonical origin, and + /// document epoch before all normalized `sharedId` values are bound transactionally. + /// + /// Success mints only [`ObservedNodeHandle`] values. It does not authenticate Chromium or an + /// adapter process, perform browser I/O, authorize policy or typed input, or turn descriptive + /// protocol evidence into an Agent capability. + pub fn bind_current_nodes( + self, + validated: ValidatedBrowserProtocolUse, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + ) -> Result, WebDriverBiDiLocateNodesAdmissionError> { + if validated.kind() != BrowserProtocolKind::WebDriverBiDi { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind(validated.kind()), + ); + } + if validated.capability() != BrowserProtocolCapability::SemanticObservation { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + validated.capability(), + ), + ); + } + let _consumed_observation_proof = validated; + let context_origin = target.context_origin(); + let context = context_origin.context(); + authority_registry + .require_context_external_identifier( + context.browser_session(), + context.browsing_context(), + self.browsing_context(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + if current_epoch != target.expected_epoch() { + return Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + }, + ); + } + + let shared_ids = self + .nodes + .iter() + .map(WebDriverBiDiRemoteNodeReference::shared_id) + .collect::>(); + authority_registry + .bind_nodes( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + &shared_ids, + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority) + } +} + +impl ValidatedWebDriverBiDiLocateNodesResponse { + /// Consume exact command-correlation evidence and admit one structured `locateNodes` result. + /// + /// The result count is checked before any item is normalized so an over-budget response fails + /// at the resource boundary even when its individual elements are malformed. Every in-budget + /// item must then be the exact WebDriver BiDi `node` remote-value type and carry a usable + /// `sharedId`. Success consumes the correlation evidence, preventing the same command response + /// from being admitted repeatedly or against a different result payload. + pub fn admit_result_nodes( + self, + items: &[(&str, Option<&str>)], + ) -> Result + { + self.validate_result_count(items.len()) + .map_err(WebDriverBiDiLocateNodesResultAdmissionError::Query)?; + + let mut nodes = Vec::with_capacity(items.len()); + for (remote_type, shared_id) in items { + nodes.push( + WebDriverBiDiRemoteNodeReference::new(remote_type, *shared_id) + .map_err(WebDriverBiDiLocateNodesResultAdmissionError::RemoteNode)?, + ); + } + + Ok(ValidatedWebDriverBiDiLocateNodesResult { + correlated: self, + nodes, + }) + } +} 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/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 1c942d481..339fde349 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -1,12 +1,80 @@ +#![allow(clippy::expect_used)] + use std::error::Error; use originweave_core::{ - BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, + ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiRemoteNodeReferenceError, }; -fn loopback_origin() -> Result> { - Ok(Origin::parse("http://127.0.0.1:43127")?) +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 loopback_origin() -> Origin { + Origin::parse("http://127.0.0.1:43127").expect("valid loopback fixture origin") +} + +fn semantic_observation_proof() -> ValidatedBrowserProtocolUse { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + ) + .expect("valid semantic-observation descriptor"); + descriptor + .validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + ) + .expect("valid semantic-observation proof") +} + +fn bind_observed_node( + registry: &mut BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifier: &str, +) -> Result { + let epoch = registry + .bind_context_origin(browser_session, browsing_context, origin) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("generic"), None, 1) + .expect("valid bounded semantic-node query"); + query + .bind_current_nodes( + semantic_observation_proof(), + registry, + target, + &[("node", Some(external_identifier))], + )? + .into_iter() + .next() + .ok_or(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + )) } #[test] @@ -44,7 +112,7 @@ fn public_default_and_error_contracts_are_usable_from_an_adapter() -> Result<(), let cases = [ ( BrowserRegistryError::InvalidExternalIdentifier, - "external browser identifier must contain 1 to 512 UTF-8 bytes".to_owned(), + "external browser identifier must contain 1 to 512 UTF-8 bytes without control, whitespace, or Unicode format characters".to_owned(), ), ( BrowserRegistryError::UnknownBrowserSession, @@ -90,10 +158,10 @@ fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box< let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session("webdriver-session")?; let context = registry.register_context(session, "top-level-context")?; - let origin = loopback_origin()?; + let origin = loopback_origin(); - let first = registry.bind_node(session, context, &origin, "backend-node-17")?; - let same = registry.bind_node(session, context, &origin, "backend-node-17")?; + let first = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; + let same = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; assert_eq!(first.node_id(), same.node_id()); let next_epoch = registry.advance_document(context)?; @@ -106,7 +174,7 @@ fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box< }) ); - let rebound = registry.bind_node(session, context, &origin, "backend-node-17")?; + let rebound = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; assert_eq!(rebound.document_epoch(), next_epoch); assert_ne!(first.node_id(), rebound.node_id()); Ok(()) @@ -117,8 +185,9 @@ fn retired_context_and_session_authority_cannot_be_reused() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { let owner = registry.register_session("owner-session")?; let attacker = registry.register_session("attacker-session")?; let context = registry.register_context(owner, "shared-looking-context")?; - let origin = loopback_origin()?; + let origin = loopback_origin(); assert_eq!( - registry.bind_node(attacker, context, &origin, "node"), - Err(BrowserRegistryError::ContextSessionMismatch { - expected: owner, - actual: attacker, - }) + bind_observed_node(&mut registry, attacker, context, &origin, "node"), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + } + )) ); Ok(()) } @@ -184,13 +262,28 @@ fn context_origin_cannot_change_without_document_rotation() -> Result<(), Box Result let unicode = registry.register_session("세션-opaque-✓")?; assert!(unicode.value() > 0); + + assert_eq!( + registry.register_session(" "), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\n"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\u{0000}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\u{200B}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\u{202E}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = loopback_origin(); + assert_eq!( + bind_observed_node( + &mut registry, + session, + context, + &origin, + "backend-node-17\n", + ), + Err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId + )) + ); Ok(()) } @@ -229,15 +359,13 @@ fn authority_identifier_capacity_is_bounded_and_testable() -> Result<(), Box Result<(), Bo let known = registry.register_session("known-session")?; let context = registry.register_context(known, "known-context")?; - let origin = loopback_origin()?; + let origin = loopback_origin(); assert_eq!( - registry.bind_node(unknown, context, &origin, "node"), - Err(BrowserRegistryError::UnknownBrowserSession) + bind_observed_node(&mut registry, unknown, context, &origin, "node"), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession + )) ); Ok(()) } diff --git a/crates/originweave-core/tests/browser_context_origin_binding.rs b/crates/originweave-core/tests/browser_context_origin_binding.rs index 9d404ff3c..9f79cb652 100644 --- a/crates/originweave-core/tests/browser_context_origin_binding.rs +++ b/crates/originweave-core/tests/browser_context_origin_binding.rs @@ -1,11 +1,23 @@ +#![allow(clippy::expect_used)] + use std::error::Error; use std::io; use originweave_core::{ - BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, - DocumentEpoch, Origin, + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, }; +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 first_origin() -> Result> { Origin::parse("http://127.0.0.1:43127") .map_err(|_error| io::Error::other("controlled first origin must be valid").into()) @@ -16,6 +28,61 @@ fn second_origin() -> Result> { .map_err(|_error| io::Error::other("controlled second origin must be valid").into()) } +fn semantic_observation_proof() -> ValidatedBrowserProtocolUse { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + ) + .expect("valid semantic-observation descriptor"); + descriptor + .validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + ) + .expect("valid semantic-observation proof") +} + +fn bind_observed_node( + registry: &mut BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifier: &str, +) -> Result { + let epoch = registry + .require_context_origin(browser_session, browsing_context, origin) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("generic"), None, 1) + .expect("valid bounded semantic-node query"); + query + .bind_current_nodes( + semantic_observation_proof(), + registry, + target, + &[("node", Some(external_identifier))], + )? + .into_iter() + .next() + .ok_or(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + )) +} + #[test] fn context_origin_can_be_bound_before_node_discovery() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); @@ -30,7 +97,7 @@ fn context_origin_can_be_bound_before_node_discovery() -> Result<(), Box; +type DispatchFn = fn(ValidatedBrowserProtocolUse, DocumentEpoch) -> DispatchOutcome; + +thread_local! { + static DISPATCH_CALLED: Cell = const { Cell::new(false) }; +} + +fn descriptor() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::TypedInput], + )?) +} + +fn runtime_metadata() -> BrowserProtocolRuntimeMetadata<'static> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn origin(value: &str) -> Result> { + Origin::parse(value).map_err(|_| { + Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid controlled origin fixture", + )) as Box + }) +} + +fn reset_dispatch_marker() { + DISPATCH_CALLED.with(|called| called.set(false)); +} + +fn dispatch_was_called() -> bool { + DISPATCH_CALLED.with(Cell::get) +} + +fn successful_dispatch( + validated: ValidatedBrowserProtocolUse, + current_epoch: DocumentEpoch, +) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Ok((current_epoch.value(), validated.capability())) +} + +#[test] +fn exact_context_origin_epoch_and_protocol_metadata_gate_one_dispatch_call() +-> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + let expected_epoch = registry.bind_context_origin(session, context, &expected_origin)?; + let context_origin = BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ); + let target = BrowserContextOriginEpochDispatchTarget::new(context_origin, expected_epoch); + + assert_eq!(target.context_origin(), context_origin); + assert_eq!(target.expected_epoch(), expected_epoch); + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!(result, Ok((1, BrowserProtocolCapability::TypedInput))); + Ok(()) +} + +#[test] +fn same_origin_new_document_epoch_fails_before_protocol_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + let observed_epoch = registry.bind_context_origin(session, context, &expected_origin)?; + let context_origin = BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ); + let target = BrowserContextOriginEpochDispatchTarget::new(context_origin, observed_epoch); + + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin(session, context, &expected_origin)?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + ); + let error = match result { + Err(error) => error, + Ok(_) => { + return Err(Box::new(io::Error::other( + "stale document epoch unexpectedly dispatched", + ))); + } + }; + + assert_eq!( + error, + BrowserContextProtocolDispatchError::DocumentEpochMismatch { + expected: observed_epoch, + current: current_epoch, + } + ); + assert_eq!( + error.to_string(), + "browser document epoch 2 no longer matches observed epoch 1" + ); + assert!(error.source().is_none()); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn epoch_dispatch_preserves_authority_and_protocol_failures_before_callback() +-> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let current_origin = origin("https://app.example")?; + let other_origin = origin("https://other.example")?; + let expected_epoch = registry.bind_context_origin(session, context, ¤t_origin)?; + + let wrong_origin_target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &other_origin, + ), + expected_epoch, + ); + reset_dispatch_marker(); + let authority_result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + wrong_origin_target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + ); + assert!(matches!( + authority_result, + Err(BrowserContextProtocolDispatchError::BrowserAuthority(_)) + )); + assert!(!dispatch_was_called()); + + let current_target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + ¤t_origin, + ), + expected_epoch, + ); + let drifted_runtime = BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + "originweave-bidi-v2", + PROTOCOL_REVISION, + BROWSER_REVISION, + ); + reset_dispatch_marker(); + let protocol_result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + current_target, + ORIGINWEAVE_PROTOCOL_VERSION, + drifted_runtime, + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + ); + assert!(matches!( + protocol_result, + Err(BrowserContextProtocolDispatchError::ProtocolValidation(_)) + )); + assert!(!dispatch_was_called()); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs new file mode 100644 index 000000000..986d4cdd4 --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs @@ -0,0 +1,180 @@ +use std::{cell::Cell, error::Error, io}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, + BrowserProtocolUseValidationError, BrowserRegistryError, DocumentEpoch, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type DispatchOutcome = Result<(u64, BrowserProtocolCapability), &'static str>; +type DispatchFn = fn(ValidatedBrowserProtocolUse, DocumentEpoch) -> DispatchOutcome; + +thread_local! { + static DISPATCH_CALLED: Cell = const { Cell::new(false) }; +} + +fn descriptor() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?) +} + +fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + adapter_version, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn origin(value: &str) -> Result> { + Origin::parse(value).map_err(|_| { + Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid controlled origin fixture", + )) as Box + }) +} + +fn reset_dispatch_marker() { + DISPATCH_CALLED.with(|called| called.set(false)); +} + +fn dispatch_was_called() -> bool { + DISPATCH_CALLED.with(Cell::get) +} + +fn successful_dispatch( + validated: ValidatedBrowserProtocolUse, + current_epoch: DocumentEpoch, +) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Ok((current_epoch.value(), validated.capability())) +} + +#[test] +fn exact_current_origin_and_protocol_metadata_gate_one_dispatch_call() -> Result<(), Box> +{ + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + registry.bind_context_origin(session, context, &expected_origin)?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!( + result, + Ok((1, BrowserProtocolCapability::SemanticObservation)) + ); + Ok(()) +} + +#[test] +fn origin_mismatch_or_unbound_origin_fails_before_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + let other_origin = origin("https://other.example")?; + registry.bind_context_origin(session, context, &expected_origin)?; + + reset_dispatch_marker(); + assert_eq!( + descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &other_origin, + ), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::OriginChangedWithoutDocumentAdvance + )) + ); + assert!(!dispatch_was_called()); + + registry.advance_document(context)?; + reset_dispatch_marker(); + assert_eq!( + descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::ContextOriginNotBound + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn protocol_mismatch_after_origin_revalidation_still_prevents_dispatch() +-> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + registry.bind_context_origin(session, context, &expected_origin)?; + reset_dispatch_marker(); + + assert_eq!( + descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata("originweave-bidi-v2"), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::AdapterVersionMismatch + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs new file mode 100644 index 000000000..6223b067d --- /dev/null +++ b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs @@ -0,0 +1,150 @@ +use std::{cell::Cell, error::Error, io}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolOperation, BrowserProtocolRuntimeMetadata, DocumentEpoch, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +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 descriptor( + capabilities: &[BrowserProtocolCapability], +) -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capabilities, + )?) +} + +fn runtime_metadata() -> BrowserProtocolRuntimeMetadata<'static> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn origin(value: &str) -> Result> { + Origin::parse(value).map_err(|_| { + Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid controlled origin fixture", + )) as Box + }) +} + +fn typed_input_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_origin, + ), + epoch, + )) +} + +#[test] +fn buyer_visible_operations_map_to_exact_transport_capabilities() { + let expected = [ + ( + BrowserProtocolOperation::Navigate, + BrowserProtocolCapability::Navigation, + ), + ( + BrowserProtocolOperation::QueryNodes, + BrowserProtocolCapability::SemanticObservation, + ), + ( + BrowserProtocolOperation::ClickNode, + BrowserProtocolCapability::TypedInput, + ), + ( + BrowserProtocolOperation::TypeText, + BrowserProtocolCapability::TypedInput, + ), + ( + BrowserProtocolOperation::WaitForState, + BrowserProtocolCapability::SemanticObservation, + ), + ( + BrowserProtocolOperation::ObserveNetwork, + BrowserProtocolCapability::NetworkObservation, + ), + ]; + + for (operation, capability) in expected { + assert_eq!(operation.required_capability(), capability); + } +} + +#[test] +fn typed_operation_dispatch_derives_the_required_capability() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::TypedInput])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = origin("https://app.example")?; + let target = typed_input_target(&mut registry, &expected_origin)?; + + let result = descriptor.dispatch_operation_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolOperation::TypeText, + |validated: ValidatedBrowserProtocolUse, operation, epoch: DocumentEpoch| { + (operation, validated.capability(), epoch.value()) + }, + )?; + + assert_eq!( + result, + ( + BrowserProtocolOperation::TypeText, + BrowserProtocolCapability::TypedInput, + 1, + ) + ); + Ok(()) +} + +#[test] +fn unsupported_typed_operation_fails_before_dispatch_callback() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = origin("https://app.example")?; + let target = typed_input_target(&mut registry, &expected_origin)?; + let dispatch_called = Cell::new(false); + + let result = descriptor.dispatch_operation_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolOperation::ClickNode, + |_validated, _operation, _epoch| dispatch_called.set(true), + ); + + assert!(matches!( + result, + Err(BrowserContextProtocolDispatchError::ProtocolValidation(_)) + )); + assert!(!dispatch_called.get()); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs new file mode 100644 index 000000000..360e8d4f1 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -0,0 +1,195 @@ +use std::error::Error; + +use originweave_core::{ + MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, + MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, + WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, +}; + +#[test] +fn accessibility_query_exposes_exact_bidi_method_and_locator_contract() -> Result<(), Box> +{ + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 32)?; + + assert_eq!(query.method(), WEBDRIVER_BIDI_LOCATE_NODES_METHOD); + assert_eq!(query.method(), "browsingContext.locateNodes"); + assert_eq!(query.locator_type(), "accessibility"); + assert_eq!(query.role(), Some("textbox")); + assert_eq!(query.name(), Some("Task text")); + assert_eq!(query.max_node_count(), 32); + Ok(()) +} + +#[test] +fn accessibility_query_fixes_minimal_serialization_options() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), None, 8)?; + + assert_eq!(WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, 0); + assert_eq!(WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, 0); + assert_eq!(WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, "none"); + assert_eq!(query.serialization_max_dom_depth(), 0); + assert_eq!(query.serialization_max_object_depth(), 0); + assert_eq!(query.serialization_include_shadow_tree(), "none"); + Ok(()) +} + +#[test] +fn role_only_and_name_only_queries_are_valid() -> Result<(), Box> { + let role_only = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + assert_eq!(role_only.role(), Some("button")); + assert_eq!(role_only.name(), None); + + let name_only = WebDriverBiDiAccessibilityQuery::new(None, Some("Submit task"), 1)?; + assert_eq!(name_only.role(), None); + assert_eq!(name_only.name(), Some("Submit task")); + Ok(()) +} + +#[test] +fn missing_or_empty_accessibility_locator_fields_fail_closed() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, None, 1), + Err(WebDriverBiDiAccessibilityQueryError::MissingLocatorValue) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(""), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::EmptyRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(""), 1), + Err(WebDriverBiDiAccessibilityQueryError::EmptyName) + ); +} + +#[test] +fn accessibility_role_rejects_whitespace_and_control_injection() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("text box"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\n"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\u{0000}"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); +} + +#[test] +fn accessibility_locator_text_rejects_unicode_format_and_bidi_overrides() { + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let role = format!("button{character}"); + let name = format!("Submit{character}task"); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(&role), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(&name), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + } +} + +#[test] +fn accessibility_name_rejects_control_injection_and_whitespace_only_values() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\ntask"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{0000}task"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(" "), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); +} + +#[test] +fn accessibility_name_keeps_ordinary_spaces_and_multibyte_text() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("작업 텍스트"), 1)?; + assert_eq!(query.role(), Some("textbox")); + assert_eq!(query.name(), Some("작업 텍스트")); + Ok(()) +} + +#[test] +fn accessibility_locator_text_is_bounded_by_utf8_bytes() -> Result<(), Box> { + let maximum_role = "r".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES); + let maximum_name = "n".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES); + let query = WebDriverBiDiAccessibilityQuery::new( + Some(&maximum_role), + Some(&maximum_name), + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, + )?; + assert_eq!(query.role(), Some(maximum_role.as_str())); + assert_eq!(query.name(), Some(maximum_name.as_str())); + + let overlong_role = "r".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES + 1); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(&overlong_role), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::RoleTooLong) + ); + + let overlong_name = "n".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES + 1); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(&overlong_name), 1), + Err(WebDriverBiDiAccessibilityQueryError::NameTooLong) + ); + Ok(()) +} + +#[test] +fn accessibility_query_node_count_is_finite_and_nonzero() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 0), + Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new( + Some("button"), + None, + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT + 1, + ), + Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount) + ); +} + +#[test] +fn accessibility_query_revalidates_returned_node_count() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 2)?; + + assert_eq!(query.validate_result_count(0), Ok(())); + assert_eq!(query.validate_result_count(2), Ok(())); + assert_eq!( + query.validate_result_count(3), + Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded) + ); + Ok(()) +} + +#[test] +fn accessibility_query_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiAccessibilityQueryError::MissingLocatorValue, + WebDriverBiDiAccessibilityQueryError::EmptyRole, + WebDriverBiDiAccessibilityQueryError::RoleTooLong, + WebDriverBiDiAccessibilityQueryError::EmptyName, + WebDriverBiDiAccessibilityQueryError::InvalidRole, + WebDriverBiDiAccessibilityQueryError::InvalidName, + WebDriverBiDiAccessibilityQueryError::NameTooLong, + WebDriverBiDiAccessibilityQueryError::InvalidNodeCount, + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs new file mode 100644 index 000000000..80add05b6 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs @@ -0,0 +1,289 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiRemoteNodeReferenceError, +}; + +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 controlled_origin() -> Origin { + Origin::parse("https://app.example").expect("valid controlled fixture origin") +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_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, + )?) +} + +#[test] +fn locate_nodes_result_binds_admitted_shared_ids_to_current_authority() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 2)?; + + let handles = query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[ + ("node", Some("shared-task-text")), + ("node", Some("shared-task-text-shadow")), + ], + )?; + + assert_eq!(handles.len(), 2); + assert_eq!( + handles[0].browser_session(), + target.context_origin().context().browser_session() + ); + assert_eq!( + handles[0].browsing_context(), + target.context_origin().context().browsing_context() + ); + assert_eq!(handles[0].origin(), &expected_origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + assert_ne!(handles[0].node_id(), handles[1].node_id()); + handles[0].validate_current( + target.context_origin().context().browser_session(), + target.context_origin().context().browsing_context(), + &expected_origin, + target.expected_epoch(), + )?; + Ok(()) +} + +#[test] +fn over_budget_locate_nodes_result_fails_before_node_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[ + ("node", Some("shared-submit")), + ("node", Some("shared-extra")), + ], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded + )) + ); + Ok(()) +} + +#[test] +fn stale_document_epoch_fails_before_locate_nodes_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let stale_target = current_target(&mut registry, &expected_origin)?; + let context = stale_target.context_origin().context().browsing_context(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin( + stale_target.context_origin().context().browser_session(), + context, + &expected_origin, + )?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_ne!(current_epoch, stale_target.expected_epoch()); + assert_eq!( + query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + stale_target, + &[("node", Some("shared-submit"))], + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: stale_target.expected_epoch(), + current: current_epoch, + } + ) + ); + Ok(()) +} + +#[test] +fn exhausted_node_identifier_space_fails_after_remote_value_admission() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 2)?; + + assert_eq!( + query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[ + ("node", Some("shared-submit")), + ("node", Some("shared-extra")), + ], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::IdentifierSpaceExhausted + )) + ); + Ok(()) +} + +#[test] +fn empty_locate_nodes_result_is_valid_when_the_document_is_current() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + let handles = + query.bind_current_nodes(semantic_observation_proof()?, &mut registry, target, &[])?; + assert!(handles.is_empty()); + Ok(()) +} + +#[test] +fn unknown_browser_session_fails_before_locate_nodes_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new( + BrowserSessionId::new(99).expect("nonzero fixture session"), + BrowsingContextId::new(7).expect("nonzero fixture context"), + ), + &expected_origin, + ), + DocumentEpoch::new(1).expect("nonzero fixture epoch"), + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession + )) + ); + Ok(()) +} + +#[test] +fn untrusted_non_node_item_fails_before_registry_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[("object", Some("shared-submit"))], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType + )) + ); + Ok(()) +} + +#[test] +fn locate_nodes_admission_error_contract_is_source_aware() { + let expected = DocumentEpoch::new(1).expect("nonzero fixture epoch"); + let current = DocumentEpoch::new(2).expect("nonzero fixture epoch"); + let errors = [ + WebDriverBiDiLocateNodesAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + ), + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + ), + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { expected, current }, + WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession, + ), + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::TypedInput, + ), + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::Navigation, + ), + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation, + ), + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::NetworkObservation, + ), + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + } + assert!(errors[0].source().is_some()); + assert!(errors[1].source().is_some()); + assert!(errors[2].source().is_none()); + assert!(errors[3].source().is_some()); + assert!(errors[4].source().is_none()); + assert!( + errors[4] + .to_string() + .contains("SemanticObservation protocol-use proof, not TypedInput") + ); + assert!(errors[5].to_string().contains("not Navigation")); + assert!(errors[6].to_string().contains("not SemanticObservation")); + assert!(errors[7].to_string().contains("not NetworkObservation")); +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs new file mode 100644 index 000000000..974ea4d3f --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs @@ -0,0 +1,88 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; +use std::io; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, +}; + +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 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, + )?) +} + +#[test] +fn exhausted_locate_nodes_batch_does_not_consume_partial_node_authority() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let origin = Origin::parse("https://app.example").map_err(|_error| { + io::Error::new( + io::ErrorKind::InvalidData, + "controlled fixture origin must remain valid", + ) + })?; + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + let batch_query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 2)?; + + assert_eq!( + batch_query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[ + ("node", Some("shared-submit")), + ("node", Some("shared-extra")), + ], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::IdentifierSpaceExhausted, + )) + ); + + let recovery_query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Recovery action"), 1)?; + let handles = recovery_query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[("node", Some("shared-recovery"))], + )?; + + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].node_id(), 1); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs new file mode 100644 index 000000000..d0195649b --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs @@ -0,0 +1,121 @@ +use std::error::Error; + +use originweave_core::{ + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, MAX_WEBDRIVER_BIDI_COMMAND_ID, + UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesCommandError, +}; + +#[test] +fn locate_nodes_command_serializes_exact_bidi_envelope() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new( + Some("textbox"), + Some(r#"Task "quoted" \ review 작업"#), + 32, + )?; + let command = WebDriverBiDiLocateNodesCommand::new(42, r#"context-"quoted"\path"#, &query)?; + + assert_eq!(command.command_id(), 42); + assert_eq!(command.method(), WEBDRIVER_BIDI_LOCATE_NODES_METHOD); + assert_eq!(command.browsing_context(), r#"context-"quoted"\path"#); + assert_eq!( + command.as_json(), + r#"{"id":42,"method":"browsingContext.locateNodes","params":{"context":"context-\"quoted\"\\path","locator":{"type":"accessibility","value":{"role":"textbox","name":"Task \"quoted\" \\ review 작업"}},"maxNodeCount":32,"serializationOptions":{"maxDomDepth":0,"maxObjectDepth":0,"includeShadowTree":"none"}}}"# + ); + Ok(()) +} + +#[test] +fn locate_nodes_command_serializes_role_only_and_name_only_locators() -> Result<(), Box> +{ + let role_only = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + let role_command = WebDriverBiDiLocateNodesCommand::new(0, "context-a", &role_only)?; + assert_eq!( + role_command.as_json(), + r#"{"id":0,"method":"browsingContext.locateNodes","params":{"context":"context-a","locator":{"type":"accessibility","value":{"role":"button"}},"maxNodeCount":1,"serializationOptions":{"maxDomDepth":0,"maxObjectDepth":0,"includeShadowTree":"none"}}}"# + ); + + let name_only = WebDriverBiDiAccessibilityQuery::new(None, Some("Submit task"), 2)?; + let name_command = WebDriverBiDiLocateNodesCommand::new( + MAX_WEBDRIVER_BIDI_COMMAND_ID, + "context-b", + &name_only, + )?; + assert_eq!(name_command.command_id(), MAX_WEBDRIVER_BIDI_COMMAND_ID); + assert_eq!( + name_command.as_json(), + r#"{"id":9007199254740991,"method":"browsingContext.locateNodes","params":{"context":"context-b","locator":{"type":"accessibility","value":{"name":"Submit task"}},"maxNodeCount":2,"serializationOptions":{"maxDomDepth":0,"maxObjectDepth":0,"includeShadowTree":"none"}}}"# + ); + Ok(()) +} + +#[test] +fn locate_nodes_command_rejects_out_of_range_command_id() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + WebDriverBiDiLocateNodesCommand::new( + MAX_WEBDRIVER_BIDI_COMMAND_ID + 1, + "context-a", + &query, + ), + Err(WebDriverBiDiLocateNodesCommandError::InvalidCommandId) + ); + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(u64::MAX, "context-a", &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidCommandId) + ); + Ok(()) +} + +#[test] +fn locate_nodes_command_rejects_invalid_browsing_context_text() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + for invalid_context in ["", "context with space", "context\nline"] { + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(1, invalid_context, &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext) + ); + } + + let overlong = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1); + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(1, &overlong, &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext) + ); + + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let context = format!("context{character}"); + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(1, &context, &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext) + ); + } + Ok(()) +} + +#[test] +fn locate_nodes_command_accepts_maximum_bounded_context() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + let context = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + let command = WebDriverBiDiLocateNodesCommand::new(1, &context, &query)?; + + assert_eq!(command.browsing_context(), context); + assert!(command.as_json().contains(&context)); + Ok(()) +} + +#[test] +fn locate_nodes_command_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiLocateNodesCommandError::InvalidCommandId, + WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs new file mode 100644 index 000000000..91f7fc143 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs @@ -0,0 +1,80 @@ +use std::error::Error; + +use originweave_core::{ + MAX_WEBDRIVER_BIDI_COMMAND_ID, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseEnvelopeError, +}; + +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 locate_nodes_response_requires_exact_command_id() -> Result<(), Box> { + let correlated = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?; + + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn locate_nodes_response_rejects_mismatched_command_id() -> Result<(), Box> { + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(41)); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 41, + } + )) + ); + Ok(()) +} + +#[test] +fn locate_nodes_response_rejects_out_of_range_id_before_correlation() -> Result<(), Box> +{ + let error = locate_nodes_command(1)?.correlate_response_envelope( + WebDriverBiDiCommandResponseKind::Success, + Some(MAX_WEBDRIVER_BIDI_COMMAND_ID + 1), + ); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId + )) + ); + Ok(()) +} + +#[test] +fn response_correlation_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId, + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 2, + actual: 1, + }, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} 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_response_envelope.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs new file mode 100644 index 000000000..4425be6ee --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs @@ -0,0 +1,141 @@ +use std::error::Error; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseEnvelopeError, +}; + +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 success_envelope_requires_and_retains_exact_response_id() -> Result<(), Box> { + let correlated = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))?; + + assert_eq!(correlated.kind(), WebDriverBiDiCommandResponseKind::Success); + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn correlated_success_can_be_consumed_as_success_evidence() -> Result<(), Box> { + let validated = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?; + + assert_eq!(validated.command_id(), 42); + assert_eq!(validated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn correlated_success_enforces_exact_serialized_result_budget() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 1)?; + let validated = WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?; + + assert_eq!(validated.max_node_count(), 1); + assert_eq!(validated.validate_result_count(1), Ok(())); + assert_eq!( + validated.validate_result_count(2), + Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded) + ); + Ok(()) +} + +#[test] +fn error_envelope_with_id_is_correlated_but_remains_error_kind() -> Result<(), Box> { + let correlated = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Error, Some(42))?; + + assert_eq!(correlated.kind(), WebDriverBiDiCommandResponseKind::Error); + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn correlated_error_cannot_become_success_evidence() -> Result<(), Box> { + let result = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Error, Some(42))? + .into_validated_success(); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse) + ); + Ok(()) +} + +#[test] +fn success_envelope_rejects_missing_id() -> Result<(), Box> { + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, None); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId) + ); + Ok(()) +} + +#[test] +fn null_error_id_is_explicitly_uncorrelatable() -> Result<(), Box> { + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Error, None); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse) + ); + Ok(()) +} + +#[test] +fn envelope_preserves_exact_correlation_failures() -> Result<(), Box> { + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(41)); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 41, + } + )) + ); + Ok(()) +} + +#[test] +fn envelope_error_sources_distinguish_protocol_shape_from_correlation() { + let direct_errors = [ + WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId, + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse, + ]; + for error in direct_errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } + + let correlation = WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId, + ); + assert!(correlation.source().is_some()); + assert!(!correlation.to_string().is_empty()); +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs new file mode 100644 index 000000000..5c70cdc7a --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs @@ -0,0 +1,297 @@ +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiCommandResponseKind, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResultAdmissionError, WebDriverBiDiRemoteNodeReferenceError, +}; + +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 correlated_success( + max_node_count: u16, +) -> Result> { + let query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), max_node_count)?; + Ok( + WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?, + ) +} + +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 controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn protocol_proof( + kind: BrowserProtocolKind, + capability: BrowserProtocolCapability, +) -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + kind, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[capability], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + kind, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capability, + )?) +} + +fn semantic_observation_proof() -> Result> { + protocol_proof( + BrowserProtocolKind::WebDriverBiDi, + BrowserProtocolCapability::SemanticObservation, + ) +} + +#[test] +fn correlated_result_admission_retains_exact_command_and_normalized_nodes() +-> Result<(), Box> { + let result = correlated_success(2)?.admit_result_nodes(&[ + ("node", Some("shared-node-a")), + ("node", Some("shared-node-b")), + ])?; + + assert_eq!(result.command_id(), 42); + assert_eq!(result.browsing_context(), "context-a"); + assert_eq!(result.max_node_count(), 2); + assert_eq!(result.nodes().len(), 2); + assert_eq!(result.nodes()[0].remote_type(), "node"); + assert_eq!(result.nodes()[0].shared_id(), "shared-node-a"); + assert_eq!(result.nodes()[1].shared_id(), "shared-node-b"); + Ok(()) +} + +#[test] +fn correlated_result_admission_rejects_over_budget_batch_before_node_normalization() +-> Result<(), Box> { + let error = + correlated_success(1)?.admit_result_nodes(&[("not-a-node", None), ("not-a-node", None)]); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResultAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + )) + ); + Ok(()) +} + +#[test] +fn correlated_result_admission_rejects_invalid_remote_node_shape() -> Result<(), Box> { + let error = correlated_success(1)?.admit_result_nodes(&[("string", Some("shared-node-a"))]); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResultAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType, + )) + ); + Ok(()) +} + +#[test] +fn correlated_result_admission_error_preserves_typed_source() { + let query_error = WebDriverBiDiLocateNodesResultAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + ); + assert!(query_error.source().is_some()); + assert!(!query_error.to_string().is_empty()); + + let remote_error = WebDriverBiDiLocateNodesResultAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + ); + assert!(remote_error.source().is_some()); + assert!(!remote_error.to_string().is_empty()); +} + +#[test] +fn correlated_result_binds_only_to_its_exact_registered_context() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let result = correlated_success(2)?.admit_result_nodes(&[ + ("node", Some("shared-node-a")), + ("node", Some("shared-node-b")), + ])?; + + let handles = + result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target)?; + + assert_eq!(handles.len(), 2); + assert_eq!( + handles[0].browsing_context(), + target.context_origin().context().browsing_context() + ); + assert_eq!(handles[0].origin(), &origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + Ok(()) +} + +#[test] +fn correlated_result_rejects_cross_context_rebinding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-b")?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + let error = result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::ContextExternalIdentifierMismatch, + )) + ); + let error = error.err().ok_or("expected context mismatch")?; + assert!(error.to_string().contains("external identifier")); + Ok(()) +} + +#[test] +fn correlated_result_rejects_non_bidi_protocol_proof() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + assert_eq!( + result.bind_current_nodes( + protocol_proof( + BrowserProtocolKind::ChromeDevToolsProtocol, + BrowserProtocolCapability::SemanticObservation, + )?, + &mut registry, + target, + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind( + BrowserProtocolKind::ChromeDevToolsProtocol, + ) + ) + ); + Ok(()) +} + +#[test] +fn correlated_result_rejects_non_observation_protocol_proof() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + assert_eq!( + result.bind_current_nodes( + protocol_proof( + BrowserProtocolKind::WebDriverBiDi, + BrowserProtocolCapability::TypedInput, + )?, + &mut registry, + target, + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::TypedInput, + ) + ) + ); + Ok(()) +} + +#[test] +fn correlated_result_rejects_missing_current_origin_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let context = target.context_origin().context().browsing_context(); + registry.advance_document(context)?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + assert_eq!( + result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::ContextOriginNotBound, + )) + ); + Ok(()) +} + +#[test] +fn correlated_result_rejects_stale_document_epoch() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let context = target.context_origin().context().browsing_context(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin( + target.context_origin().context().browser_session(), + context, + &origin, + )?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + assert_eq!( + result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target), + Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + } + ) + ); + Ok(()) +} + +#[test] +fn correlated_result_keeps_node_binding_transactional_on_identifier_exhaustion() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let result = correlated_success(2)?.admit_result_nodes(&[ + ("node", Some("shared-node-a")), + ("node", Some("shared-node-b")), + ])?; + + assert_eq!( + result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::IdentifierSpaceExhausted, + )) + ); + Ok(()) +} 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_protocol_kind_admission.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs new file mode 100644 index 000000000..8c764060d --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs @@ -0,0 +1,75 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const CDP_ADAPTER_VERSION: &str = "originweave-cdp-v1"; +const CDP_PROTOCOL_REVISION: &str = "cdp-pdl-2026-08-17"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn cdp_semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::ChromeDevToolsProtocol, + ORIGINWEAVE_PROTOCOL_VERSION, + CDP_ADAPTER_VERSION, + CDP_PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::ChromeDevToolsProtocol, + CDP_ADAPTER_VERSION, + CDP_PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn webdriver_bidi_locate_nodes_rejects_cdp_semantic_observation_proof() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let origin = Origin::parse("https://app.example").expect("valid controlled fixture origin"); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 1)?; + + let error = query + .bind_current_nodes( + cdp_semantic_observation_proof()?, + &mut registry, + target, + &[("node", Some("shared-task-text"))], + ) + .expect_err("CDP proof must not authorize WebDriver BiDi locateNodes admission"); + assert_eq!( + error, + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind( + BrowserProtocolKind::ChromeDevToolsProtocol, + ) + ); + assert_eq!( + error.to_string(), + "locateNodes admission requires a WebDriverBiDi protocol-use proof, not ChromeDevToolsProtocol" + ); + assert!(error.source().is_none()); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs new file mode 100644 index 000000000..69f407aa2 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs @@ -0,0 +1,365 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolKind, BrowserProtocolOperation, + BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiQueryNodesAdmissionError, + WebDriverBiDiRemoteNodeReferenceError, +}; + +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 descriptor( + capabilities: &[BrowserProtocolCapability], +) -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capabilities, + )?) +} + +fn runtime_metadata() -> BrowserProtocolRuntimeMetadata<'static> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn controlled_origin() -> Origin { + Origin::parse("https://app.example").expect("valid controlled fixture origin") +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_origin, + ), + epoch, + )) +} + +fn admit_query_nodes<'a>( + descriptor: &BrowserProtocolAdapterDescriptor, + registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'a>, + query: &WebDriverBiDiAccessibilityQuery, + items: &[(&str, Option<&str>)], +) -> Result, WebDriverBiDiQueryNodesAdmissionError> { + descriptor.admit_query_nodes( + registry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + query, + items, + ) +} + +#[test] +fn query_nodes_admission_requires_semantic_observation_and_binds_current_handles() +-> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 2)?; + + let handles = admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[ + ("node", Some("shared-task-text")), + ("node", Some("shared-task-text-shadow")), + ], + )?; + + assert_eq!(handles.len(), 2); + assert_eq!( + handles[0].browser_session(), + target.context_origin().context().browser_session() + ); + assert_eq!( + handles[0].browsing_context(), + target.context_origin().context().browsing_context() + ); + assert_eq!(handles[0].origin(), &expected_origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + assert_ne!(handles[0].node_id(), handles[1].node_id()); + handles[0].validate_current( + target.context_origin().context().browser_session(), + target.context_origin().context().browsing_context(), + &expected_origin, + target.expected_epoch(), + )?; + Ok(()) +} + +#[test] +fn navigation_only_adapter_cannot_admit_query_nodes() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::Navigation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::Capability( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation, + ), + ), + ), + )) + ); + Ok(()) +} + +#[test] +fn typed_input_only_adapter_cannot_admit_query_nodes() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::TypedInput])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::Capability( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation, + ), + ), + ), + )) + ); + Ok(()) +} + +fn protocol_use_proof( + descriptor: &BrowserProtocolAdapterDescriptor, + capability: BrowserProtocolCapability, +) -> Result> { + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capability, + )?) +} + +#[test] +fn bind_current_nodes_rejects_typed_input_and_navigation_protocol_proofs() +-> Result<(), Box> { + let typed_input = descriptor(&[BrowserProtocolCapability::TypedInput])?; + let navigation = descriptor(&[BrowserProtocolCapability::Navigation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + let items = [("node", Some("shared-submit"))]; + + assert_eq!( + query.bind_current_nodes( + protocol_use_proof(&typed_input, BrowserProtocolCapability::TypedInput)?, + &mut registry, + target, + &items, + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::TypedInput + ) + ) + ); + assert_eq!( + query.bind_current_nodes( + protocol_use_proof(&navigation, BrowserProtocolCapability::Navigation)?, + &mut registry, + target, + &items, + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::Navigation + ) + ) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_rejects_control_bearing_and_omitted_shared_ids() +-> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit\n"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId + ) + )) + ); + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", None)], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId + ) + )) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_fails_closed_on_stale_document_epoch() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let stale_target = current_target(&mut registry, &expected_origin)?; + let context = stale_target.context_origin().context().browsing_context(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin( + stale_target.context_origin().context().browser_session(), + context, + &expected_origin, + )?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_ne!(current_epoch, stale_target.expected_epoch()); + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + stale_target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::DocumentEpochMismatch { + expected: stale_target.expected_epoch(), + current: current_epoch, + } + )) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_fails_closed_on_unknown_session() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new( + BrowserSessionId::new(99).expect("nonzero fixture session"), + BrowsingContextId::new(7).expect("nonzero fixture context"), + ), + &expected_origin, + ), + DocumentEpoch::new(1).expect("nonzero fixture epoch"), + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert!(matches!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::BrowserAuthority(_) + )) + )); + Ok(()) +} + +#[test] +fn query_nodes_maps_to_semantic_observation_and_error_contract_is_source_aware() { + assert_eq!( + BrowserProtocolOperation::QueryNodes.required_capability(), + BrowserProtocolCapability::SemanticObservation + ); + + let expected = DocumentEpoch::new(1).expect("nonzero fixture epoch"); + let current = DocumentEpoch::new(2).expect("nonzero fixture epoch"); + let errors = [ + WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::DocumentEpochMismatch { expected, current }, + ), + WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + ), + ), + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_some()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs new file mode 100644 index 000000000..d5f25bfbc --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -0,0 +1,110 @@ +use std::error::Error; + +use originweave_core::{ + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, +}; + +#[test] +fn remote_node_reference_requires_exact_node_type_and_shared_id() -> Result<(), Box> { + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + + assert_eq!( + reference.remote_type(), + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE + ); + assert_eq!(reference.remote_type(), "node"); + assert_eq!(reference.shared_id(), "shared-node-42"); + Ok(()) +} + +#[test] +fn remote_node_reference_rejects_non_node_remote_values() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("object", Some("shared-node-42")), + Err(WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType) + ); +} + +#[test] +fn remote_node_reference_requires_a_usable_shared_id() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", None), + Err(WebDriverBiDiRemoteNodeReferenceError::MissingSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); +} + +#[test] +fn remote_node_reference_rejects_unicode_format_and_bidi_overrides() { + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let shared_id = format!("shared-node-42{character}"); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&shared_id)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + } +} + +#[test] +fn remote_node_reference_rejects_whitespace_and_control_injection() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(" ")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\n")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{0000}")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); +} + +#[test] +fn remote_node_reference_reuses_the_registry_identifier_budget() -> Result<(), Box> { + let maximum = "n".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some(&maximum))?; + assert_eq!(reference.shared_id(), maximum); + + let overlong = "n".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&overlong)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + Ok(()) +} + +#[test] +fn remote_node_reference_bounds_multibyte_shared_ids_by_utf8_bytes() -> Result<(), Box> { + let exact = "한".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES / "한".len()); + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some(&exact))?; + assert_eq!(reference.shared_id(), exact); + + let overlong = format!("{exact}한"); + assert!(overlong.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&overlong)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + Ok(()) +} + +#[test] +fn remote_node_reference_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType, + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs new file mode 100644 index 000000000..8d440272e --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs @@ -0,0 +1,98 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, + WebDriverBiDiResponseDocumentAdmissionError, +}; + +#[test] +fn bounded_response_document_retains_exact_wire_text() -> Result<(), Box> { + let raw = " \r\n{\"id\":42,\"type\":\"success\",\"result\":{}}\t"; + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + + assert_eq!(document.as_str(), raw); + Ok(()) +} + +#[test] +fn bounded_response_document_admits_transport_bytes_without_preallocating_untrusted_text() +-> 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"] { + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(raw), + Err(WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument) + ); + } +} + +#[test] +fn response_document_requires_an_object_boundary_without_claiming_json_validation() +-> Result<(), Box> { + for raw in ["[]", "null", "{", "}", "\u{00a0}{}\u{00a0}"] { + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(raw), + Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary) + ); + } + + let coarse_only = BoundedWebDriverBiDiResponseDocument::new("{not-json}")?; + assert_eq!(coarse_only.as_str(), "{not-json}"); + Ok(()) +} + +#[test] +fn response_document_budget_accepts_exact_limit_and_rejects_one_more_byte() +-> Result<(), Box> { + const OBJECT_OVERHEAD_BYTES: usize = 8; + let exact = format!( + "{{\"x\":\"{}\"}}", + "a".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - OBJECT_OVERHEAD_BYTES) + ); + assert_eq!(exact.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); + assert!(BoundedWebDriverBiDiResponseDocument::new(&exact).is_ok()); + + let oversized = format!("{exact} "); + assert_eq!( + oversized.len(), + MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1 + ); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&oversized), + Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge) + ); + Ok(()) +} + +#[test] +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()); + assert!(error.source().is_none()); + } +} 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/API_CONTRACT.md b/docs/API_CONTRACT.md index f750922fd..4ac62061d 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -227,6 +227,8 @@ Queries the OriginWeave semantic observation contract; callers do not need to sy Query predicates may include role, accessible name, state, structured field, source channel and scoped layout attributes. Results contain opaque semantic-node handles bound to the current session/context/origin/document epoch. +The first Rust control-plane slice admits an untrusted WebDriver BiDi `locateNodes` result only after the adapter transfers a non-cloneable `QueryNodes` / `SemanticObservation` protocol-use proof into `bind_current_nodes` on the exact current session, browsing context, canonical origin, and document epoch. Navigation or TypedInput proofs fail closed. That composition still performs no browser I/O and does not authorize typed input. + ## 14. Action operations ### `browser.act` diff --git a/docs/adr/0010-session-context-bound-node-authority.md b/docs/adr/0010-session-context-bound-node-authority.md index 9c085bdd3..97e792948 100644 --- a/docs/adr/0010-session-context-bound-node-authority.md +++ b/docs/adr/0010-session-context-bound-node-authority.md @@ -30,6 +30,7 @@ The numeric identifiers are internal opaque registry identities. They are not ra - The Rust core stays independent of Chromium, WebDriver, selectors, script execution, network access, storage, credentials, and model providers. - Future WebDriver BiDi and CDP adapters must own external-to-internal identity translation, registry lifecycle, epoch rotation, and immediate pre-action validation. - A valid handle proves only observation authority. It does not grant a browser capability, origin permission, resolved-destination authority, transport authority, approval, or successful post-condition. +- QueryNodes admission transfers a non-cloneable SemanticObservation protocol-use proof by ownership into `bind_current_nodes` before translating an admitted `locateNodes` `sharedId` into an `ObservedNodeHandle`. Navigation or TypedInput proofs fail closed and cannot mint observation handles. ## References diff --git a/docs/doctoring.md b/docs/doctoring.md index f0133bb5d..6ccc879d8 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,6 +8,12 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control, whitespace, or reviewed Unicode format characters. Requiring `sharedId` and rejecting control, whitespace, and format characters is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. The same-call QueryNodes admission boundary first obtains a non-cloneable SemanticObservation protocol-use proof and transfers that proof by ownership into `bind_current_nodes`, which refuses Navigation and TypedInput proofs before translating each admitted `sharedId` through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. That composition still performs no browser I/O and does not authorize typed input. + +WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace, a control character, or a Unicode format character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control and reviewed format characters that would become protocol-text injection or bidirectional spoofing, and rejects whitespace-only names as non-selectors. + +UTS #39 Revision 32 is the current Unicode security-mechanisms standard and marks Default_Ignorable and bidirectional format characters as restricted in identifier profiles. UAX #9 defines the bidirectional format controls that can reorder displayed protocol text. UTR #36 Revision 15 remains a stabilized historical security-considerations report; its identifier recommendations are superseded by UTS #39 rather than cited as current normative profile rules. OriginWeave therefore rejects the reviewed format-character set in roles, shared identifiers, and registry external identifiers, and rejects those same characters inside accessible names while still allowing ordinary U+0020 spaces. + ### Browser origin equivalence The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal, integer, hexadecimal, legacy octal-looking, and mixed-component numeric hosts as IPv4 or broken IPv4 candidates rather than ordinary DNS names. Chromium's regression suite includes values such as `192`, `0xC0a80001`, `030052000001`, and mixed hexadecimal components. A non-final empty `0x` component can participate in Chromium's multi-part IPv4 truncation behavior, but a final `0x` label does not produce an IPv4 number because stripping its prefix leaves no digits; it remains a domain label. Chromium also warns that broken IP-like hosts must not be connected because another resolver could accept them. OriginWeave therefore admits only canonical dotted-decimal IPv4 into its policy origin type, rejects browser-special numeric spellings before DNS validation, and preserves final non-numeric DNS labels such as `0x`. @@ -162,12 +168,22 @@ The Rust Project Developers. (2026). *Ipv6Addr in std::net* (Rust 1.97.1) [Softw The Rust Project Developers. (2026). *TcpStream in std::net* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/net/struct.TcpStream.html +Unicode Consortium. (2014, September 19). *Unicode security considerations* (Unicode Technical Report #36, Revision 15). https://www.unicode.org/reports/tr36/tr36-15.html + +Unicode Consortium. (2025a, September 4). *Unicode bidirectional algorithm* (Unicode Standard Annex #9, Version 17.0.0). https://www.unicode.org/reports/tr9/ + +Unicode Consortium. (2025b, September 4). *Unicode security mechanisms* (Unicode Technical Standard #39, Revision 32). https://www.unicode.org/reports/tr39/tr39-32.html + Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/2023/REC-wai-aria-1.2-20230606/ + World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ + Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 5173a32e6..dbf3ef731 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -1,6 +1,6 @@ # Browser and Agent Protocol Standards Evidence -- **Reviewed:** 2026-08-10 +- **Reviewed:** 2026-08-18 - **Purpose:** primary-source evidence for OriginWeave browser compatibility and adapter boundaries - **Canonical research index:** [`../doctoring.md`](../doctoring.md) @@ -8,9 +8,15 @@ This addendum complements the main doctoring record. The main record already car ## WebDriver BiDi -The W3C publication reviewed for this baseline is the 1 June 2026 **Working Draft**, not a Recommendation. OriginWeave therefore treats BiDi as a versioned browser-automation adapter rather than product-internal authority. Raw BiDi session/context/node identifiers do not become durable OriginWeave identities. +The latest published W3C technical-report baseline reviewed here remains the 1 June 2026 **Working Draft**, not a Recommendation. The current Editor’s Draft reviewed on 18 August 2026 identifies itself as the 20 July 2026 draft. OriginWeave therefore treats BiDi as a versioned browser-automation adapter rather than product-internal authority. Raw BiDi session/context/node identifiers do not become durable OriginWeave identities. -Primary source: World Wide Web Consortium, *WebDriver BiDi*. +For the bounded `browsingContext.locateNodes` command-serialization boundary, the reviewed Editor’s Draft defines a command envelope with `id: js-uint`, defines `js-uint` as `0..9007199254740991`, and defines `browsingContext.locateNodes` parameters containing a browsing context, locator, optional positive `maxNodeCount`, optional `serializationOptions`, and optional `startNodes`. OriginWeave serializes only its separately reviewed accessibility-locator subset and fixed minimal serialization options; this deterministic JSON value is not transport authentication or browser/Agent authority. + +WebDriver BiDi commands may execute concurrently and finish out of order. The Editor’s Draft defines the command id as the local end’s correlation identifier and sets a successful `CommandResponse.id` to that exact command id; an `ErrorResponse.id` may be `null` when no valid command id can be recovered. OriginWeave therefore fails closed unless a non-null protocol-range response id exactly matches the consumed command before later payload admission. Parsing success/error envelopes, handling nullable malformed-command errors, and authenticating the browser transport remain separate adapter boundaries. + +The same reviewed Editor’s Draft defines a closed `ErrorCode` vocabulary that currently includes `no such client window`. OriginWeave admits only the reviewed vocabulary at its bounded response-envelope parser and rejects unknown error-code text fail closed; adding a newly reviewed protocol code changes compatibility only and grants no browser, transport, node, policy, or Agent authority. + +Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). ## Chrome Manifest V3 @@ -46,13 +52,14 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re ## Product consequences -1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. -2. Pin exact Chromium/CDP compatibility evidence at release time. -3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. -4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. -5. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. -6. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. -7. Treat WARC/PROV as provenance representations, not policy or truth escalation. +1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. Admit a BiDi `script.NodeRemoteValue` only as an untrusted transport handle when its type is exactly `node` and a usable control-free `sharedId` is present; do not treat a realm-local `handle`, a missing shared identifier, or control/whitespace-bearing protocol text as OriginWeave node authority. Treat an accessibility-query role as one exact WAI-ARIA token, not a whitespace-separated fallback list. +2. Serialize reviewed BiDi commands from already validated bounded values only, then correlate each non-null response id to the exact consumed command before payload admission; a protocol-shaped JSON envelope or matching id never substitutes for authenticated browser transport, current session/context/origin/document authority, policy authorization, or post-condition evidence. +3. Pin exact Chromium/CDP compatibility evidence at release time. +4. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. +5. Keep WebMCP experimental/optional and propagate untrusted-content semantics. +6. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. +7. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. +8. Treat WARC/PROV as provenance representations, not policy or truth escalation. ## References — APA 7th @@ -74,6 +81,12 @@ Model Context Protocol. (2026). *Model Context Protocol specification (2026-07-2 World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/2023/REC-wai-aria-1.2-20230606/ + World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +World Wide Web Consortium. (2026, July 20). *WebDriver BiDi* (Editor’s Draft). https://w3c.github.io/webdriver-bidi/ + +World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ + International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html diff --git a/docs/product-roadmap.md b/docs/product-roadmap.md index c61dfee63..4aa0ace97 100644 --- a/docs/product-roadmap.md +++ b/docs/product-roadmap.md @@ -70,6 +70,7 @@ Delivered document-node authority foundation: - a nonzero `BrowsingContextId` for one independently navigable browser context inside that session; - a nonzero `DocumentEpoch` identity for one observed document lifetime inside that context; - an `ObservedNodeHandle` bound to the exact browser session, browsing context, canonical origin, document epoch, and nonzero adapter-local node identifier; +- same-call QueryNodes admission that transfers a SemanticObservation protocol-use proof by ownership into `bind_current_nodes` before translating admitted `locateNodes` `sharedId` values into those handles; - deterministic rejection of cross-session, cross-context, cross-origin, or stale-document node reuse before a future browser adapter performs an action; - reusable core contracts without Chromium, WebDriver, selector, script-execution, network, storage, or secret dependencies. diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index 1313189ea..0d55bfac5 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -11,6 +11,12 @@ class ProductDocumentationContractTests(unittest.TestCase): """Keep product requirements, technical design, diagrams, and traceability discoverable.""" + def test_changelog_records_correlated_locate_nodes_admission(self) -> None: + """Public BiDi result admission must remain visible in release evidence.""" + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("Correlated WebDriver BiDi `locateNodes` result admission", changelog) + self.assertIn("registered browsing-context identity", changelog) + def test_authoritative_product_documentation_graph_exists(self) -> None: """Major product decisions must not require reconstructing chat or PR history.""" required_paths = { diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index c6d50ff54..775eaeff9 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -187,6 +187,18 @@ def test_context_origin_revalidation_is_recorded_in_the_changelog(self) -> None: changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") self.assertIn("BrowserAuthorityRegistry::require_context_origin", changelog) + def test_context_origin_dispatch_is_recorded_in_the_changelog(self) -> None: + """The public origin-gated dispatch boundary must remain visible in release history.""" + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("dispatch_if_context_origin_current", changelog) + + def test_context_origin_epoch_dispatch_is_recorded_in_the_changelog(self) -> None: + """The public epoch-gated dispatch boundary must remain visible in release history.""" + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("dispatch_if_context_origin_epoch_current", changelog) + def test_runtime_revision_boundary_is_recorded_in_the_changelog(self) -> None: """The public runtime-revision boundary must remain visible in release history.""" 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()