diff --git a/CHANGELOG.md b/CHANGELOG.md index a41b6db9c..87ff7b68a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. - 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. @@ -88,4 +91,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs index b97daae6e..3af93cb07 100644 --- a/crates/originweave-core/src/browser_authority_registry.rs +++ b/crates/originweave-core/src/browser_authority_registry.rs @@ -89,6 +89,20 @@ impl BrowserAuthorityRegistry { .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, diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 1e9cc731b..26a05249f 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -227,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 @@ -420,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. @@ -450,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", ), @@ -627,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); @@ -641,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) @@ -803,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/lib.rs b/crates/originweave-core/src/lib.rs index 7f372bae3..fb43a48b9 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -32,6 +32,8 @@ mod browser_registry; mod browser_registry_coverage; mod contracts; mod webdriver_bidi_command; +mod webdriver_bidi_response_document; +mod webdriver_bidi_result; pub use browser_authority_registry::BrowserAuthorityRegistry; pub use browser_protocol::{ @@ -63,7 +65,16 @@ pub use browser_registry::{ }; pub use contracts::*; pub use webdriver_bidi_command::{ - MAX_WEBDRIVER_BIDI_COMMAND_ID, ValidatedWebDriverBiDiLocateNodesResponse, + CorrelatedWebDriverBiDiLocateNodesResponse, MAX_WEBDRIVER_BIDI_COMMAND_ID, + ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesCommandError, WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseEnvelopeError, +}; +pub use webdriver_bidi_response_document::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, + WebDriverBiDiResponseDocumentAdmissionError, +}; +pub use webdriver_bidi_result::{ + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs index a80e59f50..77d310ab2 100644 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -3,7 +3,8 @@ use std::fmt::{Display, Formatter}; use crate::{ MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, - WebDriverBiDiAccessibilityQuery, contains_disallowed_protocol_text, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + contains_disallowed_protocol_text, }; /// Maximum WebDriver BiDi command identifier representable by the protocol `js-uint` type. @@ -61,18 +62,74 @@ impl Display for WebDriverBiDiLocateNodesResponseCorrelationError { 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 [`WebDriverBiDiLocateNodesCommand::correlate_response_id`] can construct this value. It -/// retains the exact command identifier and bounded browsing-context identifier so a later trusted -/// transport boundary can carry correlation evidence forward without reconstructing it from -/// ambient metadata. 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. +/// 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 { @@ -87,10 +144,99 @@ impl ValidatedWebDriverBiDiLocateNodesResponse { 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, @@ -105,6 +251,7 @@ impl ValidatedWebDriverBiDiLocateNodesResponse { pub struct WebDriverBiDiLocateNodesCommand { command_id: u64, browsing_context: String, + max_node_count: u16, json: String, } @@ -160,6 +307,7 @@ impl WebDriverBiDiLocateNodesCommand { Ok(Self { command_id, browsing_context: browsing_context.to_owned(), + max_node_count: query.max_node_count(), json, }) } @@ -192,9 +340,11 @@ impl WebDriverBiDiLocateNodesCommand { /// /// 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. - /// This does not parse a response, authenticate the transport, or grant browser/Agent authority. - pub fn correlate_response_id( + /// 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. + fn correlate_response_id( self, response_id: u64, ) -> Result< @@ -216,8 +366,47 @@ impl WebDriverBiDiLocateNodesCommand { 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) { 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..4eacf84cb --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_document.rs @@ -0,0 +1,76 @@ +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 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::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(), + }) + } + + /// 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_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/tests/webdriver_bidi_locate_nodes_response_correlation.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs index a8147feb4..91f7fc143 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs @@ -2,7 +2,9 @@ use std::error::Error; use originweave_core::{ MAX_WEBDRIVER_BIDI_COMMAND_ID, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseEnvelopeError, }; fn locate_nodes_command( @@ -18,7 +20,9 @@ fn locate_nodes_command( #[test] fn locate_nodes_response_requires_exact_command_id() -> Result<(), Box> { - let correlated = locate_nodes_command(42)?.correlate_response_id(42)?; + 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"); @@ -27,16 +31,17 @@ fn locate_nodes_response_requires_exact_command_id() -> Result<(), Box Result<(), Box> { - let error = locate_nodes_command(42)?.correlate_response_id(41); + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(41)); assert_eq!( error, - Err( + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { expected: 42, actual: 41, } - ) + )) ); Ok(()) } @@ -44,11 +49,16 @@ fn locate_nodes_response_rejects_mismatched_command_id() -> Result<(), Box Result<(), Box> { - let error = locate_nodes_command(1)?.correlate_response_id(MAX_WEBDRIVER_BIDI_COMMAND_ID + 1); + let error = locate_nodes_command(1)?.correlate_response_envelope( + WebDriverBiDiCommandResponseKind::Success, + Some(MAX_WEBDRIVER_BIDI_COMMAND_ID + 1), + ); assert_eq!( error, - Err(WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId) + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId + )) ); Ok(()) } 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_response_document_budget.rs b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs new file mode 100644 index 000000000..7909c4f28 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs @@ -0,0 +1,75 @@ +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 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::InvalidObjectBoundary, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} 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 = {