diff --git a/CHANGELOG.md b/CHANGELOG.md index 143ea6088..d5558e982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ All notable changes to OriginWeave are documented in this file. The format follo - Canonical OriginWeave protocol-version parsing for exact `originweave/.` syntax, with typed fail-closed rejection of malformed, ambiguous, overflowed, or noncanonical serialized generations; parsing does not negotiate compatibility or grant adapter authority. - Public `require_runtime_revisions` validation that fails closed when caller-supplied runtime protocol or browser revision evidence is malformed or differs from the descriptor's pinned revisions, preserving typed malformed-versus-drift errors without authenticating or attesting the adapter process. - Atomic browser-protocol use validation that requires the exact OriginWeave protocol generation, caller-supplied runtime protocol family, exact pinned runtime protocol/browser revisions, and an explicitly declared capability in deterministic fail-closed order before producing non-cloneable validation evidence; this metadata proof does not authenticate the adapter or grant browser/Agent authority. +- 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. +- 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. +- 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. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index d4650ac30..3e30eccfe 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -294,7 +294,7 @@ impl BrowserProtocolAdapterDescriptor { /// /// Validation is intentionally ordered and fail closed: the exact /// OriginWeave Protocol generation is checked first, then the caller-supplied - /// runtime protocol family, then the supplied runtime protocol/browser + /// runtime protocol family, runtime adapter version, protocol/browser /// revisions, and finally the required adapter capability. Success returns /// a non-cloneable value that a later trusted transport can consume as proof /// that these metadata prerequisites were checked together. It is not @@ -304,6 +304,7 @@ impl BrowserProtocolAdapterDescriptor { &self, required_originweave_protocol_version: OriginWeaveProtocolVersion, runtime_kind: BrowserProtocolKind, + runtime_adapter_version: &str, runtime_protocol_revision: &str, runtime_browser_revision: &str, required_capability: BrowserProtocolCapability, @@ -316,6 +317,12 @@ impl BrowserProtocolAdapterDescriptor { runtime_kind, }); } + if !metadata_token_is_valid(runtime_adapter_version) { + return Err(BrowserProtocolUseValidationError::InvalidAdapterVersion); + } + if self.adapter_version != runtime_adapter_version { + return Err(BrowserProtocolUseValidationError::AdapterVersionMismatch); + } self.require_runtime_revisions(runtime_protocol_revision, runtime_browser_revision) .map_err(BrowserProtocolUseValidationError::RuntimeRevision)?; self.require_capability(required_capability) @@ -499,6 +506,10 @@ pub enum BrowserProtocolUseValidationError { /// Browser protocol family reported by the runtime transport. runtime_kind: BrowserProtocolKind, }, + /// The runtime adapter-version token was malformed. + InvalidAdapterVersion, + /// The runtime adapter version differs from the pinned descriptor version. + AdapterVersionMismatch, /// The supplied runtime protocol or browser revision is invalid or has drifted. RuntimeRevision(BrowserProtocolRuntimeRequirementError), /// The descriptor does not explicitly declare the required capability. @@ -511,6 +522,12 @@ impl fmt::Display for BrowserProtocolUseValidationError { Self::ProtocolVersion(error) => error.fmt(formatter), Self::ProtocolKindMismatch { .. } => formatter .write_str("runtime browser protocol kind does not match the pinned adapter kind"), + Self::InvalidAdapterVersion => formatter.write_str( + "runtime browser adapter version must be a bounded ASCII metadata token", + ), + Self::AdapterVersionMismatch => formatter.write_str( + "runtime browser adapter version does not match the pinned adapter version", + ), Self::RuntimeRevision(error) => error.fmt(formatter), Self::Capability(error) => error.fmt(formatter), } @@ -521,7 +538,9 @@ impl std::error::Error for BrowserProtocolUseValidationError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::ProtocolVersion(error) => Some(error), - Self::ProtocolKindMismatch { .. } => None, + Self::ProtocolKindMismatch { .. } + | Self::InvalidAdapterVersion + | Self::AdapterVersionMismatch => None, Self::RuntimeRevision(error) => Some(error), Self::Capability(error) => Some(error), } diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs new file mode 100644 index 000000000..97c7777da --- /dev/null +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -0,0 +1,185 @@ +use std::fmt; + +use crate::{ + BrowserAuthorityRegistry, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolKind, BrowserProtocolUseValidationError, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +/// Current runtime metadata sampled from the browser-protocol adapter about to perform I/O. +/// +/// This value is untrusted descriptive input. Constructing it does not validate or authenticate an +/// adapter, browser, or protocol revision and grants no browser or Agent authority. The descriptor +/// validates every field against its reviewed metadata before a dispatch callback can run. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserProtocolRuntimeMetadata<'a> { + kind: BrowserProtocolKind, + adapter_version: &'a str, + protocol_revision: &'a str, + browser_revision: &'a str, +} + +impl<'a> BrowserProtocolRuntimeMetadata<'a> { + /// Build one runtime metadata snapshot for immediate validation and dispatch. + /// + /// String syntax and descriptor equality are intentionally checked later by + /// [`BrowserProtocolAdapterDescriptor::dispatch_if_runtime_matches`], so malformed caller data + /// remains representable as input that the fail-closed boundary can reject deterministically. + pub const fn new( + kind: BrowserProtocolKind, + adapter_version: &'a str, + protocol_revision: &'a str, + browser_revision: &'a str, + ) -> Self { + Self { + kind, + adapter_version, + protocol_revision, + browser_revision, + } + } +} + +/// Exact OriginWeave browser session/context requested for one immediate protocol dispatch. +/// +/// This value only keeps the two identifiers together so a caller cannot accidentally reorder or +/// independently substitute them at the dispatch boundary. Constructing or copying it does not +/// prove that either identifier is registered, current, or authorized; the authority registry must +/// validate the pair immediately before protocol metadata validation and callback invocation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserContextDispatchTarget { + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, +} + +impl BrowserContextDispatchTarget { + /// Group one OriginWeave browser session and browsing context for immediate dispatch checking. + #[must_use] + pub const fn new( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + ) -> Self { + Self { + browser_session, + browsing_context, + } + } + + /// Return the OriginWeave browser session requested for this dispatch. + #[must_use] + pub const fn browser_session(self) -> BrowserSessionId { + self.browser_session + } + + /// Return the OriginWeave browsing context requested for this dispatch. + #[must_use] + pub const fn browsing_context(self) -> BrowsingContextId { + self.browsing_context + } +} + +impl BrowserProtocolAdapterDescriptor { + /// Validate current browser-protocol metadata and immediately invoke one dispatch callback. + /// + /// `runtime_metadata` must be sampled from the trusted adapter that is about to perform the + /// operation. Validation occurs before `dispatch` is invoked, and the callback receives the + /// resulting non-cloneable [`ValidatedBrowserProtocolUse`] by ownership so this boundary does + /// not turn successful validation into reusable ambient authority. + /// + /// A successful callback invocation does not authenticate the adapter process, authorize a + /// browser session, browsing context, origin, destination, secret, or approval, or prove a + /// browser post-condition. Those remain separate higher-level execution boundaries. + pub fn dispatch_if_runtime_matches( + &self, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + required_capability: BrowserProtocolCapability, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse) -> R, + { + let validated = self.validate_use( + required_originweave_protocol_version, + runtime_metadata.kind, + runtime_metadata.adapter_version, + runtime_metadata.protocol_revision, + runtime_metadata.browser_revision, + required_capability, + )?; + Ok(dispatch(validated)) + } + + /// Revalidate exact browser session/context ownership and runtime metadata before dispatch. + /// + /// The registry check occurs first and returns its current document epoch. The exact protocol + /// generation, runtime protocol family, adapter version, upstream/browser revisions, and + /// required capability are then validated before `dispatch` can run. The callback receives the + /// non-cloneable protocol-use proof plus the registry epoch sampled for this immediate use. + /// + /// This is a composition prerequisite, not complete browser-action authority. In particular, + /// typed input still requires separate current origin/document/node and deterministic policy + /// authorization, while navigation still requires destination/network/TLS/HTTP authority. + /// The caller remains responsible for sampling runtime metadata from the adapter about to + /// perform I/O and for preventing registry mutation across its larger execution transaction. + pub fn dispatch_if_context_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextDispatchTarget, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + required_capability: BrowserProtocolCapability, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse, DocumentEpoch) -> R, + { + let current_epoch = authority_registry + .current_context_epoch(target.browser_session(), target.browsing_context()) + .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) + } +} + +/// Failure to compose current browser context ownership with protocol validation before dispatch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserContextProtocolDispatchError { + /// The supplied browser session/context pair is not current in the authority registry. + BrowserAuthority(BrowserRegistryError), + /// The current browser-protocol metadata or required capability failed validation. + ProtocolValidation(BrowserProtocolUseValidationError), +} + +impl fmt::Display for BrowserContextProtocolDispatchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BrowserAuthority(error) => { + write!( + formatter, + "browser context authority denied protocol dispatch: {error}" + ) + } + Self::ProtocolValidation(error) => { + write!( + formatter, + "browser protocol validation denied context dispatch: {error}" + ) + } + } + } +} + +impl std::error::Error for BrowserContextProtocolDispatchError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::BrowserAuthority(error) => Some(error), + Self::ProtocolValidation(error) => Some(error), + } + } +} diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 8807c2d36..477117e4e 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -170,6 +170,91 @@ impl BrowserAuthorityRegistry { .ok_or(BrowserRegistryError::UnknownBrowsingContext) } + /// Return the current document epoch only when the supplied session owns the context. + /// + /// This is an immediate-use registry check for trusted browser adapters. It proves only that + /// the OriginWeave session/context pair is currently registered together and returns the + /// registry's current document epoch. It does not authenticate a browser process, authorize an + /// origin or action, or make the returned epoch a reusable browser capability. + pub fn current_context_epoch( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + ) -> Result { + if !self.known_sessions.contains(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let expected_session = self + .context_session + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != browser_session { + return Err(BrowserRegistryError::ContextSessionMismatch { + expected: expected_session, + actual: browser_session, + }); + } + self.current_epoch(browsing_context) + } + + /// Bind the canonical origin observed for the exact current browser document. + /// + /// This boundary lets a trusted browser adapter establish current document-origin state before + /// semantic-node discovery begins. The supplied session must own the context. Rebinding the + /// same canonical origin in the same document epoch is idempotent, while a different origin + /// fails closed until [`Self::advance_document`] rotates the document epoch and clears the old + /// binding. The returned epoch is descriptive immediate-use state, not reusable capability. + /// + /// This method does not authenticate the adapter, derive an origin from Chromium, authorize a + /// destination or action, or prove that any browser I/O occurred. + pub fn bind_context_origin( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + let epoch = self.current_context_epoch(browser_session, browsing_context)?; + match self.context_origin.get(&browsing_context) { + Some(expected_origin) if expected_origin != origin => { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Some(_expected_origin) => {} + None => { + self.context_origin.insert(browsing_context, origin.clone()); + } + } + Ok(epoch) + } + + /// Revalidate the canonical origin bound to the exact current browser document. + /// + /// This read-only immediate-use boundary lets a trusted browser adapter prove that the exact + /// OriginWeave session/context still has the expected canonical origin in its current document + /// epoch. It fails closed when the current document has no origin binding, including directly + /// after [`Self::advance_document`], and rejects a different origin without mutating registry + /// state. The returned epoch is descriptive current state, not a reusable capability. + /// + /// This method does not authenticate the adapter or browser process, derive the current origin + /// from Chromium, authorize a destination or action, perform browser I/O, or attest that the + /// caller-supplied origin came from the running browser. + pub fn require_context_origin( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + let epoch = self.current_context_epoch(browser_session, browsing_context)?; + let expected_origin = self + .context_origin + .get(&browsing_context) + .ok_or(BrowserRegistryError::ContextOriginNotBound)?; + if expected_origin != origin { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Ok(epoch) + } + /// Advance a browsing context to the next document epoch and invalidate old node bindings. /// /// Call this whenever navigation or document replacement invalidates actionable node identity. @@ -266,6 +351,8 @@ pub enum BrowserRegistryError { /// Session supplied by the current caller. actual: BrowserSessionId, }, + /// The current document has no canonical origin bound to the browsing context. + ContextOriginNotBound, /// The context origin changed without first rotating the document epoch. OriginChangedWithoutDocumentAdvance, /// The registry exhausted one of its monotonic internal identifier spaces. @@ -294,6 +381,9 @@ impl fmt::Display for BrowserRegistryError { expected.value(), actual.value() ), + Self::ContextOriginNotBound => formatter.write_str( + "browsing context has no canonical origin bound for the current document", + ), Self::OriginChangedWithoutDocumentAdvance => formatter .write_str("browsing context origin changed without advancing the document epoch"), Self::IdentifierSpaceExhausted => { @@ -583,6 +673,7 @@ mod tests { expected: expected_values[0], actual: actual_values[0], }, + BrowserRegistryError::ContextOriginNotBound, BrowserRegistryError::OriginChangedWithoutDocumentAdvance, BrowserRegistryError::IdentifierSpaceExhausted, BrowserRegistryError::DocumentEpochExhausted, diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 66b120985..d0a40c413 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -9,6 +9,7 @@ #![deny(missing_docs)] mod browser_protocol; +mod browser_protocol_dispatch; mod browser_registry; #[cfg(test)] mod browser_registry_coverage; @@ -23,6 +24,10 @@ pub use browser_protocol::{ BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, }; +pub use browser_protocol_dispatch::{ + BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolRuntimeMetadata, +}; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; diff --git a/crates/originweave-core/tests/browser_context_origin_binding.rs b/crates/originweave-core/tests/browser_context_origin_binding.rs new file mode 100644 index 000000000..9d404ff3c --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_binding.rs @@ -0,0 +1,91 @@ +use std::error::Error; +use std::io; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, + DocumentEpoch, Origin, +}; + +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()) +} + +fn second_origin() -> Result> { + Origin::parse("http://localhost:43127") + .map_err(|_error| io::Error::other("controlled second origin must be valid").into()) +} + +#[test] +fn context_origin_can_be_bound_before_node_discovery() -> 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 = first_origin()?; + + let epoch = registry.bind_context_origin(session, context, &origin)?; + assert_eq!(epoch, DocumentEpoch::new(1)?); + assert_eq!( + registry.bind_context_origin(session, context, &origin)?, + epoch + ); + + let node = registry.bind_node(session, context, &origin, "backend-node-17")?; + assert_eq!(node.document_epoch(), epoch); + assert_eq!(node.origin(), &origin); + Ok(()) +} + +#[test] +fn context_origin_change_requires_document_rotation() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + assert_eq!( + registry.bind_context_origin(session, context, &second), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + + let next_epoch = registry.advance_document(context)?; + assert_eq!(next_epoch, DocumentEpoch::new(2)?); + assert_eq!( + registry.bind_context_origin(session, context, &second)?, + next_epoch + ); + Ok(()) +} + +#[test] +fn context_origin_binding_rejects_cross_session_and_unknown_authority() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = first_origin()?; + + assert_eq!( + registry.bind_context_origin(attacker, context, &origin), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); + + let unknown_session = BrowserSessionId::new(999)?; + assert_eq!( + registry.bind_context_origin(unknown_session, context, &origin), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let unknown_context = BrowsingContextId::new(999)?; + assert_eq!( + registry.bind_context_origin(owner, unknown_context, &origin), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_context_origin_revalidation.rs b/crates/originweave-core/tests/browser_context_origin_revalidation.rs new file mode 100644 index 000000000..7f35fab1d --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_revalidation.rs @@ -0,0 +1,97 @@ +use std::error::Error; +use std::io; + +use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, Origin}; + +fn first_origin() -> Result { + Origin::parse("http://127.0.0.1:43127") + .map_err(|_error| io::Error::other("controlled first origin must be valid")) +} + +fn second_origin() -> Result { + Origin::parse("http://localhost:43127") + .map_err(|_error| io::Error::other("controlled second origin must be valid")) +} + +#[test] +fn current_context_origin_must_be_bound_before_revalidation() -> 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 = first_origin()?; + + assert_eq!( + registry.require_context_origin(session, context, &origin), + Err(BrowserRegistryError::ContextOriginNotBound) + ); + + let epoch = registry.bind_context_origin(session, context, &origin)?; + assert_eq!( + registry.require_context_origin(session, context, &origin), + Ok(epoch) + ); + Ok(()) +} + +#[test] +fn current_context_origin_revalidation_fails_closed_on_mismatch() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + assert_eq!( + registry.require_context_origin(session, context, &second), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + assert!( + registry + .require_context_origin(session, context, &first) + .is_ok() + ); + Ok(()) +} + +#[test] +fn document_rotation_requires_fresh_origin_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + let next_epoch = registry.advance_document(context)?; + assert_eq!( + registry.require_context_origin(session, context, &first), + Err(BrowserRegistryError::ContextOriginNotBound) + ); + + registry.bind_context_origin(session, context, &second)?; + assert_eq!( + registry.require_context_origin(session, context, &second), + Ok(next_epoch) + ); + Ok(()) +} + +#[test] +fn context_origin_revalidation_preserves_session_ownership() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = first_origin()?; + + registry.bind_context_origin(owner, context, &origin)?; + assert_eq!( + registry.require_context_origin(attacker, context, &origin), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs new file mode 100644 index 000000000..333b28701 --- /dev/null +++ b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs @@ -0,0 +1,234 @@ +use std::{cell::Cell, error::Error}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserRegistryError, + BrowserSessionId, BrowsingContextId, DocumentEpoch, 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::Navigation], + )?) +} + +fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + adapter_version, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn target( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, +) -> BrowserContextDispatchTarget { + BrowserContextDispatchTarget::new(browser_session, browsing_context) +} + +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 context_dispatch_target_preserves_requested_ids_without_granting_authority() +-> Result<(), Box> { + let session = BrowserSessionId::new(7)?; + let context = BrowsingContextId::new(11)?; + let target = target(session, context); + + assert_eq!(target.browser_session(), session); + assert_eq!(target.browsing_context(), context); + Ok(()) +} + +#[test] +fn exact_context_and_runtime_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")?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_current( + ®istry, + target(session, context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!(result, Ok((1, BrowserProtocolCapability::Navigation))); + + registry.advance_document(context)?; + reset_dispatch_marker(); + let next = descriptor.dispatch_if_context_current( + ®istry, + target(session, context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + )?; + assert!(dispatch_was_called()); + assert_eq!(next, Ok((2, BrowserProtocolCapability::Navigation))); + Ok(()) +} + +#[test] +fn cross_session_context_reuse_fails_before_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "top-level-context")?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_current( + ®istry, + target(attacker, context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ); + + assert_eq!( + result, + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + } + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn unknown_session_or_context_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 unknown_session = BrowserSessionId::new(999)?; + let unknown_context = BrowsingContextId::new(999)?; + + reset_dispatch_marker(); + assert_eq!( + descriptor.dispatch_if_context_current( + ®istry, + target(unknown_session, context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession + )) + ); + assert!(!dispatch_was_called()); + + assert_eq!( + descriptor.dispatch_if_context_current( + ®istry, + target(session, unknown_context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::UnknownBrowsingContext + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn protocol_mismatch_after_context_validation_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")?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_current( + ®istry, + target(session, context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata("originweave-bidi-v2"), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ); + + assert_eq!( + result, + Err(BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::AdapterVersionMismatch + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn context_protocol_dispatch_errors_preserve_typed_sources() { + let authority = BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::UnknownBrowsingContext, + ); + assert!(authority.source().is_some()); + assert_eq!( + authority.to_string(), + "browser context authority denied protocol dispatch: browsing context is not registered in this authority registry" + ); + + let protocol = BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::AdapterVersionMismatch, + ); + assert!(protocol.source().is_some()); + assert_eq!( + protocol.to_string(), + "browser protocol validation denied context dispatch: runtime browser adapter version does not match the pinned adapter version" + ); +} diff --git a/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs b/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs new file mode 100644 index 000000000..d3ea4c7fe --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs @@ -0,0 +1,94 @@ +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, +}; + +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() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?) +} + +#[test] +fn runtime_adapter_version_is_bound_into_atomic_use_validation() -> Result<(), Box> { + let descriptor = descriptor()?; + + let validated = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::Navigation, + )?; + + assert_eq!(validated.adapter_version(), ADAPTER_VERSION); + Ok(()) +} + +#[test] +fn runtime_adapter_version_mismatch_precedes_revision_and_capability_checks() +-> Result<(), Box> { + let descriptor = descriptor()?; + + let error = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + "originweave-bidi-v2", + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ); + + assert_eq!( + error, + Err(BrowserProtocolUseValidationError::AdapterVersionMismatch) + ); + let error = error.err().ok_or("expected adapter version mismatch")?; + assert_eq!( + error.to_string(), + "runtime browser adapter version does not match the pinned adapter version" + ); + assert!(error.source().is_none()); + Ok(()) +} + +#[test] +fn malformed_runtime_adapter_version_fails_closed_before_revision_checks() +-> Result<(), Box> { + let descriptor = descriptor()?; + + let error = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + "runtime adapter/version", + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ); + + assert_eq!( + error, + Err(BrowserProtocolUseValidationError::InvalidAdapterVersion) + ); + let error = error.err().ok_or("expected invalid adapter version")?; + assert_eq!( + error.to_string(), + "runtime browser adapter version must be a bounded ASCII metadata token" + ); + assert!(error.source().is_none()); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs new file mode 100644 index 000000000..0ca669d7d --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs @@ -0,0 +1,121 @@ +use std::{cell::Cell, error::Error}; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, 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<(String, BrowserProtocolCapability), &'static str>; +type DispatchFn = fn(ValidatedBrowserProtocolUse) -> 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::Navigation], + )?) +} + +fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + adapter_version, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +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) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Ok(( + validated.adapter_version().to_owned(), + validated.capability(), + )) +} + +fn failing_dispatch(_: ValidatedBrowserProtocolUse) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Err("adapter-failure") +} + +#[test] +fn exact_runtime_validation_hands_single_use_proof_to_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + reset_dispatch_marker(); + + let dispatch_result = descriptor.dispatch_if_runtime_matches( + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!( + dispatch_result, + Ok(( + ADAPTER_VERSION.to_owned(), + BrowserProtocolCapability::Navigation + )) + ); + Ok(()) +} + +#[test] +fn runtime_mismatch_prevents_dispatch_callback() -> Result<(), Box> { + let descriptor = descriptor()?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_runtime_matches( + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata("originweave-bidi-v2"), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ); + + assert_eq!( + result, + Err(BrowserProtocolUseValidationError::AdapterVersionMismatch) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn adapter_callback_failure_remains_separate_after_validation() -> Result<(), Box> { + let descriptor = descriptor()?; + reset_dispatch_marker(); + + let dispatch_result = descriptor.dispatch_if_runtime_matches( + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + failing_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!(dispatch_result, Err("adapter-failure")); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_protocol_use_validation.rs b/crates/originweave-core/tests/browser_protocol_use_validation.rs index 8994253ec..31a15238c 100644 --- a/crates/originweave-core/tests/browser_protocol_use_validation.rs +++ b/crates/originweave-core/tests/browser_protocol_use_validation.rs @@ -33,6 +33,7 @@ fn validated_use_binds_all_required_adapter_metadata() -> Result<(), Box Result<(), Box> { let descriptor = descriptor()?; let error = descriptor.validate_use( ORIGINWEAVE_PROTOCOL_VERSION, BrowserProtocolKind::ChromeDevToolsProtocol, + "runtime adapter/version", "runtime revision with spaces", "browser/revision", BrowserProtocolCapability::NetworkObservation, @@ -114,6 +117,7 @@ fn runtime_revision_validation_precedes_capability_check() -> Result<(), Box Result<(), Box Self { + Self { + kind: validated.kind(), + originweave_protocol_version: validated.originweave_protocol_version(), + adapter_version: validated.adapter_version().to_owned(), + protocol_revision: validated.protocol_revision().to_owned(), + browser_revision: validated.browser_revision().to_owned(), + capability: validated.capability(), + } + } + + /// Return the validated browser protocol family. + #[must_use] + pub const fn kind(&self) -> BrowserProtocolKind { + self.kind + } + + /// Return the validated OriginWeave Protocol generation. + #[must_use] + pub const fn originweave_protocol_version(&self) -> OriginWeaveProtocolVersion { + self.originweave_protocol_version + } + + /// Return the bounded validated adapter-version metadata token. + #[must_use] + pub fn adapter_version(&self) -> &str { + &self.adapter_version + } + + /// Return the bounded validated upstream protocol-revision metadata token. + #[must_use] + pub fn protocol_revision(&self) -> &str { + &self.protocol_revision + } + + /// Return the bounded validated browser-revision metadata token. + #[must_use] + pub fn browser_revision(&self) -> &str { + &self.browser_revision + } + + /// Return the exact browser protocol capability validated for this use. + #[must_use] + pub const fn capability(&self) -> BrowserProtocolCapability { + self.capability + } +} + /// An HTTP method recorded for network evidence. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum HttpMethod { diff --git a/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs b/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs new file mode 100644 index 000000000..fdab7d12c --- /dev/null +++ b/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs @@ -0,0 +1,83 @@ +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + OriginWeaveProtocolVersion, +}; +use originweave_evidence::BrowserProtocolValidationEvidence; + +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"; + +#[test] +fn records_exact_metadata_from_one_validated_browser_protocol_use() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + let validated = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?; + + let evidence = BrowserProtocolValidationEvidence::from_validated_use(&validated); + + assert_eq!(evidence.kind(), BrowserProtocolKind::WebDriverBiDi); + assert_eq!( + evidence.originweave_protocol_version(), + ORIGINWEAVE_PROTOCOL_VERSION + ); + assert_eq!(evidence.adapter_version(), ADAPTER_VERSION); + assert_eq!(evidence.protocol_revision(), PROTOCOL_REVISION); + assert_eq!(evidence.browser_revision(), BROWSER_REVISION); + assert_eq!( + evidence.capability(), + BrowserProtocolCapability::SemanticObservation + ); + Ok(()) +} + +#[test] +fn evidence_is_owned_audit_metadata_not_reusable_validation_authority() -> Result<(), Box> +{ + let cdp_adapter_version = "originweave-cdp-v1"; + let cdp_protocol_revision = "cdp-1-3-r1639810"; + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::ChromeDevToolsProtocol, + ORIGINWEAVE_PROTOCOL_VERSION, + cdp_adapter_version, + cdp_protocol_revision, + BROWSER_REVISION, + &[BrowserProtocolCapability::NetworkObservation], + )?; + let validated = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::ChromeDevToolsProtocol, + cdp_adapter_version, + cdp_protocol_revision, + BROWSER_REVISION, + BrowserProtocolCapability::NetworkObservation, + )?; + + let evidence = BrowserProtocolValidationEvidence::from_validated_use(&validated); + let cloned = evidence.clone(); + + assert_eq!(cloned, evidence); + assert_eq!(cloned.kind(), BrowserProtocolKind::ChromeDevToolsProtocol); + assert_eq!( + cloned.capability(), + BrowserProtocolCapability::NetworkObservation + ); + Ok(()) +} diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 4c529bd21..c6d50ff54 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -175,6 +175,18 @@ def test_product_name_is_consistent_in_binding_documents(self) -> None: self.assertNotIn("TraceWeave", text, relative) self.assertNotIn("ProofRail", text, relative) + def test_context_origin_binding_is_recorded_in_the_changelog(self) -> None: + """The public origin-binding boundary must remain visible in release history.""" + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("BrowserAuthorityRegistry::bind_context_origin", changelog) + + def test_context_origin_revalidation_is_recorded_in_the_changelog(self) -> None: + """The public origin-revalidation boundary must remain visible in release history.""" + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("BrowserAuthorityRegistry::require_context_origin", changelog) + def test_runtime_revision_boundary_is_recorded_in_the_changelog(self) -> None: """The public runtime-revision boundary must remain visible in release history."""