From b2fa94b9b8a1be44877c3023acd1a39b9e8d35db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 03:21:14 +0900 Subject: [PATCH 01/23] test(browser): require protocol identity registry --- .../tests/browser_authority_registry.rs | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 crates/originweave-core/tests/browser_authority_registry.rs diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs new file mode 100644 index 00000000..a6cd1cbd --- /dev/null +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -0,0 +1,131 @@ +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, NodeHandleError, Origin, +}; + +fn loopback_origin() -> Origin { + Origin::parse("http://127.0.0.1:43127").expect("controlled loopback origin") +} + +#[test] +fn external_protocol_identifiers_are_scoped_and_never_become_authority() { + let mut registry = BrowserAuthorityRegistry::new(); + + let first_session = registry + .register_session("webdriver-session-A") + .expect("first session must register"); + let repeated_session = registry + .register_session("webdriver-session-A") + .expect("same external session must resolve consistently"); + let second_session = registry + .register_session("webdriver-session-B") + .expect("second session must register"); + + assert_eq!(first_session, repeated_session); + assert_ne!(first_session, second_session); + + let first_context = registry + .register_context(first_session, "frame-root") + .expect("first context must register"); + let second_context = registry + .register_context(second_session, "frame-root") + .expect("the same adapter context string is session scoped"); + + assert_ne!(first_context, second_context); + assert_eq!( + registry.current_epoch(first_context).expect("known context"), + originweave_core::DocumentEpoch::new(1).expect("nonzero epoch") + ); +} + +#[test] +fn document_rotation_invalidates_old_external_node_bindings() { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry + .register_session("webdriver-session") + .expect("session must register"); + let context = registry + .register_context(session, "top-level-context") + .expect("context must register"); + let origin = loopback_origin(); + + let first = registry + .bind_node(session, context, &origin, "backend-node-17") + .expect("node must bind"); + let same = registry + .bind_node(session, context, &origin, "backend-node-17") + .expect("same node in same document must be stable"); + assert_eq!(first.node_id(), same.node_id()); + + let next_epoch = registry + .advance_document(context) + .expect("navigation must advance the document epoch"); + assert_eq!(next_epoch.value(), 2); + assert_eq!( + first.validate_current(session, context, &origin, next_epoch), + Err(NodeHandleError::StaleDocumentEpoch { + observed: first.document_epoch(), + current: next_epoch, + }) + ); + + let rebound = registry + .bind_node(session, context, &origin, "backend-node-17") + .expect("adapter node identifiers may be reused only in the new epoch"); + assert_eq!(rebound.document_epoch(), next_epoch); + assert_ne!(first.node_id(), rebound.node_id()); +} + +#[test] +fn context_cannot_be_reused_by_another_session() { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry + .register_session("owner-session") + .expect("owner session must register"); + let attacker = registry + .register_session("attacker-session") + .expect("second session must register"); + let context = registry + .register_context(owner, "shared-looking-context") + .expect("owner context must register"); + + let error = registry + .bind_node(attacker, context, &loopback_origin(), "node") + .expect_err("cross-session context reuse must fail closed"); + assert_eq!( + error, + BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + } + ); +} + +#[test] +fn external_identifiers_are_bounded_without_assuming_protocol_syntax() { + let mut registry = BrowserAuthorityRegistry::new(); + + assert_eq!( + registry.register_session(""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session(&"x".repeat(513)), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let unicode = registry + .register_session("세션-opaque-✓") + .expect("opaque protocol identifiers may contain bounded Unicode"); + assert!(unicode.value() > 0); +} + +#[test] +fn unknown_internal_authority_is_rejected_before_node_binding() { + let mut registry = BrowserAuthorityRegistry::new(); + let unknown = BrowserSessionId::new(999).expect("nonzero internal identifier"); + + assert_eq!( + registry.register_context(unknown, "context"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); +} From 0e5917acabec56ee3d9fd7c5c8df78a00be6fa03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 03:22:49 +0900 Subject: [PATCH 02/23] style(browser): apply canonical rustfmt --- crates/originweave-core/tests/browser_authority_registry.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index a6cd1cbd..7cad16ee 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -32,7 +32,9 @@ fn external_protocol_identifiers_are_scoped_and_never_become_authority() { assert_ne!(first_context, second_context); assert_eq!( - registry.current_epoch(first_context).expect("known context"), + registry + .current_epoch(first_context) + .expect("known context"), originweave_core::DocumentEpoch::new(1).expect("nonzero epoch") ); } From bcc8f10b1e2ab5a1826ad353517c18d78b014307 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 04:38:50 +0900 Subject: [PATCH 03/23] feat(browser): implement authority registry --- .../originweave-core/src/browser_registry.rs | 502 ++++++++ crates/originweave-core/src/contracts.rs | 1065 ++++++++++++++++ crates/originweave-core/src/lib.rs | 1067 +---------------- 3 files changed, 1576 insertions(+), 1058 deletions(-) create mode 100644 crates/originweave-core/src/browser_registry.rs create mode 100644 crates/originweave-core/src/contracts.rs diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs new file mode 100644 index 00000000..6256f9fa --- /dev/null +++ b/crates/originweave-core/src/browser_registry.rs @@ -0,0 +1,502 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin}; + +/// Maximum UTF-8 byte length of an opaque browser-protocol identifier retained by the registry. +pub const MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES: usize = 512; + +/// A bounded in-memory mapping from untrusted adapter identifiers to OriginWeave authority values. +/// +/// External WebDriver BiDi, CDP, renderer, frame, and DOM identifiers are retained only as +/// private lookup keys. Callers receive OriginWeave-owned numeric identities whose meaning is +/// scoped to this registry instance. Node identities are additionally scoped to one browsing +/// context, document epoch, and canonical origin. +pub struct BrowserAuthorityRegistry { + session_by_external: BTreeMap, + known_sessions: BTreeSet, + context_by_external: BTreeMap<(BrowserSessionId, String), BrowsingContextId>, + context_session: BTreeMap, + context_epoch: BTreeMap, + context_origin: BTreeMap, + node_by_external: BTreeMap<(BrowsingContextId, DocumentEpoch, String), u64>, + next_session_id: u64, + next_context_id: u64, + next_node_id: u64, +} + +impl BrowserAuthorityRegistry { + /// Create an empty registry whose first internal identities are one. + #[must_use] + pub fn new() -> Self { + Self { + session_by_external: BTreeMap::new(), + known_sessions: BTreeSet::new(), + context_by_external: BTreeMap::new(), + context_session: BTreeMap::new(), + context_epoch: BTreeMap::new(), + context_origin: BTreeMap::new(), + node_by_external: BTreeMap::new(), + next_session_id: 1, + next_context_id: 1, + next_node_id: 1, + } + } + + /// Register one opaque external browser-session identifier. + /// + /// Re-registering the same identifier in this registry returns the same OriginWeave session. + pub fn register_session( + &mut self, + external_identifier: &str, + ) -> Result { + validate_external_identifier(external_identifier)?; + if let Some(existing) = self.session_by_external.get(external_identifier) { + return Ok(*existing); + } + let identifier = take_identifier(&mut self.next_session_id)?; + let session = browser_session_id(identifier)?; + self.session_by_external + .insert(external_identifier.to_owned(), session); + self.known_sessions.insert(session); + Ok(session) + } + + /// Register one opaque external browsing-context identifier inside a known browser session. + /// + /// A newly registered context starts at document epoch one. The same external context text in + /// another browser session receives a different OriginWeave context identity. + pub fn register_context( + &mut self, + browser_session: BrowserSessionId, + external_identifier: &str, + ) -> Result { + validate_external_identifier(external_identifier)?; + if !self.known_sessions.contains(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let key = (browser_session, external_identifier.to_owned()); + if let Some(existing) = self.context_by_external.get(&key) { + return Ok(*existing); + } + let identifier = take_identifier(&mut self.next_context_id)?; + let context = browsing_context_id(identifier)?; + self.context_by_external.insert(key, context); + self.context_session.insert(context, browser_session); + self.context_epoch.insert(context, document_epoch(1)?); + Ok(context) + } + + /// Return the currently active document epoch for a known browsing context. + pub fn current_epoch( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.context_epoch + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext) + } + + /// 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. + pub fn advance_document( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + let current = self + .context_epoch + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + let next_value = current + .value() + .checked_add(1) + .ok_or(BrowserRegistryError::DocumentEpochExhausted)?; + let next = document_epoch(next_value)?; + self.context_epoch.insert(browsing_context, next); + self.context_origin.remove(&browsing_context); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + Ok(next) + } + + /// Bind one opaque adapter-local node identifier to the exact current browser authority. + /// + /// Rebinding the same adapter node inside the same document returns a stable OriginWeave node + /// identifier. A document advance discards that mapping, so adapter node-number reuse cannot + /// revive stale authority. An origin change without a document advance fails closed. + pub fn bind_node( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifier: &str, + ) -> Result { + validate_external_identifier(external_identifier)?; + 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, + }); + } + 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()); + } + } + let epoch = self.current_epoch(browsing_context)?; + let key = (browsing_context, epoch, external_identifier.to_owned()); + let node_id = if let Some(existing) = self.node_by_external.get(&key) { + *existing + } else { + let allocated = take_identifier(&mut self.next_node_id)?; + self.node_by_external.insert(key, allocated); + allocated + }; + observed_node_handle(browser_session, browsing_context, origin, epoch, node_id) + } +} + +impl Default for BrowserAuthorityRegistry { + fn default() -> Self { + Self::new() + } +} + +/// A fail-closed error produced while translating external browser identifiers into local authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserRegistryError { + /// An external identifier was empty or exceeded the reviewed byte bound. + InvalidExternalIdentifier, + /// The supplied OriginWeave browser session is not registered in this registry. + UnknownBrowserSession, + /// The supplied OriginWeave browsing context is not registered in this registry. + UnknownBrowsingContext, + /// The browsing context belongs to another browser session. + ContextSessionMismatch { + /// Session that owns the registered context. + expected: BrowserSessionId, + /// Session supplied by the current caller. + actual: BrowserSessionId, + }, + /// The context origin changed without first rotating the document epoch. + OriginChangedWithoutDocumentAdvance, + /// The registry exhausted one of its monotonic internal identifier spaces. + IdentifierSpaceExhausted, + /// A document epoch reached the maximum representable value. + DocumentEpochExhausted, + /// An internal nonzero authority invariant was violated. + InternalAuthorityInvariant, +} + +impl fmt::Display for BrowserRegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidExternalIdentifier => formatter.write_str( + "external browser identifier must contain 1 to 512 UTF-8 bytes", + ), + Self::UnknownBrowserSession => { + formatter.write_str("browser session is not registered in this authority registry") + } + Self::UnknownBrowsingContext => formatter + .write_str("browsing context is not registered in this authority registry"), + Self::ContextSessionMismatch { expected, actual } => write!( + formatter, + "browsing context belongs to session {}, not session {}", + expected.value(), + actual.value() + ), + Self::OriginChangedWithoutDocumentAdvance => formatter.write_str( + "browsing context origin changed without advancing the document epoch", + ), + Self::IdentifierSpaceExhausted => { + formatter.write_str("browser authority identifier space is exhausted") + } + Self::DocumentEpochExhausted => { + formatter.write_str("browser document epoch space is exhausted") + } + Self::InternalAuthorityInvariant => { + formatter.write_str("browser authority registry violated a nonzero invariant") + } + } + } +} + +impl std::error::Error for BrowserRegistryError {} + +fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { + if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { + return Err(BrowserRegistryError::InvalidExternalIdentifier); + } + Ok(()) +} + +fn take_identifier(next: &mut u64) -> Result { + if *next == 0 { + return Err(BrowserRegistryError::IdentifierSpaceExhausted); + } + let identifier = *next; + *next = identifier.wrapping_add(1); + Ok(identifier) +} + +fn browser_session_id(value: u64) -> Result { + BrowserSessionId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn browsing_context_id(value: u64) -> Result { + BrowsingContextId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn document_epoch(value: u64) -> Result { + DocumentEpoch::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn observed_node_handle( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + document_epoch: DocumentEpoch, + node_id: u64, +) -> Result { + ObservedNodeHandle::new( + browser_session, + browsing_context, + origin.clone(), + document_epoch, + node_id, + ) + .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids() -> Option<(BrowserSessionId, BrowsingContextId, DocumentEpoch)> { + let session = BrowserSessionId::new(1).ok()?; + let context = BrowsingContextId::new(1).ok()?; + let epoch = DocumentEpoch::new(1).ok()?; + Some((session, context, epoch)) + } + + fn loopback_origin() -> Option { + Origin::parse("http://127.0.0.1:43127").ok() + } + + #[test] + fn helper_invariants_fail_closed() { + assert_eq!( + browser_session_id(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + assert_eq!( + browsing_context_id(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + assert_eq!( + document_epoch(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + let Some((session, context, epoch)) = ids() else { + return; + }; + let Some(origin) = loopback_origin() else { + return; + }; + assert_eq!( + observed_node_handle(session, context, &origin, epoch, 0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + } + + #[test] + fn monotonic_identifier_exhaustion_is_fail_closed() { + let mut next = u64::MAX; + assert_eq!(take_identifier(&mut next), Ok(u64::MAX)); + assert_eq!(next, 0); + assert_eq!( + take_identifier(&mut next), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + } + + #[test] + fn registry_reports_all_resource_and_authority_failures() { + let Some((known_session, unknown_context, initial_epoch)) = ids() else { + return; + }; + let Some(origin) = loopback_origin() else { + return; + }; + let mut registry = BrowserAuthorityRegistry::default(); + assert_eq!( + registry.current_epoch(unknown_context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.advance_document(unknown_context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.bind_node(known_session, unknown_context, &origin, "node"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + registry.next_session_id = 0; + assert_eq!( + registry.register_session("new-session"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + registry.next_session_id = 1; + let Ok(session) = registry.register_session("session") else { + return; + }; + + registry.next_context_id = 0; + assert_eq!( + registry.register_context(session, "context-a"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + registry.next_context_id = 1; + let Ok(context) = registry.register_context(session, "context-a") else { + return; + }; + + let Ok(max_epoch) = DocumentEpoch::new(u64::MAX) else { + return; + }; + registry.context_epoch.insert(context, max_epoch); + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::DocumentEpochExhausted) + ); + registry.context_epoch.insert(context, initial_epoch); + + registry.next_node_id = 0; + assert_eq!( + registry.bind_node(session, context, &origin, "node-a"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let Ok(unknown_known_session) = BrowserSessionId::new(999) else { + return; + }; + assert_eq!( + registry.bind_node(unknown_known_session, context, &origin, "node"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + let Ok(unknown_known_context) = BrowsingContextId::new(999) else { + return; + }; + assert_eq!( + registry.bind_node(session, unknown_known_context, &origin, "node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + } + + #[test] + fn origin_rotation_and_node_cleanup_are_explicit() { + let mut registry = BrowserAuthorityRegistry::new(); + let Ok(session) = registry.register_session("session") else { + return; + }; + let Ok(context) = registry.register_context(session, "context") else { + return; + }; + let Ok(second_context) = registry.register_context(session, "context-two") else { + return; + }; + assert_eq!(registry.register_context(session, "context"), Ok(context)); + + let Some(first_origin) = loopback_origin() else { + return; + }; + let Ok(second_origin) = Origin::parse("http://localhost:43127") else { + return; + }; + assert!( + registry + .bind_node(session, context, &first_origin, "node-a") + .is_ok() + ); + assert!( + registry + .bind_node(session, second_context, &first_origin, "node-b") + .is_ok() + ); + assert_eq!( + registry.bind_node(session, context, &second_origin, "node-a"), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + assert_eq!(registry.node_by_external.len(), 2); + assert!(registry.advance_document(context).is_ok()); + assert_eq!(registry.node_by_external.len(), 1); + assert!( + registry + .bind_node(session, context, &second_origin, "node-a") + .is_ok() + ); + } + + #[test] + fn invalid_node_and_context_inputs_are_rejected() { + let mut registry = BrowserAuthorityRegistry::new(); + let Ok(session) = registry.register_session("session") else { + return; + }; + assert_eq!( + registry.register_context(session, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + let Ok(context) = registry.register_context(session, "context") else { + return; + }; + let Some(origin) = loopback_origin() else { + return; + }; + assert_eq!( + registry.bind_node(session, context, &origin, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + } + + #[test] + fn browser_registry_errors_have_non_sensitive_deterministic_text() { + let Some((expected, _context, _epoch)) = ids() else { + return; + }; + let Ok(actual) = BrowserSessionId::new(2) else { + return; + }; + let errors = [ + BrowserRegistryError::InvalidExternalIdentifier, + BrowserRegistryError::UnknownBrowserSession, + BrowserRegistryError::UnknownBrowsingContext, + BrowserRegistryError::ContextSessionMismatch { expected, actual }, + BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + BrowserRegistryError::IdentifierSpaceExhausted, + BrowserRegistryError::DocumentEpochExhausted, + BrowserRegistryError::InternalAuthorityInvariant, + ]; + for error in errors { + let text = error.to_string(); + assert!(!text.is_empty()); + assert!(!text.contains("webdriver-session")); + } + } +} diff --git a/crates/originweave-core/src/contracts.rs b/crates/originweave-core/src/contracts.rs new file mode 100644 index 00000000..88dd2e58 --- /dev/null +++ b/crates/originweave-core/src/contracts.rs @@ -0,0 +1,1065 @@ +//! Shared security and governance contracts for OriginWeave. +//! +//! The crate deliberately contains no browser-engine integration. It defines +//! small, deterministic value types that can be reused by the browser shell, +//! headless runtime, MCP adapter, and enterprise policy service. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use std::collections::BTreeSet; +use std::fmt; +use std::net::{Ipv4Addr, Ipv6Addr}; + +/// A normalized web origin accepted by the OriginWeave trust boundary. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Origin { + canonical: String, +} + +impl Origin { + /// Parse one origin and reject paths, credentials, fragments, insecure + /// remote HTTP endpoints, and browser-special numeric host spellings. + pub fn parse(input: &str) -> Result { + if input.trim() != input + || input + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return Err(OriginError::InvalidAuthority); + } + + let Some((raw_scheme, authority)) = input.split_once("://") else { + return Err(OriginError::MissingScheme); + }; + let scheme = raw_scheme.to_ascii_lowercase(); + if scheme != "https" && scheme != "http" { + return Err(OriginError::UnsupportedScheme); + } + if authority.is_empty() { + return Err(OriginError::MissingAuthority); + } + if authority.contains('@') { + return Err(OriginError::UserInfoNotAllowed); + } + if authority + .chars() + .any(|character| matches!(character, '/' | '?' | '#')) + { + return Err(OriginError::PathNotAllowed); + } + + let (host, port, is_loopback) = parse_authority(authority)?; + if scheme == "http" && !is_loopback { + return Err(OriginError::InsecureRemoteOrigin); + } + let normalized_port = normalize_default_port(&scheme, port); + let canonical = match normalized_port { + Some(port_number) => format!("{scheme}://{host}:{port_number}"), + None => format!("{scheme}://{host}"), + }; + Ok(Self { canonical }) + } + + /// Return the normalized origin string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } + + /// Return the validated lowercase origin scheme. + #[must_use] + pub fn scheme(&self) -> &str { + if self.canonical.starts_with("https://") { + "https" + } else { + "http" + } + } + + /// Return the validated canonical host without IPv6 brackets. + #[must_use] + pub fn host(&self) -> &str { + let authority = &self.canonical[self.scheme().len() + 3..]; + let bracketed = authority.starts_with('['); + let host_start = usize::from(bracketed); + let host_end = if bracketed { + authority.find(']').unwrap_or(authority.len()) + } else { + authority.find(':').unwrap_or(authority.len()) + }; + &authority[host_start..host_end] + } +} + +impl fmt::Display for Origin { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +fn normalize_default_port(scheme: &str, port: Option) -> Option { + match (scheme, port) { + ("https", Some(443)) | ("http", Some(80)) => None, + (_, other) => other, + } +} + +fn parse_authority(authority: &str) -> Result<(String, Option, bool), OriginError> { + if authority.starts_with('[') { + return parse_bracketed_ipv6(authority); + } + if authority.matches(':').count() > 1 { + return Err(OriginError::InvalidAuthority); + } + + let (host_text, port) = match authority.rsplit_once(':') { + Some((host, port_text)) => (host, Some(parse_port(port_text)?)), + None => (authority, None), + }; + let host = host_text.to_ascii_lowercase(); + if let Ok(address) = host.parse::() { + return Ok((host, port, address.is_loopback())); + } + if looks_like_browser_ipv4_host(&host) { + return Err(OriginError::AmbiguousNumericHost); + } + validate_dns_host(&host)?; + Ok((host.clone(), port, host == "localhost")) +} + +fn looks_like_browser_ipv4_host(host: &str) -> bool { + host.rsplit('.') + .next() + .is_some_and(looks_like_browser_ipv4_number) +} + +fn looks_like_browser_ipv4_number(label: &str) -> bool { + if label.is_empty() { + return false; + } + let lowercase = label.to_ascii_lowercase(); + if let Some(hexadecimal) = lowercase.strip_prefix("0x") { + return !hexadecimal.is_empty() && hexadecimal.bytes().all(|byte| byte.is_ascii_hexdigit()); + } + label.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), OriginError> { + let Some(close_index) = authority.find(']') else { + return Err(OriginError::InvalidAuthority); + }; + let address_text = &authority[1..close_index]; + let address = address_text + .parse::() + .map_err(|_error| OriginError::InvalidAuthority)?; + let remainder = &authority[close_index + 1..]; + let port = if remainder.is_empty() { + None + } else if let Some(port_text) = remainder.strip_prefix(':') { + Some(parse_port(port_text)?) + } else { + return Err(OriginError::InvalidAuthority); + }; + Ok((format!("[{address}]"), port, address.is_loopback())) +} + +fn parse_port(port_text: &str) -> Result { + let port = port_text + .parse::() + .map_err(|_error| OriginError::InvalidPort)?; + if port == 0 { + return Err(OriginError::InvalidPort); + } + Ok(port) +} + +fn validate_dns_host(host: &str) -> Result<(), OriginError> { + if host.is_empty() { + return Err(OriginError::InvalidAuthority); + } + if host.len() > 253 { + return Err(OriginError::InvalidAuthority); + } + if !host.is_ascii() { + return Err(OriginError::InvalidAuthority); + } + if host.starts_with('.') || host.ends_with('.') { + return Err(OriginError::InvalidAuthority); + } + for label in host.split('.') { + if label.is_empty() { + return Err(OriginError::InvalidAuthority); + } + if label.len() > 63 { + return Err(OriginError::InvalidAuthority); + } + let bytes = label.as_bytes(); + if !bytes[0].is_ascii_alphanumeric() { + return Err(OriginError::InvalidAuthority); + } + if !bytes[bytes.len() - 1].is_ascii_alphanumeric() { + return Err(OriginError::InvalidAuthority); + } + if !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + { + return Err(OriginError::InvalidAuthority); + } + } + Ok(()) +} + +/// A reason that an origin string could not enter the trust boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OriginError { + /// The input did not contain a `scheme://` separator. + MissingScheme, + /// The scheme was neither HTTPS nor locally scoped HTTP. + UnsupportedScheme, + /// HTTP was requested for a non-loopback host. + InsecureRemoteOrigin, + /// No authority followed the scheme. + MissingAuthority, + /// User information appeared before the host. + UserInfoNotAllowed, + /// A path, query, or fragment was supplied where only an origin is valid. + PathNotAllowed, + /// The host or authority syntax was ambiguous or malformed. + InvalidAuthority, + /// A browser could reinterpret the host as a non-canonical IPv4 address. + AmbiguousNumericHost, + /// The explicit port was outside `1..=65535` or was not numeric. + InvalidPort, +} + +/// A nonzero identity for one active browser automation session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowserSessionId(u64); + +impl BrowserSessionId { + /// Validate one adapter-supplied browser-session identifier. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidBrowserSessionId); + } + Ok(Self(value)) + } + + /// Return the validated browser-session identifier. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A nonzero identity for one independently navigable browser context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowsingContextId(u64); + +impl BrowsingContextId { + /// Validate one adapter-supplied browsing-context identifier. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidBrowsingContextId); + } + Ok(Self(value)) + } + + /// Return the validated browsing-context identifier. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A nonzero identity for one observed browser document lifetime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DocumentEpoch(u64); + +impl DocumentEpoch { + /// Validate one adapter-supplied document epoch. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidDocumentEpoch); + } + Ok(Self(value)) + } + + /// Return the validated document epoch value. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A node identity bound to the exact session, context, origin, and document that produced it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservedNodeHandle { + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, +} + +impl ObservedNodeHandle { + /// Create one authority-bound observed node handle from a nonzero adapter node identifier. + pub fn new( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, + ) -> Result { + if node_id == 0 { + return Err(NodeHandleError::InvalidNodeId); + } + Ok(Self { + browser_session, + browsing_context, + origin, + document_epoch, + node_id, + }) + } + + /// Return the browser session that produced the node observation. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the browsing context that produced the node observation. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } + + /// Return the canonical origin that produced the node observation. + #[must_use] + pub const fn origin(&self) -> &Origin { + &self.origin + } + + /// Return the document epoch that produced the node observation. + #[must_use] + pub const fn document_epoch(&self) -> DocumentEpoch { + self.document_epoch + } + + /// Return the adapter-local nonzero node identifier. + #[must_use] + pub const fn node_id(&self) -> u64 { + self.node_id + } + + /// Reject use when the session, browsing context, origin, or document epoch has changed. + pub fn validate_current( + &self, + current_session: BrowserSessionId, + current_context: BrowsingContextId, + current_origin: &Origin, + current_epoch: DocumentEpoch, + ) -> Result<(), NodeHandleError> { + if self.browser_session != current_session { + return Err(NodeHandleError::BrowserSessionMismatch { + observed: self.browser_session, + current: current_session, + }); + } + if self.browsing_context != current_context { + return Err(NodeHandleError::BrowsingContextMismatch { + observed: self.browsing_context, + current: current_context, + }); + } + if &self.origin != current_origin { + return Err(NodeHandleError::OriginMismatch); + } + if self.document_epoch != current_epoch { + return Err(NodeHandleError::StaleDocumentEpoch { + observed: self.document_epoch, + current: current_epoch, + }); + } + Ok(()) + } +} + +/// A failure to construct or reuse an authority- and document-bound node handle safely. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeHandleError { + /// Browser-session identifiers are one-based and zero was supplied. + InvalidBrowserSessionId, + /// Browsing-context identifiers are one-based and zero was supplied. + InvalidBrowsingContextId, + /// Document epochs are one-based and zero was supplied. + InvalidDocumentEpoch, + /// Adapter-local node identifiers are one-based and zero was supplied. + InvalidNodeId, + /// The node handle belongs to a different browser automation session. + BrowserSessionMismatch { + /// Session that originally produced the node handle. + observed: BrowserSessionId, + /// Session currently active for the requested action. + current: BrowserSessionId, + }, + /// The node handle belongs to a different independently navigable context. + BrowsingContextMismatch { + /// Context that originally produced the node handle. + observed: BrowsingContextId, + /// Context currently active for the requested action. + current: BrowsingContextId, + }, + /// The browser context is now at a different canonical origin. + OriginMismatch, + /// The browser context is now at a different document epoch. + StaleDocumentEpoch { + /// Epoch that originally produced the node handle. + observed: DocumentEpoch, + /// Epoch currently active in the browser context. + current: DocumentEpoch, + }, +} + +impl fmt::Display for NodeHandleError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidBrowserSessionId => { + formatter.write_str("browser session identifier must be nonzero") + } + Self::InvalidBrowsingContextId => { + formatter.write_str("browsing context identifier must be nonzero") + } + Self::InvalidDocumentEpoch => formatter.write_str("document epoch must be nonzero"), + Self::InvalidNodeId => formatter.write_str("observed node identifier must be nonzero"), + Self::BrowserSessionMismatch { observed, current } => write!( + formatter, + "observed node browser session {} does not match current session {}", + observed.value(), + current.value() + ), + Self::BrowsingContextMismatch { observed, current } => write!( + formatter, + "observed node browsing context {} does not match current context {}", + observed.value(), + current.value() + ), + Self::OriginMismatch => { + formatter.write_str("observed node origin does not match the current origin") + } + Self::StaleDocumentEpoch { observed, current } => write!( + formatter, + "observed node document epoch {} is stale; current epoch is {}", + observed.value(), + current.value() + ), + } + } +} + +impl std::error::Error for NodeHandleError {} + +/// An immutable digest of the complete canonical action intent. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ActionIntentDigest { + canonical: String, +} + +impl ActionIntentDigest { + /// Parse a lowercase `sha256:` digest of the complete canonical intent. + pub fn parse(input: &str) -> Result { + let Some(hexadecimal) = input.strip_prefix("sha256:") else { + return Err(ActionIntentDigestError::InvalidFormat); + }; + if hexadecimal.len() != 64 + || !hexadecimal + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ActionIntentDigestError::InvalidFormat); + } + Ok(Self { + canonical: input.to_owned(), + }) + } + + /// Return the canonical lowercase digest. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } +} + +/// A validation error for an action-intent digest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionIntentDigestError { + /// The value was not `sha256:` followed by 64 lowercase hexadecimal digits. + InvalidFormat, +} + +/// The browser execution mode that owns an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SessionMode { + /// A person controls the browser without agent execution privileges. + Human, + /// An agent assists a person while write actions remain governed. + Assist, + /// An isolated task session is delegated to an agent. + AgentTask, + /// A read-only crawler performs policy-bounded collection. + Crawler, +} + +/// The declared business purpose of one browser execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExecutionPurpose { + /// Public content is collected under crawler policy. + PublicCrawl, + /// A person delegated a bounded task in their own context. + UserDelegatedTask, + /// An enterprise policy authorized a managed task. + EnterpriseAuthorizedTask, + /// The action is running in a non-production test environment. + TestingEnvironment, +} + +/// The trust class of the instruction that proposed an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum InstructionSource { + /// A human user supplied the instruction. + User, + /// A managed enterprise policy supplied the instruction. + EnterprisePolicy, + /// Untrusted page or document content supplied the instruction. + WebContent, +} + +/// The result of applying a robots-exclusion policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RobotsDecision { + /// The requested crawl is explicitly allowed. + Allowed, + /// The requested crawl is explicitly disallowed. + Disallowed, + /// The policy could not be fetched or interpreted safely. + Unknown, + /// Robots policy was not evaluated for this execution purpose. + NotApplicable, +} + +/// How secret material is delivered to a browser action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SecretDelivery { + /// The action carries no secret material. + None, + /// A trusted broker resolves an opaque secret handle outside the model. + BrokerHandle, + /// A raw secret value would be exposed directly to the caller. + RawValue, +} + +/// The ordered risk class assigned to an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RiskClass { + /// Read-only observation with no state change. + R0, + /// Low-risk navigation or local retrieval. + R1, + /// Reversible preparation such as creating a draft. + R2, + /// External submission or sensitive interaction requiring approval. + R3, + /// High-impact purchase, deletion, or permission change. + R4, + /// Legal or similarly non-delegable consent. + R5, +} + +impl RiskClass { + /// Return whether the risk class requires approval before execution. + #[must_use] + pub const fn requires_approval(self) -> bool { + matches!(self, Self::R3 | Self::R4 | Self::R5) + } +} + +/// A capability that may be granted to an isolated agent session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Capability { + /// Observe a page's governed semantic representation. + Observe, + /// Extract structured information from allowed evidence. + Extract, + /// Navigate to an allowed origin. + Navigate, + /// Download a resource from an allowed origin. + Download, + /// Prepare a reversible draft. + Draft, + /// Submit data to an allowed origin. + Submit, + /// Upload a pre-approved artifact. + Upload, + /// Fill a secret through the trusted secret broker. + FillSecret, + /// Complete a purchase after approval. + Purchase, + /// Delete a remote object after approval. + Delete, + /// Change a permission after approval. + ManagePermission, + /// Record legal consent, which agents cannot perform autonomously. + LegalConsent, +} + +/// A typed browser action exposed to policy evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ActionKind { + /// Observe governed page state. + Observe, + /// Extract structured data. + Extract, + /// Navigate the browser. + Navigate, + /// Download a resource. + Download, + /// Create or update a reversible draft. + Draft, + /// Submit data externally. + Submit, + /// Upload an approved file. + Upload, + /// Fill a secret using an opaque broker handle. + FillSecret, + /// Complete a purchase. + Purchase, + /// Delete remote state. + Delete, + /// Change access permissions. + ManagePermission, + /// Accept legally binding terms. + LegalConsent, +} + +impl ActionKind { + /// Return the action's fixed risk classification. + #[must_use] + pub const fn risk_class(self) -> RiskClass { + match self { + Self::Observe | Self::Extract => RiskClass::R0, + Self::Navigate | Self::Download => RiskClass::R1, + Self::Draft => RiskClass::R2, + Self::Submit | Self::Upload | Self::FillSecret => RiskClass::R3, + Self::Purchase | Self::Delete | Self::ManagePermission => RiskClass::R4, + Self::LegalConsent => RiskClass::R5, + } + } + + /// Return the capability required to request this action. + #[must_use] + pub const fn required_capability(self) -> Capability { + match self { + Self::Observe => Capability::Observe, + Self::Extract => Capability::Extract, + Self::Navigate => Capability::Navigate, + Self::Download => Capability::Download, + Self::Draft => Capability::Draft, + Self::Submit => Capability::Submit, + Self::Upload => Capability::Upload, + Self::FillSecret => Capability::FillSecret, + Self::Purchase => Capability::Purchase, + Self::Delete => Capability::Delete, + Self::ManagePermission => Capability::ManagePermission, + Self::LegalConsent => Capability::LegalConsent, + } + } + + /// Return whether execution can mutate browser or remote state. + #[must_use] + pub const fn mutates_state(self) -> bool { + !matches!( + self, + Self::Observe | Self::Extract | Self::Navigate | Self::Download + ) + } + + /// Return whether this action is designed to resolve a brokered secret. + #[must_use] + pub const fn uses_secret(self) -> bool { + matches!(self, Self::FillSecret) + } +} + +/// The exact action, target origin, and complete intent covered by an approval. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalScope { + action: ActionKind, + target_origin: Origin, + intent_digest: ActionIntentDigest, +} + +impl ApprovalScope { + /// Create one exact approval scope. + #[must_use] + pub const fn new( + action: ActionKind, + target_origin: Origin, + intent_digest: ActionIntentDigest, + ) -> Self { + Self { + action, + target_origin, + intent_digest, + } + } + + /// Return the approved action kind. + #[must_use] + pub const fn action(&self) -> ActionKind { + self.action + } + + /// Return the approved target origin. + #[must_use] + pub const fn target_origin(&self) -> &Origin { + &self.target_origin + } + + /// Return the approved complete-intent digest. + #[must_use] + pub const fn intent_digest(&self) -> &ActionIntentDigest { + &self.intent_digest + } +} + +/// Evidence that a high-risk action was approved for an exact scope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApprovalEvidence { + /// No approval was supplied. + None, + /// A person confirmed the exact action, target, and complete intent. + UserConfirmed(ApprovalScope), + /// A managed policy approved the exact action, target, and complete intent. + EnterprisePolicy(ApprovalScope), +} + +impl ApprovalEvidence { + /// Return whether this evidence authorizes the exact required scope. + #[must_use] + pub fn authorizes(&self, required: &ApprovalScope) -> bool { + match self { + Self::None => false, + Self::UserConfirmed(scope) | Self::EnterprisePolicy(scope) => scope == required, + } + } +} + +/// A complete typed request presented to the policy engine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionRequest { + action: ActionKind, + source_origin: Origin, + target_origin: Origin, + instruction_source: InstructionSource, + secret_delivery: SecretDelivery, + intent_digest: ActionIntentDigest, +} + +impl ActionRequest { + /// Create one action request without executing it. + #[must_use] + pub const fn new( + action: ActionKind, + source_origin: Origin, + target_origin: Origin, + instruction_source: InstructionSource, + secret_delivery: SecretDelivery, + intent_digest: ActionIntentDigest, + ) -> Self { + Self { + action, + source_origin, + target_origin, + instruction_source, + secret_delivery, + intent_digest, + } + } + + /// Return the requested action. + #[must_use] + pub const fn action(&self) -> ActionKind { + self.action + } + + /// Return the origin that currently owns the browser context. + #[must_use] + pub const fn source_origin(&self) -> &Origin { + &self.source_origin + } + + /// Return the origin affected by the action. + #[must_use] + pub const fn target_origin(&self) -> &Origin { + &self.target_origin + } + + /// Return the trust class of the proposing instruction. + #[must_use] + pub const fn instruction_source(&self) -> InstructionSource { + self.instruction_source + } + + /// Return how secret material would be delivered. + #[must_use] + pub const fn secret_delivery(&self) -> SecretDelivery { + self.secret_delivery + } + + /// Return the digest of the complete canonical action intent. + #[must_use] + pub const fn intent_digest(&self) -> &ActionIntentDigest { + &self.intent_digest + } +} + +/// Immutable grants and mutable evidence used for one policy decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyContext { + mode: SessionMode, + purpose: ExecutionPurpose, + capabilities: BTreeSet, + read_origins: BTreeSet, + write_origins: BTreeSet, + robots_decision: RobotsDecision, + approval: ApprovalEvidence, +} + +impl PolicyContext { + /// Create one policy context from explicitly granted capabilities and origins. + #[must_use] + pub const fn new( + mode: SessionMode, + purpose: ExecutionPurpose, + capabilities: BTreeSet, + read_origins: BTreeSet, + write_origins: BTreeSet, + robots_decision: RobotsDecision, + approval: ApprovalEvidence, + ) -> Self { + Self { + mode, + purpose, + capabilities, + read_origins, + write_origins, + robots_decision, + approval, + } + } + + /// Return the browser execution mode. + #[must_use] + pub const fn mode(&self) -> SessionMode { + self.mode + } + + /// Return the declared execution purpose. + #[must_use] + pub const fn purpose(&self) -> ExecutionPurpose { + self.purpose + } + + /// Return the granted capabilities. + #[must_use] + pub const fn capabilities(&self) -> &BTreeSet { + &self.capabilities + } + + /// Return the origins that may be read. + #[must_use] + pub const fn read_origins(&self) -> &BTreeSet { + &self.read_origins + } + + /// Return the origins that may be mutated. + #[must_use] + pub const fn write_origins(&self) -> &BTreeSet { + &self.write_origins + } + + /// Return the robots-exclusion decision. + #[must_use] + pub const fn robots_decision(&self) -> RobotsDecision { + self.robots_decision + } + + /// Replace robots evidence after a fresh policy lookup. + pub const fn set_robots_decision(&mut self, decision: RobotsDecision) { + self.robots_decision = decision; + } + + /// Return the supplied approval evidence. + #[must_use] + pub const fn approval(&self) -> &ApprovalEvidence { + &self.approval + } + + /// Replace approval evidence after a user or enterprise decision. + pub fn set_approval(&mut self, approval: ApprovalEvidence) { + self.approval = approval; + } +} + +/// A canonical Chromium extension identifier admitted to OriginWeave policy. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExtensionId { + canonical: String, +} + +impl ExtensionId { + /// Parse one canonical 32-character lowercase Chromium extension identifier. + /// + /// Chromium extension identifiers use only the lowercase `a` through `p` + /// alphabet. OriginWeave rejects any non-canonical spelling rather than + /// normalizing caller-controlled identity text. + pub fn parse(input: &str) -> Result { + if input.len() != 32 { + return Err(ExtensionIdError::InvalidExtensionId); + } + if !input.bytes().all(|byte| (b'a'..=b'p').contains(&byte)) { + return Err(ExtensionIdError::InvalidExtensionId); + } + Ok(Self { + canonical: input.to_owned(), + }) + } + + /// Return the canonical extension identifier. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } +} + +/// A validation error for a Chromium extension identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionIdError { + /// The value was not exactly 32 lowercase characters from `a` through `p`. + InvalidExtensionId, +} + +/// An OriginWeave Agent capability that a browser extension may request explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtensionAgentCapability { + /// Observe the governed semantic representation of the exact current context. + ObserveCurrentContext, + /// Propose a typed action for independent OriginWeave policy evaluation. + ProposeTypedAction, +} + +/// An explicit host-originated grant from one extension to bounded Agent capabilities. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAgentGrant { + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capabilities: BTreeSet, +} + +impl ExtensionAgentGrant { + /// Build an exact extension-to-Agent grant for one browser session and context. + #[must_use] + pub fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capabilities: I, + ) -> Self + where + I: IntoIterator, + { + Self { + extension_id, + browser_session, + browsing_context, + capabilities: capabilities.into_iter().collect(), + } + } +} + +/// One extension request to use a bounded OriginWeave Agent capability. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAccessRequest { + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capability: ExtensionAgentCapability, +} + +impl ExtensionAccessRequest { + /// Build one exact extension capability request without granting authority. + #[must_use] + pub const fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capability: ExtensionAgentCapability, + ) -> Self { + Self { + extension_id, + browser_session, + browsing_context, + capability, + } + } +} + +/// Result of evaluating an extension request against one explicit Agent grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionAccessDecision { + /// The exact extension, session, context, and capability are explicitly granted. + Allow, + /// No explicit extension-to-Agent grant was supplied. + DenyMissingGrant, + /// The request belongs to a different extension identity. + DenyExtensionMismatch, + /// The request belongs to a different browser automation session. + DenyBrowserSessionMismatch, + /// The request belongs to a different independently navigable browser context. + DenyBrowsingContextMismatch, + /// The extension grant does not contain the requested OriginWeave capability. + DenyCapabilityNotGranted, +} + +/// Evaluate extension Agent access without inheriting ambient Chrome permissions. +/// +/// A Chrome extension permission, installation state, or page capability is never +/// consulted here. A future Chromium adapter must construct a host-originated +/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session/context +/// request at the boundary where Agent authority would otherwise cross. +#[must_use] +pub fn evaluate_extension_access( + request: &ExtensionAccessRequest, + grant: Option<&ExtensionAgentGrant>, +) -> ExtensionAccessDecision { + let Some(grant) = grant else { + return ExtensionAccessDecision::DenyMissingGrant; + }; + if request.extension_id != grant.extension_id { + return ExtensionAccessDecision::DenyExtensionMismatch; + } + if request.browser_session != grant.browser_session { + return ExtensionAccessDecision::DenyBrowserSessionMismatch; + } + if request.browsing_context != grant.browsing_context { + return ExtensionAccessDecision::DenyBrowsingContextMismatch; + } + if !grant.capabilities.contains(&request.capability) { + return ExtensionAccessDecision::DenyCapabilityNotGranted; + } + ExtensionAccessDecision::Allow +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 88dd2e58..b0ad9fd7 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1,1065 +1,16 @@ //! Shared security and governance contracts for OriginWeave. //! -//! The crate deliberately contains no browser-engine integration. It defines -//! small, deterministic value types that can be reused by the browser shell, -//! headless runtime, MCP adapter, and enterprise policy service. +//! This crate keeps the long-lived value contracts in `contracts` and the +//! protocol-identifier registry in a focused module so browser adapters can +//! evolve without turning raw CDP or WebDriver identifiers into authority. #![forbid(unsafe_code)] #![deny(missing_docs)] -use std::collections::BTreeSet; -use std::fmt; -use std::net::{Ipv4Addr, Ipv6Addr}; +mod browser_registry; +mod contracts; -/// A normalized web origin accepted by the OriginWeave trust boundary. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Origin { - canonical: String, -} - -impl Origin { - /// Parse one origin and reject paths, credentials, fragments, insecure - /// remote HTTP endpoints, and browser-special numeric host spellings. - pub fn parse(input: &str) -> Result { - if input.trim() != input - || input - .chars() - .any(|character| character.is_control() || character.is_whitespace()) - { - return Err(OriginError::InvalidAuthority); - } - - let Some((raw_scheme, authority)) = input.split_once("://") else { - return Err(OriginError::MissingScheme); - }; - let scheme = raw_scheme.to_ascii_lowercase(); - if scheme != "https" && scheme != "http" { - return Err(OriginError::UnsupportedScheme); - } - if authority.is_empty() { - return Err(OriginError::MissingAuthority); - } - if authority.contains('@') { - return Err(OriginError::UserInfoNotAllowed); - } - if authority - .chars() - .any(|character| matches!(character, '/' | '?' | '#')) - { - return Err(OriginError::PathNotAllowed); - } - - let (host, port, is_loopback) = parse_authority(authority)?; - if scheme == "http" && !is_loopback { - return Err(OriginError::InsecureRemoteOrigin); - } - let normalized_port = normalize_default_port(&scheme, port); - let canonical = match normalized_port { - Some(port_number) => format!("{scheme}://{host}:{port_number}"), - None => format!("{scheme}://{host}"), - }; - Ok(Self { canonical }) - } - - /// Return the normalized origin string. - #[must_use] - pub fn as_str(&self) -> &str { - &self.canonical - } - - /// Return the validated lowercase origin scheme. - #[must_use] - pub fn scheme(&self) -> &str { - if self.canonical.starts_with("https://") { - "https" - } else { - "http" - } - } - - /// Return the validated canonical host without IPv6 brackets. - #[must_use] - pub fn host(&self) -> &str { - let authority = &self.canonical[self.scheme().len() + 3..]; - let bracketed = authority.starts_with('['); - let host_start = usize::from(bracketed); - let host_end = if bracketed { - authority.find(']').unwrap_or(authority.len()) - } else { - authority.find(':').unwrap_or(authority.len()) - }; - &authority[host_start..host_end] - } -} - -impl fmt::Display for Origin { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -fn normalize_default_port(scheme: &str, port: Option) -> Option { - match (scheme, port) { - ("https", Some(443)) | ("http", Some(80)) => None, - (_, other) => other, - } -} - -fn parse_authority(authority: &str) -> Result<(String, Option, bool), OriginError> { - if authority.starts_with('[') { - return parse_bracketed_ipv6(authority); - } - if authority.matches(':').count() > 1 { - return Err(OriginError::InvalidAuthority); - } - - let (host_text, port) = match authority.rsplit_once(':') { - Some((host, port_text)) => (host, Some(parse_port(port_text)?)), - None => (authority, None), - }; - let host = host_text.to_ascii_lowercase(); - if let Ok(address) = host.parse::() { - return Ok((host, port, address.is_loopback())); - } - if looks_like_browser_ipv4_host(&host) { - return Err(OriginError::AmbiguousNumericHost); - } - validate_dns_host(&host)?; - Ok((host.clone(), port, host == "localhost")) -} - -fn looks_like_browser_ipv4_host(host: &str) -> bool { - host.rsplit('.') - .next() - .is_some_and(looks_like_browser_ipv4_number) -} - -fn looks_like_browser_ipv4_number(label: &str) -> bool { - if label.is_empty() { - return false; - } - let lowercase = label.to_ascii_lowercase(); - if let Some(hexadecimal) = lowercase.strip_prefix("0x") { - return !hexadecimal.is_empty() && hexadecimal.bytes().all(|byte| byte.is_ascii_hexdigit()); - } - label.bytes().all(|byte| byte.is_ascii_digit()) -} - -fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), OriginError> { - let Some(close_index) = authority.find(']') else { - return Err(OriginError::InvalidAuthority); - }; - let address_text = &authority[1..close_index]; - let address = address_text - .parse::() - .map_err(|_error| OriginError::InvalidAuthority)?; - let remainder = &authority[close_index + 1..]; - let port = if remainder.is_empty() { - None - } else if let Some(port_text) = remainder.strip_prefix(':') { - Some(parse_port(port_text)?) - } else { - return Err(OriginError::InvalidAuthority); - }; - Ok((format!("[{address}]"), port, address.is_loopback())) -} - -fn parse_port(port_text: &str) -> Result { - let port = port_text - .parse::() - .map_err(|_error| OriginError::InvalidPort)?; - if port == 0 { - return Err(OriginError::InvalidPort); - } - Ok(port) -} - -fn validate_dns_host(host: &str) -> Result<(), OriginError> { - if host.is_empty() { - return Err(OriginError::InvalidAuthority); - } - if host.len() > 253 { - return Err(OriginError::InvalidAuthority); - } - if !host.is_ascii() { - return Err(OriginError::InvalidAuthority); - } - if host.starts_with('.') || host.ends_with('.') { - return Err(OriginError::InvalidAuthority); - } - for label in host.split('.') { - if label.is_empty() { - return Err(OriginError::InvalidAuthority); - } - if label.len() > 63 { - return Err(OriginError::InvalidAuthority); - } - let bytes = label.as_bytes(); - if !bytes[0].is_ascii_alphanumeric() { - return Err(OriginError::InvalidAuthority); - } - if !bytes[bytes.len() - 1].is_ascii_alphanumeric() { - return Err(OriginError::InvalidAuthority); - } - if !bytes - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') - { - return Err(OriginError::InvalidAuthority); - } - } - Ok(()) -} - -/// A reason that an origin string could not enter the trust boundary. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OriginError { - /// The input did not contain a `scheme://` separator. - MissingScheme, - /// The scheme was neither HTTPS nor locally scoped HTTP. - UnsupportedScheme, - /// HTTP was requested for a non-loopback host. - InsecureRemoteOrigin, - /// No authority followed the scheme. - MissingAuthority, - /// User information appeared before the host. - UserInfoNotAllowed, - /// A path, query, or fragment was supplied where only an origin is valid. - PathNotAllowed, - /// The host or authority syntax was ambiguous or malformed. - InvalidAuthority, - /// A browser could reinterpret the host as a non-canonical IPv4 address. - AmbiguousNumericHost, - /// The explicit port was outside `1..=65535` or was not numeric. - InvalidPort, -} - -/// A nonzero identity for one active browser automation session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct BrowserSessionId(u64); - -impl BrowserSessionId { - /// Validate one adapter-supplied browser-session identifier. - pub const fn new(value: u64) -> Result { - if value == 0 { - return Err(NodeHandleError::InvalidBrowserSessionId); - } - Ok(Self(value)) - } - - /// Return the validated browser-session identifier. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - -/// A nonzero identity for one independently navigable browser context. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct BrowsingContextId(u64); - -impl BrowsingContextId { - /// Validate one adapter-supplied browsing-context identifier. - pub const fn new(value: u64) -> Result { - if value == 0 { - return Err(NodeHandleError::InvalidBrowsingContextId); - } - Ok(Self(value)) - } - - /// Return the validated browsing-context identifier. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - -/// A nonzero identity for one observed browser document lifetime. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct DocumentEpoch(u64); - -impl DocumentEpoch { - /// Validate one adapter-supplied document epoch. - pub const fn new(value: u64) -> Result { - if value == 0 { - return Err(NodeHandleError::InvalidDocumentEpoch); - } - Ok(Self(value)) - } - - /// Return the validated document epoch value. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - -/// A node identity bound to the exact session, context, origin, and document that produced it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ObservedNodeHandle { - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - document_epoch: DocumentEpoch, - node_id: u64, -} - -impl ObservedNodeHandle { - /// Create one authority-bound observed node handle from a nonzero adapter node identifier. - pub fn new( - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - document_epoch: DocumentEpoch, - node_id: u64, - ) -> Result { - if node_id == 0 { - return Err(NodeHandleError::InvalidNodeId); - } - Ok(Self { - browser_session, - browsing_context, - origin, - document_epoch, - node_id, - }) - } - - /// Return the browser session that produced the node observation. - #[must_use] - pub const fn browser_session(&self) -> BrowserSessionId { - self.browser_session - } - - /// Return the browsing context that produced the node observation. - #[must_use] - pub const fn browsing_context(&self) -> BrowsingContextId { - self.browsing_context - } - - /// Return the canonical origin that produced the node observation. - #[must_use] - pub const fn origin(&self) -> &Origin { - &self.origin - } - - /// Return the document epoch that produced the node observation. - #[must_use] - pub const fn document_epoch(&self) -> DocumentEpoch { - self.document_epoch - } - - /// Return the adapter-local nonzero node identifier. - #[must_use] - pub const fn node_id(&self) -> u64 { - self.node_id - } - - /// Reject use when the session, browsing context, origin, or document epoch has changed. - pub fn validate_current( - &self, - current_session: BrowserSessionId, - current_context: BrowsingContextId, - current_origin: &Origin, - current_epoch: DocumentEpoch, - ) -> Result<(), NodeHandleError> { - if self.browser_session != current_session { - return Err(NodeHandleError::BrowserSessionMismatch { - observed: self.browser_session, - current: current_session, - }); - } - if self.browsing_context != current_context { - return Err(NodeHandleError::BrowsingContextMismatch { - observed: self.browsing_context, - current: current_context, - }); - } - if &self.origin != current_origin { - return Err(NodeHandleError::OriginMismatch); - } - if self.document_epoch != current_epoch { - return Err(NodeHandleError::StaleDocumentEpoch { - observed: self.document_epoch, - current: current_epoch, - }); - } - Ok(()) - } -} - -/// A failure to construct or reuse an authority- and document-bound node handle safely. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NodeHandleError { - /// Browser-session identifiers are one-based and zero was supplied. - InvalidBrowserSessionId, - /// Browsing-context identifiers are one-based and zero was supplied. - InvalidBrowsingContextId, - /// Document epochs are one-based and zero was supplied. - InvalidDocumentEpoch, - /// Adapter-local node identifiers are one-based and zero was supplied. - InvalidNodeId, - /// The node handle belongs to a different browser automation session. - BrowserSessionMismatch { - /// Session that originally produced the node handle. - observed: BrowserSessionId, - /// Session currently active for the requested action. - current: BrowserSessionId, - }, - /// The node handle belongs to a different independently navigable context. - BrowsingContextMismatch { - /// Context that originally produced the node handle. - observed: BrowsingContextId, - /// Context currently active for the requested action. - current: BrowsingContextId, - }, - /// The browser context is now at a different canonical origin. - OriginMismatch, - /// The browser context is now at a different document epoch. - StaleDocumentEpoch { - /// Epoch that originally produced the node handle. - observed: DocumentEpoch, - /// Epoch currently active in the browser context. - current: DocumentEpoch, - }, -} - -impl fmt::Display for NodeHandleError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidBrowserSessionId => { - formatter.write_str("browser session identifier must be nonzero") - } - Self::InvalidBrowsingContextId => { - formatter.write_str("browsing context identifier must be nonzero") - } - Self::InvalidDocumentEpoch => formatter.write_str("document epoch must be nonzero"), - Self::InvalidNodeId => formatter.write_str("observed node identifier must be nonzero"), - Self::BrowserSessionMismatch { observed, current } => write!( - formatter, - "observed node browser session {} does not match current session {}", - observed.value(), - current.value() - ), - Self::BrowsingContextMismatch { observed, current } => write!( - formatter, - "observed node browsing context {} does not match current context {}", - observed.value(), - current.value() - ), - Self::OriginMismatch => { - formatter.write_str("observed node origin does not match the current origin") - } - Self::StaleDocumentEpoch { observed, current } => write!( - formatter, - "observed node document epoch {} is stale; current epoch is {}", - observed.value(), - current.value() - ), - } - } -} - -impl std::error::Error for NodeHandleError {} - -/// An immutable digest of the complete canonical action intent. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ActionIntentDigest { - canonical: String, -} - -impl ActionIntentDigest { - /// Parse a lowercase `sha256:` digest of the complete canonical intent. - pub fn parse(input: &str) -> Result { - let Some(hexadecimal) = input.strip_prefix("sha256:") else { - return Err(ActionIntentDigestError::InvalidFormat); - }; - if hexadecimal.len() != 64 - || !hexadecimal - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(ActionIntentDigestError::InvalidFormat); - } - Ok(Self { - canonical: input.to_owned(), - }) - } - - /// Return the canonical lowercase digest. - #[must_use] - pub fn as_str(&self) -> &str { - &self.canonical - } -} - -/// A validation error for an action-intent digest. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ActionIntentDigestError { - /// The value was not `sha256:` followed by 64 lowercase hexadecimal digits. - InvalidFormat, -} - -/// The browser execution mode that owns an action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum SessionMode { - /// A person controls the browser without agent execution privileges. - Human, - /// An agent assists a person while write actions remain governed. - Assist, - /// An isolated task session is delegated to an agent. - AgentTask, - /// A read-only crawler performs policy-bounded collection. - Crawler, -} - -/// The declared business purpose of one browser execution. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ExecutionPurpose { - /// Public content is collected under crawler policy. - PublicCrawl, - /// A person delegated a bounded task in their own context. - UserDelegatedTask, - /// An enterprise policy authorized a managed task. - EnterpriseAuthorizedTask, - /// The action is running in a non-production test environment. - TestingEnvironment, -} - -/// The trust class of the instruction that proposed an action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum InstructionSource { - /// A human user supplied the instruction. - User, - /// A managed enterprise policy supplied the instruction. - EnterprisePolicy, - /// Untrusted page or document content supplied the instruction. - WebContent, -} - -/// The result of applying a robots-exclusion policy. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RobotsDecision { - /// The requested crawl is explicitly allowed. - Allowed, - /// The requested crawl is explicitly disallowed. - Disallowed, - /// The policy could not be fetched or interpreted safely. - Unknown, - /// Robots policy was not evaluated for this execution purpose. - NotApplicable, -} - -/// How secret material is delivered to a browser action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum SecretDelivery { - /// The action carries no secret material. - None, - /// A trusted broker resolves an opaque secret handle outside the model. - BrokerHandle, - /// A raw secret value would be exposed directly to the caller. - RawValue, -} - -/// The ordered risk class assigned to an action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RiskClass { - /// Read-only observation with no state change. - R0, - /// Low-risk navigation or local retrieval. - R1, - /// Reversible preparation such as creating a draft. - R2, - /// External submission or sensitive interaction requiring approval. - R3, - /// High-impact purchase, deletion, or permission change. - R4, - /// Legal or similarly non-delegable consent. - R5, -} - -impl RiskClass { - /// Return whether the risk class requires approval before execution. - #[must_use] - pub const fn requires_approval(self) -> bool { - matches!(self, Self::R3 | Self::R4 | Self::R5) - } -} - -/// A capability that may be granted to an isolated agent session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Capability { - /// Observe a page's governed semantic representation. - Observe, - /// Extract structured information from allowed evidence. - Extract, - /// Navigate to an allowed origin. - Navigate, - /// Download a resource from an allowed origin. - Download, - /// Prepare a reversible draft. - Draft, - /// Submit data to an allowed origin. - Submit, - /// Upload a pre-approved artifact. - Upload, - /// Fill a secret through the trusted secret broker. - FillSecret, - /// Complete a purchase after approval. - Purchase, - /// Delete a remote object after approval. - Delete, - /// Change a permission after approval. - ManagePermission, - /// Record legal consent, which agents cannot perform autonomously. - LegalConsent, -} - -/// A typed browser action exposed to policy evaluation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ActionKind { - /// Observe governed page state. - Observe, - /// Extract structured data. - Extract, - /// Navigate the browser. - Navigate, - /// Download a resource. - Download, - /// Create or update a reversible draft. - Draft, - /// Submit data externally. - Submit, - /// Upload an approved file. - Upload, - /// Fill a secret using an opaque broker handle. - FillSecret, - /// Complete a purchase. - Purchase, - /// Delete remote state. - Delete, - /// Change access permissions. - ManagePermission, - /// Accept legally binding terms. - LegalConsent, -} - -impl ActionKind { - /// Return the action's fixed risk classification. - #[must_use] - pub const fn risk_class(self) -> RiskClass { - match self { - Self::Observe | Self::Extract => RiskClass::R0, - Self::Navigate | Self::Download => RiskClass::R1, - Self::Draft => RiskClass::R2, - Self::Submit | Self::Upload | Self::FillSecret => RiskClass::R3, - Self::Purchase | Self::Delete | Self::ManagePermission => RiskClass::R4, - Self::LegalConsent => RiskClass::R5, - } - } - - /// Return the capability required to request this action. - #[must_use] - pub const fn required_capability(self) -> Capability { - match self { - Self::Observe => Capability::Observe, - Self::Extract => Capability::Extract, - Self::Navigate => Capability::Navigate, - Self::Download => Capability::Download, - Self::Draft => Capability::Draft, - Self::Submit => Capability::Submit, - Self::Upload => Capability::Upload, - Self::FillSecret => Capability::FillSecret, - Self::Purchase => Capability::Purchase, - Self::Delete => Capability::Delete, - Self::ManagePermission => Capability::ManagePermission, - Self::LegalConsent => Capability::LegalConsent, - } - } - - /// Return whether execution can mutate browser or remote state. - #[must_use] - pub const fn mutates_state(self) -> bool { - !matches!( - self, - Self::Observe | Self::Extract | Self::Navigate | Self::Download - ) - } - - /// Return whether this action is designed to resolve a brokered secret. - #[must_use] - pub const fn uses_secret(self) -> bool { - matches!(self, Self::FillSecret) - } -} - -/// The exact action, target origin, and complete intent covered by an approval. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ApprovalScope { - action: ActionKind, - target_origin: Origin, - intent_digest: ActionIntentDigest, -} - -impl ApprovalScope { - /// Create one exact approval scope. - #[must_use] - pub const fn new( - action: ActionKind, - target_origin: Origin, - intent_digest: ActionIntentDigest, - ) -> Self { - Self { - action, - target_origin, - intent_digest, - } - } - - /// Return the approved action kind. - #[must_use] - pub const fn action(&self) -> ActionKind { - self.action - } - - /// Return the approved target origin. - #[must_use] - pub const fn target_origin(&self) -> &Origin { - &self.target_origin - } - - /// Return the approved complete-intent digest. - #[must_use] - pub const fn intent_digest(&self) -> &ActionIntentDigest { - &self.intent_digest - } -} - -/// Evidence that a high-risk action was approved for an exact scope. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ApprovalEvidence { - /// No approval was supplied. - None, - /// A person confirmed the exact action, target, and complete intent. - UserConfirmed(ApprovalScope), - /// A managed policy approved the exact action, target, and complete intent. - EnterprisePolicy(ApprovalScope), -} - -impl ApprovalEvidence { - /// Return whether this evidence authorizes the exact required scope. - #[must_use] - pub fn authorizes(&self, required: &ApprovalScope) -> bool { - match self { - Self::None => false, - Self::UserConfirmed(scope) | Self::EnterprisePolicy(scope) => scope == required, - } - } -} - -/// A complete typed request presented to the policy engine. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActionRequest { - action: ActionKind, - source_origin: Origin, - target_origin: Origin, - instruction_source: InstructionSource, - secret_delivery: SecretDelivery, - intent_digest: ActionIntentDigest, -} - -impl ActionRequest { - /// Create one action request without executing it. - #[must_use] - pub const fn new( - action: ActionKind, - source_origin: Origin, - target_origin: Origin, - instruction_source: InstructionSource, - secret_delivery: SecretDelivery, - intent_digest: ActionIntentDigest, - ) -> Self { - Self { - action, - source_origin, - target_origin, - instruction_source, - secret_delivery, - intent_digest, - } - } - - /// Return the requested action. - #[must_use] - pub const fn action(&self) -> ActionKind { - self.action - } - - /// Return the origin that currently owns the browser context. - #[must_use] - pub const fn source_origin(&self) -> &Origin { - &self.source_origin - } - - /// Return the origin affected by the action. - #[must_use] - pub const fn target_origin(&self) -> &Origin { - &self.target_origin - } - - /// Return the trust class of the proposing instruction. - #[must_use] - pub const fn instruction_source(&self) -> InstructionSource { - self.instruction_source - } - - /// Return how secret material would be delivered. - #[must_use] - pub const fn secret_delivery(&self) -> SecretDelivery { - self.secret_delivery - } - - /// Return the digest of the complete canonical action intent. - #[must_use] - pub const fn intent_digest(&self) -> &ActionIntentDigest { - &self.intent_digest - } -} - -/// Immutable grants and mutable evidence used for one policy decision. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PolicyContext { - mode: SessionMode, - purpose: ExecutionPurpose, - capabilities: BTreeSet, - read_origins: BTreeSet, - write_origins: BTreeSet, - robots_decision: RobotsDecision, - approval: ApprovalEvidence, -} - -impl PolicyContext { - /// Create one policy context from explicitly granted capabilities and origins. - #[must_use] - pub const fn new( - mode: SessionMode, - purpose: ExecutionPurpose, - capabilities: BTreeSet, - read_origins: BTreeSet, - write_origins: BTreeSet, - robots_decision: RobotsDecision, - approval: ApprovalEvidence, - ) -> Self { - Self { - mode, - purpose, - capabilities, - read_origins, - write_origins, - robots_decision, - approval, - } - } - - /// Return the browser execution mode. - #[must_use] - pub const fn mode(&self) -> SessionMode { - self.mode - } - - /// Return the declared execution purpose. - #[must_use] - pub const fn purpose(&self) -> ExecutionPurpose { - self.purpose - } - - /// Return the granted capabilities. - #[must_use] - pub const fn capabilities(&self) -> &BTreeSet { - &self.capabilities - } - - /// Return the origins that may be read. - #[must_use] - pub const fn read_origins(&self) -> &BTreeSet { - &self.read_origins - } - - /// Return the origins that may be mutated. - #[must_use] - pub const fn write_origins(&self) -> &BTreeSet { - &self.write_origins - } - - /// Return the robots-exclusion decision. - #[must_use] - pub const fn robots_decision(&self) -> RobotsDecision { - self.robots_decision - } - - /// Replace robots evidence after a fresh policy lookup. - pub const fn set_robots_decision(&mut self, decision: RobotsDecision) { - self.robots_decision = decision; - } - - /// Return the supplied approval evidence. - #[must_use] - pub const fn approval(&self) -> &ApprovalEvidence { - &self.approval - } - - /// Replace approval evidence after a user or enterprise decision. - pub fn set_approval(&mut self, approval: ApprovalEvidence) { - self.approval = approval; - } -} - -/// A canonical Chromium extension identifier admitted to OriginWeave policy. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ExtensionId { - canonical: String, -} - -impl ExtensionId { - /// Parse one canonical 32-character lowercase Chromium extension identifier. - /// - /// Chromium extension identifiers use only the lowercase `a` through `p` - /// alphabet. OriginWeave rejects any non-canonical spelling rather than - /// normalizing caller-controlled identity text. - pub fn parse(input: &str) -> Result { - if input.len() != 32 { - return Err(ExtensionIdError::InvalidExtensionId); - } - if !input.bytes().all(|byte| (b'a'..=b'p').contains(&byte)) { - return Err(ExtensionIdError::InvalidExtensionId); - } - Ok(Self { - canonical: input.to_owned(), - }) - } - - /// Return the canonical extension identifier. - #[must_use] - pub fn as_str(&self) -> &str { - &self.canonical - } -} - -/// A validation error for a Chromium extension identifier. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExtensionIdError { - /// The value was not exactly 32 lowercase characters from `a` through `p`. - InvalidExtensionId, -} - -/// An OriginWeave Agent capability that a browser extension may request explicitly. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ExtensionAgentCapability { - /// Observe the governed semantic representation of the exact current context. - ObserveCurrentContext, - /// Propose a typed action for independent OriginWeave policy evaluation. - ProposeTypedAction, -} - -/// An explicit host-originated grant from one extension to bounded Agent capabilities. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtensionAgentGrant { - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - capabilities: BTreeSet, -} - -impl ExtensionAgentGrant { - /// Build an exact extension-to-Agent grant for one browser session and context. - #[must_use] - pub fn new( - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - capabilities: I, - ) -> Self - where - I: IntoIterator, - { - Self { - extension_id, - browser_session, - browsing_context, - capabilities: capabilities.into_iter().collect(), - } - } -} - -/// One extension request to use a bounded OriginWeave Agent capability. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtensionAccessRequest { - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - capability: ExtensionAgentCapability, -} - -impl ExtensionAccessRequest { - /// Build one exact extension capability request without granting authority. - #[must_use] - pub const fn new( - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - capability: ExtensionAgentCapability, - ) -> Self { - Self { - extension_id, - browser_session, - browsing_context, - capability, - } - } -} - -/// Result of evaluating an extension request against one explicit Agent grant. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExtensionAccessDecision { - /// The exact extension, session, context, and capability are explicitly granted. - Allow, - /// No explicit extension-to-Agent grant was supplied. - DenyMissingGrant, - /// The request belongs to a different extension identity. - DenyExtensionMismatch, - /// The request belongs to a different browser automation session. - DenyBrowserSessionMismatch, - /// The request belongs to a different independently navigable browser context. - DenyBrowsingContextMismatch, - /// The extension grant does not contain the requested OriginWeave capability. - DenyCapabilityNotGranted, -} - -/// Evaluate extension Agent access without inheriting ambient Chrome permissions. -/// -/// A Chrome extension permission, installation state, or page capability is never -/// consulted here. A future Chromium adapter must construct a host-originated -/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session/context -/// request at the boundary where Agent authority would otherwise cross. -#[must_use] -pub fn evaluate_extension_access( - request: &ExtensionAccessRequest, - grant: Option<&ExtensionAgentGrant>, -) -> ExtensionAccessDecision { - let Some(grant) = grant else { - return ExtensionAccessDecision::DenyMissingGrant; - }; - if request.extension_id != grant.extension_id { - return ExtensionAccessDecision::DenyExtensionMismatch; - } - if request.browser_session != grant.browser_session { - return ExtensionAccessDecision::DenyBrowserSessionMismatch; - } - if request.browsing_context != grant.browsing_context { - return ExtensionAccessDecision::DenyBrowsingContextMismatch; - } - if !grant.capabilities.contains(&request.capability) { - return ExtensionAccessDecision::DenyCapabilityNotGranted; - } - ExtensionAccessDecision::Allow -} +pub use browser_registry::{ + BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, +}; +pub use contracts::*; From 2e4c359777a0cb3aa9ecdbb0cfa3b4ac35efe711 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 04:51:07 +0900 Subject: [PATCH 04/23] test(browser): close registry coverage gaps --- .../originweave-core/src/browser_registry.rs | 171 +++++++++--------- 1 file changed, 85 insertions(+), 86 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 6256f9fa..47e8ae63 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -206,23 +206,23 @@ pub enum BrowserRegistryError { impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidExternalIdentifier => formatter.write_str( - "external browser identifier must contain 1 to 512 UTF-8 bytes", - ), + Self::InvalidExternalIdentifier => { + formatter.write_str("external browser identifier must contain 1 to 512 UTF-8 bytes") + } Self::UnknownBrowserSession => { formatter.write_str("browser session is not registered in this authority registry") } - Self::UnknownBrowsingContext => formatter - .write_str("browsing context is not registered in this authority registry"), + Self::UnknownBrowsingContext => { + formatter.write_str("browsing context is not registered in this authority registry") + } Self::ContextSessionMismatch { expected, actual } => write!( formatter, "browsing context belongs to session {}, not session {}", expected.value(), actual.value() ), - Self::OriginChangedWithoutDocumentAdvance => formatter.write_str( - "browsing context origin changed without advancing the document epoch", - ), + Self::OriginChangedWithoutDocumentAdvance => formatter + .write_str("browsing context origin changed without advancing the document epoch"), Self::IdentifierSpaceExhausted => { formatter.write_str("browser authority identifier space is exhausted") } @@ -287,15 +287,8 @@ fn observed_node_handle( mod tests { use super::*; - fn ids() -> Option<(BrowserSessionId, BrowsingContextId, DocumentEpoch)> { - let session = BrowserSessionId::new(1).ok()?; - let context = BrowsingContextId::new(1).ok()?; - let epoch = DocumentEpoch::new(1).ok()?; - Some((session, context, epoch)) - } - - fn loopback_origin() -> Option { - Origin::parse("http://127.0.0.1:43127").ok() + fn values(result: Result) -> Vec { + result.into_iter().collect() } #[test] @@ -312,14 +305,17 @@ mod tests { document_epoch(0), Err(BrowserRegistryError::InternalAuthorityInvariant) ); - let Some((session, context, epoch)) = ids() else { - return; - }; - let Some(origin) = loopback_origin() else { - return; - }; + + let sessions = values(BrowserSessionId::new(1)); + let contexts = values(BrowsingContextId::new(1)); + let epochs = values(DocumentEpoch::new(1)); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(sessions.len(), 1); + assert_eq!(contexts.len(), 1); + assert_eq!(epochs.len(), 1); + assert_eq!(origins.len(), 1); assert_eq!( - observed_node_handle(session, context, &origin, epoch, 0), + observed_node_handle(sessions[0], contexts[0], &origins[0], epochs[0], 0), Err(BrowserRegistryError::InternalAuthorityInvariant) ); } @@ -337,12 +333,19 @@ mod tests { #[test] fn registry_reports_all_resource_and_authority_failures() { - let Some((known_session, unknown_context, initial_epoch)) = ids() else { - return; - }; - let Some(origin) = loopback_origin() else { - return; - }; + let known_sessions = values(BrowserSessionId::new(1)); + let unknown_contexts = values(BrowsingContextId::new(1)); + let initial_epochs = values(DocumentEpoch::new(1)); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(known_sessions.len(), 1); + assert_eq!(unknown_contexts.len(), 1); + assert_eq!(initial_epochs.len(), 1); + assert_eq!(origins.len(), 1); + let known_session = known_sessions[0]; + let unknown_context = unknown_contexts[0]; + let initial_epoch = initial_epochs[0]; + let origin = &origins[0]; + let mut registry = BrowserAuthorityRegistry::default(); assert_eq!( registry.current_epoch(unknown_context), @@ -353,7 +356,7 @@ mod tests { Err(BrowserRegistryError::UnknownBrowsingContext) ); assert_eq!( - registry.bind_node(known_session, unknown_context, &origin, "node"), + registry.bind_node(known_session, unknown_context, origin, "node"), Err(BrowserRegistryError::UnknownBrowserSession) ); @@ -363,9 +366,9 @@ mod tests { Err(BrowserRegistryError::IdentifierSpaceExhausted) ); registry.next_session_id = 1; - let Ok(session) = registry.register_session("session") else { - return; - }; + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; registry.next_context_id = 0; assert_eq!( @@ -373,14 +376,13 @@ mod tests { Err(BrowserRegistryError::IdentifierSpaceExhausted) ); registry.next_context_id = 1; - let Ok(context) = registry.register_context(session, "context-a") else { - return; - }; + let contexts = values(registry.register_context(session, "context-a")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; - let Ok(max_epoch) = DocumentEpoch::new(u64::MAX) else { - return; - }; - registry.context_epoch.insert(context, max_epoch); + let maximum_epochs = values(DocumentEpoch::new(u64::MAX)); + assert_eq!(maximum_epochs.len(), 1); + registry.context_epoch.insert(context, maximum_epochs[0]); assert_eq!( registry.advance_document(context), Err(BrowserRegistryError::DocumentEpochExhausted) @@ -389,22 +391,20 @@ mod tests { registry.next_node_id = 0; assert_eq!( - registry.bind_node(session, context, &origin, "node-a"), + registry.bind_node(session, context, origin, "node-a"), Err(BrowserRegistryError::IdentifierSpaceExhausted) ); - let Ok(unknown_known_session) = BrowserSessionId::new(999) else { - return; - }; + let unknown_sessions = values(BrowserSessionId::new(999)); + let unknown_contexts = values(BrowsingContextId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + assert_eq!(unknown_contexts.len(), 1); assert_eq!( - registry.bind_node(unknown_known_session, context, &origin, "node"), + registry.bind_node(unknown_sessions[0], context, origin, "node"), Err(BrowserRegistryError::UnknownBrowserSession) ); - let Ok(unknown_known_context) = BrowsingContextId::new(999) else { - return; - }; assert_eq!( - registry.bind_node(session, unknown_known_context, &origin, "node"), + registry.bind_node(session, unknown_contexts[0], origin, "node"), Err(BrowserRegistryError::UnknownBrowsingContext) ); } @@ -412,35 +412,35 @@ mod tests { #[test] fn origin_rotation_and_node_cleanup_are_explicit() { let mut registry = BrowserAuthorityRegistry::new(); - let Ok(session) = registry.register_session("session") else { - return; - }; - let Ok(context) = registry.register_context(session, "context") else { - return; - }; - let Ok(second_context) = registry.register_context(session, "context-two") else { - return; - }; + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + let second_contexts = values(registry.register_context(session, "context-two")); + assert_eq!(contexts.len(), 1); + assert_eq!(second_contexts.len(), 1); + let context = contexts[0]; + let second_context = second_contexts[0]; assert_eq!(registry.register_context(session, "context"), Ok(context)); - let Some(first_origin) = loopback_origin() else { - return; - }; - let Ok(second_origin) = Origin::parse("http://localhost:43127") else { - return; - }; + let first_origins = values(Origin::parse("http://127.0.0.1:43127")); + let second_origins = values(Origin::parse("http://localhost:43127")); + assert_eq!(first_origins.len(), 1); + assert_eq!(second_origins.len(), 1); + let first_origin = &first_origins[0]; + let second_origin = &second_origins[0]; assert!( registry - .bind_node(session, context, &first_origin, "node-a") + .bind_node(session, context, first_origin, "node-a") .is_ok() ); assert!( registry - .bind_node(session, second_context, &first_origin, "node-b") + .bind_node(session, second_context, first_origin, "node-b") .is_ok() ); assert_eq!( - registry.bind_node(session, context, &second_origin, "node-a"), + registry.bind_node(session, context, second_origin, "node-a"), Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) ); assert_eq!(registry.node_by_external.len(), 2); @@ -448,7 +448,7 @@ mod tests { assert_eq!(registry.node_by_external.len(), 1); assert!( registry - .bind_node(session, context, &second_origin, "node-a") + .bind_node(session, context, second_origin, "node-a") .is_ok() ); } @@ -456,38 +456,37 @@ mod tests { #[test] fn invalid_node_and_context_inputs_are_rejected() { let mut registry = BrowserAuthorityRegistry::new(); - let Ok(session) = registry.register_session("session") else { - return; - }; + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; assert_eq!( registry.register_context(session, ""), Err(BrowserRegistryError::InvalidExternalIdentifier) ); - let Ok(context) = registry.register_context(session, "context") else { - return; - }; - let Some(origin) = loopback_origin() else { - return; - }; + let contexts = values(registry.register_context(session, "context")); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(contexts.len(), 1); + assert_eq!(origins.len(), 1); assert_eq!( - registry.bind_node(session, context, &origin, ""), + registry.bind_node(session, contexts[0], &origins[0], ""), Err(BrowserRegistryError::InvalidExternalIdentifier) ); } #[test] fn browser_registry_errors_have_non_sensitive_deterministic_text() { - let Some((expected, _context, _epoch)) = ids() else { - return; - }; - let Ok(actual) = BrowserSessionId::new(2) else { - return; - }; + let expected_values = values(BrowserSessionId::new(1)); + let actual_values = values(BrowserSessionId::new(2)); + assert_eq!(expected_values.len(), 1); + assert_eq!(actual_values.len(), 1); let errors = [ BrowserRegistryError::InvalidExternalIdentifier, BrowserRegistryError::UnknownBrowserSession, BrowserRegistryError::UnknownBrowsingContext, - BrowserRegistryError::ContextSessionMismatch { expected, actual }, + BrowserRegistryError::ContextSessionMismatch { + expected: expected_values[0], + actual: actual_values[0], + }, BrowserRegistryError::OriginChangedWithoutDocumentAdvance, BrowserRegistryError::IdentifierSpaceExhausted, BrowserRegistryError::DocumentEpochExhausted, From 1a1027f36272c6d41d3164d70c74c605557678b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 05:01:57 +0900 Subject: [PATCH 05/23] test(browser): satisfy strict clippy contracts --- .../tests/browser_authority_registry.rs | 113 +++++++----------- 1 file changed, 44 insertions(+), 69 deletions(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 7cad16ee..dc3fc6d5 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -1,66 +1,48 @@ +use std::error::Error; + use originweave_core::{ - BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, NodeHandleError, Origin, + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, NodeHandleError, + Origin, }; -fn loopback_origin() -> Origin { - Origin::parse("http://127.0.0.1:43127").expect("controlled loopback origin") +fn loopback_origin() -> Result> { + Ok(Origin::parse("http://127.0.0.1:43127")?) } #[test] -fn external_protocol_identifiers_are_scoped_and_never_become_authority() { +fn external_protocol_identifiers_are_scoped_and_never_become_authority( +) -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); - let first_session = registry - .register_session("webdriver-session-A") - .expect("first session must register"); - let repeated_session = registry - .register_session("webdriver-session-A") - .expect("same external session must resolve consistently"); - let second_session = registry - .register_session("webdriver-session-B") - .expect("second session must register"); + let first_session = registry.register_session("webdriver-session-A")?; + let repeated_session = registry.register_session("webdriver-session-A")?; + let second_session = registry.register_session("webdriver-session-B")?; assert_eq!(first_session, repeated_session); assert_ne!(first_session, second_session); - let first_context = registry - .register_context(first_session, "frame-root") - .expect("first context must register"); - let second_context = registry - .register_context(second_session, "frame-root") - .expect("the same adapter context string is session scoped"); + let first_context = registry.register_context(first_session, "frame-root")?; + let repeated_context = registry.register_context(first_session, "frame-root")?; + let second_context = registry.register_context(second_session, "frame-root")?; + assert_eq!(first_context, repeated_context); assert_ne!(first_context, second_context); - assert_eq!( - registry - .current_epoch(first_context) - .expect("known context"), - originweave_core::DocumentEpoch::new(1).expect("nonzero epoch") - ); + assert_eq!(registry.current_epoch(first_context)?, DocumentEpoch::new(1)?); + Ok(()) } #[test] -fn document_rotation_invalidates_old_external_node_bindings() { +fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); - let session = registry - .register_session("webdriver-session") - .expect("session must register"); - let context = registry - .register_context(session, "top-level-context") - .expect("context must register"); - let origin = loopback_origin(); - - let first = registry - .bind_node(session, context, &origin, "backend-node-17") - .expect("node must bind"); - let same = registry - .bind_node(session, context, &origin, "backend-node-17") - .expect("same node in same document must be stable"); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = loopback_origin()?; + + let first = registry.bind_node(session, context, &origin, "backend-node-17")?; + let same = registry.bind_node(session, context, &origin, "backend-node-17")?; assert_eq!(first.node_id(), same.node_id()); - let next_epoch = registry - .advance_document(context) - .expect("navigation must advance the document epoch"); + let next_epoch = registry.advance_document(context)?; assert_eq!(next_epoch.value(), 2); assert_eq!( first.validate_current(session, context, &origin, next_epoch), @@ -70,40 +52,33 @@ fn document_rotation_invalidates_old_external_node_bindings() { }) ); - let rebound = registry - .bind_node(session, context, &origin, "backend-node-17") - .expect("adapter node identifiers may be reused only in the new epoch"); + let rebound = registry.bind_node(session, context, &origin, "backend-node-17")?; assert_eq!(rebound.document_epoch(), next_epoch); assert_ne!(first.node_id(), rebound.node_id()); + Ok(()) } #[test] -fn context_cannot_be_reused_by_another_session() { +fn context_cannot_be_reused_by_another_session() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); - let owner = registry - .register_session("owner-session") - .expect("owner session must register"); - let attacker = registry - .register_session("attacker-session") - .expect("second session must register"); - let context = registry - .register_context(owner, "shared-looking-context") - .expect("owner context must register"); - - let error = registry - .bind_node(attacker, context, &loopback_origin(), "node") - .expect_err("cross-session context reuse must fail closed"); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "shared-looking-context")?; + let origin = loopback_origin()?; + assert_eq!( - error, - BrowserRegistryError::ContextSessionMismatch { + registry.bind_node(attacker, context, &origin, "node"), + Err(BrowserRegistryError::ContextSessionMismatch { expected: owner, actual: attacker, - } + }) ); + Ok(()) } #[test] -fn external_identifiers_are_bounded_without_assuming_protocol_syntax() { +fn external_identifiers_are_bounded_without_assuming_protocol_syntax( +) -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); assert_eq!( @@ -115,19 +90,19 @@ fn external_identifiers_are_bounded_without_assuming_protocol_syntax() { Err(BrowserRegistryError::InvalidExternalIdentifier) ); - let unicode = registry - .register_session("세션-opaque-✓") - .expect("opaque protocol identifiers may contain bounded Unicode"); + let unicode = registry.register_session("세션-opaque-✓")?; assert!(unicode.value() > 0); + Ok(()) } #[test] -fn unknown_internal_authority_is_rejected_before_node_binding() { +fn unknown_internal_authority_is_rejected_before_node_binding() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); - let unknown = BrowserSessionId::new(999).expect("nonzero internal identifier"); + let unknown = BrowserSessionId::new(999)?; assert_eq!( registry.register_context(unknown, "context"), Err(BrowserRegistryError::UnknownBrowserSession) ); + Ok(()) } From 938ae8c121ef40e4a847bfb4cf838c216c870dbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 05:15:12 +0900 Subject: [PATCH 06/23] style(browser): apply canonical registry test formatting --- .../tests/browser_authority_registry.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index dc3fc6d5..23a9e767 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -1,8 +1,8 @@ use std::error::Error; use originweave_core::{ - BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, NodeHandleError, - Origin, + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, + NodeHandleError, Origin, }; fn loopback_origin() -> Result> { @@ -10,8 +10,8 @@ fn loopback_origin() -> Result> { } #[test] -fn external_protocol_identifiers_are_scoped_and_never_become_authority( -) -> Result<(), Box> { +fn external_protocol_identifiers_are_scoped_and_never_become_authority() +-> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); let first_session = registry.register_session("webdriver-session-A")?; @@ -27,7 +27,10 @@ fn external_protocol_identifiers_are_scoped_and_never_become_authority( assert_eq!(first_context, repeated_context); assert_ne!(first_context, second_context); - assert_eq!(registry.current_epoch(first_context)?, DocumentEpoch::new(1)?); + assert_eq!( + registry.current_epoch(first_context)?, + DocumentEpoch::new(1)? + ); Ok(()) } @@ -77,8 +80,8 @@ fn context_cannot_be_reused_by_another_session() -> Result<(), Box> { } #[test] -fn external_identifiers_are_bounded_without_assuming_protocol_syntax( -) -> Result<(), Box> { +fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result<(), Box> +{ let mut registry = BrowserAuthorityRegistry::new(); assert_eq!( From 76cddbc89b45079eab5a8f3b0cd77d9bb653ff1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:05:58 +0900 Subject: [PATCH 07/23] test(browser): keep fixture origin errors local --- .../tests/browser_authority_registry.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 23a9e767..e4c355da 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -1,3 +1,5 @@ +#![allow(clippy::expect_used)] + use std::error::Error; use originweave_core::{ @@ -5,8 +7,8 @@ use originweave_core::{ NodeHandleError, Origin, }; -fn loopback_origin() -> Result> { - Ok(Origin::parse("http://127.0.0.1:43127")?) +fn loopback_origin() -> Origin { + Origin::parse("http://127.0.0.1:43127").expect("valid loopback fixture origin") } #[test] @@ -39,7 +41,7 @@ fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box< let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session("webdriver-session")?; let context = registry.register_context(session, "top-level-context")?; - let origin = loopback_origin()?; + let origin = loopback_origin(); let first = registry.bind_node(session, context, &origin, "backend-node-17")?; let same = registry.bind_node(session, context, &origin, "backend-node-17")?; @@ -67,7 +69,7 @@ fn context_cannot_be_reused_by_another_session() -> Result<(), Box> { let owner = registry.register_session("owner-session")?; let attacker = registry.register_session("attacker-session")?; let context = registry.register_context(owner, "shared-looking-context")?; - let origin = loopback_origin()?; + let origin = loopback_origin(); assert_eq!( registry.bind_node(attacker, context, &origin, "node"), From 4ea14a1ddcb15497887168852aeb05088218a68e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:52:26 +0900 Subject: [PATCH 08/23] ci: surface exact browser-registry coverage gaps --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f804f749..99d8d6ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: --branch --text --show-missing-lines - > missing-lines.txt + | tee missing-lines.txt - name: Upload exact coverage diagnostics uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: From b2981119b33539da57ce971f9c2cc7da7b361b7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 13:13:19 +0900 Subject: [PATCH 09/23] test(browser): require bounded authority identifier capacity --- .../tests/browser_authority_registry.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index e4c355da..4032f96c 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -100,6 +100,30 @@ fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result Ok(()) } +#[test] +fn authority_identifier_capacity_is_bounded_and_testable() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let session = registry.register_session("session-one")?; + assert_eq!( + registry.register_session("session-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let context = registry.register_context(session, "context-one")?; + assert_eq!( + registry.register_context(session, "context-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let origin = loopback_origin(); + assert!(registry.bind_node(session, context, &origin, "node-one").is_ok()); + assert_eq!( + registry.bind_node(session, context, &origin, "node-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + Ok(()) +} + #[test] fn unknown_internal_authority_is_rejected_before_node_binding() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); From f528f6a67bb2c3afc4911fb30670f486d9bba738 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 13:17:02 +0900 Subject: [PATCH 10/23] feat(browser): bound registry identifier capacity --- .../originweave-core/src/browser_registry.rs | 100 ++++++++++++------ 1 file changed, 70 insertions(+), 30 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 47e8ae63..ff773aa1 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -6,6 +6,9 @@ use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHand /// Maximum UTF-8 byte length of an opaque browser-protocol identifier retained by the registry. pub const MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES: usize = 512; +/// Default maximum number of authority identifiers allocated per registry namespace. +const DEFAULT_MAX_BROWSER_AUTHORITY_IDENTIFIERS: u64 = 1_000_000; + /// A bounded in-memory mapping from untrusted adapter identifiers to OriginWeave authority values. /// /// External WebDriver BiDi, CDP, renderer, frame, and DOM identifiers are retained only as @@ -20,15 +23,28 @@ pub struct BrowserAuthorityRegistry { context_epoch: BTreeMap, context_origin: BTreeMap, node_by_external: BTreeMap<(BrowsingContextId, DocumentEpoch, String), u64>, + maximum_identifier: u64, next_session_id: u64, next_context_id: u64, next_node_id: u64, } impl BrowserAuthorityRegistry { - /// Create an empty registry whose first internal identities are one. + /// Create an empty registry with the reviewed default per-namespace identifier capacity. #[must_use] pub fn new() -> Self { + Self::with_identifier_limit(DEFAULT_MAX_BROWSER_AUTHORITY_IDENTIFIERS) + } + + /// Create an empty registry with a caller-selected per-namespace identifier capacity. + /// + /// Session, browsing-context, and node identifiers each have an independent monotonic + /// namespace capped at `maximum_identifier`. A zero limit intentionally rejects every new + /// allocation. Values above `u64::MAX - 1` are clamped so incrementing the next identifier + /// never wraps to zero. + #[must_use] + pub fn with_identifier_limit(maximum_identifier: u64) -> Self { + let maximum_identifier = maximum_identifier.min(u64::MAX - 1); Self { session_by_external: BTreeMap::new(), known_sessions: BTreeSet::new(), @@ -37,6 +53,7 @@ impl BrowserAuthorityRegistry { context_epoch: BTreeMap::new(), context_origin: BTreeMap::new(), node_by_external: BTreeMap::new(), + maximum_identifier, next_session_id: 1, next_context_id: 1, next_node_id: 1, @@ -54,7 +71,7 @@ impl BrowserAuthorityRegistry { if let Some(existing) = self.session_by_external.get(external_identifier) { return Ok(*existing); } - let identifier = take_identifier(&mut self.next_session_id)?; + let identifier = take_identifier(&mut self.next_session_id, self.maximum_identifier)?; let session = browser_session_id(identifier)?; self.session_by_external .insert(external_identifier.to_owned(), session); @@ -79,7 +96,7 @@ impl BrowserAuthorityRegistry { if let Some(existing) = self.context_by_external.get(&key) { return Ok(*existing); } - let identifier = take_identifier(&mut self.next_context_id)?; + let identifier = take_identifier(&mut self.next_context_id, self.maximum_identifier)?; let context = browsing_context_id(identifier)?; self.context_by_external.insert(key, context); self.context_session.insert(context, browser_session); @@ -163,7 +180,7 @@ impl BrowserAuthorityRegistry { let node_id = if let Some(existing) = self.node_by_external.get(&key) { *existing } else { - let allocated = take_identifier(&mut self.next_node_id)?; + let allocated = take_identifier(&mut self.next_node_id, self.maximum_identifier)?; self.node_by_external.insert(key, allocated); allocated }; @@ -245,12 +262,12 @@ fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryE Ok(()) } -fn take_identifier(next: &mut u64) -> Result { - if *next == 0 { +fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { + if *next > maximum_identifier { return Err(BrowserRegistryError::IdentifierSpaceExhausted); } let identifier = *next; - *next = identifier.wrapping_add(1); + *next = identifier + 1; Ok(identifier) } @@ -322,11 +339,11 @@ mod tests { #[test] fn monotonic_identifier_exhaustion_is_fail_closed() { - let mut next = u64::MAX; - assert_eq!(take_identifier(&mut next), Ok(u64::MAX)); - assert_eq!(next, 0); + let mut next = 1; + assert_eq!(take_identifier(&mut next, 1), Ok(1)); + assert_eq!(next, 2); assert_eq!( - take_identifier(&mut next), + take_identifier(&mut next, 1), Err(BrowserRegistryError::IdentifierSpaceExhausted) ); } @@ -346,6 +363,32 @@ mod tests { let initial_epoch = initial_epochs[0]; let origin = &origins[0]; + let mut limited_registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let limited_sessions = values(limited_registry.register_session("session-one")); + assert_eq!(limited_sessions.len(), 1); + let limited_session = limited_sessions[0]; + assert_eq!( + limited_registry.register_session("session-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + let limited_contexts = + values(limited_registry.register_context(limited_session, "context-one")); + assert_eq!(limited_contexts.len(), 1); + let limited_context = limited_contexts[0]; + assert_eq!( + limited_registry.register_context(limited_session, "context-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert!( + limited_registry + .bind_node(limited_session, limited_context, origin, "node-one") + .is_ok() + ); + assert_eq!( + limited_registry.bind_node(limited_session, limited_context, origin, "node-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + let mut registry = BrowserAuthorityRegistry::default(); assert_eq!( registry.current_epoch(unknown_context), @@ -360,22 +403,9 @@ mod tests { Err(BrowserRegistryError::UnknownBrowserSession) ); - registry.next_session_id = 0; - assert_eq!( - registry.register_session("new-session"), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - registry.next_session_id = 1; let sessions = values(registry.register_session("session")); assert_eq!(sessions.len(), 1); let session = sessions[0]; - - registry.next_context_id = 0; - assert_eq!( - registry.register_context(session, "context-a"), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - registry.next_context_id = 1; let contexts = values(registry.register_context(session, "context-a")); assert_eq!(contexts.len(), 1); let context = contexts[0]; @@ -389,12 +419,6 @@ mod tests { ); registry.context_epoch.insert(context, initial_epoch); - registry.next_node_id = 0; - assert_eq!( - registry.bind_node(session, context, origin, "node-a"), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - let unknown_sessions = values(BrowserSessionId::new(999)); let unknown_contexts = values(BrowsingContextId::new(999)); assert_eq!(unknown_sessions.len(), 1); @@ -463,6 +487,13 @@ mod tests { registry.register_context(session, ""), Err(BrowserRegistryError::InvalidExternalIdentifier) ); + assert_eq!( + registry.register_context( + session, + &"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1), + ), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); let contexts = values(registry.register_context(session, "context")); let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(contexts.len(), 1); @@ -471,6 +502,15 @@ mod tests { registry.bind_node(session, contexts[0], &origins[0], ""), Err(BrowserRegistryError::InvalidExternalIdentifier) ); + assert_eq!( + registry.bind_node( + session, + contexts[0], + &origins[0], + &"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1), + ), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); } #[test] From eaadbf5e8272989a0da54b8ccfca20b2f4950a66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:16:03 +0900 Subject: [PATCH 11/23] test(browser): cover exact registry denial branches --- .../tests/browser_authority_registry.rs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 4032f96c..d3e69c72 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -81,6 +81,22 @@ fn context_cannot_be_reused_by_another_session() -> Result<(), Box> { Ok(()) } +#[test] +fn context_origin_cannot_change_without_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_origin = loopback_origin(); + let second_origin = Origin::parse("http://localhost:43127")?; + + registry.bind_node(session, context, &first_origin, "backend-node-17")?; + assert_eq!( + registry.bind_node(session, context, &second_origin, "backend-node-18"), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + Ok(()) +} + #[test] fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result<(), Box> { @@ -116,7 +132,11 @@ fn authority_identifier_capacity_is_bounded_and_testable() -> Result<(), Box Result<(), Bo registry.register_context(unknown, "context"), Err(BrowserRegistryError::UnknownBrowserSession) ); + + let known = registry.register_session("known-session")?; + let context = registry.register_context(known, "known-context")?; + let origin = loopback_origin(); + assert_eq!( + registry.bind_node(unknown, context, &origin, "node"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); Ok(()) } From 235fd1f2ee03268e891e5fe866f683067299f8d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:19:01 +0900 Subject: [PATCH 12/23] test(browser): keep origin mismatch fixture deterministic --- crates/originweave-core/tests/browser_authority_registry.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index d3e69c72..6a529de0 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -87,7 +87,7 @@ fn context_origin_cannot_change_without_document_rotation() -> Result<(), Box Date: Mon, 10 Aug 2026 15:31:47 +0900 Subject: [PATCH 13/23] style(browser): format authority registry regression --- crates/originweave-core/tests/browser_authority_registry.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 6a529de0..6514bc5b 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -87,7 +87,8 @@ fn context_origin_cannot_change_without_document_rotation() -> Result<(), Box Date: Mon, 10 Aug 2026 18:17:32 +0900 Subject: [PATCH 14/23] refactor(browser): preserve fallible invariants without uncovered propagation --- .../originweave-core/src/browser_registry.rs | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index ff773aa1..eeca3166 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -72,11 +72,12 @@ impl BrowserAuthorityRegistry { return Ok(*existing); } let identifier = take_identifier(&mut self.next_session_id, self.maximum_identifier)?; - let session = browser_session_id(identifier)?; - self.session_by_external - .insert(external_identifier.to_owned(), session); - self.known_sessions.insert(session); - Ok(session) + browser_session_id(identifier).map(|session| { + self.session_by_external + .insert(external_identifier.to_owned(), session); + self.known_sessions.insert(session); + session + }) } /// Register one opaque external browsing-context identifier inside a known browser session. @@ -97,11 +98,14 @@ impl BrowserAuthorityRegistry { return Ok(*existing); } let identifier = take_identifier(&mut self.next_context_id, self.maximum_identifier)?; - let context = browsing_context_id(identifier)?; - self.context_by_external.insert(key, context); - self.context_session.insert(context, browser_session); - self.context_epoch.insert(context, document_epoch(1)?); - Ok(context) + browsing_context_id(identifier).and_then(|context| { + document_epoch(1).map(|initial_epoch| { + self.context_by_external.insert(key, context); + self.context_session.insert(context, browser_session); + self.context_epoch.insert(context, initial_epoch); + context + }) + }) } /// Return the currently active document epoch for a known browsing context. @@ -131,12 +135,13 @@ impl BrowserAuthorityRegistry { .value() .checked_add(1) .ok_or(BrowserRegistryError::DocumentEpochExhausted)?; - let next = document_epoch(next_value)?; - self.context_epoch.insert(browsing_context, next); - self.context_origin.remove(&browsing_context); - self.node_by_external - .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); - Ok(next) + document_epoch(next_value).map(|next| { + self.context_epoch.insert(browsing_context, next); + self.context_origin.remove(&browsing_context); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + next + }) } /// Bind one opaque adapter-local node identifier to the exact current browser authority. @@ -175,16 +180,17 @@ impl BrowserAuthorityRegistry { self.context_origin.insert(browsing_context, origin.clone()); } } - let epoch = self.current_epoch(browsing_context)?; - let key = (browsing_context, epoch, external_identifier.to_owned()); - let node_id = if let Some(existing) = self.node_by_external.get(&key) { - *existing - } else { - let allocated = take_identifier(&mut self.next_node_id, self.maximum_identifier)?; - self.node_by_external.insert(key, allocated); - allocated - }; - observed_node_handle(browser_session, browsing_context, origin, epoch, node_id) + self.current_epoch(browsing_context).and_then(|epoch| { + let key = (browsing_context, epoch, external_identifier.to_owned()); + let node_id = if let Some(existing) = self.node_by_external.get(&key) { + *existing + } else { + let allocated = take_identifier(&mut self.next_node_id, self.maximum_identifier)?; + self.node_by_external.insert(key, allocated); + allocated + }; + observed_node_handle(browser_session, browsing_context, origin, epoch, node_id) + }) } } From c78dc7068e6ca00cfd1cd0e391c3dd58b9ad0456 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 18:24:54 +0900 Subject: [PATCH 15/23] fix(browser): satisfy strict clippy on invariant side effects --- crates/originweave-core/src/browser_registry.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index eeca3166..85e4af3a 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -72,11 +72,10 @@ impl BrowserAuthorityRegistry { return Ok(*existing); } let identifier = take_identifier(&mut self.next_session_id, self.maximum_identifier)?; - browser_session_id(identifier).map(|session| { + browser_session_id(identifier).inspect(|&session| { self.session_by_external .insert(external_identifier.to_owned(), session); self.known_sessions.insert(session); - session }) } @@ -135,12 +134,11 @@ impl BrowserAuthorityRegistry { .value() .checked_add(1) .ok_or(BrowserRegistryError::DocumentEpochExhausted)?; - document_epoch(next_value).map(|next| { + document_epoch(next_value).inspect(|&next| { self.context_epoch.insert(browsing_context, next); self.context_origin.remove(&browsing_context); self.node_by_external .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); - next }) } From 39966aa893a80ce16458bcedab7c541e4953a9df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 19:16:51 +0900 Subject: [PATCH 16/23] test(browser): cover repeated node binding in unit crate --- .../src/browser_registry_coverage.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 crates/originweave-core/src/browser_registry_coverage.rs diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs new file mode 100644 index 00000000..e23ef027 --- /dev/null +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -0,0 +1,18 @@ +use std::error::Error; + +use crate::{BrowserAuthorityRegistry, Origin}; + +#[test] +fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("unit-session")?; + let context = registry.register_context(session, "unit-context")?; + let origin = Origin::parse("http://127.0.0.1:43127")?; + + let first = registry.bind_node(session, context, &origin, "unit-node")?; + let repeated = registry.bind_node(session, context, &origin, "unit-node")?; + + assert_eq!(first, repeated); + Ok(()) +} From f3130ead3015f5c89812f7184760fff243563fbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 19:17:14 +0900 Subject: [PATCH 17/23] test(browser): run repeated binding in unit crate --- crates/originweave-core/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b0ad9fd7..bdd1b2aa 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -8,6 +8,8 @@ #![deny(missing_docs)] mod browser_registry; +#[cfg(test)] +mod browser_registry_coverage; mod contracts; pub use browser_registry::{ From 45ed9bc34ed7847ae817f60021b3bfc3f5c775f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 19:21:14 +0900 Subject: [PATCH 18/23] test(browser): use non-panicking unit origin fixture --- .../src/browser_registry_coverage.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index e23ef027..5beb9218 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -3,15 +3,19 @@ use std::error::Error; use crate::{BrowserAuthorityRegistry, Origin}; #[test] -fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() --> Result<(), Box> { +fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() -> Result<(), Box> +{ let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session("unit-session")?; let context = registry.register_context(session, "unit-context")?; - let origin = Origin::parse("http://127.0.0.1:43127")?; + let origins: Vec<_> = Origin::parse("http://127.0.0.1:43127")? + .into_iter() + .collect(); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; - let first = registry.bind_node(session, context, &origin, "unit-node")?; - let repeated = registry.bind_node(session, context, &origin, "unit-node")?; + let first = registry.bind_node(session, context, origin, "unit-node")?; + let repeated = registry.bind_node(session, context, origin, "unit-node")?; assert_eq!(first, repeated); Ok(()) From c30e2e4a4b788120850d1b9271bdb6ede139a31e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 19:22:38 +0900 Subject: [PATCH 19/23] test(browser): fix unit origin fixture conversion --- crates/originweave-core/src/browser_registry_coverage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 5beb9218..3a1c21d2 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -8,7 +8,7 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() -> Result let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session("unit-session")?; let context = registry.register_context(session, "unit-context")?; - let origins: Vec<_> = Origin::parse("http://127.0.0.1:43127")? + let origins: Vec<_> = Origin::parse("http://127.0.0.1:43127") .into_iter() .collect(); assert_eq!(origins.len(), 1); From 78ba71b510d1ed445908b2f54527ccb0ce291c6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 20:17:07 +0900 Subject: [PATCH 20/23] test(browser): exercise public default and error contracts --- .../tests/browser_authority_registry.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 6514bc5b..547695b3 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -36,6 +36,57 @@ fn external_protocol_identifiers_are_scoped_and_never_become_authority() Ok(()) } +#[test] +fn public_default_and_error_contracts_are_usable_from_an_adapter() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::default(); + assert!(registry.register_session("adapter-session")?.value() > 0); + + let first_session = BrowserSessionId::new(1)?; + let second_session = BrowserSessionId::new(2)?; + let cases = [ + ( + BrowserRegistryError::InvalidExternalIdentifier, + "external browser identifier must contain 1 to 512 UTF-8 bytes".to_owned(), + ), + ( + BrowserRegistryError::UnknownBrowserSession, + "browser session is not registered in this authority registry".to_owned(), + ), + ( + BrowserRegistryError::UnknownBrowsingContext, + "browsing context is not registered in this authority registry".to_owned(), + ), + ( + BrowserRegistryError::ContextSessionMismatch { + expected: first_session, + actual: second_session, + }, + "browsing context belongs to session 1, not session 2".to_owned(), + ), + ( + BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + "browsing context origin changed without advancing the document epoch".to_owned(), + ), + ( + BrowserRegistryError::IdentifierSpaceExhausted, + "browser authority identifier space is exhausted".to_owned(), + ), + ( + BrowserRegistryError::DocumentEpochExhausted, + "browser document epoch space is exhausted".to_owned(), + ), + ( + BrowserRegistryError::InternalAuthorityInvariant, + "browser authority registry violated a nonzero invariant".to_owned(), + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + } + Ok(()) +} + #[test] fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); From 504f8cfa85375f5c733d38050411c048dcce0af2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 20:20:47 +0900 Subject: [PATCH 21/23] test(browser): cover unit-crate session authority failures --- .../src/browser_registry_coverage.rs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 3a1c21d2..46487967 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -1,6 +1,6 @@ use std::error::Error; -use crate::{BrowserAuthorityRegistry, Origin}; +use crate::{BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, Origin}; #[test] fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() -> Result<(), Box> @@ -20,3 +20,30 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() -> Result assert_eq!(first, repeated); Ok(()) } + +#[test] +fn session_authority_failures_are_exercised_in_the_unit_crate() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let unknown = BrowserSessionId::new(999)?; + assert_eq!( + registry.register_context(unknown, "unknown-context"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "owner-context")?; + let origins: Vec<_> = Origin::parse("http://127.0.0.1:43127") + .into_iter() + .collect(); + assert_eq!(origins.len(), 1); + + assert_eq!( + registry.bind_node(attacker, context, &origins[0], "unit-node"), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); + Ok(()) +} From b5395390ff371602990527c07176743b26cf81d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 23:16:40 +0900 Subject: [PATCH 22/23] test(core): remove synthetic coverage-only error branches --- .../src/browser_registry_coverage.rs | 57 +++++++++++-------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 46487967..1860bc7b 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -1,42 +1,54 @@ -use std::error::Error; - use crate::{BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, Origin}; +fn values(result: Result) -> Vec { + result.into_iter().collect() +} + #[test] -fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() -> Result<(), Box> -{ +fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("unit-session")?; - let context = registry.register_context(session, "unit-context")?; - let origins: Vec<_> = Origin::parse("http://127.0.0.1:43127") - .into_iter() - .collect(); + let sessions = values(registry.register_session("unit-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + + let contexts = values(registry.register_context(session, "unit-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + + let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); let origin = &origins[0]; - let first = registry.bind_node(session, context, origin, "unit-node")?; - let repeated = registry.bind_node(session, context, origin, "unit-node")?; - - assert_eq!(first, repeated); - Ok(()) + let first = values(registry.bind_node(session, context, origin, "unit-node")); + let repeated = values(registry.bind_node(session, context, origin, "unit-node")); + assert_eq!(first.len(), 1); + assert_eq!(repeated.len(), 1); + assert_eq!(first[0], repeated[0]); } #[test] -fn session_authority_failures_are_exercised_in_the_unit_crate() -> Result<(), Box> { +fn session_authority_failures_are_exercised_in_the_unit_crate() { let mut registry = BrowserAuthorityRegistry::new(); - let unknown = BrowserSessionId::new(999)?; + let unknown_sessions = values(BrowserSessionId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + let unknown = unknown_sessions[0]; assert_eq!( registry.register_context(unknown, "unknown-context"), Err(BrowserRegistryError::UnknownBrowserSession) ); - let owner = registry.register_session("owner-session")?; - let attacker = registry.register_session("attacker-session")?; - let context = registry.register_context(owner, "owner-context")?; - let origins: Vec<_> = Origin::parse("http://127.0.0.1:43127") - .into_iter() - .collect(); + let owner_sessions = values(registry.register_session("owner-session")); + let attacker_sessions = values(registry.register_session("attacker-session")); + assert_eq!(owner_sessions.len(), 1); + assert_eq!(attacker_sessions.len(), 1); + let owner = owner_sessions[0]; + let attacker = attacker_sessions[0]; + + let contexts = values(registry.register_context(owner, "owner-context")); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(contexts.len(), 1); assert_eq!(origins.len(), 1); + let context = contexts[0]; assert_eq!( registry.bind_node(attacker, context, &origins[0], "unit-node"), @@ -45,5 +57,4 @@ fn session_authority_failures_are_exercised_in_the_unit_crate() -> Result<(), Bo actual: attacker, }) ); - Ok(()) } From 9e635e80e9813a1d2a9c408155d52221b76eeed3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 00:41:00 +0900 Subject: [PATCH 23/23] test(browser): derive identifier boundary from public contract --- crates/originweave-core/tests/browser_authority_registry.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 547695b3..f55c69f7 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -4,7 +4,7 @@ use std::error::Error; use originweave_core::{ BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, - NodeHandleError, Origin, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, }; fn loopback_origin() -> Origin { @@ -159,7 +159,7 @@ fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result Err(BrowserRegistryError::InvalidExternalIdentifier) ); assert_eq!( - registry.register_session(&"x".repeat(513)), + registry.register_session(&"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1)), Err(BrowserRegistryError::InvalidExternalIdentifier) );