diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs index 58b4f2a18..14bafb3d6 100644 --- a/crates/originweave-core/src/browser_protocol_dispatch.rs +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -114,6 +114,44 @@ impl<'a> BrowserContextOriginDispatchTarget<'a> { } } +/// 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. /// @@ -223,6 +261,56 @@ impl BrowserProtocolAdapterDescriptor { ) .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. @@ -230,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), } @@ -243,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, @@ -257,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/lib.rs b/crates/originweave-core/src/lib.rs index aa1040604..e44cd71eb 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -25,7 +25,8 @@ pub use browser_protocol::{ }; pub use browser_protocol_dispatch::{ BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, - BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolRuntimeMetadata, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, diff --git a/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs new file mode 100644 index 000000000..da3bf51db --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs @@ -0,0 +1,214 @@ +use std::{cell::Cell, error::Error, io}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + 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"; + +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::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(()) +}