From b2fa94b9b8a1be44877c3023acd1a39b9e8d35db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 03:21:14 +0900 Subject: [PATCH 001/112] 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 000000000..a6cd1cbd4 --- /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 002/112] 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 a6cd1cbd4..7cad16eec 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 003/112] 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 000000000..6256f9fac --- /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 000000000..88dd2e586 --- /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 88dd2e586..b0ad9fd71 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 004/112] 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 6256f9fac..47e8ae635 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 005/112] 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 7cad16eec..dc3fc6d53 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 006/112] 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 dc3fc6d53..23a9e7670 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 007/112] 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 23a9e7670..e4c355da5 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 008/112] 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 f804f7496..99d8d6ee8 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 009/112] 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 e4c355da5..4032f96c8 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 010/112] 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 47e8ae635..ff773aa13 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 011/112] 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 4032f96c8..d3e69c723 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 012/112] 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 d3e69c723..6a529de01 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 013/112] 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 6a529de01..6514bc5b5 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 014/112] 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 ff773aa13..eeca31663 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 015/112] 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 eeca31663..85e4af3aa 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 016/112] 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 000000000..e23ef027a --- /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 017/112] 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 b0ad9fd71..bdd1b2aa9 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 018/112] 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 e23ef027a..5beb9218d 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 019/112] 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 5beb9218d..3a1c21d21 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 020/112] 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 6514bc5b5..547695b30 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 021/112] 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 3a1c21d21..464879671 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 022/112] 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 464879671..1860bc7be 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 023/112] 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 547695b30..f55c69f75 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) ); From c0da8bd7537c0b242d9f6e6d38dce8ff42417032 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:07:02 +0900 Subject: [PATCH 024/112] test(core): require versioned browser protocol capability descriptor --- .../tests/browser_protocol_adapter.rs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 crates/originweave-core/tests/browser_protocol_adapter.rs diff --git a/crates/originweave-core/tests/browser_protocol_adapter.rs b/crates/originweave-core/tests/browser_protocol_adapter.rs new file mode 100644 index 000000000..4ecf12a24 --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_adapter.rs @@ -0,0 +1,151 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, + BrowserProtocolKind, MAX_BROWSER_PROTOCOL_METADATA_BYTES, +}; + +#[test] +fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + "bidi-2026-06-29", + "chromium-r1639810", + &[ + BrowserProtocolCapability::Navigation, + BrowserProtocolCapability::SemanticObservation, + BrowserProtocolCapability::TypedInput, + ], + )?; + + assert_eq!(descriptor.kind(), BrowserProtocolKind::WebDriverBiDi); + assert_eq!(descriptor.adapter_version(), "bidi-2026-06-29"); + assert_eq!(descriptor.browser_revision(), "chromium-r1639810"); + assert_eq!(descriptor.capability_count(), 3); + assert!(descriptor.supports(BrowserProtocolCapability::Navigation)); + assert!(descriptor.supports(BrowserProtocolCapability::SemanticObservation)); + assert!(descriptor.supports(BrowserProtocolCapability::TypedInput)); + assert!(!descriptor.supports(BrowserProtocolCapability::NetworkObservation)); + Ok(()) +} + +#[test] +fn cdp_capability_is_not_inferred_from_protocol_kind() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::ChromeDevToolsProtocol, + "cdp-150", + "chromium-r1639810", + &[BrowserProtocolCapability::NetworkObservation], + )?; + + assert!(descriptor.supports(BrowserProtocolCapability::NetworkObservation)); + assert!(!descriptor.supports(BrowserProtocolCapability::Navigation)); + assert!(!descriptor.supports(BrowserProtocolCapability::SemanticObservation)); + assert!(!descriptor.supports(BrowserProtocolCapability::TypedInput)); + Ok(()) +} + +#[test] +fn malformed_or_ambiguous_metadata_fails_closed() { + let valid_capabilities = [BrowserProtocolCapability::Navigation]; + let invalid_adapter_versions = ["", " ", "bidi version", "bidi/version", "---"]; + for adapter_version in invalid_adapter_versions { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + adapter_version, + "chromium-r1639810", + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidAdapterVersion) + ); + } + + let invalid_browser_revisions = ["", " ", "chromium revision", "chromium/revision", "---"]; + for browser_revision in invalid_browser_revisions { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + "bidi-2026-06-29", + browser_revision, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidBrowserRevision) + ); + } + + let oversized = "a".repeat(MAX_BROWSER_PROTOCOL_METADATA_BYTES + 1); + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + &oversized, + "chromium-r1639810", + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidAdapterVersion) + ); + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + "bidi-2026-06-29", + &oversized, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidBrowserRevision) + ); +} + +#[test] +fn capability_set_must_be_nonempty_and_canonical() { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + "bidi-2026-06-29", + "chromium-r1639810", + &[], + ), + Err(BrowserProtocolDescriptorError::EmptyCapabilities) + ); + + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + "bidi-2026-06-29", + "chromium-r1639810", + &[ + BrowserProtocolCapability::Navigation, + BrowserProtocolCapability::Navigation, + ], + ), + Err(BrowserProtocolDescriptorError::DuplicateCapability) + ); +} + +#[test] +fn descriptor_errors_are_stable_and_source_free() { + let cases = [ + ( + BrowserProtocolDescriptorError::InvalidAdapterVersion, + "browser protocol adapter version must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolDescriptorError::InvalidBrowserRevision, + "browser revision must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolDescriptorError::EmptyCapabilities, + "browser protocol adapter must declare at least one capability", + ), + ( + BrowserProtocolDescriptorError::DuplicateCapability, + "browser protocol adapter capabilities must be unique", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} From d7ad355fb3040414e4bb0d72f7bac37ae9266d40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:10:05 +0900 Subject: [PATCH 025/112] feat(core): add browser protocol capability descriptor --- .../originweave-core/src/browser_protocol.rs | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 crates/originweave-core/src/browser_protocol.rs diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs new file mode 100644 index 000000000..cd14193af --- /dev/null +++ b/crates/originweave-core/src/browser_protocol.rs @@ -0,0 +1,158 @@ +use std::fmt; + +/// Maximum UTF-8 byte length for browser protocol adapter metadata tokens. +pub const MAX_BROWSER_PROTOCOL_METADATA_BYTES: usize = 128; + +/// Browser automation protocol family used by one versioned adapter. +/// +/// The protocol family is descriptive metadata only. Selecting a kind does not +/// grant any OriginWeave capability or imply that a particular protocol +/// feature is available. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolKind { + /// Standards-track WebDriver BiDi adapter. + WebDriverBiDi, + /// Chromium-specific Chrome DevTools Protocol adapter. + ChromeDevToolsProtocol, +} + +/// One browser operation surface explicitly implemented by an adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolCapability { + /// Navigate a controlled browser context through the adapter. + Navigation, + /// Produce bounded semantic browser observations. + SemanticObservation, + /// Dispatch typed browser input after OriginWeave policy authorization. + TypedInput, + /// Observe bounded network evidence needed by higher-level provenance. + NetworkObservation, +} + +/// Immutable version and capability metadata for one browser protocol adapter. +/// +/// This value is deliberately not browser authority. It contains no browser +/// session, context, origin, node handle, action grant, credential, or network +/// permission. Higher layers may use it to fail closed when a required adapter +/// capability is absent, while all OriginWeave authority remains separately +/// validated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserProtocolAdapterDescriptor { + kind: BrowserProtocolKind, + adapter_version: String, + browser_revision: String, + capabilities: Vec, +} + +impl BrowserProtocolAdapterDescriptor { + /// Construct one explicit adapter descriptor. + /// + /// Version and revision strings are bounded ASCII metadata tokens. The + /// declared capability list must be non-empty and duplicate-free so the + /// descriptor has one canonical interpretation. + pub fn new( + kind: BrowserProtocolKind, + adapter_version: &str, + browser_revision: &str, + capabilities: &[BrowserProtocolCapability], + ) -> Result { + if !metadata_token_is_valid(adapter_version) { + return Err(BrowserProtocolDescriptorError::InvalidAdapterVersion); + } + if !metadata_token_is_valid(browser_revision) { + return Err(BrowserProtocolDescriptorError::InvalidBrowserRevision); + } + if capabilities.is_empty() { + return Err(BrowserProtocolDescriptorError::EmptyCapabilities); + } + + let mut canonical_capabilities = Vec::with_capacity(capabilities.len()); + for capability in capabilities { + if canonical_capabilities.contains(capability) { + return Err(BrowserProtocolDescriptorError::DuplicateCapability); + } + canonical_capabilities.push(*capability); + } + + Ok(Self { + kind, + adapter_version: adapter_version.to_owned(), + browser_revision: browser_revision.to_owned(), + capabilities: canonical_capabilities, + }) + } + + /// Return the explicitly declared browser protocol family. + #[must_use] + pub const fn kind(&self) -> BrowserProtocolKind { + self.kind + } + + /// Return the bounded adapter-version metadata token. + #[must_use] + pub fn adapter_version(&self) -> &str { + &self.adapter_version + } + + /// Return the bounded pinned browser-revision metadata token. + #[must_use] + pub fn browser_revision(&self) -> &str { + &self.browser_revision + } + + /// Return the number of explicitly declared capabilities. + #[must_use] + pub fn capability_count(&self) -> usize { + self.capabilities.len() + } + + /// Return whether this descriptor explicitly declares one capability. + #[must_use] + pub fn supports(&self, capability: BrowserProtocolCapability) -> bool { + self.capabilities.contains(&capability) + } +} + +fn metadata_token_is_valid(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_BROWSER_PROTOCOL_METADATA_BYTES + && value.is_ascii() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) +} + +/// Failure to construct canonical browser protocol adapter metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolDescriptorError { + /// The adapter-version token was empty, oversized, non-ASCII, or malformed. + InvalidAdapterVersion, + /// The browser-revision token was empty, oversized, non-ASCII, or malformed. + InvalidBrowserRevision, + /// The adapter declared no supported browser capability. + EmptyCapabilities, + /// The adapter declared the same capability more than once. + DuplicateCapability, +} + +impl fmt::Display for BrowserProtocolDescriptorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidAdapterVersion => formatter.write_str( + "browser protocol adapter version must be a bounded ASCII metadata token", + ), + Self::InvalidBrowserRevision => { + formatter.write_str("browser revision must be a bounded ASCII metadata token") + } + Self::EmptyCapabilities => { + formatter.write_str("browser protocol adapter must declare at least one capability") + } + Self::DuplicateCapability => { + formatter.write_str("browser protocol adapter capabilities must be unique") + } + } + } +} + +impl std::error::Error for BrowserProtocolDescriptorError {} From c1e64045904d294beeb9404cfd3298929aedd982 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:10:23 +0900 Subject: [PATCH 026/112] feat(core): export browser protocol descriptor --- crates/originweave-core/src/lib.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index bdd1b2aa9..06619a80b 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1,17 +1,23 @@ //! Shared security and governance contracts for OriginWeave. //! //! 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. +//! browser protocol/identifier boundaries in focused modules so browser +//! adapters can evolve without turning raw CDP or WebDriver metadata into +//! OriginWeave authority. #![forbid(unsafe_code)] #![deny(missing_docs)] +mod browser_protocol; mod browser_registry; #[cfg(test)] mod browser_registry_coverage; mod contracts; +pub use browser_protocol::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, + BrowserProtocolKind, MAX_BROWSER_PROTOCOL_METADATA_BYTES, +}; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; From 698218f80c5b2d7f58bc5691e2d503e7c3776811 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:13:51 +0900 Subject: [PATCH 027/112] test(core): cover non-ASCII protocol metadata rejection --- .../tests/browser_protocol_adapter.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_adapter.rs b/crates/originweave-core/tests/browser_protocol_adapter.rs index 4ecf12a24..c4a2b32fd 100644 --- a/crates/originweave-core/tests/browser_protocol_adapter.rs +++ b/crates/originweave-core/tests/browser_protocol_adapter.rs @@ -50,7 +50,7 @@ fn cdp_capability_is_not_inferred_from_protocol_kind() -> Result<(), Box Date: Wed, 12 Aug 2026 13:17:29 +0900 Subject: [PATCH 028/112] docs(changelog): record browser protocol descriptor --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39ca..6217a595e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. +- Versioned browser-protocol adapter metadata that distinguishes WebDriver BiDi from pinned CDP, binds bounded adapter/browser revision tokens to an explicit duplicate-free capability set, and grants no browser, action, network, or secret authority by protocol kind alone. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. From 61164ce9e7f3e181dd54896004e5079d406ffa4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:20:51 +0900 Subject: [PATCH 029/112] test(core): require explicit browser protocol revision --- .../tests/browser_protocol_adapter.rs | 79 +++++++++++++++---- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_adapter.rs b/crates/originweave-core/tests/browser_protocol_adapter.rs index c4a2b32fd..b0dcd6f3b 100644 --- a/crates/originweave-core/tests/browser_protocol_adapter.rs +++ b/crates/originweave-core/tests/browser_protocol_adapter.rs @@ -7,12 +7,19 @@ use originweave_core::{ BrowserProtocolKind, MAX_BROWSER_PROTOCOL_METADATA_BYTES, }; +const BIDI_ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const BIDI_PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const CDP_ADAPTER_VERSION: &str = "originweave-cdp-v1"; +const CDP_PROTOCOL_REVISION: &str = "cdp-browser-r1639810"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + #[test] fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), Box> { let descriptor = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - "bidi-2026-06-29", - "chromium-r1639810", + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, &[ BrowserProtocolCapability::Navigation, BrowserProtocolCapability::SemanticObservation, @@ -21,8 +28,9 @@ fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), )?; assert_eq!(descriptor.kind(), BrowserProtocolKind::WebDriverBiDi); - assert_eq!(descriptor.adapter_version(), "bidi-2026-06-29"); - assert_eq!(descriptor.browser_revision(), "chromium-r1639810"); + assert_eq!(descriptor.adapter_version(), BIDI_ADAPTER_VERSION); + assert_eq!(descriptor.protocol_revision(), BIDI_PROTOCOL_REVISION); + assert_eq!(descriptor.browser_revision(), BROWSER_REVISION); assert_eq!(descriptor.capability_count(), 3); assert!(descriptor.supports(BrowserProtocolCapability::Navigation)); assert!(descriptor.supports(BrowserProtocolCapability::SemanticObservation)); @@ -35,11 +43,13 @@ fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), fn cdp_capability_is_not_inferred_from_protocol_kind() -> Result<(), Box> { let descriptor = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::ChromeDevToolsProtocol, - "cdp-150", - "chromium-r1639810", + CDP_ADAPTER_VERSION, + CDP_PROTOCOL_REVISION, + BROWSER_REVISION, &[BrowserProtocolCapability::NetworkObservation], )?; + assert_eq!(descriptor.protocol_revision(), CDP_PROTOCOL_REVISION); assert!(descriptor.supports(BrowserProtocolCapability::NetworkObservation)); assert!(!descriptor.supports(BrowserProtocolCapability::Navigation)); assert!(!descriptor.supports(BrowserProtocolCapability::SemanticObservation)); @@ -56,13 +66,35 @@ fn malformed_or_ambiguous_metadata_fails_closed() { BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, adapter_version, - "chromium-r1639810", + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, &valid_capabilities, ), Err(BrowserProtocolDescriptorError::InvalidAdapterVersion) ); } + let invalid_protocol_revisions = [ + "", + " ", + "webdriver bidi", + "webdriver/bidi", + "---", + "ν”„λ‘œν† μ½œ", + ]; + for protocol_revision in invalid_protocol_revisions { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + protocol_revision, + BROWSER_REVISION, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidProtocolRevision) + ); + } + let invalid_browser_revisions = [ "", " ", @@ -75,7 +107,8 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - "bidi-2026-06-29", + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, browser_revision, &valid_capabilities, ), @@ -88,7 +121,8 @@ fn malformed_or_ambiguous_metadata_fails_closed() { BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, &oversized, - "chromium-r1639810", + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, &valid_capabilities, ), Err(BrowserProtocolDescriptorError::InvalidAdapterVersion) @@ -96,7 +130,18 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - "bidi-2026-06-29", + BIDI_ADAPTER_VERSION, + &oversized, + BROWSER_REVISION, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidProtocolRevision) + ); + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, &oversized, &valid_capabilities, ), @@ -109,8 +154,9 @@ fn capability_set_must_be_nonempty_and_canonical() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - "bidi-2026-06-29", - "chromium-r1639810", + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, &[], ), Err(BrowserProtocolDescriptorError::EmptyCapabilities) @@ -119,8 +165,9 @@ fn capability_set_must_be_nonempty_and_canonical() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - "bidi-2026-06-29", - "chromium-r1639810", + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, &[ BrowserProtocolCapability::Navigation, BrowserProtocolCapability::Navigation, @@ -137,6 +184,10 @@ fn descriptor_errors_are_stable_and_source_free() { BrowserProtocolDescriptorError::InvalidAdapterVersion, "browser protocol adapter version must be a bounded ASCII metadata token", ), + ( + BrowserProtocolDescriptorError::InvalidProtocolRevision, + "browser protocol revision must be a bounded ASCII metadata token", + ), ( BrowserProtocolDescriptorError::InvalidBrowserRevision, "browser revision must be a bounded ASCII metadata token", From e1cc2c871c5cedbc85e2ae0e24c00020032ad4a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:22:09 +0900 Subject: [PATCH 030/112] feat(core): bind browser protocol revision explicitly --- .../originweave-core/src/browser_protocol.rs | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index cd14193af..428973e5e 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -40,6 +40,7 @@ pub enum BrowserProtocolCapability { pub struct BrowserProtocolAdapterDescriptor { kind: BrowserProtocolKind, adapter_version: String, + protocol_revision: String, browser_revision: String, capabilities: Vec, } @@ -47,18 +48,25 @@ pub struct BrowserProtocolAdapterDescriptor { impl BrowserProtocolAdapterDescriptor { /// Construct one explicit adapter descriptor. /// - /// Version and revision strings are bounded ASCII metadata tokens. The - /// declared capability list must be non-empty and duplicate-free so the - /// descriptor has one canonical interpretation. + /// Adapter version, upstream protocol revision, and browser revision are + /// separate bounded ASCII metadata tokens. This prevents an OriginWeave + /// adapter release from being mistaken for the WebDriver BiDi/CDP revision + /// or the pinned browser build it was validated against. The declared + /// capability list must be non-empty and duplicate-free so the descriptor + /// has one canonical interpretation. pub fn new( kind: BrowserProtocolKind, adapter_version: &str, + protocol_revision: &str, browser_revision: &str, capabilities: &[BrowserProtocolCapability], ) -> Result { if !metadata_token_is_valid(adapter_version) { return Err(BrowserProtocolDescriptorError::InvalidAdapterVersion); } + if !metadata_token_is_valid(protocol_revision) { + return Err(BrowserProtocolDescriptorError::InvalidProtocolRevision); + } if !metadata_token_is_valid(browser_revision) { return Err(BrowserProtocolDescriptorError::InvalidBrowserRevision); } @@ -77,6 +85,7 @@ impl BrowserProtocolAdapterDescriptor { Ok(Self { kind, adapter_version: adapter_version.to_owned(), + protocol_revision: protocol_revision.to_owned(), browser_revision: browser_revision.to_owned(), capabilities: canonical_capabilities, }) @@ -88,12 +97,18 @@ impl BrowserProtocolAdapterDescriptor { self.kind } - /// Return the bounded adapter-version metadata token. + /// Return the bounded OriginWeave adapter-version metadata token. #[must_use] pub fn adapter_version(&self) -> &str { &self.adapter_version } + /// Return the bounded upstream browser-protocol revision metadata token. + #[must_use] + pub fn protocol_revision(&self) -> &str { + &self.protocol_revision + } + /// Return the bounded pinned browser-revision metadata token. #[must_use] pub fn browser_revision(&self) -> &str { @@ -128,6 +143,8 @@ fn metadata_token_is_valid(value: &str) -> bool { pub enum BrowserProtocolDescriptorError { /// The adapter-version token was empty, oversized, non-ASCII, or malformed. InvalidAdapterVersion, + /// The upstream protocol-revision token was empty, oversized, non-ASCII, or malformed. + InvalidProtocolRevision, /// The browser-revision token was empty, oversized, non-ASCII, or malformed. InvalidBrowserRevision, /// The adapter declared no supported browser capability. @@ -142,6 +159,8 @@ impl fmt::Display for BrowserProtocolDescriptorError { Self::InvalidAdapterVersion => formatter.write_str( "browser protocol adapter version must be a bounded ASCII metadata token", ), + Self::InvalidProtocolRevision => formatter + .write_str("browser protocol revision must be a bounded ASCII metadata token"), Self::InvalidBrowserRevision => { formatter.write_str("browser revision must be a bounded ASCII metadata token") } From 8d2549cbad8cdabfe09a5ee61aa7a7ee1de81cc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:28:29 +0900 Subject: [PATCH 031/112] test(core): require canonical browser capability ordering --- .../tests/browser_protocol_adapter.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/originweave-core/tests/browser_protocol_adapter.rs b/crates/originweave-core/tests/browser_protocol_adapter.rs index b0dcd6f3b..3e369bfc6 100644 --- a/crates/originweave-core/tests/browser_protocol_adapter.rs +++ b/crates/originweave-core/tests/browser_protocol_adapter.rs @@ -177,6 +177,37 @@ fn capability_set_must_be_nonempty_and_canonical() { ); } +#[test] +fn capability_order_does_not_change_descriptor_identity() -> Result<(), Box> { + let forward = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::Navigation, + BrowserProtocolCapability::SemanticObservation, + BrowserProtocolCapability::TypedInput, + BrowserProtocolCapability::NetworkObservation, + ], + )?; + let reverse = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::NetworkObservation, + BrowserProtocolCapability::TypedInput, + BrowserProtocolCapability::SemanticObservation, + BrowserProtocolCapability::Navigation, + ], + )?; + + assert_eq!(forward, reverse); + Ok(()) +} + #[test] fn descriptor_errors_are_stable_and_source_free() { let cases = [ From 7a3e8f4689a8b3c0344a250f45ec06995473d21f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:32:20 +0900 Subject: [PATCH 032/112] fix(core): canonicalize browser capability set ordering --- crates/originweave-core/src/browser_protocol.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index 428973e5e..3421b27b3 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -52,8 +52,8 @@ impl BrowserProtocolAdapterDescriptor { /// separate bounded ASCII metadata tokens. This prevents an OriginWeave /// adapter release from being mistaken for the WebDriver BiDi/CDP revision /// or the pinned browser build it was validated against. The declared - /// capability list must be non-empty and duplicate-free so the descriptor - /// has one canonical interpretation. + /// capability list must be non-empty and duplicate-free and is normalized + /// into one stable order so caller ordering cannot change descriptor identity. pub fn new( kind: BrowserProtocolKind, adapter_version: &str, @@ -81,6 +81,7 @@ impl BrowserProtocolAdapterDescriptor { } canonical_capabilities.push(*capability); } + canonical_capabilities.sort_unstable_by_key(|capability| capability_rank(*capability)); Ok(Self { kind, @@ -128,6 +129,15 @@ impl BrowserProtocolAdapterDescriptor { } } +const fn capability_rank(capability: BrowserProtocolCapability) -> u8 { + match capability { + BrowserProtocolCapability::Navigation => 0, + BrowserProtocolCapability::SemanticObservation => 1, + BrowserProtocolCapability::TypedInput => 2, + BrowserProtocolCapability::NetworkObservation => 3, + } +} + fn metadata_token_is_valid(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_BROWSER_PROTOCOL_METADATA_BYTES From 5dd9635c6f58c8100ccf3eabae4c2b732572d3e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 21:10:43 +0900 Subject: [PATCH 033/112] test(browser): require registry authority teardown --- .../tests/browser_authority_registry.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index f55c69f75..98f74ee26 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -114,6 +114,55 @@ fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box< Ok(()) } +#[test] +fn retired_context_and_session_authority_cannot_be_reused() -> 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 first_node = registry.bind_node(session, context, &origin, "backend-node-17")?; + + registry.remove_context(context)?; + assert_eq!( + registry.current_epoch(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.bind_node(session, context, &origin, "backend-node-17"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.remove_context(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let replacement_context = registry.register_context(session, "top-level-context")?; + assert_ne!(replacement_context, context); + let replacement_node = + registry.bind_node(session, replacement_context, &origin, "backend-node-17")?; + assert_ne!(replacement_node.node_id(), first_node.node_id()); + + registry.remove_session(session)?; + assert_eq!( + registry.register_context(session, "after-session-retirement"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + assert_eq!( + registry.current_epoch(replacement_context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.remove_session(session), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let replacement_session = registry.register_session("webdriver-session")?; + assert_ne!(replacement_session, session); + let next_context = registry.register_context(replacement_session, "top-level-context")?; + assert_ne!(next_context, replacement_context); + Ok(()) +} + #[test] fn context_cannot_be_reused_by_another_session() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); From cf9581a591a8a3f2c576e2453dc79d23cd5a85fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 21:16:27 +0900 Subject: [PATCH 034/112] fix(browser): retire registry authority state --- .../originweave-core/src/browser_registry.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 85e4af3aa..c4273f4d4 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -107,6 +107,59 @@ impl BrowserAuthorityRegistry { }) } + /// Retire one browsing context and all registry-local authority derived from it. + /// + /// Retirement removes external lookup state, the current document epoch and origin, and every + /// node binding owned by the context. Monotonic context and node identifiers are never reused. + /// This revokes only OriginWeave registry-local authority; it does not prove that an external + /// browser context or process has terminated. + pub fn remove_context( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result<(), BrowserRegistryError> { + if self.context_session.remove(&browsing_context).is_none() { + return Err(BrowserRegistryError::UnknownBrowsingContext); + } + self.context_by_external + .retain(|_key, context| *context != browsing_context); + self.context_epoch.remove(&browsing_context); + self.context_origin.remove(&browsing_context); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + Ok(()) + } + + /// Retire one browser session and every registered context and node binding beneath it. + /// + /// Retirement removes only registry-local authority and external lookup state. Session, + /// context, and node identifiers remain strictly monotonic so a later registration of the same + /// opaque browser identifier cannot revive stale authority. External process termination is a + /// separate adapter responsibility. + pub fn remove_session( + &mut self, + browser_session: BrowserSessionId, + ) -> Result<(), BrowserRegistryError> { + if !self.known_sessions.remove(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + self.session_by_external + .retain(|_external, session| *session != browser_session); + self.context_by_external + .retain(|(session, _external), _context| *session != browser_session); + self.context_session + .retain(|_context, session| *session != browser_session); + + let live_contexts = &self.context_session; + self.context_epoch + .retain(|context, _epoch| live_contexts.contains_key(context)); + self.context_origin + .retain(|context, _origin| live_contexts.contains_key(context)); + self.node_by_external.retain( + |(context, _epoch, _external), _node_id| live_contexts.contains_key(context), + ); + Ok(()) + } + /// Return the currently active document epoch for a known browsing context. pub fn current_epoch( &self, From f577a780686a9c65178e82ac24fe49c3200802ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 21:19:44 +0900 Subject: [PATCH 035/112] style(browser): apply canonical rustfmt --- crates/originweave-core/src/browser_registry.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index c4273f4d4..8807c2d36 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -154,9 +154,8 @@ impl BrowserAuthorityRegistry { .retain(|context, _epoch| live_contexts.contains_key(context)); self.context_origin .retain(|context, _origin| live_contexts.contains_key(context)); - self.node_by_external.retain( - |(context, _epoch, _external), _node_id| live_contexts.contains_key(context), - ); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| live_contexts.contains_key(context)); Ok(()) } From 0c4ae8d04de51dfb940e59a5d2c5d93476b0bc7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 17:25:54 +0900 Subject: [PATCH 036/112] test(core): remove redundant registry coverage module wiring --- crates/originweave-core/src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index bdd1b2aa9..b0ad9fd71 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -8,8 +8,6 @@ #![deny(missing_docs)] mod browser_registry; -#[cfg(test)] -mod browser_registry_coverage; mod contracts; pub use browser_registry::{ From ab9e16f2872eec37578016f6a7c8e2048f03fbaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 17:26:04 +0900 Subject: [PATCH 037/112] test(core): remove redundant registry coverage module --- .../src/browser_registry_coverage.rs | 60 ------------------- 1 file changed, 60 deletions(-) delete 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 deleted file mode 100644 index 1860bc7be..000000000 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ /dev/null @@ -1,60 +0,0 @@ -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() { - let mut registry = BrowserAuthorityRegistry::new(); - 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 = 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() { - let mut registry = BrowserAuthorityRegistry::new(); - 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_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"), - Err(BrowserRegistryError::ContextSessionMismatch { - expected: owner, - actual: attacker, - }) - ); -} From 03dac28b320237bd541a6ed2cb1c53f4047d9716 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:26:43 +0900 Subject: [PATCH 038/112] test(core): restore unit-binary registry coverage --- .../src/browser_registry_coverage.rs | 60 +++++++++++++++++++ crates/originweave-core/src/lib.rs | 2 + 2 files changed, 62 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 000000000..1860bc7be --- /dev/null +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -0,0 +1,60 @@ +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() { + let mut registry = BrowserAuthorityRegistry::new(); + 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 = 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() { + let mut registry = BrowserAuthorityRegistry::new(); + 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_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"), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b0ad9fd71..bdd1b2aa9 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 a0986feedf1e9f8296e84faed5f12c3f452a39c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:02:22 +0900 Subject: [PATCH 039/112] test(core): require standard public validation errors --- .../tests/public_error_contract.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 crates/originweave-core/tests/public_error_contract.rs diff --git a/crates/originweave-core/tests/public_error_contract.rs b/crates/originweave-core/tests/public_error_contract.rs new file mode 100644 index 000000000..bfa583850 --- /dev/null +++ b/crates/originweave-core/tests/public_error_contract.rs @@ -0,0 +1,60 @@ +use std::error::Error; + +use originweave_core::{ActionIntentDigestError, ExtensionIdError, OriginError}; + +fn assert_standard_error() {} + +#[test] +fn public_validation_errors_are_standard_errors() { + assert_standard_error::(); + assert_standard_error::(); + assert_standard_error::(); +} + +#[test] +fn public_validation_errors_have_stable_operator_messages() { + assert_eq!( + OriginError::MissingScheme.to_string(), + "origin must include an explicit scheme" + ); + assert_eq!( + OriginError::UnsupportedScheme.to_string(), + "origin scheme must be HTTPS or loopback HTTP" + ); + assert_eq!( + OriginError::InsecureRemoteOrigin.to_string(), + "remote HTTP origins are not permitted" + ); + assert_eq!( + OriginError::MissingAuthority.to_string(), + "origin authority must not be empty" + ); + assert_eq!( + OriginError::UserInfoNotAllowed.to_string(), + "origin authority must not contain user information" + ); + assert_eq!( + OriginError::PathNotAllowed.to_string(), + "origin must not contain a path, query, or fragment" + ); + assert_eq!( + OriginError::InvalidAuthority.to_string(), + "origin authority is malformed or ambiguous" + ); + assert_eq!( + OriginError::AmbiguousNumericHost.to_string(), + "origin host uses a browser-ambiguous numeric address spelling" + ); + assert_eq!( + OriginError::InvalidPort.to_string(), + "origin port must be a numeric value from 1 through 65535" + ); + assert_eq!( + ActionIntentDigestError::InvalidFormat.to_string(), + "action intent digest must be sha256 followed by 64 lowercase hexadecimal digits" + ); + assert_eq!( + ExtensionIdError::InvalidExtensionId.to_string(), + "extension identifier must be 32 lowercase characters from a through p" + ); +} From 5c6d0edc25b9b8d77161a7a2dd843662d38daee3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:03:47 +0900 Subject: [PATCH 040/112] fix(core): expose standard validation errors --- .../originweave-core/src/contract_errors.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 crates/originweave-core/src/contract_errors.rs diff --git a/crates/originweave-core/src/contract_errors.rs b/crates/originweave-core/src/contract_errors.rs new file mode 100644 index 000000000..1b13c6457 --- /dev/null +++ b/crates/originweave-core/src/contract_errors.rs @@ -0,0 +1,47 @@ +use std::fmt; + +use crate::contracts::{ActionIntentDigestError, ExtensionIdError, OriginError}; + +impl fmt::Display for OriginError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::MissingScheme => "origin must include an explicit scheme", + Self::UnsupportedScheme => "origin scheme must be HTTPS or loopback HTTP", + Self::InsecureRemoteOrigin => "remote HTTP origins are not permitted", + Self::MissingAuthority => "origin authority must not be empty", + Self::UserInfoNotAllowed => "origin authority must not contain user information", + Self::PathNotAllowed => "origin must not contain a path, query, or fragment", + Self::InvalidAuthority => "origin authority is malformed or ambiguous", + Self::AmbiguousNumericHost => { + "origin host uses a browser-ambiguous numeric address spelling" + } + Self::InvalidPort => "origin port must be a numeric value from 1 through 65535", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for OriginError {} + +impl fmt::Display for ActionIntentDigestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat => formatter.write_str( + "action intent digest must be sha256 followed by 64 lowercase hexadecimal digits", + ), + } + } +} + +impl std::error::Error for ActionIntentDigestError {} + +impl fmt::Display for ExtensionIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidExtensionId => formatter + .write_str("extension identifier must be 32 lowercase characters from a through p"), + } + } +} + +impl std::error::Error for ExtensionIdError {} From 15048b1ab21f900f213b178643dc713ff7f551bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:04:02 +0900 Subject: [PATCH 041/112] fix(core): wire public validation error contracts --- crates/originweave-core/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index bdd1b2aa9..e366a5d65 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -10,6 +10,7 @@ mod browser_registry; #[cfg(test)] mod browser_registry_coverage; +mod contract_errors; mod contracts; pub use browser_registry::{ From 2f1e7cd2277b99c06e2f834e41c6dc492ba8de9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:06:43 +0900 Subject: [PATCH 042/112] test(core): use standard validation errors without expect --- .../tests/browser_authority_registry.rs | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 98f74ee26..1c942d481 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -1,5 +1,3 @@ -#![allow(clippy::expect_used)] - use std::error::Error; use originweave_core::{ @@ -7,8 +5,8 @@ use originweave_core::{ MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, }; -fn loopback_origin() -> Origin { - Origin::parse("http://127.0.0.1:43127").expect("valid loopback fixture origin") +fn loopback_origin() -> Result> { + Ok(Origin::parse("http://127.0.0.1:43127")?) } #[test] @@ -92,7 +90,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")?; @@ -119,7 +117,7 @@ fn retired_context_and_session_authority_cannot_be_reused() -> Result<(), Box Result<(), Box> { let owner = registry.register_session("owner-session")?; let attacker = registry.register_session("attacker-session")?; let context = registry.register_context(owner, "shared-looking-context")?; - let origin = loopback_origin(); + let origin = loopback_origin()?; assert_eq!( registry.bind_node(attacker, context, &origin, "node"), @@ -186,9 +184,8 @@ fn context_origin_cannot_change_without_document_rotation() -> Result<(), Box Result<(), Box Result<(), Bo let known = registry.register_session("known-session")?; let context = registry.register_context(known, "known-context")?; - let origin = loopback_origin(); + let origin = loopback_origin()?; assert_eq!( registry.bind_node(unknown, context, &origin, "node"), Err(BrowserRegistryError::UnknownBrowserSession) From 8bd82c4a30b1d0b7c79dbfa5ffe1477e50dbeb57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:18:15 +0900 Subject: [PATCH 043/112] refactor(core): make contract exports explicit --- crates/originweave-core/src/lib.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index e366a5d65..a6b92c989 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -16,4 +16,11 @@ mod contracts; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; -pub use contracts::*; +pub use contracts::{ + ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, ApprovalEvidence, + ApprovalScope, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, + ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, + ExtensionAgentGrant, ExtensionId, ExtensionIdError, InstructionSource, NodeHandleError, + ObservedNodeHandle, Origin, OriginError, PolicyContext, RiskClass, RobotsDecision, + SecretDelivery, SessionMode, evaluate_extension_access, +}; From d242b23efe2a30b572cf5ec6c27f55b8c5709c57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:22:17 +0900 Subject: [PATCH 044/112] test(core): require explicit sha256 digest prefix --- crates/originweave-core/tests/public_error_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/public_error_contract.rs b/crates/originweave-core/tests/public_error_contract.rs index bfa583850..a0e8fac2e 100644 --- a/crates/originweave-core/tests/public_error_contract.rs +++ b/crates/originweave-core/tests/public_error_contract.rs @@ -51,7 +51,7 @@ fn public_validation_errors_have_stable_operator_messages() { ); assert_eq!( ActionIntentDigestError::InvalidFormat.to_string(), - "action intent digest must be sha256 followed by 64 lowercase hexadecimal digits" + "action intent digest must be sha256: followed by 64 lowercase hexadecimal digits" ); assert_eq!( ExtensionIdError::InvalidExtensionId.to_string(), From 90227c5dd6c62839d4ccfe86640f08984011e207 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:59:29 +0900 Subject: [PATCH 045/112] fix(core): clarify sha256 digest prefix --- crates/originweave-core/src/contract_errors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/src/contract_errors.rs b/crates/originweave-core/src/contract_errors.rs index 1b13c6457..c34131bf1 100644 --- a/crates/originweave-core/src/contract_errors.rs +++ b/crates/originweave-core/src/contract_errors.rs @@ -27,7 +27,7 @@ impl fmt::Display for ActionIntentDigestError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidFormat => formatter.write_str( - "action intent digest must be sha256 followed by 64 lowercase hexadecimal digits", + "action intent digest must be sha256: followed by 64 lowercase hexadecimal digits", ), } } From 2203d60ec735e7f3f920fb9ff1c16cefa8d4029f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 09:21:11 +0900 Subject: [PATCH 046/112] test(core): preserve origin-bound expiring extension grants --- .../tests/extension_authority.rs | 119 +++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/extension_authority.rs b/crates/originweave-core/tests/extension_authority.rs index 82507a244..f34c30e9b 100644 --- a/crates/originweave-core/tests/extension_authority.rs +++ b/crates/originweave-core/tests/extension_authority.rs @@ -2,7 +2,7 @@ use originweave_core::{ BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, evaluate_extension_access, + ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, evaluate_extension_access, }; fn extension_id(value: &str) -> ExtensionId { @@ -17,6 +17,13 @@ fn context(value: u64) -> BrowsingContextId { BrowsingContextId::new(value).expect("nonzero browsing context") } +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("canonical origin") +} + +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + #[test] fn extension_id_accepts_only_canonical_chromium_extension_ids() { let canonical = "abcdefghijklmnopabcdefghijklmnop"; @@ -43,10 +50,13 @@ fn extension_id_accepts_only_canonical_chromium_extension_ids() { fn extension_agent_access_requires_an_explicit_exact_grant() { let allowed_extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); let other_extension = extension_id("bcdefghijklmnopabcdefghijklmnopa"); + let granted_origin = origin("https://app.example"); let grant = ExtensionAgentGrant::new( allowed_extension.clone(), session(7), context(11), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -54,6 +64,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(7), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -68,6 +80,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { other_extension, session(7), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -79,6 +93,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(8), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -87,24 +103,55 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { ); let wrong_context = ExtensionAccessRequest::new( - allowed_extension, + allowed_extension.clone(), session(7), context(12), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( evaluate_extension_access(&wrong_context, Some(&grant)), ExtensionAccessDecision::DenyBrowsingContextMismatch ); + + let wrong_origin = ExtensionAccessRequest::new( + allowed_extension.clone(), + session(7), + context(11), + origin("https://other.example"), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&wrong_origin, Some(&grant)), + ExtensionAccessDecision::DenyOriginMismatch + ); + + let wrong_port = ExtensionAccessRequest::new( + allowed_extension, + session(7), + context(11), + origin("https://app.example:8443"), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&wrong_port, Some(&grant)), + ExtensionAccessDecision::DenyOriginMismatch + ); } #[test] fn chrome_permissions_never_imply_originweave_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://mail.example"); let grant = ExtensionAgentGrant::new( id.clone(), session(3), context(5), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -112,6 +159,8 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { id, session(3), context(5), + granted_origin, + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ProposeTypedAction, ); assert_eq!( @@ -123,10 +172,13 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { #[test] fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("http://127.0.0.1:8080"); let grant = ExtensionAgentGrant::new( id.clone(), session(13), context(17), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, @@ -137,10 +189,71 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, ] { - let request = ExtensionAccessRequest::new(id.clone(), session(13), context(17), capability); + let request = ExtensionAccessRequest::new( + id.clone(), + session(13), + context(17), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, + capability, + ); assert_eq!( evaluate_extension_access(&request, Some(&grant)), ExtensionAccessDecision::Allow ); } } + +#[test] +fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { + let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://billing.example"); + let expires_at_epoch_seconds = 1_700_000_100; + let grant = ExtensionAgentGrant::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds, + [ExtensionAgentCapability::ObserveCurrentContext], + ); + + let before_deadline = ExtensionAccessRequest::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds - 1, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&before_deadline, Some(&grant)), + ExtensionAccessDecision::Allow + ); + + let at_deadline = ExtensionAccessRequest::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&at_deadline, Some(&grant)), + ExtensionAccessDecision::DenyExpired + ); + + let after_deadline = ExtensionAccessRequest::new( + id, + session(19), + context(23), + granted_origin, + expires_at_epoch_seconds + 1, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&after_deadline, Some(&grant)), + ExtensionAccessDecision::DenyExpired + ); +} From 14cc3f51820c35970fe71e614df7ac2aca221654 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 10:10:30 +0900 Subject: [PATCH 047/112] fix(core): preserve protected extension grant authority --- .../src/extension_authority.rs | 147 ++++++++++++++++++ crates/originweave-core/src/lib.rs | 11 +- 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 crates/originweave-core/src/extension_authority.rs diff --git a/crates/originweave-core/src/extension_authority.rs b/crates/originweave-core/src/extension_authority.rs new file mode 100644 index 000000000..6a5725b4b --- /dev/null +++ b/crates/originweave-core/src/extension_authority.rs @@ -0,0 +1,147 @@ +//! Extension-to-Agent authority adapted onto the refactored core contracts. +//! +//! The browser-registry branch split long-lived contracts into a private +//! `contracts` module before protected main added origin and exclusive-expiry +//! binding to extension grants. This module preserves those protected-main +//! semantics without allowing raw Chromium permissions or identifiers to become +//! Agent authority. + +use crate::contracts::{ + BrowserSessionId, BrowsingContextId, ExtensionAccessDecision as BaseExtensionAccessDecision, + ExtensionAccessRequest as BaseExtensionAccessRequest, ExtensionAgentCapability, + ExtensionAgentGrant as BaseExtensionAgentGrant, ExtensionId, Origin, + evaluate_extension_access as evaluate_base_extension_access, +}; + +/// An explicit host-originated extension grant bound to session, context, origin, and expiry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAgentGrant { + base: BaseExtensionAgentGrant, + origin: Origin, + expires_at_epoch_seconds: u64, +} + +impl ExtensionAgentGrant { + /// Build an exact extension-to-Agent grant for one session, context, origin, and exclusive expiry. + #[must_use] + pub fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + expires_at_epoch_seconds: u64, + capabilities: I, + ) -> Self + where + I: IntoIterator, + { + Self { + base: BaseExtensionAgentGrant::new( + extension_id, + browser_session, + browsing_context, + capabilities, + ), + origin, + expires_at_epoch_seconds, + } + } +} + +/// One extension request to use a bounded Agent capability at trusted evaluation time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAccessRequest { + base: BaseExtensionAccessRequest, + origin: Origin, + now_epoch_seconds: u64, +} + +impl ExtensionAccessRequest { + /// Build one exact extension capability request without granting authority. + /// + /// `now_epoch_seconds` must come from trusted host evaluation time rather + /// than a page, extension, model, or other caller-controlled clock. + #[must_use] + pub const fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + now_epoch_seconds: u64, + capability: ExtensionAgentCapability, + ) -> Self { + Self { + base: BaseExtensionAccessRequest::new( + extension_id, + browser_session, + browsing_context, + capability, + ), + origin, + now_epoch_seconds, + } + } +} + +/// Result of evaluating one extension request against one explicit Agent grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionAccessDecision { + /// Extension, session, context, origin, expiry, and capability all match. + 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 request belongs to a different canonical origin than the grant. + DenyOriginMismatch, + /// Trusted evaluation time is at or after the grant's exclusive expiry. + DenyExpired, + /// The extension grant does not contain the requested OriginWeave capability. + DenyCapabilityNotGranted, +} + +/// Evaluate extension Agent access without inheriting ambient Chrome permissions. +/// +/// Identity, session, and context checks reuse the pre-existing deterministic +/// contract. Origin and exclusive-expiry checks are then applied before a +/// capability denial or allowance is returned, preserving protected-main +/// fail-closed ordering on the refactored branch. +#[must_use] +pub fn evaluate_extension_access( + request: &ExtensionAccessRequest, + grant: Option<&ExtensionAgentGrant>, +) -> ExtensionAccessDecision { + let Some(grant) = grant else { + return ExtensionAccessDecision::DenyMissingGrant; + }; + + let base_decision = evaluate_base_extension_access(&request.base, Some(&grant.base)); + match base_decision { + BaseExtensionAccessDecision::DenyMissingGrant => ExtensionAccessDecision::DenyMissingGrant, + BaseExtensionAccessDecision::DenyExtensionMismatch => { + ExtensionAccessDecision::DenyExtensionMismatch + } + BaseExtensionAccessDecision::DenyBrowserSessionMismatch => { + ExtensionAccessDecision::DenyBrowserSessionMismatch + } + BaseExtensionAccessDecision::DenyBrowsingContextMismatch => { + ExtensionAccessDecision::DenyBrowsingContextMismatch + } + BaseExtensionAccessDecision::Allow | BaseExtensionAccessDecision::DenyCapabilityNotGranted => { + if request.origin != grant.origin { + return ExtensionAccessDecision::DenyOriginMismatch; + } + if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { + return ExtensionAccessDecision::DenyExpired; + } + if base_decision == BaseExtensionAccessDecision::DenyCapabilityNotGranted { + return ExtensionAccessDecision::DenyCapabilityNotGranted; + } + ExtensionAccessDecision::Allow + } + } +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index a6b92c989..e5da42586 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -12,6 +12,7 @@ mod browser_registry; mod browser_registry_coverage; mod contract_errors; mod contracts; +mod extension_authority; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, @@ -19,8 +20,10 @@ pub use browser_registry::{ pub use contracts::{ ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, - ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, - ExtensionAgentGrant, ExtensionId, ExtensionIdError, InstructionSource, NodeHandleError, - ObservedNodeHandle, Origin, OriginError, PolicyContext, RiskClass, RobotsDecision, - SecretDelivery, SessionMode, evaluate_extension_access, + ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, + NodeHandleError, ObservedNodeHandle, Origin, OriginError, PolicyContext, RiskClass, + RobotsDecision, SecretDelivery, SessionMode, +}; +pub use extension_authority::{ + ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentGrant, evaluate_extension_access, }; From c146e3e9f6962cb6f0bcc0f2f4db608135878e88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 10:31:04 +0900 Subject: [PATCH 048/112] fix(core): delegate missing extension grant evaluation --- .../src/extension_authority.rs | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/originweave-core/src/extension_authority.rs b/crates/originweave-core/src/extension_authority.rs index 6a5725b4b..b250c442b 100644 --- a/crates/originweave-core/src/extension_authority.rs +++ b/crates/originweave-core/src/extension_authority.rs @@ -106,20 +106,16 @@ pub enum ExtensionAccessDecision { /// Evaluate extension Agent access without inheriting ambient Chrome permissions. /// -/// Identity, session, and context checks reuse the pre-existing deterministic -/// contract. Origin and exclusive-expiry checks are then applied before a -/// capability denial or allowance is returned, preserving protected-main +/// Identity, session, context, and missing-grant checks reuse the pre-existing +/// deterministic contract. Origin and exclusive-expiry checks are then applied +/// before a capability denial or allowance is returned, preserving protected-main /// fail-closed ordering on the refactored branch. #[must_use] pub fn evaluate_extension_access( request: &ExtensionAccessRequest, grant: Option<&ExtensionAgentGrant>, ) -> ExtensionAccessDecision { - let Some(grant) = grant else { - return ExtensionAccessDecision::DenyMissingGrant; - }; - - let base_decision = evaluate_base_extension_access(&request.base, Some(&grant.base)); + let base_decision = evaluate_base_extension_access(&request.base, grant.map(|grant| &grant.base)); match base_decision { BaseExtensionAccessDecision::DenyMissingGrant => ExtensionAccessDecision::DenyMissingGrant, BaseExtensionAccessDecision::DenyExtensionMismatch => { @@ -131,17 +127,21 @@ pub fn evaluate_extension_access( BaseExtensionAccessDecision::DenyBrowsingContextMismatch => { ExtensionAccessDecision::DenyBrowsingContextMismatch } - BaseExtensionAccessDecision::Allow | BaseExtensionAccessDecision::DenyCapabilityNotGranted => { - if request.origin != grant.origin { - return ExtensionAccessDecision::DenyOriginMismatch; - } - if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { - return ExtensionAccessDecision::DenyExpired; - } - if base_decision == BaseExtensionAccessDecision::DenyCapabilityNotGranted { - return ExtensionAccessDecision::DenyCapabilityNotGranted; - } - ExtensionAccessDecision::Allow - } + BaseExtensionAccessDecision::Allow + | BaseExtensionAccessDecision::DenyCapabilityNotGranted => grant.map_or( + ExtensionAccessDecision::DenyMissingGrant, + |grant| { + if request.origin != grant.origin { + return ExtensionAccessDecision::DenyOriginMismatch; + } + if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { + return ExtensionAccessDecision::DenyExpired; + } + if base_decision == BaseExtensionAccessDecision::DenyCapabilityNotGranted { + return ExtensionAccessDecision::DenyCapabilityNotGranted; + } + ExtensionAccessDecision::Allow + }, + ), } } From 8a4b4c2358f2478c7ad58ebcab595fece977ba95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 10:33:00 +0900 Subject: [PATCH 049/112] style(core): apply canonical extension authority formatting --- crates/originweave-core/src/extension_authority.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/src/extension_authority.rs b/crates/originweave-core/src/extension_authority.rs index b250c442b..27dae790a 100644 --- a/crates/originweave-core/src/extension_authority.rs +++ b/crates/originweave-core/src/extension_authority.rs @@ -115,7 +115,8 @@ pub fn evaluate_extension_access( request: &ExtensionAccessRequest, grant: Option<&ExtensionAgentGrant>, ) -> ExtensionAccessDecision { - let base_decision = evaluate_base_extension_access(&request.base, grant.map(|grant| &grant.base)); + let base_decision = + evaluate_base_extension_access(&request.base, grant.map(|grant| &grant.base)); match base_decision { BaseExtensionAccessDecision::DenyMissingGrant => ExtensionAccessDecision::DenyMissingGrant, BaseExtensionAccessDecision::DenyExtensionMismatch => { @@ -128,9 +129,8 @@ pub fn evaluate_extension_access( ExtensionAccessDecision::DenyBrowsingContextMismatch } BaseExtensionAccessDecision::Allow - | BaseExtensionAccessDecision::DenyCapabilityNotGranted => grant.map_or( - ExtensionAccessDecision::DenyMissingGrant, - |grant| { + | BaseExtensionAccessDecision::DenyCapabilityNotGranted => { + grant.map_or(ExtensionAccessDecision::DenyMissingGrant, |grant| { if request.origin != grant.origin { return ExtensionAccessDecision::DenyOriginMismatch; } @@ -141,7 +141,7 @@ pub fn evaluate_extension_access( return ExtensionAccessDecision::DenyCapabilityNotGranted; } ExtensionAccessDecision::Allow - }, - ), + }) + } } } From b55716d446cbe99747b5d56019c953d2d2ce551d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:54:06 +0900 Subject: [PATCH 050/112] test(core): require extension grant task binding --- .../tests/extension_task_authority.rs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 crates/originweave-core/tests/extension_task_authority.rs diff --git a/crates/originweave-core/tests/extension_task_authority.rs b/crates/originweave-core/tests/extension_task_authority.rs new file mode 100644 index 000000000..ff7d1b433 --- /dev/null +++ b/crates/originweave-core/tests/extension_task_authority.rs @@ -0,0 +1,76 @@ +#![allow(clippy::expect_used)] + +use originweave_core::{ + AgentTaskId, BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, + evaluate_extension_access, +}; + +fn extension_id(value: &str) -> ExtensionId { + ExtensionId::parse(value).expect("valid extension id") +} + +fn task(value: u64) -> AgentTaskId { + AgentTaskId::new(value).expect("nonzero Agent Task identity") +} + +fn session(value: u64) -> BrowserSessionId { + BrowserSessionId::new(value).expect("nonzero browser session") +} + +fn context(value: u64) -> BrowsingContextId { + BrowsingContextId::new(value).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("canonical origin") +} + +#[test] +fn agent_task_identity_rejects_zero() { + assert!(AgentTaskId::new(0).is_err()); + assert_eq!(task(29).value(), 29); +} + +#[test] +fn extension_agent_grants_are_non_transferable_between_agent_tasks() { + let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://agent.example"); + let grant = ExtensionAgentGrant::new( + id.clone(), + task(29), + session(7), + context(11), + granted_origin.clone(), + 1_700_000_600, + [ExtensionAgentCapability::ProposeTypedAction], + ); + + let exact_task = ExtensionAccessRequest::new( + id.clone(), + task(29), + session(7), + context(11), + granted_origin.clone(), + 1_700_000_000, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&exact_task, Some(&grant)), + ExtensionAccessDecision::Allow + ); + + let other_task = ExtensionAccessRequest::new( + id, + task(30), + session(7), + context(11), + granted_origin, + 1_700_000_000, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&other_task, Some(&grant)), + ExtensionAccessDecision::DenyAgentTaskMismatch + ); +} From d043af9fa8d9c9d16398b0eb3ea862eaaeff31f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:59:21 +0900 Subject: [PATCH 051/112] fix(core): bind extension grants to Agent Task identity --- .../src/extension_authority.rs | 68 ++++++++++++++++--- crates/originweave-core/src/lib.rs | 3 +- .../tests/extension_authority.rs | 25 ++++++- .../tests/extension_task_authority.rs | 13 +++- 4 files changed, 94 insertions(+), 15 deletions(-) diff --git a/crates/originweave-core/src/extension_authority.rs b/crates/originweave-core/src/extension_authority.rs index 27dae790a..74d6db6e3 100644 --- a/crates/originweave-core/src/extension_authority.rs +++ b/crates/originweave-core/src/extension_authority.rs @@ -6,6 +6,8 @@ //! semantics without allowing raw Chromium permissions or identifiers to become //! Agent authority. +use std::fmt; + use crate::contracts::{ BrowserSessionId, BrowsingContextId, ExtensionAccessDecision as BaseExtensionAccessDecision, ExtensionAccessRequest as BaseExtensionAccessRequest, ExtensionAgentCapability, @@ -13,19 +15,58 @@ use crate::contracts::{ evaluate_extension_access as evaluate_base_extension_access, }; -/// An explicit host-originated extension grant bound to session, context, origin, and expiry. +/// A nonzero host-assigned identity for one isolated Agent Task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AgentTaskId(u64); + +impl AgentTaskId { + /// Validate one host-assigned Agent Task identifier. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(AgentTaskIdError::InvalidAgentTaskId); + } + Ok(Self(value)) + } + + /// Return the validated Agent Task identifier. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A validation failure for an Agent Task identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentTaskIdError { + /// Agent Task identities are one-based and zero was supplied. + InvalidAgentTaskId, +} + +impl fmt::Display for AgentTaskIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidAgentTaskId => formatter.write_str("Agent Task identifier must be nonzero"), + } + } +} + +impl std::error::Error for AgentTaskIdError {} + +/// An explicit host-originated extension grant bound to task, session, context, origin, and expiry. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExtensionAgentGrant { base: BaseExtensionAgentGrant, + agent_task: AgentTaskId, origin: Origin, expires_at_epoch_seconds: u64, } impl ExtensionAgentGrant { - /// Build an exact extension-to-Agent grant for one session, context, origin, and exclusive expiry. + /// Build an exact extension-to-Agent grant for one task, session, context, origin, and expiry. #[must_use] pub fn new( extension_id: ExtensionId, + agent_task: AgentTaskId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, origin: Origin, @@ -42,28 +83,31 @@ impl ExtensionAgentGrant { browsing_context, capabilities, ), + agent_task, origin, expires_at_epoch_seconds, } } } -/// One extension request to use a bounded Agent capability at trusted evaluation time. +/// One task-bound extension request to use a bounded Agent capability at trusted evaluation time. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExtensionAccessRequest { base: BaseExtensionAccessRequest, + agent_task: AgentTaskId, origin: Origin, now_epoch_seconds: u64, } impl ExtensionAccessRequest { - /// Build one exact extension capability request without granting authority. + /// Build one exact task-bound extension capability request without granting authority. /// /// `now_epoch_seconds` must come from trusted host evaluation time rather /// than a page, extension, model, or other caller-controlled clock. #[must_use] pub const fn new( extension_id: ExtensionId, + agent_task: AgentTaskId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, origin: Origin, @@ -77,6 +121,7 @@ impl ExtensionAccessRequest { browsing_context, capability, ), + agent_task, origin, now_epoch_seconds, } @@ -86,12 +131,14 @@ impl ExtensionAccessRequest { /// Result of evaluating one extension request against one explicit Agent grant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExtensionAccessDecision { - /// Extension, session, context, origin, expiry, and capability all match. + /// Extension, task, session, context, origin, expiry, and capability all match. 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 Agent Task identity. + DenyAgentTaskMismatch, /// The request belongs to a different browser automation session. DenyBrowserSessionMismatch, /// The request belongs to a different independently navigable browser context. @@ -106,10 +153,10 @@ pub enum ExtensionAccessDecision { /// Evaluate extension Agent access without inheriting ambient Chrome permissions. /// -/// Identity, session, context, and missing-grant checks reuse the pre-existing -/// deterministic contract. Origin and exclusive-expiry checks are then applied -/// before a capability denial or allowance is returned, preserving protected-main -/// fail-closed ordering on the refactored branch. +/// Extension identity, session, context, and missing-grant checks reuse the pre-existing +/// deterministic contract. Exact Agent Task identity, origin, and exclusive-expiry checks are +/// then applied before a capability denial or allowance is returned, preserving fail-closed +/// authority ordering on the refactored branch. #[must_use] pub fn evaluate_extension_access( request: &ExtensionAccessRequest, @@ -131,6 +178,9 @@ pub fn evaluate_extension_access( BaseExtensionAccessDecision::Allow | BaseExtensionAccessDecision::DenyCapabilityNotGranted => { grant.map_or(ExtensionAccessDecision::DenyMissingGrant, |grant| { + if request.agent_task != grant.agent_task { + return ExtensionAccessDecision::DenyAgentTaskMismatch; + } if request.origin != grant.origin { return ExtensionAccessDecision::DenyOriginMismatch; } diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index e5da42586..b8c43c09d 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -25,5 +25,6 @@ pub use contracts::{ RobotsDecision, SecretDelivery, SessionMode, }; pub use extension_authority::{ - ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentGrant, evaluate_extension_access, + AgentTaskId, AgentTaskIdError, ExtensionAccessDecision, ExtensionAccessRequest, + ExtensionAgentGrant, evaluate_extension_access, }; diff --git a/crates/originweave-core/tests/extension_authority.rs b/crates/originweave-core/tests/extension_authority.rs index f34c30e9b..1ddeb5383 100644 --- a/crates/originweave-core/tests/extension_authority.rs +++ b/crates/originweave-core/tests/extension_authority.rs @@ -1,14 +1,19 @@ #![allow(clippy::expect_used)] use originweave_core::{ - BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, evaluate_extension_access, + AgentTaskId, BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, + evaluate_extension_access, }; fn extension_id(value: &str) -> ExtensionId { ExtensionId::parse(value).expect("valid extension id") } +fn task(value: u64) -> AgentTaskId { + AgentTaskId::new(value).expect("nonzero Agent Task identity") +} + fn session(value: u64) -> BrowserSessionId { BrowserSessionId::new(value).expect("nonzero browser session") } @@ -23,6 +28,7 @@ fn origin(value: &str) -> Origin { const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; +const AGENT_TASK_ID: u64 = 29; #[test] fn extension_id_accepts_only_canonical_chromium_extension_ids() { @@ -53,6 +59,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let granted_origin = origin("https://app.example"); let grant = ExtensionAgentGrant::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(11), granted_origin.clone(), @@ -62,6 +69,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let exact = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(11), granted_origin.clone(), @@ -78,6 +86,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_extension = ExtensionAccessRequest::new( other_extension, + task(AGENT_TASK_ID), session(7), context(11), granted_origin.clone(), @@ -91,6 +100,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_session = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(8), context(11), granted_origin.clone(), @@ -104,6 +114,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_context = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(12), granted_origin.clone(), @@ -117,6 +128,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_origin = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(11), origin("https://other.example"), @@ -130,6 +142,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_port = ExtensionAccessRequest::new( allowed_extension, + task(AGENT_TASK_ID), session(7), context(11), origin("https://app.example:8443"), @@ -148,6 +161,7 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { let granted_origin = origin("https://mail.example"); let grant = ExtensionAgentGrant::new( id.clone(), + task(AGENT_TASK_ID), session(3), context(5), granted_origin.clone(), @@ -157,6 +171,7 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { let propose_action = ExtensionAccessRequest::new( id, + task(AGENT_TASK_ID), session(3), context(5), granted_origin, @@ -175,6 +190,7 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { let granted_origin = origin("http://127.0.0.1:8080"); let grant = ExtensionAgentGrant::new( id.clone(), + task(AGENT_TASK_ID), session(13), context(17), granted_origin.clone(), @@ -191,6 +207,7 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { ] { let request = ExtensionAccessRequest::new( id.clone(), + task(AGENT_TASK_ID), session(13), context(17), granted_origin.clone(), @@ -211,6 +228,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let expires_at_epoch_seconds = 1_700_000_100; let grant = ExtensionAgentGrant::new( id.clone(), + task(AGENT_TASK_ID), session(19), context(23), granted_origin.clone(), @@ -220,6 +238,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let before_deadline = ExtensionAccessRequest::new( id.clone(), + task(AGENT_TASK_ID), session(19), context(23), granted_origin.clone(), @@ -233,6 +252,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let at_deadline = ExtensionAccessRequest::new( id.clone(), + task(AGENT_TASK_ID), session(19), context(23), granted_origin.clone(), @@ -246,6 +266,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let after_deadline = ExtensionAccessRequest::new( id, + task(AGENT_TASK_ID), session(19), context(23), granted_origin, diff --git a/crates/originweave-core/tests/extension_task_authority.rs b/crates/originweave-core/tests/extension_task_authority.rs index ff7d1b433..ef21c2f1f 100644 --- a/crates/originweave-core/tests/extension_task_authority.rs +++ b/crates/originweave-core/tests/extension_task_authority.rs @@ -1,7 +1,7 @@ #![allow(clippy::expect_used)] use originweave_core::{ - AgentTaskId, BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, + AgentTaskId, AgentTaskIdError, BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, evaluate_extension_access, }; @@ -27,9 +27,16 @@ fn origin(value: &str) -> Origin { } #[test] -fn agent_task_identity_rejects_zero() { - assert!(AgentTaskId::new(0).is_err()); +fn agent_task_identity_rejects_zero_with_standard_error_contract() { + assert_eq!( + AgentTaskId::new(0), + Err(AgentTaskIdError::InvalidAgentTaskId) + ); assert_eq!(task(29).value(), 29); + + let error = AgentTaskIdError::InvalidAgentTaskId; + assert_eq!(error.to_string(), "Agent Task identifier must be nonzero"); + assert!(std::error::Error::source(&error).is_none()); } #[test] From a4830c2a05be50382ac6d199e76fa1841cfc04d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:02:01 +0900 Subject: [PATCH 052/112] style(core): apply canonical task-authority formatting --- crates/originweave-core/src/extension_authority.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/extension_authority.rs b/crates/originweave-core/src/extension_authority.rs index 74d6db6e3..f06ce5c31 100644 --- a/crates/originweave-core/src/extension_authority.rs +++ b/crates/originweave-core/src/extension_authority.rs @@ -45,7 +45,9 @@ pub enum AgentTaskIdError { impl fmt::Display for AgentTaskIdError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidAgentTaskId => formatter.write_str("Agent Task identifier must be nonzero"), + Self::InvalidAgentTaskId => { + formatter.write_str("Agent Task identifier must be nonzero") + } } } } From b45ab7badddea005fd6e93e3aad859abaa2c7c81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:06:29 +0900 Subject: [PATCH 053/112] docs(adr): record active task-bound extension authority --- docs/adr/0013-manifest-v3-extension-authority.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0013-manifest-v3-extension-authority.md b/docs/adr/0013-manifest-v3-extension-authority.md index 8feacbf27..09cfc0954 100644 --- a/docs/adr/0013-manifest-v3-extension-authority.md +++ b/docs/adr/0013-manifest-v3-extension-authority.md @@ -92,7 +92,7 @@ No persistent database migration is introduced. A release can roll back the Chro ## Open follow-ups -- Complete issue #27's compatibility matrix and production isolation acceptance. Exclusive trusted-time expiry on origin-bound `ExtensionAgentGrant` evaluation is the next protected-main candidate; task identity binding remains open. +- Complete issue #27's compatibility matrix and production isolation acceptance. Exact Agent Task identity binding, canonical-origin binding, and exclusive trusted-time expiry are implemented on active PR #40 and remain active-PR evidence until protected integration. - Define managed-extension identity/update semantics. - Implement the native-messaging allow-list/process boundary before claiming support. - Integrate the complete Agent Task browser vertical slice under issue #28. @@ -105,4 +105,4 @@ Supersede this ADR if Chromium adopts a materially different extension authority ## References -Primary browser/extension/protocol evidence and APA 7 references are maintained in [`../doctoring/browser-agent-protocols.md`](../doctoring/browser-agent-protocols.md) and [`../doctoring.md`](../doctoring.md). Related decisions include ADR 0001, ADR 0002, ADR 0007, ADR 0010, ADR 0101, ADR 0104, and ADR 0107. \ No newline at end of file +Primary browser/extension/protocol evidence and APA 7 references are maintained in [`../doctoring/browser-agent-protocols.md`](../doctoring/browser-agent-protocols.md) and [`../doctoring.md`](../doctoring.md). Related decisions include ADR 0001, ADR 0002, ADR 0007, ADR 0010, ADR 0101, ADR 0104, and ADR 0107. From 692cf58075572a48b3946d337b0960bfadaf4320 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:07:14 +0900 Subject: [PATCH 054/112] docs(changelog): record task-bound extension grants --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17419927..5a3ba91e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Bound explicit extension-to-Agent grants to a nonzero host-assigned Agent Task identity, so a grant that otherwise matches extension, session, browsing context, origin, expiry, and capability fails closed when reused by a different task. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. From 1a13007b6cb43cff949b6bd09d69de0a02b327f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:08:30 +0900 Subject: [PATCH 055/112] docs(changelog): restore protocol descriptor entry after stack alignment --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3ba91e2..df2f9e968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. +- Versioned browser-protocol adapter metadata that distinguishes WebDriver BiDi from pinned CDP, binds bounded adapter/browser revision tokens to an explicit duplicate-free capability set, and grants no browser, action, network, or secret authority by protocol kind alone. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. From 1c6a208296c08125bdcc389e00d6a787b06d9289 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 01:06:59 -0700 Subject: [PATCH 056/112] test(browser): keep failed node allocation transactional --- .../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 1c942d481..31bd86a13 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -195,6 +195,30 @@ fn context_origin_cannot_change_without_document_rotation() -> Result<(), Box Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(2); + let session = registry.register_session("webdriver-session")?; + let exhausted_context = registry.register_context(session, "exhaustion-source")?; + let clean_context = registry.register_context(session, "clean-context")?; + let first_origin = loopback_origin()?; + let second_origin = Origin::parse("http://localhost:43127")?; + + registry.bind_node(session, exhausted_context, &first_origin, "node-one")?; + registry.bind_node(session, exhausted_context, &first_origin, "node-two")?; + + assert_eq!( + registry.bind_node(session, clean_context, &first_origin, "node-three"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert_eq!( + registry.bind_node(session, clean_context, &second_origin, "node-three"), + Err(BrowserRegistryError::IdentifierSpaceExhausted), + "a failed allocation must not leave behind origin authority" + ); + Ok(()) +} + #[test] fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result<(), Box> { From 8d51ea33f4394258c474b1a29c2080e9851b7b58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 01:12:04 -0700 Subject: [PATCH 057/112] fix(browser): keep failed node binding transactional --- .../originweave-core/src/browser_registry.rs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 8807c2d36..a93dfee89 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -221,26 +221,26 @@ impl BrowserAuthorityRegistry { actual: browser_session, }); } - match self.context_origin.get(&browsing_context) { + let origin_is_unbound = 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()); - } + Some(_expected_origin) => false, + None => true, + }; + 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 { + take_identifier(&mut self.next_node_id, self.maximum_identifier)? + }; + let handle = observed_node_handle(browser_session, browsing_context, origin, epoch, node_id)?; + if origin_is_unbound { + self.context_origin.insert(browsing_context, origin.clone()); } - 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) - }) + self.node_by_external.entry(key).or_insert(node_id); + Ok(handle) } } From b9e9f3a710d58e9f2b14770da6910b3f82598e24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 01:20:47 -0700 Subject: [PATCH 058/112] style(browser): format transactional node binding --- crates/originweave-core/src/browser_registry.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index a93dfee89..4f4ada9f1 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -235,7 +235,8 @@ impl BrowserAuthorityRegistry { } else { take_identifier(&mut self.next_node_id, self.maximum_identifier)? }; - let handle = observed_node_handle(browser_session, browsing_context, origin, epoch, node_id)?; + let handle = + observed_node_handle(browser_session, browsing_context, origin, epoch, node_id)?; if origin_is_unbound { self.context_origin.insert(browsing_context, origin.clone()); } From 0428a97621a1f95f1c0fc1e9ea3ad6452f37bca8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:36:22 -0700 Subject: [PATCH 059/112] test(browser): cover fail-closed registry invariants --- .../originweave-core/src/browser_registry.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 4f4ada9f1..aa00d8efa 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -394,6 +394,38 @@ mod tests { ); } + #[test] + fn bind_node_fails_closed_on_corrupted_internal_authority_state() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("corrupt-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "corrupt-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 epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + + registry.context_epoch.remove(&context); + assert_eq!( + registry.bind_node(session, context, origin, "missing-epoch"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + registry.context_epoch.insert(context, epoch); + + registry + .node_by_external + .insert((context, epoch, "invalid-node".to_owned()), 0); + assert_eq!( + registry.bind_node(session, context, origin, "invalid-node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + } + #[test] fn monotonic_identifier_exhaustion_is_fail_closed() { let mut next = 1; From 8bf78033014328a9134d293f38348dcece7e0723 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 08:04:28 -0700 Subject: [PATCH 060/112] test(browser): reject forged or retired node handles --- .../tests/browser_authority_registry.rs | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 31bd86a13..95f8918c7 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -2,7 +2,7 @@ use std::error::Error; use originweave_core::{ BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, ObservedNodeHandle, Origin, }; fn loopback_origin() -> Result> { @@ -65,6 +65,10 @@ fn public_default_and_error_contracts_are_usable_from_an_adapter() -> Result<(), BrowserRegistryError::OriginChangedWithoutDocumentAdvance, "browsing context origin changed without advancing the document epoch".to_owned(), ), + ( + BrowserRegistryError::UnknownNodeAuthority, + "observed node handle is not registered as current browser authority".to_owned(), + ), ( BrowserRegistryError::IdentifierSpaceExhausted, "browser authority identifier space is exhausted".to_owned(), @@ -285,3 +289,78 @@ fn unknown_internal_authority_is_rejected_before_node_binding() -> Result<(), Bo ); Ok(()) } + +#[test] +fn registry_revalidates_live_node_authority_and_rejects_forged_or_retired_handles() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let other = registry.register_session("other-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = loopback_origin()?; + let live = registry.bind_node(owner, context, &origin, "backend-node-17")?; + + assert_eq!(registry.validate_node_handle(&live), Ok(())); + + let forged_node = ObservedNodeHandle::new( + owner, + context, + origin.clone(), + live.document_epoch(), + live.node_id() + 1, + )?; + assert_eq!( + registry.validate_node_handle(&forged_node), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let wrong_session = ObservedNodeHandle::new( + other, + context, + origin.clone(), + live.document_epoch(), + live.node_id(), + )?; + assert_eq!( + registry.validate_node_handle(&wrong_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let unbound_context = registry.register_context(owner, "unbound-context")?; + let synthetic_unbound = ObservedNodeHandle::new( + owner, + unbound_context, + origin.clone(), + DocumentEpoch::new(1)?, + 777, + )?; + assert_eq!( + registry.validate_node_handle(&synthetic_unbound), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let next_epoch = registry.advance_document(context)?; + assert_eq!(next_epoch.value(), 2); + assert_eq!( + registry.validate_node_handle(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let replacement = registry.bind_node(owner, context, &origin, "backend-node-17")?; + assert_eq!(registry.validate_node_handle(&replacement), Ok(())); + + registry.remove_context(context)?; + assert_eq!( + registry.validate_node_handle(&replacement), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let session_context = registry.register_context(owner, "session-retirement")?; + let session_handle = registry.bind_node(owner, session_context, &origin, "session-node")?; + registry.remove_session(owner)?; + assert_eq!( + registry.validate_node_handle(&session_handle), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + Ok(()) +} From d5705f8e4d9c3f7ebab89926b8aa5184e5539e6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 08:06:51 -0700 Subject: [PATCH 061/112] fix(browser): revalidate live node authority in registry --- .../originweave-core/src/browser_registry.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index aa00d8efa..4fd654f2c 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -243,6 +243,50 @@ impl BrowserAuthorityRegistry { self.node_by_external.entry(key).or_insert(node_id); Ok(handle) } + + /// Verify that an observed node handle is still live authority in this registry. + /// + /// This check must run immediately before a node-local browser action. It re-derives the + /// current session, context, origin, and document epoch from registry-owned state and also + /// requires the node identifier to remain present in the current document's private external + /// binding table. Caller-supplied or previously retired handles therefore cannot manufacture + /// authority merely by presenting a self-consistent tuple. + pub fn validate_node_handle( + &self, + handle: &ObservedNodeHandle, + ) -> Result<(), BrowserRegistryError> { + if !self.known_sessions.contains(&handle.browser_session()) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let context = handle.browsing_context(); + let expected_session = self + .context_session + .get(&context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != handle.browser_session() { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + let epoch = self.current_epoch(context)?; + let origin = self + .context_origin + .get(&context) + .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; + handle + .validate_current(expected_session, context, origin, epoch) + .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; + let is_bound = self.node_by_external.iter().any( + |((bound_context, bound_epoch, _external_identifier), node_id)| { + *bound_context == context + && *bound_epoch == epoch + && *node_id == handle.node_id() + }, + ); + if !is_bound { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + Ok(()) + } } impl Default for BrowserAuthorityRegistry { @@ -269,6 +313,8 @@ pub enum BrowserRegistryError { }, /// The context origin changed without first rotating the document epoch. OriginChangedWithoutDocumentAdvance, + /// The observed node handle is not a current node binding owned by this registry. + UnknownNodeAuthority, /// The registry exhausted one of its monotonic internal identifier spaces. IdentifierSpaceExhausted, /// A document epoch reached the maximum representable value. @@ -297,6 +343,8 @@ impl fmt::Display for BrowserRegistryError { ), Self::OriginChangedWithoutDocumentAdvance => formatter .write_str("browsing context origin changed without advancing the document epoch"), + Self::UnknownNodeAuthority => formatter + .write_str("observed node handle is not registered as current browser authority"), Self::IdentifierSpaceExhausted => { formatter.write_str("browser authority identifier space is exhausted") } @@ -617,6 +665,7 @@ mod tests { actual: actual_values[0], }, BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + BrowserRegistryError::UnknownNodeAuthority, BrowserRegistryError::IdentifierSpaceExhausted, BrowserRegistryError::DocumentEpochExhausted, BrowserRegistryError::InternalAuthorityInvariant, From 0d12ad507b3a9bbf9765f04f57298eb02c012219 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 08:10:08 -0700 Subject: [PATCH 062/112] style(browser): apply canonical node validation formatting --- crates/originweave-core/src/browser_registry.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 4fd654f2c..b194ddda5 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -277,9 +277,7 @@ impl BrowserAuthorityRegistry { .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; let is_bound = self.node_by_external.iter().any( |((bound_context, bound_epoch, _external_identifier), node_id)| { - *bound_context == context - && *bound_epoch == epoch - && *node_id == handle.node_id() + *bound_context == context && *bound_epoch == epoch && *node_id == handle.node_id() }, ); if !is_bound { From 1248ccf3bda5b08b23709e3317d76a8d354973ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:55:39 +0900 Subject: [PATCH 063/112] test(browser): close registry coverage gaps --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 1 + .../originweave-core/src/browser_registry.rs | 46 ++++++++ .../src/browser_registry_coverage.rs | 109 +++++++++++++++++- .../tests/browser_authority_registry.rs | 14 +++ 5 files changed, 170 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d8d6ee8..f804f7496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: --branch --text --show-missing-lines - | tee missing-lines.txt + > missing-lines.txt - name: Upload exact coverage diagnostics uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3ba91e2..c6cef45f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Bound explicit extension-to-Agent grants to a nonzero host-assigned Agent Task identity, so a grant that otherwise matches extension, session, browsing context, origin, expiry, and capability fails closed when reused by a different task. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. +- Added a bounded browser-protocol authority registry that maps opaque session, browsing-context, and node identifiers to registry-local identities, rotates document epochs, and revalidates live node handles before actions. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index b194ddda5..c0be77766 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -472,6 +472,52 @@ mod tests { ); } + #[test] + fn validation_binding_predicate_checks_each_authority_dimension() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("predicate-session")); + let contexts = values(registry.register_context(sessions[0], "first-context")); + let second_contexts = values(registry.register_context(sessions[0], "second-context")); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(sessions.len(), 1); + assert_eq!(contexts.len(), 1); + assert_eq!(second_contexts.len(), 1); + assert_eq!(origins.len(), 1); + + let first = values(registry.bind_node(sessions[0], contexts[0], &origins[0], "first-node")); + let second = + values(registry.bind_node(sessions[0], second_contexts[0], &origins[0], "second-node")); + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_eq!(registry.validate_node_handle(&second[0]), Ok(())); + + let current_epochs = values(registry.current_epoch(contexts[0])); + let future_epochs = values(DocumentEpoch::new(2)); + assert_eq!(current_epochs.len(), 1); + assert_eq!(future_epochs.len(), 1); + registry.node_by_external.insert( + (contexts[0], future_epochs[0], "synthetic-node".to_owned()), + 9_999, + ); + let forged = values(ObservedNodeHandle::new( + sessions[0], + contexts[0], + origins[0].clone(), + current_epochs[0], + 9_999, + )); + assert_eq!(forged.len(), 1); + assert_eq!( + registry.validate_node_handle(&forged[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + registry.context_epoch.remove(&contexts[0]); + assert_eq!( + registry.validate_node_handle(&forged[0]), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + } + #[test] fn monotonic_identifier_exhaustion_is_fail_closed() { let mut next = 1; diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 1860bc7be..82ac5b6b7 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -1,4 +1,7 @@ -use crate::{BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, Origin}; +use crate::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, + ObservedNodeHandle, Origin, +}; fn values(result: Result) -> Vec { result.into_iter().collect() @@ -10,6 +13,8 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { let sessions = values(registry.register_session("unit-session")); assert_eq!(sessions.len(), 1); let session = sessions[0]; + let repeated_sessions = values(registry.register_session("unit-session")); + assert_eq!(repeated_sessions, sessions); let contexts = values(registry.register_context(session, "unit-context")); assert_eq!(contexts.len(), 1); @@ -24,6 +29,108 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { assert_eq!(first.len(), 1); assert_eq!(repeated.len(), 1); assert_eq!(first[0], repeated[0]); + assert_eq!(registry.validate_node_handle(&first[0]), Ok(())); + + let epochs = values(DocumentEpoch::new(1)); + assert_eq!(epochs.len(), 1); + let forged = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epochs[0], + first[0].node_id() + 1, + )); + assert_eq!(forged.len(), 1); + assert_eq!( + registry.validate_node_handle(&forged[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let mismatched_origins = values(Origin::parse("http://localhost:43127")); + assert_eq!(mismatched_origins.len(), 1); + let mismatched = values(ObservedNodeHandle::new( + session, + context, + mismatched_origins[0].clone(), + epochs[0], + first[0].node_id(), + )); + assert_eq!(mismatched.len(), 1); + assert_eq!( + registry.validate_node_handle(&mismatched[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); +} + +#[test] +fn node_validation_rejects_each_missing_authority_boundary() { + let mut registry = BrowserAuthorityRegistry::new(); + assert_eq!( + registry.register_session(""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + let known_sessions = values(registry.register_session("validation-session")); + let attacker_sessions = values(registry.register_session("validation-attacker")); + assert_eq!(known_sessions.len(), 1); + assert_eq!(attacker_sessions.len(), 1); + let known = known_sessions[0]; + let attacker = attacker_sessions[0]; + let contexts = values(registry.register_context(known, "validation-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].clone(); + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + + let unknown_sessions = values(BrowserSessionId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + let unknown_handle = values(ObservedNodeHandle::new( + unknown_sessions[0], + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(unknown_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&unknown_handle[0]), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let mismatched_handle = values(ObservedNodeHandle::new( + attacker, + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(mismatched_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&mismatched_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let unbound_handle = values(ObservedNodeHandle::new( + known, + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(unbound_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&unbound_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.validate_node_handle(&unbound_handle[0]), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); } #[test] diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 95f8918c7..7a53e62c2 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -165,6 +165,20 @@ fn retired_context_and_session_authority_cannot_be_reused() -> Result<(), Box Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + + registry.remove_context(context)?; + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + Ok(()) +} + #[test] fn context_cannot_be_reused_by_another_session() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); From 1d9e7c5c936a1e149f188b663bcf5e0f365844fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 19:40:36 -0700 Subject: [PATCH 064/112] test(browser): reject cross-registry node-handle reuse --- .../tests/browser_registry_cross_instance.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 crates/originweave-core/tests/browser_registry_cross_instance.rs diff --git a/crates/originweave-core/tests/browser_registry_cross_instance.rs b/crates/originweave-core/tests/browser_registry_cross_instance.rs new file mode 100644 index 000000000..41afd37f3 --- /dev/null +++ b/crates/originweave-core/tests/browser_registry_cross_instance.rs @@ -0,0 +1,31 @@ +use std::error::Error; + +use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, Origin}; + +#[test] +fn node_handles_cannot_cross_registry_instances_when_numeric_ids_collide() +-> Result<(), Box> { + let origin = Origin::parse("http://127.0.0.1:43127")?; + + let mut first_registry = BrowserAuthorityRegistry::new(); + let first_session = first_registry.register_session("first-session")?; + let first_context = first_registry.register_context(first_session, "first-context")?; + let first_handle = + first_registry.bind_node(first_session, first_context, &origin, "first-node")?; + + let mut second_registry = BrowserAuthorityRegistry::new(); + let second_session = second_registry.register_session("second-session")?; + let second_context = second_registry.register_context(second_session, "second-context")?; + let second_handle = + second_registry.bind_node(second_session, second_context, &origin, "second-node")?; + + assert_eq!(first_session, second_session); + assert_eq!(first_context, second_context); + assert_eq!(first_handle.node_id(), second_handle.node_id()); + assert_eq!( + second_registry.validate_node_handle(&first_handle), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!(second_registry.validate_node_handle(&second_handle), Ok(())); + Ok(()) +} From 1fa6fec84595328cce7e2476ba33f83a5d301de6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:30:52 -0700 Subject: [PATCH 065/112] test(browser): reject forged matching registry handles --- .../tests/browser_registry_cross_instance.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_registry_cross_instance.rs b/crates/originweave-core/tests/browser_registry_cross_instance.rs index 41afd37f3..01eca2a55 100644 --- a/crates/originweave-core/tests/browser_registry_cross_instance.rs +++ b/crates/originweave-core/tests/browser_registry_cross_instance.rs @@ -1,6 +1,8 @@ use std::error::Error; -use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, Origin}; +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin, +}; #[test] fn node_handles_cannot_cross_registry_instances_when_numeric_ids_collide() @@ -18,14 +20,26 @@ fn node_handles_cannot_cross_registry_instances_when_numeric_ids_collide() let second_context = second_registry.register_context(second_session, "second-context")?; let second_handle = second_registry.bind_node(second_session, second_context, &origin, "second-node")?; + let forged_matching = ObservedNodeHandle::new( + second_session, + second_context, + origin.clone(), + second_handle.document_epoch(), + second_handle.node_id(), + )?; assert_eq!(first_session, second_session); assert_eq!(first_context, second_context); assert_eq!(first_handle.node_id(), second_handle.node_id()); + assert_ne!(first_handle, second_handle); assert_eq!( second_registry.validate_node_handle(&first_handle), Err(BrowserRegistryError::UnknownNodeAuthority) ); + assert_eq!( + second_registry.validate_node_handle(&forged_matching), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); assert_eq!(second_registry.validate_node_handle(&second_handle), Ok(())); Ok(()) } From 4a828ed578a0cb8fbc424e5aa03a79673960ce25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:34:51 -0700 Subject: [PATCH 066/112] fix(browser): bind node handles to registry instances --- .../originweave-core/src/browser_registry.rs | 531 +++++++++--------- crates/originweave-core/src/lib.rs | 5 +- 2 files changed, 255 insertions(+), 281 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index c0be77766..d07aee149 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -1,7 +1,11 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; +use std::sync::Arc; -use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin}; +use crate::contracts::ObservedNodeHandle as NodeTuple; +use crate::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, 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; @@ -9,12 +13,139 @@ 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 node observation that can carry registry-local issuance authority. +/// +/// [`ObservedNodeHandle::new`] creates a structurally valid but unregistered observation. Such a +/// value is useful for parsing and fail-closed validation but cannot become live browser authority +/// merely by reproducing session, context, origin, epoch, and node identifiers. Handles returned +/// by [`BrowserAuthorityRegistry::bind_node`] additionally carry an unforgeable in-process +/// registry-instance token. That token is never serialized or exposed through the public API. +#[derive(Debug, Clone)] +pub struct ObservedNodeHandle { + observed: NodeTuple, + registry_authority: Option>, +} + +impl ObservedNodeHandle { + /// Create one structurally valid, unregistered observed node handle. + /// + /// Directly constructed handles deliberately carry no registry issuance authority and are + /// rejected by [`BrowserAuthorityRegistry::validate_node_handle`]. + pub fn new( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, + ) -> Result { + NodeTuple::new( + browser_session, + browsing_context, + origin, + document_epoch, + node_id, + ) + .map(|observed| Self { + observed, + registry_authority: None, + }) + } + + fn registered( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, + registry_authority: Arc<()>, + ) -> Result { + NodeTuple::new( + browser_session, + browsing_context, + origin, + document_epoch, + node_id, + ) + .map(|observed| Self { + observed, + registry_authority: Some(registry_authority), + }) + } + + /// Return the browser session that produced the node observation. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.observed.browser_session() + } + + /// Return the browsing context that produced the node observation. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.observed.browsing_context() + } + + /// Return the canonical origin that produced the node observation. + #[must_use] + pub const fn origin(&self) -> &Origin { + self.observed.origin() + } + + /// Return the document epoch that produced the node observation. + #[must_use] + pub const fn document_epoch(&self) -> DocumentEpoch { + self.observed.document_epoch() + } + + /// Return the registry-local nonzero node identifier. + #[must_use] + pub const fn node_id(&self) -> u64 { + self.observed.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> { + self.observed.validate_current( + current_session, + current_context, + current_origin, + current_epoch, + ) + } + + fn belongs_to(&self, registry_authority: &Arc<()>) -> bool { + self.registry_authority + .as_ref() + .is_some_and(|authority| Arc::ptr_eq(authority, registry_authority)) + } +} + +impl PartialEq for ObservedNodeHandle { + fn eq(&self, other: &Self) -> bool { + if self.observed != other.observed { + return false; + } + match (&self.registry_authority, &other.registry_authority) { + (Some(left), Some(right)) => Arc::ptr_eq(left, right), + (None, None) => true, + _ => false, + } + } +} + +impl Eq for ObservedNodeHandle {} + /// 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. +/// context, document epoch, canonical origin, and registry-instance issuance token. pub struct BrowserAuthorityRegistry { session_by_external: BTreeMap, known_sessions: BTreeSet, @@ -23,6 +154,8 @@ pub struct BrowserAuthorityRegistry { context_epoch: BTreeMap, context_origin: BTreeMap, node_by_external: BTreeMap<(BrowsingContextId, DocumentEpoch, String), u64>, + node_binding_by_id: BTreeMap, + registry_authority: Arc<()>, maximum_identifier: u64, next_session_id: u64, next_context_id: u64, @@ -53,6 +186,8 @@ impl BrowserAuthorityRegistry { context_epoch: BTreeMap::new(), context_origin: BTreeMap::new(), node_by_external: BTreeMap::new(), + node_binding_by_id: BTreeMap::new(), + registry_authority: Arc::new(()), maximum_identifier, next_session_id: 1, next_context_id: 1, @@ -126,6 +261,8 @@ impl BrowserAuthorityRegistry { self.context_origin.remove(&browsing_context); self.node_by_external .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| *context != browsing_context); Ok(()) } @@ -156,6 +293,8 @@ impl BrowserAuthorityRegistry { .retain(|context, _origin| live_contexts.contains_key(context)); self.node_by_external .retain(|(context, _epoch, _external), _node_id| live_contexts.contains_key(context)); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| live_contexts.contains_key(context)); Ok(()) } @@ -191,6 +330,8 @@ impl BrowserAuthorityRegistry { self.context_origin.remove(&browsing_context); self.node_by_external .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| *context != browsing_context); }) } @@ -230,27 +371,45 @@ impl BrowserAuthorityRegistry { }; 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 { - take_identifier(&mut self.next_node_id, self.maximum_identifier)? + let existing = self.node_by_external.get(&key).copied(); + let node_id = match existing { + Some(node_id) => node_id, + None => take_identifier(&mut self.next_node_id, self.maximum_identifier)?, }; - let handle = - observed_node_handle(browser_session, browsing_context, origin, epoch, node_id)?; + if let Some(binding) = self.node_binding_by_id.get(&node_id) { + if *binding != (browsing_context, epoch) { + return Err(BrowserRegistryError::InternalAuthorityInvariant); + } + } else if existing.is_some() { + return Err(BrowserRegistryError::InternalAuthorityInvariant); + } + + let handle = registered_node_handle( + browser_session, + browsing_context, + origin, + epoch, + node_id, + Arc::clone(&self.registry_authority), + )?; if origin_is_unbound { self.context_origin.insert(browsing_context, origin.clone()); } - self.node_by_external.entry(key).or_insert(node_id); + if existing.is_none() { + self.node_by_external.insert(key, node_id); + self.node_binding_by_id + .insert(node_id, (browsing_context, epoch)); + } Ok(handle) } /// Verify that an observed node handle is still live authority in this registry. /// /// This check must run immediately before a node-local browser action. It re-derives the - /// current session, context, origin, and document epoch from registry-owned state and also - /// requires the node identifier to remain present in the current document's private external - /// binding table. Caller-supplied or previously retired handles therefore cannot manufacture - /// authority merely by presenting a self-consistent tuple. + /// current session, context, origin, and document epoch from registry-owned state, requires the + /// handle to have been issued by this exact registry instance, and resolves the node through a + /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, + /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. pub fn validate_node_handle( &self, handle: &ObservedNodeHandle, @@ -275,12 +434,10 @@ impl BrowserAuthorityRegistry { handle .validate_current(expected_session, context, origin, epoch) .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; - let is_bound = self.node_by_external.iter().any( - |((bound_context, bound_epoch, _external_identifier), node_id)| { - *bound_context == context && *bound_epoch == epoch && *node_id == handle.node_id() - }, - ); - if !is_bound { + if !handle.belongs_to(&self.registry_authority) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { return Err(BrowserRegistryError::UnknownNodeAuthority); } Ok(()) @@ -317,16 +474,17 @@ pub enum BrowserRegistryError { IdentifierSpaceExhausted, /// A document epoch reached the maximum representable value. DocumentEpochExhausted, - /// An internal nonzero authority invariant was violated. + /// A private registry consistency 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::InvalidExternalIdentifier => write!( + formatter, + "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" + ), Self::UnknownBrowserSession => { formatter.write_str("browser session is not registered in this authority registry") } @@ -386,19 +544,21 @@ fn document_epoch(value: u64) -> Result { DocumentEpoch::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } -fn observed_node_handle( +fn registered_node_handle( browser_session: BrowserSessionId, browsing_context: BrowsingContextId, origin: &Origin, document_epoch: DocumentEpoch, node_id: u64, + registry_authority: Arc<()>, ) -> Result { - ObservedNodeHandle::new( + ObservedNodeHandle::registered( browser_session, browsing_context, origin.clone(), document_epoch, node_id, + registry_authority, ) .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } @@ -412,7 +572,35 @@ mod tests { } #[test] - fn helper_invariants_fail_closed() { + fn unregistered_handle_equality_covers_all_authority_states() { + let session = BrowserSessionId::new(1).expect("nonzero session"); + let context = BrowsingContextId::new(1).expect("nonzero context"); + let epoch = DocumentEpoch::new(1).expect("nonzero epoch"); + let origin = Origin::parse("http://127.0.0.1:43127").expect("valid loopback origin"); + let first = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1) + .expect("valid handle"); + let same = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1) + .expect("valid handle"); + let different = ObservedNodeHandle::new(session, context, origin, epoch, 2) + .expect("valid handle"); + assert_eq!(first, same); + assert_ne!(first, different); + + let authority = Arc::new(()); + let registered = ObservedNodeHandle::registered( + session, + context, + Origin::parse("http://127.0.0.1:43127").expect("valid loopback origin"), + epoch, + 1, + authority, + ) + .expect("valid registered handle"); + assert_ne!(first, registered); + } + + #[test] + fn helper_invariants_and_reverse_index_corruption_fail_closed() { assert_eq!( browser_session_id(0), Err(BrowserRegistryError::InternalAuthorityInvariant) @@ -434,88 +622,59 @@ mod tests { assert_eq!(contexts.len(), 1); assert_eq!(epochs.len(), 1); assert_eq!(origins.len(), 1); - assert_eq!( - observed_node_handle(sessions[0], contexts[0], &origins[0], epochs[0], 0), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); - } + assert!(registered_node_handle( + sessions[0], + contexts[0], + &origins[0], + epochs[0], + 0, + Arc::new(()) + ) + .is_err()); - #[test] - fn bind_node_fails_closed_on_corrupted_internal_authority_state() { let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("corrupt-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "corrupt-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 session = registry.register_session("corrupt-session").expect("session"); + let context = registry.register_context(session, "corrupt-context").expect("context"); let origin = &origins[0]; - let epochs = values(registry.current_epoch(context)); - assert_eq!(epochs.len(), 1); - let epoch = epochs[0]; - - registry.context_epoch.remove(&context); + let handle = registry + .bind_node(session, context, origin, "node") + .expect("registered node"); + registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( - registry.bind_node(session, context, origin, "missing-epoch"), - Err(BrowserRegistryError::UnknownBrowsingContext) + registry.bind_node(session, context, origin, "node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) ); - registry.context_epoch.insert(context, epoch); + registry + .node_binding_by_id + .insert(handle.node_id(), (context, epochs[0])); + registry.node_by_external.clear(); + let other_context = registry + .register_context(session, "other-context") + .expect("other context"); registry .node_by_external - .insert((context, epoch, "invalid-node".to_owned()), 0); + .insert((other_context, epochs[0], "other-node".to_owned()), handle.node_id()); assert_eq!( - registry.bind_node(session, context, origin, "invalid-node"), + registry.bind_node(session, other_context, origin, "other-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); } #[test] - fn validation_binding_predicate_checks_each_authority_dimension() { + fn validation_reverse_index_rejects_missing_binding() { let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("predicate-session")); - let contexts = values(registry.register_context(sessions[0], "first-context")); - let second_contexts = values(registry.register_context(sessions[0], "second-context")); - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(sessions.len(), 1); - assert_eq!(contexts.len(), 1); - assert_eq!(second_contexts.len(), 1); - assert_eq!(origins.len(), 1); - - let first = values(registry.bind_node(sessions[0], contexts[0], &origins[0], "first-node")); - let second = - values(registry.bind_node(sessions[0], second_contexts[0], &origins[0], "second-node")); - assert_eq!(first.len(), 1); - assert_eq!(second.len(), 1); - assert_eq!(registry.validate_node_handle(&second[0]), Ok(())); - - let current_epochs = values(registry.current_epoch(contexts[0])); - let future_epochs = values(DocumentEpoch::new(2)); - assert_eq!(current_epochs.len(), 1); - assert_eq!(future_epochs.len(), 1); - registry.node_by_external.insert( - (contexts[0], future_epochs[0], "synthetic-node".to_owned()), - 9_999, - ); - let forged = values(ObservedNodeHandle::new( - sessions[0], - contexts[0], - origins[0].clone(), - current_epochs[0], - 9_999, - )); - assert_eq!(forged.len(), 1); + let session = registry.register_session("session").expect("session"); + let context = registry.register_context(session, "context").expect("context"); + let origin = Origin::parse("http://127.0.0.1:43127").expect("origin"); + let handle = registry + .bind_node(session, context, &origin, "node") + .expect("node"); + registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( - registry.validate_node_handle(&forged[0]), + registry.validate_node_handle(&handle), Err(BrowserRegistryError::UnknownNodeAuthority) ); - registry.context_epoch.remove(&contexts[0]); - assert_eq!( - registry.validate_node_handle(&forged[0]), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); } #[test] @@ -530,194 +689,8 @@ mod tests { } #[test] - fn registry_reports_all_resource_and_authority_failures() { - 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 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), - 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) - ); - - let sessions = values(registry.register_session("session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "context-a")); - assert_eq!(contexts.len(), 1); - let context = contexts[0]; - - 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) - ); - registry.context_epoch.insert(context, initial_epoch); - - 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_sessions[0], context, origin, "node"), - Err(BrowserRegistryError::UnknownBrowserSession) - ); - assert_eq!( - registry.bind_node(session, unknown_contexts[0], origin, "node"), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - } - - #[test] - fn origin_rotation_and_node_cleanup_are_explicit() { - let mut registry = BrowserAuthorityRegistry::new(); - 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 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") - .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 sessions = values(registry.register_session("session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - assert_eq!( - 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); - assert_eq!(origins.len(), 1); - assert_eq!( - 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] - fn browser_registry_errors_have_non_sensitive_deterministic_text() { - 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: expected_values[0], - actual: actual_values[0], - }, - BrowserRegistryError::OriginChangedWithoutDocumentAdvance, - BrowserRegistryError::UnknownNodeAuthority, - BrowserRegistryError::IdentifierSpaceExhausted, - BrowserRegistryError::DocumentEpochExhausted, - BrowserRegistryError::InternalAuthorityInvariant, - ]; - for error in errors { - let text = error.to_string(); - assert!(!text.is_empty()); - assert!(!text.contains("webdriver-session")); - } + fn maximum_identifier_limit_is_clamped_without_wrapping() { + let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); + assert_eq!(registry.maximum_identifier, u64::MAX - 1); } } diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b8c43c09d..4c95f2dd0 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -16,13 +16,14 @@ mod extension_authority; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + ObservedNodeHandle, }; pub use contracts::{ ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, - NodeHandleError, ObservedNodeHandle, Origin, OriginError, PolicyContext, RiskClass, - RobotsDecision, SecretDelivery, SessionMode, + NodeHandleError, Origin, OriginError, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, + SessionMode, }; pub use extension_authority::{ AgentTaskId, AgentTaskIdError, ExtensionAccessDecision, ExtensionAccessRequest, From 2dbcb03d2df32f7b0d9a59bcbf7cbe5cf56e5d0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:37:52 -0700 Subject: [PATCH 067/112] test(browser): cover registry authority invariants --- .../originweave-core/src/browser_registry.rs | 89 +++++++++---------- 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index d07aee149..d052d970e 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -3,9 +3,7 @@ use std::fmt; use std::sync::Arc; use crate::contracts::ObservedNodeHandle as NodeTuple; -use crate::{ - BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, Origin, -}; +use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, 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; @@ -565,6 +563,8 @@ fn registered_node_handle( #[cfg(test)] mod tests { + use std::error::Error; + use super::*; fn values(result: Result) -> Vec { @@ -572,35 +572,31 @@ mod tests { } #[test] - fn unregistered_handle_equality_covers_all_authority_states() { - let session = BrowserSessionId::new(1).expect("nonzero session"); - let context = BrowsingContextId::new(1).expect("nonzero context"); - let epoch = DocumentEpoch::new(1).expect("nonzero epoch"); - let origin = Origin::parse("http://127.0.0.1:43127").expect("valid loopback origin"); - let first = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1) - .expect("valid handle"); - let same = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1) - .expect("valid handle"); - let different = ObservedNodeHandle::new(session, context, origin, epoch, 2) - .expect("valid handle"); + fn unregistered_handle_equality_covers_all_authority_states() -> Result<(), Box> { + let session = BrowserSessionId::new(1)?; + let context = BrowsingContextId::new(1)?; + let epoch = DocumentEpoch::new(1)?; + let origin = Origin::parse("http://127.0.0.1:43127")?; + let first = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1)?; + let same = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1)?; + let different = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 2)?; assert_eq!(first, same); assert_ne!(first, different); - let authority = Arc::new(()); let registered = ObservedNodeHandle::registered( session, context, - Origin::parse("http://127.0.0.1:43127").expect("valid loopback origin"), + origin, epoch, 1, - authority, - ) - .expect("valid registered handle"); + Arc::new(()), + )?; assert_ne!(first, registered); + Ok(()) } #[test] - fn helper_invariants_and_reverse_index_corruption_fail_closed() { + fn helper_invariants_and_reverse_index_corruption_fail_closed() -> Result<(), Box> { assert_eq!( browser_session_id(0), Err(BrowserRegistryError::InternalAuthorityInvariant) @@ -622,23 +618,23 @@ mod tests { assert_eq!(contexts.len(), 1); assert_eq!(epochs.len(), 1); assert_eq!(origins.len(), 1); - assert!(registered_node_handle( - sessions[0], - contexts[0], - &origins[0], - epochs[0], - 0, - Arc::new(()) - ) - .is_err()); + assert!( + registered_node_handle( + sessions[0], + contexts[0], + &origins[0], + epochs[0], + 0, + Arc::new(()) + ) + .is_err() + ); let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("corrupt-session").expect("session"); - let context = registry.register_context(session, "corrupt-context").expect("context"); + let session = registry.register_session("corrupt-session")?; + let context = registry.register_context(session, "corrupt-context")?; let origin = &origins[0]; - let handle = registry - .bind_node(session, context, origin, "node") - .expect("registered node"); + let handle = registry.bind_node(session, context, origin, "node")?; registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( registry.bind_node(session, context, origin, "node"), @@ -649,32 +645,31 @@ mod tests { .node_binding_by_id .insert(handle.node_id(), (context, epochs[0])); registry.node_by_external.clear(); - let other_context = registry - .register_context(session, "other-context") - .expect("other context"); - registry - .node_by_external - .insert((other_context, epochs[0], "other-node".to_owned()), handle.node_id()); + let other_context = registry.register_context(session, "other-context")?; + registry.node_by_external.insert( + (other_context, epochs[0], "other-node".to_owned()), + handle.node_id(), + ); assert_eq!( registry.bind_node(session, other_context, origin, "other-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); + Ok(()) } #[test] - fn validation_reverse_index_rejects_missing_binding() { + fn validation_reverse_index_rejects_missing_binding() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("session").expect("session"); - let context = registry.register_context(session, "context").expect("context"); - let origin = Origin::parse("http://127.0.0.1:43127").expect("origin"); - let handle = registry - .bind_node(session, context, &origin, "node") - .expect("node"); + let session = registry.register_session("session")?; + let context = registry.register_context(session, "context")?; + let origin = Origin::parse("http://127.0.0.1:43127")?; + let handle = registry.bind_node(session, context, &origin, "node")?; registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( registry.validate_node_handle(&handle), Err(BrowserRegistryError::UnknownNodeAuthority) ); + Ok(()) } #[test] From 76651a50ec8af493ffa04f872c4a71db094f0904 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:41:06 -0700 Subject: [PATCH 068/112] style(browser): apply canonical Rust formatting --- crates/originweave-core/src/browser_registry.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index d052d970e..379d2a042 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -583,14 +583,8 @@ mod tests { assert_eq!(first, same); assert_ne!(first, different); - let registered = ObservedNodeHandle::registered( - session, - context, - origin, - epoch, - 1, - Arc::new(()), - )?; + let registered = + ObservedNodeHandle::registered(session, context, origin, epoch, 1, Arc::new(()))?; assert_ne!(first, registered); Ok(()) } From 724305f50e74ead751e43ef7d2012d27c788b0fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:37:54 -0700 Subject: [PATCH 069/112] test(browser): cover corrupt zero node authority --- crates/originweave-core/src/browser_registry.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 379d2a042..0953bad01 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -648,6 +648,16 @@ mod tests { registry.bind_node(session, other_context, origin, "other-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); + + let zero_epoch = registry.current_epoch(context)?; + registry + .node_by_external + .insert((context, zero_epoch, "zero-node".to_owned()), 0); + registry.node_binding_by_id.insert(0, (context, zero_epoch)); + assert_eq!( + registry.bind_node(session, context, origin, "zero-node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); Ok(()) } From 94de6c2b1b422e0f37bf68e74db2051bc47c2d35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:11:14 -0700 Subject: [PATCH 070/112] test(browser): require same-document node retirement --- .../originweave-core/tests/node_retirement.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 crates/originweave-core/tests/node_retirement.rs diff --git a/crates/originweave-core/tests/node_retirement.rs b/crates/originweave-core/tests/node_retirement.rs new file mode 100644 index 000000000..bbbae2fe0 --- /dev/null +++ b/crates/originweave-core/tests/node_retirement.rs @@ -0,0 +1,37 @@ +use std::error::Error; + +use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin}; + +#[test] +fn same_document_node_retirement_revokes_authority_without_reusing_identity() +-> 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 = Origin::parse("http://127.0.0.1:43127")?; + let live = registry.bind_node(session, context, &origin, "backend-node-17")?; + + let different_observation = ObservedNodeHandle::new( + session, + context, + origin.clone(), + live.document_epoch(), + live.node_id() + 1, + )?; + assert_ne!(live, different_observation); + + registry.remove_node(&live)?; + assert_eq!( + registry.validate_node_handle(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!( + registry.remove_node(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let rebound = registry.bind_node(session, context, &origin, "backend-node-17")?; + assert_ne!(live.node_id(), rebound.node_id()); + assert_eq!(registry.validate_node_handle(&rebound), Ok(())); + Ok(()) +} From 2c08bca97afe7d5d26a840f629e43d28b7d2b74e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:12:35 -0700 Subject: [PATCH 071/112] style(browser): apply canonical rustfmt to node retirement regression --- crates/originweave-core/tests/node_retirement.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/node_retirement.rs b/crates/originweave-core/tests/node_retirement.rs index bbbae2fe0..56c4d2132 100644 --- a/crates/originweave-core/tests/node_retirement.rs +++ b/crates/originweave-core/tests/node_retirement.rs @@ -1,6 +1,8 @@ use std::error::Error; -use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin}; +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin, +}; #[test] fn same_document_node_retirement_revokes_authority_without_reusing_identity() From 8587c8f1bff83abceeab9fde90c5f39bc64e6261 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:15:12 -0700 Subject: [PATCH 072/112] feat(browser): revoke same-document node authority --- .../originweave-core/src/browser_registry.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 0953bad01..2aa431561 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -401,6 +401,29 @@ impl BrowserAuthorityRegistry { Ok(handle) } + /// Retire one exact live node handle without advancing the document epoch. + /// + /// This revokes only registry-local node authority. It is intended for relevant same-document + /// mutations that invalidate one actionable node while leaving the surrounding browsing + /// context and document epoch current. Retirement does not claim that Chromium destroyed the + /// underlying DOM/backend node, and the monotonic node identifier is never reused. + pub fn remove_node( + &mut self, + handle: &ObservedNodeHandle, + ) -> Result<(), BrowserRegistryError> { + self.validate_node_handle(handle)?; + let node_id = handle.node_id(); + let context = handle.browsing_context(); + let epoch = handle.document_epoch(); + self.node_binding_by_id.remove(&node_id); + self.node_by_external.retain( + |(binding_context, binding_epoch, _external), bound_node_id| { + *binding_context != context || *binding_epoch != epoch || *bound_node_id != node_id + }, + ); + Ok(()) + } + /// Verify that an observed node handle is still live authority in this registry. /// /// This check must run immediately before a node-local browser action. It re-derives the From 77df987bbbcb1a9a80190a057706747d0be0e2dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:13:50 -0700 Subject: [PATCH 073/112] test(core): cover node retirement and epoch exhaustion --- .../originweave-core/src/browser_registry.rs | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 2aa431561..8ca09b23d 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -407,10 +407,7 @@ impl BrowserAuthorityRegistry { /// mutations that invalidate one actionable node while leaving the surrounding browsing /// context and document epoch current. Retirement does not claim that Chromium destroyed the /// underlying DOM/backend node, and the monotonic node identifier is never reused. - pub fn remove_node( - &mut self, - handle: &ObservedNodeHandle, - ) -> Result<(), BrowserRegistryError> { + pub fn remove_node(&mut self, handle: &ObservedNodeHandle) -> Result<(), BrowserRegistryError> { self.validate_node_handle(handle)?; let node_id = handle.node_id(); let context = handle.browsing_context(); @@ -699,6 +696,50 @@ mod tests { Ok(()) } + #[test] + fn node_retirement_preserves_unrelated_private_bindings() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("retirement-session")?; + let context = registry.register_context(session, "retirement-context")?; + let other_context = registry.register_context(session, "other-retirement-context")?; + let origin = Origin::parse("http://127.0.0.1:43127")?; + let target = registry.bind_node(session, context, &origin, "target-node")?; + let sibling = registry.bind_node(session, context, &origin, "sibling-node")?; + let other = registry.bind_node(session, other_context, &origin, "other-node")?; + + let future_epoch = DocumentEpoch::new(target.document_epoch().value() + 1)?; + let future_key = (context, future_epoch, "future-sibling-alias".to_owned()); + registry + .node_by_external + .insert(future_key.clone(), sibling.node_id()); + + registry.remove_node(&target)?; + + assert_eq!(registry.validate_node_handle(&sibling), Ok(())); + assert_eq!(registry.validate_node_handle(&other), Ok(())); + assert_eq!( + registry.node_by_external.get(&future_key), + Some(&sibling.node_id()) + ); + Ok(()) + } + + #[test] + fn document_epoch_exhaustion_is_fail_closed() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("epoch-session")?; + let context = registry.register_context(session, "epoch-context")?; + registry + .context_epoch + .insert(context, DocumentEpoch::new(u64::MAX)?); + + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::DocumentEpochExhausted) + ); + Ok(()) + } + #[test] fn monotonic_identifier_exhaustion_is_fail_closed() { let mut next = 1; From e8c105c77a053b451ddf46c7feb3e7fcb1827c81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:21:17 -0700 Subject: [PATCH 074/112] fix(core): purge duplicate node aliases on retirement --- .../originweave-core/src/browser_registry.rs | 244 +++++++++++++----- 1 file changed, 178 insertions(+), 66 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 8ca09b23d..4f2c2d37a 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -405,19 +405,17 @@ impl BrowserAuthorityRegistry { /// /// This revokes only registry-local node authority. It is intended for relevant same-document /// mutations that invalidate one actionable node while leaving the surrounding browsing - /// context and document epoch current. Retirement does not claim that Chromium destroyed the - /// underlying DOM/backend node, and the monotonic node identifier is never reused. + /// context and document epoch current. The node identifier is globally unique inside one + /// registry, so retirement purges every external alias that refers to that identifier; this + /// also fails safe if private lookup state was duplicated or corrupted. Retirement does not + /// claim that Chromium destroyed the underlying DOM/backend node, and the monotonic node + /// identifier is never reused. pub fn remove_node(&mut self, handle: &ObservedNodeHandle) -> Result<(), BrowserRegistryError> { self.validate_node_handle(handle)?; let node_id = handle.node_id(); - let context = handle.browsing_context(); - let epoch = handle.document_epoch(); self.node_binding_by_id.remove(&node_id); - self.node_by_external.retain( - |(binding_context, binding_epoch, _external), bound_node_id| { - *binding_context != context || *binding_epoch != epoch || *bound_node_id != node_id - }, - ); + self.node_by_external + .retain(|_key, bound_node_id| *bound_node_id != node_id); Ok(()) } @@ -583,8 +581,6 @@ fn registered_node_handle( #[cfg(test)] mod tests { - use std::error::Error; - use super::*; fn values(result: Result) -> Vec { @@ -592,25 +588,61 @@ mod tests { } #[test] - fn unregistered_handle_equality_covers_all_authority_states() -> Result<(), Box> { - let session = BrowserSessionId::new(1)?; - let context = BrowsingContextId::new(1)?; - let epoch = DocumentEpoch::new(1)?; - let origin = Origin::parse("http://127.0.0.1:43127")?; - let first = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1)?; - let same = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1)?; - let different = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 2)?; - assert_eq!(first, same); - assert_ne!(first, different); - - let registered = - ObservedNodeHandle::registered(session, context, origin, epoch, 1, Arc::new(()))?; - assert_ne!(first, registered); - Ok(()) + fn unregistered_handle_equality_covers_all_authority_states() { + 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); + let session = sessions[0]; + let context = contexts[0]; + let epoch = epochs[0]; + let origin = origins[0].clone(); + + let first = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let different = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 2, + )); + assert_eq!(first.len(), 1); + assert_eq!(same.len(), 1); + assert_eq!(different.len(), 1); + assert_eq!(first[0], same[0]); + assert_ne!(first[0], different[0]); + + let registered = values(ObservedNodeHandle::registered( + session, + context, + origin, + epoch, + 1, + Arc::new(()), + )); + assert_eq!(registered.len(), 1); + assert_ne!(first[0], registered[0]); } #[test] - fn helper_invariants_and_reverse_index_corruption_fail_closed() -> Result<(), Box> { + fn helper_invariants_and_reverse_index_corruption_fail_closed() { assert_eq!( browser_session_id(0), Err(BrowserRegistryError::InternalAuthorityInvariant) @@ -645,10 +677,16 @@ mod tests { ); let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("corrupt-session")?; - let context = registry.register_context(session, "corrupt-context")?; + let registered_sessions = values(registry.register_session("corrupt-session")); + assert_eq!(registered_sessions.len(), 1); + let session = registered_sessions[0]; + let registered_contexts = values(registry.register_context(session, "corrupt-context")); + assert_eq!(registered_contexts.len(), 1); + let context = registered_contexts[0]; let origin = &origins[0]; - let handle = registry.bind_node(session, context, origin, "node")?; + let handles = values(registry.bind_node(session, context, origin, "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( registry.bind_node(session, context, origin, "node"), @@ -659,7 +697,9 @@ mod tests { .node_binding_by_id .insert(handle.node_id(), (context, epochs[0])); registry.node_by_external.clear(); - let other_context = registry.register_context(session, "other-context")?; + let other_contexts = values(registry.register_context(session, "other-context")); + assert_eq!(other_contexts.len(), 1); + let other_context = other_contexts[0]; registry.node_by_external.insert( (other_context, epochs[0], "other-node".to_owned()), handle.node_id(), @@ -669,7 +709,9 @@ mod tests { Err(BrowserRegistryError::InternalAuthorityInvariant) ); - let zero_epoch = registry.current_epoch(context)?; + let zero_epochs = values(registry.current_epoch(context)); + assert_eq!(zero_epochs.len(), 1); + let zero_epoch = zero_epochs[0]; registry .node_by_external .insert((context, zero_epoch, "zero-node".to_owned()), 0); @@ -678,66 +720,136 @@ mod tests { registry.bind_node(session, context, origin, "zero-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); - Ok(()) } #[test] - fn validation_reverse_index_rejects_missing_binding() -> Result<(), Box> { + fn validation_reverse_index_rejects_missing_binding() { let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("session")?; - let context = registry.register_context(session, "context")?; - let origin = Origin::parse("http://127.0.0.1:43127")?; - let handle = registry.bind_node(session, context, &origin, "node")?; + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let handles = values(registry.bind_node(session, context, &origins[0], "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( - registry.validate_node_handle(&handle), + registry.validate_node_handle(handle), Err(BrowserRegistryError::UnknownNodeAuthority) ); - Ok(()) } #[test] - fn node_retirement_preserves_unrelated_private_bindings() -> Result<(), Box> { + fn unit_cfg_error_propagation_covers_private_fail_closed_boundaries() { let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("retirement-session")?; - let context = registry.register_context(session, "retirement-context")?; - let other_context = registry.register_context(session, "other-retirement-context")?; - let origin = Origin::parse("http://127.0.0.1:43127")?; - let target = registry.bind_node(session, context, &origin, "target-node")?; - let sibling = registry.bind_node(session, context, &origin, "sibling-node")?; - let other = registry.bind_node(session, other_context, &origin, "other-node")?; - - let future_epoch = DocumentEpoch::new(target.document_epoch().value() + 1)?; - let future_key = (context, future_epoch, "future-sibling-alias".to_owned()); - registry - .node_by_external - .insert(future_key.clone(), sibling.node_id()); + let sessions = values(registry.register_session("boundary-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + assert_eq!( + registry.register_context(session, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let contexts = values(registry.register_context(session, "boundary-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]; + assert_eq!( + registry.bind_node(session, context, origin, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); - registry.remove_node(&target)?; + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + registry.context_epoch.remove(&context); + assert_eq!( + registry.bind_node(session, context, origin, "missing-epoch-node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); - assert_eq!(registry.validate_node_handle(&sibling), Ok(())); - assert_eq!(registry.validate_node_handle(&other), Ok(())); + registry.context_epoch.insert(context, epoch); + let handles = values(registry.bind_node(session, context, origin, "live-node")); + assert_eq!(handles.len(), 1); + registry.context_epoch.remove(&context); assert_eq!( - registry.node_by_external.get(&future_key), - Some(&sibling.node_id()) + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownBrowsingContext) ); - Ok(()) } #[test] - fn document_epoch_exhaustion_is_fail_closed() -> Result<(), Box> { + fn node_retirement_purges_duplicate_private_aliases_fail_closed() { let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("epoch-session")?; - let context = registry.register_context(session, "epoch-context")?; + let sessions = values(registry.register_session("retirement-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-context")); + let other_contexts = values(registry.register_context(session, "other-retirement-context")); + assert_eq!(contexts.len(), 1); + assert_eq!(other_contexts.len(), 1); + let context = contexts[0]; + let other_context = other_contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + let targets = values(registry.bind_node(session, context, origin, "target-node")); + let siblings = values(registry.bind_node(session, context, origin, "sibling-node")); + let others = values(registry.bind_node(session, other_context, origin, "other-node")); + assert_eq!(targets.len(), 1); + assert_eq!(siblings.len(), 1); + assert_eq!(others.len(), 1); + let target = &targets[0]; + let sibling = &siblings[0]; + let other = &others[0]; + + let epochs = values(DocumentEpoch::new(target.document_epoch().value() + 1)); + assert_eq!(epochs.len(), 1); + let future_key = (context, epochs[0], "corrupt-future-alias".to_owned()); + let cross_context_key = ( + other_context, + target.document_epoch(), + "corrupt-cross-context-alias".to_owned(), + ); registry - .context_epoch - .insert(context, DocumentEpoch::new(u64::MAX)?); + .node_by_external + .insert(future_key.clone(), target.node_id()); + registry + .node_by_external + .insert(cross_context_key.clone(), target.node_id()); + + assert_eq!(registry.remove_node(target), Ok(())); + + assert_eq!(registry.validate_node_handle(sibling), Ok(())); + assert_eq!(registry.validate_node_handle(other), Ok(())); + assert_eq!(registry.node_by_external.get(&future_key), None); + assert_eq!(registry.node_by_external.get(&cross_context_key), None); + assert_eq!(registry.node_binding_by_id.get(&target.node_id()), None); + } + + #[test] + fn document_epoch_exhaustion_is_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("epoch-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "epoch-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + 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) ); - Ok(()) } #[test] From 1e5f85aae2ae2c3003c59250b4a20918775c1105 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:35:01 -0700 Subject: [PATCH 075/112] test(core): cover session retirement unit branches --- .../originweave-core/src/browser_registry.rs | 154 ++++++++++-------- 1 file changed, 84 insertions(+), 70 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 4f2c2d37a..0f6fbf4e4 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -430,33 +430,33 @@ impl BrowserAuthorityRegistry { &self, handle: &ObservedNodeHandle, ) -> Result<(), BrowserRegistryError> { - if !self.known_sessions.contains(&handle.browser_session()) { - return Err(BrowserRegistryError::UnknownBrowserSession); + if !handle.belongs_to(&self.registry_authority) { + return Err(BrowserRegistryError::UnregisteredNodeAuthority); } - let context = handle.browsing_context(); let expected_session = self .context_session - .get(&context) + .get(&handle.browsing_context()) .copied() .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; - if expected_session != handle.browser_session() { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } - let epoch = self.current_epoch(context)?; - let origin = self + let current_origin = self .context_origin - .get(&context) + .get(&handle.browsing_context()) .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; - handle - .validate_current(expected_session, context, origin, epoch) - .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; - if !handle.belongs_to(&self.registry_authority) { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } - if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { - return Err(BrowserRegistryError::UnknownNodeAuthority); + let current_epoch = self.current_epoch(handle.browsing_context())?; + handle.validate_current( + expected_session, + handle.browsing_context(), + current_origin, + current_epoch, + )?; + match self.node_binding_by_id.get(&handle.node_id()) { + Some((context, epoch)) + if *context == handle.browsing_context() && *epoch == handle.document_epoch() => + { + Ok(()) + } + _ => Err(BrowserRegistryError::UnknownNodeAuthority), } - Ok(()) } } @@ -466,65 +466,69 @@ impl Default for BrowserAuthorityRegistry { } } -/// A fail-closed error produced while translating external browser identifiers into local authority. +/// A deterministic error emitted while translating external browser identifiers into authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An external identifier was empty or exceeded the reviewed byte bound. + /// An untrusted adapter identifier was empty or exceeded the retained UTF-8 byte budget. InvalidExternalIdentifier, - /// The supplied OriginWeave browser session is not registered in this registry. + /// The browser session is not registered in this registry. UnknownBrowserSession, - /// The supplied OriginWeave browsing context is not registered in this registry. + /// The browsing context is not registered in this registry. UnknownBrowsingContext, - /// The browsing context belongs to another browser session. + /// The browsing context belongs to a different browser session. ContextSessionMismatch { - /// Session that owns the registered context. + /// Registered browser session. expected: BrowserSessionId, - /// Session supplied by the current caller. + /// Browser session supplied by the adapter. actual: BrowserSessionId, }, - /// The context origin changed without first rotating the document epoch. + /// The browsing context changed origin without a document-epoch transition. OriginChangedWithoutDocumentAdvance, - /// The observed node handle is not a current node binding owned by this registry. + /// A node handle was structurally valid but was not issued by this exact registry instance. + UnregisteredNodeAuthority, + /// The node handle is not registered for the current document and authority state. UnknownNodeAuthority, - /// The registry exhausted one of its monotonic internal identifier spaces. - IdentifierSpaceExhausted, - /// A document epoch reached the maximum representable value. - DocumentEpochExhausted, - /// A private registry consistency invariant was violated. + /// A browser authority namespace exhausted its configured monotonic identifier range. + AuthorityIdentifierExhausted, + /// An internal typed identifier constructor rejected a monotonic nonzero registry identifier. InternalAuthorityInvariant, + /// A document epoch reached its nonzero monotonic integer limit. + DocumentEpochExhausted, } impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidExternalIdentifier => write!( - formatter, - "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" - ), - Self::UnknownBrowserSession => { - formatter.write_str("browser session is not registered in this authority registry") + Self::InvalidExternalIdentifier => { + formatter.write_str("external browser identifier is empty or too long") } + Self::UnknownBrowserSession => formatter.write_str("browser session is not registered"), Self::UnknownBrowsingContext => { - formatter.write_str("browsing context is not registered in this authority registry") + formatter.write_str("browsing context is not registered") } Self::ContextSessionMismatch { expected, actual } => write!( formatter, - "browsing context belongs to session {}, not session {}", + "browsing context belongs to browser session {} instead of {}", expected.value(), actual.value() ), - Self::OriginChangedWithoutDocumentAdvance => formatter - .write_str("browsing context origin changed without advancing the document epoch"), - Self::UnknownNodeAuthority => formatter - .write_str("observed node handle is not registered as current browser authority"), - Self::IdentifierSpaceExhausted => { - formatter.write_str("browser authority identifier space is exhausted") + Self::OriginChangedWithoutDocumentAdvance => formatter.write_str( + "browsing context origin changed without advancing the document epoch", + ), + Self::UnregisteredNodeAuthority => { + formatter.write_str("node handle was not issued by this browser authority registry") } - Self::DocumentEpochExhausted => { - formatter.write_str("browser document epoch space is exhausted") + Self::UnknownNodeAuthority => { + formatter.write_str("node handle is not registered as current browser authority") + } + Self::AuthorityIdentifierExhausted => { + formatter.write_str("browser authority identifier namespace is exhausted") } Self::InternalAuthorityInvariant => { - formatter.write_str("browser authority registry violated a nonzero invariant") + formatter.write_str("browser authority registry invariant failed") + } + Self::DocumentEpochExhausted => { + formatter.write_str("document epoch cannot advance without identifier reuse") } } } @@ -540,24 +544,24 @@ fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryE } fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { - if *next > maximum_identifier { - return Err(BrowserRegistryError::IdentifierSpaceExhausted); + if *next == 0 || *next > maximum_identifier { + return Err(BrowserRegistryError::AuthorityIdentifierExhausted); } - let identifier = *next; - *next = identifier + 1; - Ok(identifier) + let current = *next; + *next = next.saturating_add(1); + Ok(current) } fn browser_session_id(value: u64) -> Result { - BrowserSessionId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) + BrowserSessionId::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) } fn browsing_context_id(value: u64) -> Result { - BrowsingContextId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) + BrowsingContextId::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) } fn document_epoch(value: u64) -> Result { - DocumentEpoch::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) + DocumentEpoch::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) } fn registered_node_handle( @@ -576,7 +580,7 @@ fn registered_node_handle( node_id, registry_authority, ) - .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) + .map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) } #[cfg(test)] @@ -784,6 +788,27 @@ mod tests { ); } + #[test] + fn session_retirement_covers_unit_success_and_unknown_paths() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("retirement-unit-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-unit-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + + assert_eq!(registry.remove_session(session), Ok(())); + assert_eq!( + registry.current_epoch(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.remove_session(session), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + } + #[test] fn node_retirement_purges_duplicate_private_aliases_fail_closed() { let mut registry = BrowserAuthorityRegistry::new(); @@ -852,17 +877,6 @@ mod tests { ); } - #[test] - fn monotonic_identifier_exhaustion_is_fail_closed() { - let mut next = 1; - assert_eq!(take_identifier(&mut next, 1), Ok(1)); - assert_eq!(next, 2); - assert_eq!( - take_identifier(&mut next, 1), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - } - #[test] fn maximum_identifier_limit_is_clamped_without_wrapping() { let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); From 28e417a34a17e672e72c6040b4f9a69461142640 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:39:24 -0700 Subject: [PATCH 076/112] fix(core): restore canonical browser registry source --- .../originweave-core/src/browser_registry.rs | 154 ++++++++---------- 1 file changed, 70 insertions(+), 84 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 0f6fbf4e4..4f2c2d37a 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -430,33 +430,33 @@ impl BrowserAuthorityRegistry { &self, handle: &ObservedNodeHandle, ) -> Result<(), BrowserRegistryError> { - if !handle.belongs_to(&self.registry_authority) { - return Err(BrowserRegistryError::UnregisteredNodeAuthority); + if !self.known_sessions.contains(&handle.browser_session()) { + return Err(BrowserRegistryError::UnknownBrowserSession); } + let context = handle.browsing_context(); let expected_session = self .context_session - .get(&handle.browsing_context()) + .get(&context) .copied() .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; - let current_origin = self + if expected_session != handle.browser_session() { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + let epoch = self.current_epoch(context)?; + let origin = self .context_origin - .get(&handle.browsing_context()) + .get(&context) .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; - let current_epoch = self.current_epoch(handle.browsing_context())?; - handle.validate_current( - expected_session, - handle.browsing_context(), - current_origin, - current_epoch, - )?; - match self.node_binding_by_id.get(&handle.node_id()) { - Some((context, epoch)) - if *context == handle.browsing_context() && *epoch == handle.document_epoch() => - { - Ok(()) - } - _ => Err(BrowserRegistryError::UnknownNodeAuthority), + handle + .validate_current(expected_session, context, origin, epoch) + .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; + if !handle.belongs_to(&self.registry_authority) { + return Err(BrowserRegistryError::UnknownNodeAuthority); } + if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + Ok(()) } } @@ -466,69 +466,65 @@ impl Default for BrowserAuthorityRegistry { } } -/// A deterministic error emitted while translating external browser identifiers into authority. +/// A fail-closed error produced while translating external browser identifiers into local authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An untrusted adapter identifier was empty or exceeded the retained UTF-8 byte budget. + /// An external identifier was empty or exceeded the reviewed byte bound. InvalidExternalIdentifier, - /// The browser session is not registered in this registry. + /// The supplied OriginWeave browser session is not registered in this registry. UnknownBrowserSession, - /// The browsing context is not registered in this registry. + /// The supplied OriginWeave browsing context is not registered in this registry. UnknownBrowsingContext, - /// The browsing context belongs to a different browser session. + /// The browsing context belongs to another browser session. ContextSessionMismatch { - /// Registered browser session. + /// Session that owns the registered context. expected: BrowserSessionId, - /// Browser session supplied by the adapter. + /// Session supplied by the current caller. actual: BrowserSessionId, }, - /// The browsing context changed origin without a document-epoch transition. + /// The context origin changed without first rotating the document epoch. OriginChangedWithoutDocumentAdvance, - /// A node handle was structurally valid but was not issued by this exact registry instance. - UnregisteredNodeAuthority, - /// The node handle is not registered for the current document and authority state. + /// The observed node handle is not a current node binding owned by this registry. UnknownNodeAuthority, - /// A browser authority namespace exhausted its configured monotonic identifier range. - AuthorityIdentifierExhausted, - /// An internal typed identifier constructor rejected a monotonic nonzero registry identifier. - InternalAuthorityInvariant, - /// A document epoch reached its nonzero monotonic integer limit. + /// The registry exhausted one of its monotonic internal identifier spaces. + IdentifierSpaceExhausted, + /// A document epoch reached the maximum representable value. DocumentEpochExhausted, + /// A private registry consistency 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 is empty or too long") + Self::InvalidExternalIdentifier => write!( + formatter, + "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" + ), + Self::UnknownBrowserSession => { + formatter.write_str("browser session is not registered in this authority registry") } - Self::UnknownBrowserSession => formatter.write_str("browser session is not registered"), Self::UnknownBrowsingContext => { - formatter.write_str("browsing context is not registered") + formatter.write_str("browsing context is not registered in this authority registry") } Self::ContextSessionMismatch { expected, actual } => write!( formatter, - "browsing context belongs to browser session {} instead of {}", + "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::UnregisteredNodeAuthority => { - formatter.write_str("node handle was not issued by this browser authority registry") + Self::OriginChangedWithoutDocumentAdvance => formatter + .write_str("browsing context origin changed without advancing the document epoch"), + Self::UnknownNodeAuthority => formatter + .write_str("observed node handle is not registered as current browser authority"), + Self::IdentifierSpaceExhausted => { + formatter.write_str("browser authority identifier space is exhausted") } - Self::UnknownNodeAuthority => { - formatter.write_str("node handle is not registered as current browser authority") - } - Self::AuthorityIdentifierExhausted => { - formatter.write_str("browser authority identifier namespace is exhausted") + Self::DocumentEpochExhausted => { + formatter.write_str("browser document epoch space is exhausted") } Self::InternalAuthorityInvariant => { - formatter.write_str("browser authority registry invariant failed") - } - Self::DocumentEpochExhausted => { - formatter.write_str("document epoch cannot advance without identifier reuse") + formatter.write_str("browser authority registry violated a nonzero invariant") } } } @@ -544,24 +540,24 @@ fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryE } fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { - if *next == 0 || *next > maximum_identifier { - return Err(BrowserRegistryError::AuthorityIdentifierExhausted); + if *next > maximum_identifier { + return Err(BrowserRegistryError::IdentifierSpaceExhausted); } - let current = *next; - *next = next.saturating_add(1); - Ok(current) + let identifier = *next; + *next = identifier + 1; + Ok(identifier) } fn browser_session_id(value: u64) -> Result { - BrowserSessionId::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) + BrowserSessionId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } fn browsing_context_id(value: u64) -> Result { - BrowsingContextId::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) + BrowsingContextId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } fn document_epoch(value: u64) -> Result { - DocumentEpoch::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) + DocumentEpoch::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } fn registered_node_handle( @@ -580,7 +576,7 @@ fn registered_node_handle( node_id, registry_authority, ) - .map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) + .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } #[cfg(test)] @@ -788,27 +784,6 @@ mod tests { ); } - #[test] - fn session_retirement_covers_unit_success_and_unknown_paths() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("retirement-unit-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "retirement-unit-context")); - assert_eq!(contexts.len(), 1); - let context = contexts[0]; - - assert_eq!(registry.remove_session(session), Ok(())); - assert_eq!( - registry.current_epoch(context), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - assert_eq!( - registry.remove_session(session), - Err(BrowserRegistryError::UnknownBrowserSession) - ); - } - #[test] fn node_retirement_purges_duplicate_private_aliases_fail_closed() { let mut registry = BrowserAuthorityRegistry::new(); @@ -877,6 +852,17 @@ mod tests { ); } + #[test] + fn monotonic_identifier_exhaustion_is_fail_closed() { + let mut next = 1; + assert_eq!(take_identifier(&mut next, 1), Ok(1)); + assert_eq!(next, 2); + assert_eq!( + take_identifier(&mut next, 1), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + } + #[test] fn maximum_identifier_limit_is_clamped_without_wrapping() { let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); From 42068e7af3fd1f03455f78462b89ae316080e56b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:40:21 -0700 Subject: [PATCH 077/112] test(core): cover session retirement unit branches --- .../src/browser_registry_coverage.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 82ac5b6b7..67037ea4e 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -165,3 +165,24 @@ fn session_authority_failures_are_exercised_in_the_unit_crate() { }) ); } + +#[test] +fn session_retirement_covers_unit_success_and_unknown_paths() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("retirement-unit-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-unit-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + + assert_eq!(registry.remove_session(session), Ok(())); + assert_eq!( + registry.current_epoch(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.remove_session(session), + Err(BrowserRegistryError::UnknownBrowserSession) + ); +} From 32f058f553cda0eac768c228cc91b56d4f983028 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:46:26 -0700 Subject: [PATCH 078/112] test(core): cover duplicate context and identifier bounds --- crates/originweave-core/src/browser_registry_coverage.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 67037ea4e..4db3e2733 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -18,6 +18,8 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { let contexts = values(registry.register_context(session, "unit-context")); assert_eq!(contexts.len(), 1); + let repeated_contexts = values(registry.register_context(session, "unit-context")); + assert_eq!(repeated_contexts, contexts); let context = contexts[0]; let origins = values(Origin::parse("http://127.0.0.1:43127")); @@ -69,6 +71,11 @@ fn node_validation_rejects_each_missing_authority_boundary() { registry.register_session(""), Err(BrowserRegistryError::InvalidExternalIdentifier) ); + let oversized_identifier = "x".repeat(513); + assert_eq!( + registry.register_session(&oversized_identifier), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); let known_sessions = values(registry.register_session("validation-session")); let attacker_sessions = values(registry.register_session("validation-attacker")); assert_eq!(known_sessions.len(), 1); From 755cb394a5fd48a0e82c39863b0df00f55d391d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:22:35 -0700 Subject: [PATCH 079/112] test(core): cover direct registry fail-closed branches --- .../src/browser_registry_coverage.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 4db3e2733..32f1e1621 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -173,6 +173,44 @@ fn session_authority_failures_are_exercised_in_the_unit_crate() { ); } +#[test] +fn direct_fail_closed_registry_paths_are_exercised_in_the_unit_crate() { + let mut registry = BrowserAuthorityRegistry::new(); + let unknown_sessions = values(BrowserSessionId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + let unknown = unknown_sessions[0]; + + let sessions = values(registry.register_session("direct-path-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "direct-path-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + let changed_origins = values(Origin::parse("http://localhost:43127")); + assert_eq!(origins.len(), 1); + assert_eq!(changed_origins.len(), 1); + + assert_eq!( + registry.bind_node(unknown, context, &origins[0], "unknown-session-node"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + assert_eq!( + values(registry.bind_node(session, context, &origins[0], "live-node")).len(), + 1 + ); + assert_eq!( + registry.bind_node(session, context, &changed_origins[0], "changed-origin-node"), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.remove_context(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); +} + #[test] fn session_retirement_covers_unit_success_and_unknown_paths() { let mut registry = BrowserAuthorityRegistry::new(); From 0edb86ae8891a1f575e527235daaa81a1c68cd8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:30:52 -0700 Subject: [PATCH 080/112] test(core): cover allocation and rotation error regions --- .../src/browser_registry_coverage.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 32f1e1621..b3b1e5a7e 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -211,6 +211,59 @@ fn direct_fail_closed_registry_paths_are_exercised_in_the_unit_crate() { ); } +#[test] +fn unit_cfg_allocation_rotation_and_retirement_edges_are_exercised() { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let sessions = values(registry.register_session("capacity-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + assert_eq!( + registry.register_session("capacity-session-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let contexts = values(registry.register_context(session, "capacity-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + assert_eq!( + registry.register_context(session, "capacity-context-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + let handles = values(registry.bind_node(session, context, origin, "capacity-node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + assert_eq!( + registry.bind_node(session, context, origin, "capacity-node-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let forged = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id() + 1, + )); + assert_eq!(forged.len(), 1); + assert_eq!( + registry.remove_node(&forged[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let next_epochs = values(registry.advance_document(context)); + assert_eq!(next_epochs.len(), 1); + assert_eq!(next_epochs[0].value(), 2); + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); +} + #[test] fn session_retirement_covers_unit_success_and_unknown_paths() { let mut registry = BrowserAuthorityRegistry::new(); From 556c986e6d25ba7d1581e58e991d7afdc1de7296 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:36:57 -0700 Subject: [PATCH 081/112] test(core): close remaining registry coverage region --- .../src/browser_registry_coverage.rs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index b3b1e5a7e..dfaf06311 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -264,6 +264,91 @@ fn unit_cfg_allocation_rotation_and_retirement_edges_are_exercised() { ); } +#[test] +fn unit_cfg_adapter_surface_exercises_accessors_equality_default_and_errors() { + let mut registry = BrowserAuthorityRegistry::default(); + let sessions = values(registry.register_session("adapter-surface-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "adapter-surface-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].clone(); + let handles = values(registry.bind_node(session, context, &origin, "adapter-surface-node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + + assert_eq!(handle.browser_session(), session); + assert_eq!(handle.browsing_context(), context); + assert_eq!(handle.origin(), &origin); + assert_eq!(handle.document_epoch().value(), 1); + assert_ne!(handle.node_id(), 0); + + let unregistered_same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id(), + )); + assert_eq!(unregistered_same.len(), 1); + let second_unregistered_same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id(), + )); + assert_eq!(second_unregistered_same.len(), 1); + assert_ne!(*handle, unregistered_same[0]); + assert_eq!(unregistered_same[0], second_unregistered_same[0]); + + let unregistered_other = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id() + 1, + )); + assert_eq!(unregistered_other.len(), 1); + assert_ne!(unregistered_same[0], unregistered_other[0]); + + assert_eq!( + registry.validate_node_handle(&unregistered_same[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!(registry.remove_node(handle), Ok(())); + assert_eq!( + registry.validate_node_handle(handle), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.bind_node(session, context, &origin, "retired-context-node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let display_cases = [ + BrowserRegistryError::InvalidExternalIdentifier, + BrowserRegistryError::UnknownBrowserSession, + BrowserRegistryError::UnknownBrowsingContext, + BrowserRegistryError::ContextSessionMismatch { + expected: session, + actual: session, + }, + BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + BrowserRegistryError::UnknownNodeAuthority, + BrowserRegistryError::IdentifierSpaceExhausted, + BrowserRegistryError::DocumentEpochExhausted, + BrowserRegistryError::InternalAuthorityInvariant, + ]; + for error in display_cases { + assert!(!error.to_string().is_empty()); + } +} + #[test] fn session_retirement_covers_unit_success_and_unknown_paths() { let mut registry = BrowserAuthorityRegistry::new(); From da4ccc8b38bcfc08e64c846adce89d43caaf79d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 07:09:58 -0700 Subject: [PATCH 082/112] docs(adr): cite primary MV3 authority sources --- docs/adr/0013-manifest-v3-extension-authority.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/adr/0013-manifest-v3-extension-authority.md b/docs/adr/0013-manifest-v3-extension-authority.md index 09cfc0954..a05768a9d 100644 --- a/docs/adr/0013-manifest-v3-extension-authority.md +++ b/docs/adr/0013-manifest-v3-extension-authority.md @@ -105,4 +105,12 @@ Supersede this ADR if Chromium adopts a materially different extension authority ## References -Primary browser/extension/protocol evidence and APA 7 references are maintained in [`../doctoring/browser-agent-protocols.md`](../doctoring/browser-agent-protocols.md) and [`../doctoring.md`](../doctoring.md). Related decisions include ADR 0001, ADR 0002, ADR 0007, ADR 0010, ADR 0101, ADR 0104, and ADR 0107. +The following primary Chrome extension documentation directly supports this ADR's service-worker execution plane, untrusted-message boundary, and separately bounded native-messaging decision: + +Google Chrome. (2023, May 2). *Extension service worker basics*. Chrome for Developers. https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/basics + +Google Chrome. (2023, February 27). *Native messaging*. Chrome for Developers. https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging + +Google Chrome. (2025, December 3). *Message passing*. Chrome for Developers. https://developer.chrome.com/docs/extensions/develop/concepts/messaging + +Additional primary browser/extension/protocol evidence and APA 7 references are maintained in [`../doctoring/browser-agent-protocols.md`](../doctoring/browser-agent-protocols.md) and [`../doctoring.md`](../doctoring.md). Related decisions include ADR 0001, ADR 0002, ADR 0007, ADR 0010, ADR 0101, ADR 0104, and ADR 0107. From eb29cad68bd13bebb90fa96f4f8a217a085786f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:30:36 -0700 Subject: [PATCH 083/112] test(browser): hide session membership from unissued node handles --- .../tests/browser_registry_cross_instance.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/originweave-core/tests/browser_registry_cross_instance.rs b/crates/originweave-core/tests/browser_registry_cross_instance.rs index 01eca2a55..c38fcf227 100644 --- a/crates/originweave-core/tests/browser_registry_cross_instance.rs +++ b/crates/originweave-core/tests/browser_registry_cross_instance.rs @@ -43,3 +43,46 @@ fn node_handles_cannot_cross_registry_instances_when_numeric_ids_collide() assert_eq!(second_registry.validate_node_handle(&second_handle), Ok(())); Ok(()) } + +#[test] +fn unissued_handles_do_not_reveal_registered_session_membership() -> Result<(), Box> { + let origin = Origin::parse("http://127.0.0.1:43128")?; + + let mut issuing_registry = BrowserAuthorityRegistry::new(); + let known_numeric_session = issuing_registry.register_session("issuing-session")?; + let unknown_numeric_session = issuing_registry.register_session("issuing-extra-session")?; + + let mut target_registry = BrowserAuthorityRegistry::new(); + let target_session = target_registry.register_session("target-session")?; + let target_context = target_registry.register_context(target_session, "target-context")?; + let target_handle = + target_registry.bind_node(target_session, target_context, &origin, "target-node")?; + + assert_eq!(known_numeric_session, target_session); + assert_ne!(unknown_numeric_session, target_session); + + let forged_known_session = ObservedNodeHandle::new( + known_numeric_session, + target_context, + origin.clone(), + target_handle.document_epoch(), + target_handle.node_id(), + )?; + let forged_unknown_session = ObservedNodeHandle::new( + unknown_numeric_session, + target_context, + origin, + target_handle.document_epoch(), + target_handle.node_id(), + )?; + + assert_eq!( + target_registry.validate_node_handle(&forged_known_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!( + target_registry.validate_node_handle(&forged_unknown_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + Ok(()) +} From a8b03e0b7df348b9afd7aed6babb117f982c6ead Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:33:40 -0700 Subject: [PATCH 084/112] fix(browser): authenticate node issuance before registry lookup --- crates/originweave-core/src/browser_registry.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 4f2c2d37a..ecdd8070f 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -430,6 +430,9 @@ impl BrowserAuthorityRegistry { &self, handle: &ObservedNodeHandle, ) -> Result<(), BrowserRegistryError> { + if !handle.belongs_to(&self.registry_authority) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } if !self.known_sessions.contains(&handle.browser_session()) { return Err(BrowserRegistryError::UnknownBrowserSession); } @@ -450,9 +453,6 @@ impl BrowserAuthorityRegistry { handle .validate_current(expected_session, context, origin, epoch) .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; - if !handle.belongs_to(&self.registry_authority) { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { return Err(BrowserRegistryError::UnknownNodeAuthority); } From c0ef5d5debcc102fbc79bc2f044d6e7bd16ccb89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:34:14 -0700 Subject: [PATCH 085/112] docs(changelog): record node-handle oracle hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6cef45f6..ef1410c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security +- Registry-issued node validation authenticates registry-instance issuance before session or context lookup, so caller-constructed and cross-registry handles cannot probe whether numeric browser-session identifiers are currently registered. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. From eb992a4c3d01362bea15e83f2db78809fcf3367c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:40:51 -0700 Subject: [PATCH 086/112] test(browser): cover authenticated retired-authority failures --- .../src/browser_registry_coverage.rs | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index dfaf06311..71bcd6c49 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -104,7 +104,7 @@ fn node_validation_rejects_each_missing_authority_boundary() { assert_eq!(unknown_handle.len(), 1); assert_eq!( registry.validate_node_handle(&unknown_handle[0]), - Err(BrowserRegistryError::UnknownBrowserSession) + Err(BrowserRegistryError::UnknownNodeAuthority) ); let mismatched_handle = values(ObservedNodeHandle::new( @@ -136,8 +136,60 @@ fn node_validation_rejects_each_missing_authority_boundary() { assert_eq!(registry.remove_context(context), Ok(())); assert_eq!( registry.validate_node_handle(&unbound_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); +} + +#[test] +fn issued_handles_report_retired_authority_boundaries() { + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + + let mut context_registry = BrowserAuthorityRegistry::new(); + let context_sessions = values(context_registry.register_session("context-retirement-session")); + assert_eq!(context_sessions.len(), 1); + let context_session = context_sessions[0]; + let contexts = values( + context_registry.register_context(context_session, "context-retirement-context"), + ); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let context_handles = values(context_registry.bind_node( + context_session, + context, + origin, + "context-retirement-node", + )); + assert_eq!(context_handles.len(), 1); + let context_handle = &context_handles[0]; + assert_eq!(context_registry.remove_context(context), Ok(())); + assert_eq!( + context_registry.validate_node_handle(context_handle), Err(BrowserRegistryError::UnknownBrowsingContext) ); + + let mut session_registry = BrowserAuthorityRegistry::new(); + let sessions = values(session_registry.register_session("session-retirement-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let session_contexts = + values(session_registry.register_context(session, "session-retirement-context")); + assert_eq!(session_contexts.len(), 1); + let session_context = session_contexts[0]; + let session_handles = values(session_registry.bind_node( + session, + session_context, + origin, + "session-retirement-node", + )); + assert_eq!(session_handles.len(), 1); + let session_handle = &session_handles[0]; + assert_eq!(session_registry.remove_session(session), Ok(())); + assert_eq!( + session_registry.validate_node_handle(session_handle), + Err(BrowserRegistryError::UnknownBrowserSession) + ); } #[test] From 8093169b607bd136bd4922e012567a6ab0f81022 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:45:11 -0700 Subject: [PATCH 087/112] test(browser): apply canonical formatting --- crates/originweave-core/src/browser_registry_coverage.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 71bcd6c49..8834d7062 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -150,9 +150,8 @@ fn issued_handles_report_retired_authority_boundaries() { let context_sessions = values(context_registry.register_session("context-retirement-session")); assert_eq!(context_sessions.len(), 1); let context_session = context_sessions[0]; - let contexts = values( - context_registry.register_context(context_session, "context-retirement-context"), - ); + let contexts = + values(context_registry.register_context(context_session, "context-retirement-context")); assert_eq!(contexts.len(), 1); let context = contexts[0]; let context_handles = values(context_registry.bind_node( From 7020dbee6537c33fdc915d5f8f240bd0020d25a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:50:37 -0700 Subject: [PATCH 088/112] test(browser): cover issued context-session corruption --- .../originweave-core/src/browser_registry.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index ecdd8070f..090d7b01f 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -743,6 +743,36 @@ mod tests { ); } + #[test] + fn issued_handle_rejects_private_context_session_corruption() { + let mut registry = BrowserAuthorityRegistry::new(); + let owners = values(registry.register_session("corrupt-owner-session")); + let attackers = values(registry.register_session("corrupt-attacker-session")); + assert_eq!(owners.len(), 1); + assert_eq!(attackers.len(), 1); + let owner = owners[0]; + let attacker = attackers[0]; + + let contexts = values(registry.register_context(owner, "corrupt-context-session")); + 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 handles = values(registry.bind_node( + owner, + context, + &origins[0], + "corrupt-context-node", + )); + assert_eq!(handles.len(), 1); + + registry.context_session.insert(context, attacker); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + } + #[test] fn unit_cfg_error_propagation_covers_private_fail_closed_boundaries() { let mut registry = BrowserAuthorityRegistry::new(); From 3a29725b910f87f0bf5a457ad7530a4a9d5200ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 21:16:47 -0700 Subject: [PATCH 089/112] test(browser): cover context-origin corruption --- .../originweave-core/src/browser_registry.rs | 700 +----------------- 1 file changed, 1 insertion(+), 699 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 090d7b01f..cbcf1213c 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -200,702 +200,4 @@ impl BrowserAuthorityRegistry { &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, self.maximum_identifier)?; - browser_session_id(identifier).inspect(|&session| { - self.session_by_external - .insert(external_identifier.to_owned(), session); - self.known_sessions.insert(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, self.maximum_identifier)?; - 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 - }) - }) - } - - /// Retire one browsing context and all registry-local authority derived from it. - /// - /// Retirement removes external lookup state, the current document epoch and origin, and every - /// node binding owned by the context. Monotonic context and node identifiers are never reused. - /// This revokes only OriginWeave registry-local authority; it does not prove that an external - /// browser context or process has terminated. - pub fn remove_context( - &mut self, - browsing_context: BrowsingContextId, - ) -> Result<(), BrowserRegistryError> { - if self.context_session.remove(&browsing_context).is_none() { - return Err(BrowserRegistryError::UnknownBrowsingContext); - } - self.context_by_external - .retain(|_key, context| *context != browsing_context); - self.context_epoch.remove(&browsing_context); - self.context_origin.remove(&browsing_context); - self.node_by_external - .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); - self.node_binding_by_id - .retain(|_node_id, (context, _epoch)| *context != browsing_context); - Ok(()) - } - - /// Retire one browser session and every registered context and node binding beneath it. - /// - /// Retirement removes only registry-local authority and external lookup state. Session, - /// context, and node identifiers remain strictly monotonic so a later registration of the same - /// opaque browser identifier cannot revive stale authority. External process termination is a - /// separate adapter responsibility. - pub fn remove_session( - &mut self, - browser_session: BrowserSessionId, - ) -> Result<(), BrowserRegistryError> { - if !self.known_sessions.remove(&browser_session) { - return Err(BrowserRegistryError::UnknownBrowserSession); - } - self.session_by_external - .retain(|_external, session| *session != browser_session); - self.context_by_external - .retain(|(session, _external), _context| *session != browser_session); - self.context_session - .retain(|_context, session| *session != browser_session); - - let live_contexts = &self.context_session; - self.context_epoch - .retain(|context, _epoch| live_contexts.contains_key(context)); - self.context_origin - .retain(|context, _origin| live_contexts.contains_key(context)); - self.node_by_external - .retain(|(context, _epoch, _external), _node_id| live_contexts.contains_key(context)); - self.node_binding_by_id - .retain(|_node_id, (context, _epoch)| live_contexts.contains_key(context)); - Ok(()) - } - - /// 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)?; - 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); - self.node_binding_by_id - .retain(|_node_id, (context, _epoch)| *context != browsing_context); - }) - } - - /// 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, - }); - } - let origin_is_unbound = match self.context_origin.get(&browsing_context) { - Some(expected_origin) if expected_origin != origin => { - return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); - } - Some(_expected_origin) => false, - None => true, - }; - let epoch = self.current_epoch(browsing_context)?; - let key = (browsing_context, epoch, external_identifier.to_owned()); - let existing = self.node_by_external.get(&key).copied(); - let node_id = match existing { - Some(node_id) => node_id, - None => take_identifier(&mut self.next_node_id, self.maximum_identifier)?, - }; - if let Some(binding) = self.node_binding_by_id.get(&node_id) { - if *binding != (browsing_context, epoch) { - return Err(BrowserRegistryError::InternalAuthorityInvariant); - } - } else if existing.is_some() { - return Err(BrowserRegistryError::InternalAuthorityInvariant); - } - - let handle = registered_node_handle( - browser_session, - browsing_context, - origin, - epoch, - node_id, - Arc::clone(&self.registry_authority), - )?; - if origin_is_unbound { - self.context_origin.insert(browsing_context, origin.clone()); - } - if existing.is_none() { - self.node_by_external.insert(key, node_id); - self.node_binding_by_id - .insert(node_id, (browsing_context, epoch)); - } - Ok(handle) - } - - /// Retire one exact live node handle without advancing the document epoch. - /// - /// This revokes only registry-local node authority. It is intended for relevant same-document - /// mutations that invalidate one actionable node while leaving the surrounding browsing - /// context and document epoch current. The node identifier is globally unique inside one - /// registry, so retirement purges every external alias that refers to that identifier; this - /// also fails safe if private lookup state was duplicated or corrupted. Retirement does not - /// claim that Chromium destroyed the underlying DOM/backend node, and the monotonic node - /// identifier is never reused. - pub fn remove_node(&mut self, handle: &ObservedNodeHandle) -> Result<(), BrowserRegistryError> { - self.validate_node_handle(handle)?; - let node_id = handle.node_id(); - self.node_binding_by_id.remove(&node_id); - self.node_by_external - .retain(|_key, bound_node_id| *bound_node_id != node_id); - Ok(()) - } - - /// Verify that an observed node handle is still live authority in this registry. - /// - /// This check must run immediately before a node-local browser action. It re-derives the - /// current session, context, origin, and document epoch from registry-owned state, requires the - /// handle to have been issued by this exact registry instance, and resolves the node through a - /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, - /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. - pub fn validate_node_handle( - &self, - handle: &ObservedNodeHandle, - ) -> Result<(), BrowserRegistryError> { - if !handle.belongs_to(&self.registry_authority) { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } - if !self.known_sessions.contains(&handle.browser_session()) { - return Err(BrowserRegistryError::UnknownBrowserSession); - } - let context = handle.browsing_context(); - let expected_session = self - .context_session - .get(&context) - .copied() - .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; - if expected_session != handle.browser_session() { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } - let epoch = self.current_epoch(context)?; - let origin = self - .context_origin - .get(&context) - .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; - handle - .validate_current(expected_session, context, origin, epoch) - .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; - if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } - Ok(()) - } -} - -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 observed node handle is not a current node binding owned by this registry. - UnknownNodeAuthority, - /// The registry exhausted one of its monotonic internal identifier spaces. - IdentifierSpaceExhausted, - /// A document epoch reached the maximum representable value. - DocumentEpochExhausted, - /// A private registry consistency invariant was violated. - InternalAuthorityInvariant, -} - -impl fmt::Display for BrowserRegistryError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidExternalIdentifier => write!( - formatter, - "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} 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::UnknownNodeAuthority => formatter - .write_str("observed node handle is not registered as current browser authority"), - 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, maximum_identifier: u64) -> Result { - if *next > maximum_identifier { - return Err(BrowserRegistryError::IdentifierSpaceExhausted); - } - let identifier = *next; - *next = identifier + 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 registered_node_handle( - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: &Origin, - document_epoch: DocumentEpoch, - node_id: u64, - registry_authority: Arc<()>, -) -> Result { - ObservedNodeHandle::registered( - browser_session, - browsing_context, - origin.clone(), - document_epoch, - node_id, - registry_authority, - ) - .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn values(result: Result) -> Vec { - result.into_iter().collect() - } - - #[test] - fn unregistered_handle_equality_covers_all_authority_states() { - 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); - let session = sessions[0]; - let context = contexts[0]; - let epoch = epochs[0]; - let origin = origins[0].clone(); - - let first = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 1, - )); - let same = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 1, - )); - let different = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 2, - )); - assert_eq!(first.len(), 1); - assert_eq!(same.len(), 1); - assert_eq!(different.len(), 1); - assert_eq!(first[0], same[0]); - assert_ne!(first[0], different[0]); - - let registered = values(ObservedNodeHandle::registered( - session, - context, - origin, - epoch, - 1, - Arc::new(()), - )); - assert_eq!(registered.len(), 1); - assert_ne!(first[0], registered[0]); - } - - #[test] - fn helper_invariants_and_reverse_index_corruption_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 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!( - registered_node_handle( - sessions[0], - contexts[0], - &origins[0], - epochs[0], - 0, - Arc::new(()) - ) - .is_err() - ); - - let mut registry = BrowserAuthorityRegistry::new(); - let registered_sessions = values(registry.register_session("corrupt-session")); - assert_eq!(registered_sessions.len(), 1); - let session = registered_sessions[0]; - let registered_contexts = values(registry.register_context(session, "corrupt-context")); - assert_eq!(registered_contexts.len(), 1); - let context = registered_contexts[0]; - let origin = &origins[0]; - let handles = values(registry.bind_node(session, context, origin, "node")); - assert_eq!(handles.len(), 1); - let handle = &handles[0]; - registry.node_binding_by_id.remove(&handle.node_id()); - assert_eq!( - registry.bind_node(session, context, origin, "node"), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); - - registry - .node_binding_by_id - .insert(handle.node_id(), (context, epochs[0])); - registry.node_by_external.clear(); - let other_contexts = values(registry.register_context(session, "other-context")); - assert_eq!(other_contexts.len(), 1); - let other_context = other_contexts[0]; - registry.node_by_external.insert( - (other_context, epochs[0], "other-node".to_owned()), - handle.node_id(), - ); - assert_eq!( - registry.bind_node(session, other_context, origin, "other-node"), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); - - let zero_epochs = values(registry.current_epoch(context)); - assert_eq!(zero_epochs.len(), 1); - let zero_epoch = zero_epochs[0]; - registry - .node_by_external - .insert((context, zero_epoch, "zero-node".to_owned()), 0); - registry.node_binding_by_id.insert(0, (context, zero_epoch)); - assert_eq!( - registry.bind_node(session, context, origin, "zero-node"), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); - } - - #[test] - fn validation_reverse_index_rejects_missing_binding() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "context")); - assert_eq!(contexts.len(), 1); - let context = contexts[0]; - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(origins.len(), 1); - let handles = values(registry.bind_node(session, context, &origins[0], "node")); - assert_eq!(handles.len(), 1); - let handle = &handles[0]; - registry.node_binding_by_id.remove(&handle.node_id()); - assert_eq!( - registry.validate_node_handle(handle), - Err(BrowserRegistryError::UnknownNodeAuthority) - ); - } - - #[test] - fn issued_handle_rejects_private_context_session_corruption() { - let mut registry = BrowserAuthorityRegistry::new(); - let owners = values(registry.register_session("corrupt-owner-session")); - let attackers = values(registry.register_session("corrupt-attacker-session")); - assert_eq!(owners.len(), 1); - assert_eq!(attackers.len(), 1); - let owner = owners[0]; - let attacker = attackers[0]; - - let contexts = values(registry.register_context(owner, "corrupt-context-session")); - 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 handles = values(registry.bind_node( - owner, - context, - &origins[0], - "corrupt-context-node", - )); - assert_eq!(handles.len(), 1); - - registry.context_session.insert(context, attacker); - assert_eq!( - registry.validate_node_handle(&handles[0]), - Err(BrowserRegistryError::UnknownNodeAuthority) - ); - } - - #[test] - fn unit_cfg_error_propagation_covers_private_fail_closed_boundaries() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("boundary-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - assert_eq!( - registry.register_context(session, ""), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - - let contexts = values(registry.register_context(session, "boundary-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]; - assert_eq!( - registry.bind_node(session, context, origin, ""), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - - let epochs = values(registry.current_epoch(context)); - assert_eq!(epochs.len(), 1); - let epoch = epochs[0]; - registry.context_epoch.remove(&context); - assert_eq!( - registry.bind_node(session, context, origin, "missing-epoch-node"), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - - registry.context_epoch.insert(context, epoch); - let handles = values(registry.bind_node(session, context, origin, "live-node")); - assert_eq!(handles.len(), 1); - registry.context_epoch.remove(&context); - assert_eq!( - registry.validate_node_handle(&handles[0]), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - } - - #[test] - fn node_retirement_purges_duplicate_private_aliases_fail_closed() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("retirement-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "retirement-context")); - let other_contexts = values(registry.register_context(session, "other-retirement-context")); - assert_eq!(contexts.len(), 1); - assert_eq!(other_contexts.len(), 1); - let context = contexts[0]; - let other_context = other_contexts[0]; - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(origins.len(), 1); - let origin = &origins[0]; - let targets = values(registry.bind_node(session, context, origin, "target-node")); - let siblings = values(registry.bind_node(session, context, origin, "sibling-node")); - let others = values(registry.bind_node(session, other_context, origin, "other-node")); - assert_eq!(targets.len(), 1); - assert_eq!(siblings.len(), 1); - assert_eq!(others.len(), 1); - let target = &targets[0]; - let sibling = &siblings[0]; - let other = &others[0]; - - let epochs = values(DocumentEpoch::new(target.document_epoch().value() + 1)); - assert_eq!(epochs.len(), 1); - let future_key = (context, epochs[0], "corrupt-future-alias".to_owned()); - let cross_context_key = ( - other_context, - target.document_epoch(), - "corrupt-cross-context-alias".to_owned(), - ); - registry - .node_by_external - .insert(future_key.clone(), target.node_id()); - registry - .node_by_external - .insert(cross_context_key.clone(), target.node_id()); - - assert_eq!(registry.remove_node(target), Ok(())); - - assert_eq!(registry.validate_node_handle(sibling), Ok(())); - assert_eq!(registry.validate_node_handle(other), Ok(())); - assert_eq!(registry.node_by_external.get(&future_key), None); - assert_eq!(registry.node_by_external.get(&cross_context_key), None); - assert_eq!(registry.node_binding_by_id.get(&target.node_id()), None); - } - - #[test] - fn document_epoch_exhaustion_is_fail_closed() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("epoch-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "epoch-context")); - assert_eq!(contexts.len(), 1); - let context = contexts[0]; - 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) - ); - } - - #[test] - fn monotonic_identifier_exhaustion_is_fail_closed() { - let mut next = 1; - assert_eq!(take_identifier(&mut next, 1), Ok(1)); - assert_eq!(next, 2); - assert_eq!( - take_identifier(&mut next, 1), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - } - - #[test] - fn maximum_identifier_limit_is_clamped_without_wrapping() { - let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); - assert_eq!(registry.maximum_identifier, u64::MAX - 1); - } -} + va²Θ="25ΑΙ₯Ω…Ρ•}½ΉΡ•αΡ}Ν•ΝΝ₯½Ή}½ΙΙΥΑΡ₯½Έ €μ(€€€€€€€±•Ё΅ΥЁɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιΉ•ά €μ(€€€€€€€±•Ё½έΉ•ΙΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰½ΙΙΥΑΠ΅½έΉ•Θ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€±•Ё…ΡΡ…­•ΙΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰½ΙΙΥΑΠ΅…ΡΡ…­•Θ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½έΉ•ΙΜΉ±•Έ €°€Δ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘…ΡΡ…­•ΙΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½έΉ•Θ€τ½έΉ•ΙΝlΑtμ(€€€€€€€±•Ё…ΡΡ…­•Θ€τ…ΡΡ…­•ΙΝlΑtμ((€€€€€€€±•Ё½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘½έΉ•Θ°€‰½ΙΙΥΑΠ΅½ΉΡ•αΠ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½ΉΡ•αΠ€τ½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё½Ι₯₯ΉΜ€τΩ…±Υ•Μ‘=Ι₯₯ΈθιΑ…ΙΝ” ‰‘ΡΡΐθΌΌΔΘάΈΐΈΐΈΔθΠΜΔΘ܈€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ι₯₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё‘…Ή‘±•Μ€τ(€€€€€€€€€€€Ω…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘½έΉ•Θ°½ΉΡ•αΠ°€™½Ι₯₯ΉΝlΑt°€‰½ΙΙΥΑΠ΅½ΉΡ•αΠ΅Ή½‘”ˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘‘…Ή‘±•ΜΉ±•Έ €°€Δ€μ((€€€€€€€Ι•₯ΝΡΙ乍½ΉΡ•αΡ}Ν•ΝΝ₯½ΈΉ₯ΉΝ•ΙΠ‘½ΉΡ•αΠ°…ΡΡ…­•Θ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉΩ…±₯‘…Ρ•}Ή½‘•}‘…Ή‘±” ™‘…Ή‘±•ΝlΑt€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½ΘθιUΉ­Ή½έΉ9½‘•ΥΡ‘½Ι₯Ρδ€(€€€€€€€€€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™Έ₯ΝΝΥ•‘}‘…Ή‘±•}Ι•©•ΡΝ}ΑΙ₯Ω…Ρ•}½ΉΡ•αΡ}½Ι₯₯Ή}½ΙΙΥΑΡ₯½Έ €μ(€€€€€€€±•Ё΅ΥЁɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιΉ•ά €μ(€€€€€€€±•Ё͕ΝΝ₯½ΉΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰½ΙΙΥΑΠ΅½Ι₯₯Έ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ν•ΝΝ₯½ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё͕ΝΝ₯½Έ€τΝ•ΝΝ₯½ΉΝlΑtμ(€€€€€€€±•Ё½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€‰½ΙΙΥΑΠ΅½Ι₯₯Έ΅½ΉΡ•αЈ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½ΉΡ•αΠ€τ½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё½Ι₯₯ΉΜ€τΩ…±Υ•Μ‘=Ι₯₯ΈθιΑ…ΙΝ” ‰‘ΡΡΐθΌΌΔΘάΈΐΈΐΈΔθΠΜΔΘ܈€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ι₯₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё‘…Ή‘±•Μ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘” (€€€€€€€€€€€Ν•ΝΝ₯½Έ°(€€€€€€€€€€€½ΉΡ•αΠ°(€€€€€€€€€€€€™½Ι₯₯ΉΝlΑt°(€€€€€€€€€€€€‰½ΙΙΥΑΠ΅½Ι₯₯Έ΅Ή½‘”ˆ°(€€€€€€€€€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘‘…Ή‘±•ΜΉ±•Έ €°€Δ€μ((€€€€€€€±•ЁɕΑ±…•΅•ΉΡ}½Ι₯₯ΉΜ€τΩ…±Υ•Μ‘=Ι₯₯ΈθιΑ…ΙΝ” ‰‘ΡΡΐθΌ½±½…±‘½ΝΠθΠΜΔΘ܈€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•Α±…•΅•ΉΡ}½Ι₯₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€Ι•₯ΝΡΙδ(€€€€€€€€€€€€Ή½ΉΡ•αΡ}½Ι₯₯Έ(€€€€€€€€€€€€Ή₯ΉΝ•ΙΠ‘½ΉΡ•αΠ°Ι•Α±…•΅•ΉΡ}½Ι₯₯ΉΝlΑtΉ±½Ή” €€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉΩ…±₯‘…Ρ•}Ή½‘•}‘…Ή‘±” ™‘…Ή‘±•ΝlΑt€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½ΘθιUΉ­Ή½έΉ9½‘•ΥΡ‘½Ι₯Ρδ€(€€€€€€€€€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™ΈΥΉ₯Ρ}™}•ΙΙ½Ι}ΑΙ½Α……Ρ₯½Ή}½Ω•ΙΝ}ΑΙ₯Ω…Ρ•}™…₯±}±½Ν•‘}‰½ΥΉ‘…Ι₯•Μ €μ(€€€€€€€±•Ё΅ΥЁɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιΉ•ά €μ(€€€€€€€±•Ё͕ΝΝ₯½ΉΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰‰½ΥΉ‘…Ιδ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ν•ΝΝ₯½ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё͕ΝΝ₯½Έ€τΝ•ΝΝ₯½ΉΝlΑtμ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€ˆˆ€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½Θθι%ΉΩ…±₯‘αΡ•ΙΉ…±%‘•ΉΡ₯™₯•Θ€(€€€€€€€€€μ((€€€€€€€±•Ё½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€‰‰½ΥΉ‘…Ι䡍½ΉΡ•αЈ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½ΉΡ•αΠ€τ½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё½Ι₯₯ΉΜ€τΩ…±Υ•Μ‘=Ι₯₯ΈθιΑ…ΙΝ” ‰‘ΡΡΐθΌΌΔΘάΈΐΈΐΈΔθΠΜΔΘ܈€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ι₯₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½Ι₯₯Έ€τ€™½Ι₯₯ΉΝlΑtμ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½ΉΡ•αΠ°½Ι₯₯Έ°€ˆˆ€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½Θθι%ΉΩ…±₯‘αΡ•ΙΉ…±%‘•ΉΡ₯™₯•Θ€(€€€€€€€€€μ((€€€€€€€±•Ё•Α½‘Μ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙ乍ΥΙΙ•ΉΡ}•Α½ ‘½ΉΡ•αΠ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘•Α½‘ΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё•Α½ €τ•Α½‘ΝlΑtμ(€€€€€€€Ι•₯ΝΡΙ乍½ΉΡ•αΡ}•Α½ ΉΙ•΅½Ω” ™½ΉΡ•αΠ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½ΉΡ•αΠ°½Ι₯₯Έ°€‰΅₯ΝΝ₯Ήœ΅•Α½ ΅Ή½‘”ˆ€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½ΘθιUΉ­Ή½έΉ Ι½έΝ₯Ή ½ΉΡ•αΠ€(€€€€€€€€€μ((€€€€€€€Ι•₯ΝΡΙ乍½ΉΡ•αΡ}•Α½ Ή₯ΉΝ•ΙΠ‘½ΉΡ•αΠ°•Α½ €μ(€€€€€€€±•Ё‘…Ή‘±•Μ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½ΉΡ•αΠ°½Ι₯₯Έ°€‰±₯Ω”΅Ή½‘”ˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘‘…Ή‘±•ΜΉ±•Έ €°€Δ€μ(€€€€€€€Ι•₯ΝΡΙ乍½ΉΡ•αΡ}•Α½ ΉΙ•΅½Ω” ™½ΉΡ•αΠ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉΩ…±₯‘…Ρ•}Ή½‘•}‘…Ή‘±” ™‘…Ή‘±•ΝlΑt€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½ΘθιUΉ­Ή½έΉ Ι½έΝ₯Ή ½ΉΡ•αΠ€(€€€€€€€€€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™ΈΉ½‘•}Ι•Ρ₯Ι•΅•ΉΡ}ΑΥɝ•Ν}‘ΥΑ±₯…Ρ•}ΑΙ₯Ω…Ρ•}…±₯…Ν•Ν}™…₯±}±½Ν• €μ(€€€€€€€±•Ё΅ΥЁɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιΉ•ά €μ(€€€€€€€±•Ё͕ΝΝ₯½ΉΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰Ι•Ρ₯Ι•΅•ΉΠ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ν•ΝΝ₯½ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё͕ΝΝ₯½Έ€τΝ•ΝΝ₯½ΉΝlΑtμ(€€€€€€€±•Ё½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€‰Ι•Ρ₯Ι•΅•ΉΠ΅½ΉΡ•αЈ€€μ(€€€€€€€±•Ё½Ρ‘•Ι}½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€‰½Ρ‘•Θ΅Ι•Ρ₯Ι•΅•ΉΠ΅½ΉΡ•αЈ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ρ‘•Ι}½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½ΉΡ•αΠ€τ½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё½Ρ‘•Ι}½ΉΡ•αΠ€τ½Ρ‘•Ι}½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё½Ι₯₯ΉΜ€τΩ…±Υ•Μ‘=Ι₯₯ΈθιΑ…ΙΝ” ‰‘ΡΡΐθΌΌΔΘάΈΐΈΐΈΔθΠΜΔΘ܈€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ι₯₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½Ι₯₯Έ€τ€™½Ι₯₯ΉΝlΑtμ(€€€€€€€±•Ёхɝ•ΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½ΉΡ•αΠ°½Ι₯₯Έ°€‰Ρ…ɝ•Π΅Ή½‘”ˆ€€μ(€€€€€€€±•ЁΝ₯‰±₯ΉΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½ΉΡ•αΠ°½Ι₯₯Έ°€‰Ν₯‰±₯Ήœ΅Ή½‘”ˆ€€μ(€€€€€€€±•Ё½Ρ‘•ΙΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½Ρ‘•Ι}½ΉΡ•αΠ°½Ι₯₯Έ°€‰½Ρ‘•Θ΅Ή½‘”ˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘хɝ•ΡΜΉ±•Έ €°€Δ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ν₯‰±₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ρ‘•ΙΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ёхɝ•Π€τ€™Ρ…ɝ•ΡΝlΑtμ(€€€€€€€±•ЁΝ₯‰±₯Ήœ€τ€™Ν₯‰±₯ΉΝlΑtμ(€€€€€€€±•Ё½Ρ‘•Θ€τ€™½Ρ‘•ΙΝlΑtμ((€€€€€€€±•Ё•Α½‘Μ€τΩ…±Υ•Μ‘½Υ΅•ΉΡΑ½ θ鹕ܑхɝ•ΠΉ‘½Υ΅•ΉΡ}•Α½  €ΉΩ…±Υ” €€¬€Δ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘•Α½‘ΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё™ΥΡΥΙ•}­•δ€τ€‘½ΉΡ•αΠ°•Α½‘ΝlΑt°€‰½ΙΙΥΑΠ΅™ΥΡΥΙ”΅…±₯…ΜˆΉΡ½}½έΉ• €€μ(€€€€€€€±•ЁΙ½ΝΝ}½ΉΡ•αΡ}­•δ€τ€ (€€€€€€€€€€€½Ρ‘•Ι}½ΉΡ•αΠ°(€€€€€€€€€€€Ρ…ɝ•ΠΉ‘½Υ΅•ΉΡ}•Α½  €°(€€€€€€€€€€€€‰½ΙΙΥΑΠ΅Ι½ΝΜ΅½ΉΡ•αΠ΅…±₯…ΜˆΉΡ½}½έΉ• €°(€€€€€€€€€μ(€€€€€€€Ι•₯ΝΡΙδ(€€€€€€€€€€€€ΉΉ½‘•}‰ε}•αΡ•ΙΉ…°(€€€€€€€€€€€€Ή₯ΉΝ•ΙΠ‘™ΥΡΥΙ•}­•乍±½Ή” €°Ρ…ɝ•ΠΉΉ½‘•}₯ €€μ(€€€€€€€Ι•₯ΝΡΙδ(€€€€€€€€€€€€ΉΉ½‘•}‰ε}•αΡ•ΙΉ…°(€€€€€€€€€€€€Ή₯ΉΝ•ΙΠ‘Ι½ΝΝ}½ΉΡ•αΡ}­•乍±½Ή” €°Ρ…ɝ•ΠΉΉ½‘•}₯ €€μ((€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΙ•΅½Ω•}Ή½‘”‘хɝ•Π€°=¬  €€€μ((€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΩ…±₯‘…Ρ•}Ή½‘•}‘…Ή‘±”‘Ν₯‰±₯Ήœ€°=¬  €€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΩ…±₯‘…Ρ•}Ή½‘•}‘…Ή‘±”‘½Ρ‘•Θ€°=¬  €€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΉ½‘•}‰ε}•αΡ•ΙΉ…°Ή•Π ™™ΥΡΥΙ•}­•δ€°9½Ή”€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΉ½‘•}‰ε}•αΡ•ΙΉ…°Ή•Π ™Ι½ΝΝ}½ΉΡ•αΡ}­•δ€°9½Ή”€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΉ½‘•}‰₯Ή‘₯Ή}‰ε}₯Ή•Π ™Ρ…ɝ•ΠΉΉ½‘•}₯ €€°9½Ή”€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™Έ‘½Υ΅•ΉΡ}•Α½‘}•α‘…ΥΝΡ₯½Ή}₯Ν}™…₯±}±½Ν• €μ(€€€€€€€±•Ё΅ΥЁɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιΉ•ά €μ(€€€€€€€±•Ё͕ΝΝ₯½ΉΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰•Α½ ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ν•ΝΝ₯½ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё͕ΝΝ₯½Έ€τΝ•ΝΝ₯½ΉΝlΑtμ(€€€€€€€±•Ё½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€‰•Α½ ΅½ΉΡ•αЈ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½ΉΡ•αΠ€τ½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё΅…α₯΅Υ΅}•Α½‘Μ€τΩ…±Υ•Μ‘½Υ΅•ΉΡΑ½ θιΉ•ά‘ΤΨΠθι5`€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘΅…α₯΅Υ΅}•Α½‘ΜΉ±•Έ €°€Δ€μ(€€€€€€€Ι•₯ΝΡΙ乍½ΉΡ•αΡ}•Α½ Ή₯ΉΝ•ΙΠ‘½ΉΡ•αΠ°΅…α₯΅Υ΅}•Α½‘ΝlΑt€μ((€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉ…‘Ω…Ή•}‘½Υ΅•ΉΠ‘½ΉΡ•αΠ€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½Θθ齍ա•ΉΡΑ½‘α‘…ΥΝΡ•€(€€€€€€€€€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™Έ΅½Ή½Ρ½Ή₯}₯‘•ΉΡ₯™₯•Ι}•α‘…ΥΝΡ₯½Ή}₯Ν}™…₯±}±½Ν• €μ(€€€€€€€±•Ё΅ΥЁΉ•αΠ€τ€Δμ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ρ…­•}₯‘•ΉΡ₯™₯•Θ ™΅ΥЁΉ•αΠ°€Δ€°=¬ Δ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ή•αΠ°€Θ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ρ…­•}₯‘•ΉΡ₯™₯•Θ ™΅ΥЁΉ•αΠ°€Δ€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½Θθι%‘•ΉΡ₯™₯•ΙMΑ…•α‘…ΥΝΡ•€(€€€€€€€€€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™Έ΅…α₯΅Υ΅}₯‘•ΉΡ₯™₯•Ι}±₯΅₯Ρ}₯Ν}±…΅Α•‘}έ₯Ρ‘½ΥΡ}έΙ…ΑΑ₯Ήœ €μ(€€€€€€€±•Ёɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιέ₯Ρ‘}₯‘•ΉΡ₯™₯•Ι}±₯΅₯Π‘ΤΨΠθι5`€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉ΅…α₯΅Υ΅}₯‘•ΉΡ₯™₯•Θ°ΤΨΠθι5`€΄€Δ€μ(€€€τ)τ( \ No newline at end of file From 281737b7709650121ad65a882481efa8b4351580 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 21:18:00 -0700 Subject: [PATCH 090/112] repair(browser): restore registry source after transport corruption --- .../originweave-core/src/browser_registry.rs | 700 +++++++++++++++++- 1 file changed, 699 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index cbcf1213c..090d7b01f 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -200,4 +200,702 @@ impl BrowserAuthorityRegistry { &mut self, external_identifier: &str, ) -> Result { - va²Θ="25ΑΙ₯Ω…Ρ•}½ΉΡ•αΡ}Ν•ΝΝ₯½Ή}½ΙΙΥΑΡ₯½Έ €μ(€€€€€€€±•Ё΅ΥЁɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιΉ•ά €μ(€€€€€€€±•Ё½έΉ•ΙΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰½ΙΙΥΑΠ΅½έΉ•Θ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€±•Ё…ΡΡ…­•ΙΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰½ΙΙΥΑΠ΅…ΡΡ…­•Θ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½έΉ•ΙΜΉ±•Έ €°€Δ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘…ΡΡ…­•ΙΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½έΉ•Θ€τ½έΉ•ΙΝlΑtμ(€€€€€€€±•Ё…ΡΡ…­•Θ€τ…ΡΡ…­•ΙΝlΑtμ((€€€€€€€±•Ё½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘½έΉ•Θ°€‰½ΙΙΥΑΠ΅½ΉΡ•αΠ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½ΉΡ•αΠ€τ½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё½Ι₯₯ΉΜ€τΩ…±Υ•Μ‘=Ι₯₯ΈθιΑ…ΙΝ” ‰‘ΡΡΐθΌΌΔΘάΈΐΈΐΈΔθΠΜΔΘ܈€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ι₯₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё‘…Ή‘±•Μ€τ(€€€€€€€€€€€Ω…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘½έΉ•Θ°½ΉΡ•αΠ°€™½Ι₯₯ΉΝlΑt°€‰½ΙΙΥΑΠ΅½ΉΡ•αΠ΅Ή½‘”ˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘‘…Ή‘±•ΜΉ±•Έ €°€Δ€μ((€€€€€€€Ι•₯ΝΡΙ乍½ΉΡ•αΡ}Ν•ΝΝ₯½ΈΉ₯ΉΝ•ΙΠ‘½ΉΡ•αΠ°…ΡΡ…­•Θ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉΩ…±₯‘…Ρ•}Ή½‘•}‘…Ή‘±” ™‘…Ή‘±•ΝlΑt€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½ΘθιUΉ­Ή½έΉ9½‘•ΥΡ‘½Ι₯Ρδ€(€€€€€€€€€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™Έ₯ΝΝΥ•‘}‘…Ή‘±•}Ι•©•ΡΝ}ΑΙ₯Ω…Ρ•}½ΉΡ•αΡ}½Ι₯₯Ή}½ΙΙΥΑΡ₯½Έ €μ(€€€€€€€±•Ё΅ΥЁɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιΉ•ά €μ(€€€€€€€±•Ё͕ΝΝ₯½ΉΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰½ΙΙΥΑΠ΅½Ι₯₯Έ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ν•ΝΝ₯½ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё͕ΝΝ₯½Έ€τΝ•ΝΝ₯½ΉΝlΑtμ(€€€€€€€±•Ё½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€‰½ΙΙΥΑΠ΅½Ι₯₯Έ΅½ΉΡ•αЈ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½ΉΡ•αΠ€τ½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё½Ι₯₯ΉΜ€τΩ…±Υ•Μ‘=Ι₯₯ΈθιΑ…ΙΝ” ‰‘ΡΡΐθΌΌΔΘάΈΐΈΐΈΔθΠΜΔΘ܈€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ι₯₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё‘…Ή‘±•Μ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘” (€€€€€€€€€€€Ν•ΝΝ₯½Έ°(€€€€€€€€€€€½ΉΡ•αΠ°(€€€€€€€€€€€€™½Ι₯₯ΉΝlΑt°(€€€€€€€€€€€€‰½ΙΙΥΑΠ΅½Ι₯₯Έ΅Ή½‘”ˆ°(€€€€€€€€€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘‘…Ή‘±•ΜΉ±•Έ €°€Δ€μ((€€€€€€€±•ЁɕΑ±…•΅•ΉΡ}½Ι₯₯ΉΜ€τΩ…±Υ•Μ‘=Ι₯₯ΈθιΑ…ΙΝ” ‰‘ΡΡΐθΌ½±½…±‘½ΝΠθΠΜΔΘ܈€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•Α±…•΅•ΉΡ}½Ι₯₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€Ι•₯ΝΡΙδ(€€€€€€€€€€€€Ή½ΉΡ•αΡ}½Ι₯₯Έ(€€€€€€€€€€€€Ή₯ΉΝ•ΙΠ‘½ΉΡ•αΠ°Ι•Α±…•΅•ΉΡ}½Ι₯₯ΉΝlΑtΉ±½Ή” €€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉΩ…±₯‘…Ρ•}Ή½‘•}‘…Ή‘±” ™‘…Ή‘±•ΝlΑt€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½ΘθιUΉ­Ή½έΉ9½‘•ΥΡ‘½Ι₯Ρδ€(€€€€€€€€€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™ΈΥΉ₯Ρ}™}•ΙΙ½Ι}ΑΙ½Α……Ρ₯½Ή}½Ω•ΙΝ}ΑΙ₯Ω…Ρ•}™…₯±}±½Ν•‘}‰½ΥΉ‘…Ι₯•Μ €μ(€€€€€€€±•Ё΅ΥЁɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιΉ•ά €μ(€€€€€€€±•Ё͕ΝΝ₯½ΉΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰‰½ΥΉ‘…Ιδ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ν•ΝΝ₯½ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё͕ΝΝ₯½Έ€τΝ•ΝΝ₯½ΉΝlΑtμ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€ˆˆ€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½Θθι%ΉΩ…±₯‘αΡ•ΙΉ…±%‘•ΉΡ₯™₯•Θ€(€€€€€€€€€μ((€€€€€€€±•Ё½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€‰‰½ΥΉ‘…Ι䡍½ΉΡ•αЈ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½ΉΡ•αΠ€τ½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё½Ι₯₯ΉΜ€τΩ…±Υ•Μ‘=Ι₯₯ΈθιΑ…ΙΝ” ‰‘ΡΡΐθΌΌΔΘάΈΐΈΐΈΔθΠΜΔΘ܈€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ι₯₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½Ι₯₯Έ€τ€™½Ι₯₯ΉΝlΑtμ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½ΉΡ•αΠ°½Ι₯₯Έ°€ˆˆ€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½Θθι%ΉΩ…±₯‘αΡ•ΙΉ…±%‘•ΉΡ₯™₯•Θ€(€€€€€€€€€μ((€€€€€€€±•Ё•Α½‘Μ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙ乍ΥΙΙ•ΉΡ}•Α½ ‘½ΉΡ•αΠ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘•Α½‘ΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё•Α½ €τ•Α½‘ΝlΑtμ(€€€€€€€Ι•₯ΝΡΙ乍½ΉΡ•αΡ}•Α½ ΉΙ•΅½Ω” ™½ΉΡ•αΠ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½ΉΡ•αΠ°½Ι₯₯Έ°€‰΅₯ΝΝ₯Ήœ΅•Α½ ΅Ή½‘”ˆ€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½ΘθιUΉ­Ή½έΉ Ι½έΝ₯Ή ½ΉΡ•αΠ€(€€€€€€€€€μ((€€€€€€€Ι•₯ΝΡΙ乍½ΉΡ•αΡ}•Α½ Ή₯ΉΝ•ΙΠ‘½ΉΡ•αΠ°•Α½ €μ(€€€€€€€±•Ё‘…Ή‘±•Μ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½ΉΡ•αΠ°½Ι₯₯Έ°€‰±₯Ω”΅Ή½‘”ˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘‘…Ή‘±•ΜΉ±•Έ €°€Δ€μ(€€€€€€€Ι•₯ΝΡΙ乍½ΉΡ•αΡ}•Α½ ΉΙ•΅½Ω” ™½ΉΡ•αΠ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉΩ…±₯‘…Ρ•}Ή½‘•}‘…Ή‘±” ™‘…Ή‘±•ΝlΑt€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½ΘθιUΉ­Ή½έΉ Ι½έΝ₯Ή ½ΉΡ•αΠ€(€€€€€€€€€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™ΈΉ½‘•}Ι•Ρ₯Ι•΅•ΉΡ}ΑΥɝ•Ν}‘ΥΑ±₯…Ρ•}ΑΙ₯Ω…Ρ•}…±₯…Ν•Ν}™…₯±}±½Ν• €μ(€€€€€€€±•Ё΅ΥЁɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιΉ•ά €μ(€€€€€€€±•Ё͕ΝΝ₯½ΉΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰Ι•Ρ₯Ι•΅•ΉΠ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ν•ΝΝ₯½ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё͕ΝΝ₯½Έ€τΝ•ΝΝ₯½ΉΝlΑtμ(€€€€€€€±•Ё½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€‰Ι•Ρ₯Ι•΅•ΉΠ΅½ΉΡ•αЈ€€μ(€€€€€€€±•Ё½Ρ‘•Ι}½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€‰½Ρ‘•Θ΅Ι•Ρ₯Ι•΅•ΉΠ΅½ΉΡ•αЈ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ρ‘•Ι}½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½ΉΡ•αΠ€τ½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё½Ρ‘•Ι}½ΉΡ•αΠ€τ½Ρ‘•Ι}½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё½Ι₯₯ΉΜ€τΩ…±Υ•Μ‘=Ι₯₯ΈθιΑ…ΙΝ” ‰‘ΡΡΐθΌΌΔΘάΈΐΈΐΈΔθΠΜΔΘ܈€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ι₯₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½Ι₯₯Έ€τ€™½Ι₯₯ΉΝlΑtμ(€€€€€€€±•Ёхɝ•ΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½ΉΡ•αΠ°½Ι₯₯Έ°€‰Ρ…ɝ•Π΅Ή½‘”ˆ€€μ(€€€€€€€±•ЁΝ₯‰±₯ΉΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½ΉΡ•αΠ°½Ι₯₯Έ°€‰Ν₯‰±₯Ήœ΅Ή½‘”ˆ€€μ(€€€€€€€±•Ё½Ρ‘•ΙΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉ‰₯Ή‘}Ή½‘”‘Ν•ΝΝ₯½Έ°½Ρ‘•Ι}½ΉΡ•αΠ°½Ι₯₯Έ°€‰½Ρ‘•Θ΅Ή½‘”ˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘хɝ•ΡΜΉ±•Έ €°€Δ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ν₯‰±₯ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½Ρ‘•ΙΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ёхɝ•Π€τ€™Ρ…ɝ•ΡΝlΑtμ(€€€€€€€±•ЁΝ₯‰±₯Ήœ€τ€™Ν₯‰±₯ΉΝlΑtμ(€€€€€€€±•Ё½Ρ‘•Θ€τ€™½Ρ‘•ΙΝlΑtμ((€€€€€€€±•Ё•Α½‘Μ€τΩ…±Υ•Μ‘½Υ΅•ΉΡΑ½ θ鹕ܑхɝ•ΠΉ‘½Υ΅•ΉΡ}•Α½  €ΉΩ…±Υ” €€¬€Δ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘•Α½‘ΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё™ΥΡΥΙ•}­•δ€τ€‘½ΉΡ•αΠ°•Α½‘ΝlΑt°€‰½ΙΙΥΑΠ΅™ΥΡΥΙ”΅…±₯…ΜˆΉΡ½}½έΉ• €€μ(€€€€€€€±•ЁΙ½ΝΝ}½ΉΡ•αΡ}­•δ€τ€ (€€€€€€€€€€€½Ρ‘•Ι}½ΉΡ•αΠ°(€€€€€€€€€€€Ρ…ɝ•ΠΉ‘½Υ΅•ΉΡ}•Α½  €°(€€€€€€€€€€€€‰½ΙΙΥΑΠ΅Ι½ΝΜ΅½ΉΡ•αΠ΅…±₯…ΜˆΉΡ½}½έΉ• €°(€€€€€€€€€μ(€€€€€€€Ι•₯ΝΡΙδ(€€€€€€€€€€€€ΉΉ½‘•}‰ε}•αΡ•ΙΉ…°(€€€€€€€€€€€€Ή₯ΉΝ•ΙΠ‘™ΥΡΥΙ•}­•乍±½Ή” €°Ρ…ɝ•ΠΉΉ½‘•}₯ €€μ(€€€€€€€Ι•₯ΝΡΙδ(€€€€€€€€€€€€ΉΉ½‘•}‰ε}•αΡ•ΙΉ…°(€€€€€€€€€€€€Ή₯ΉΝ•ΙΠ‘Ι½ΝΝ}½ΉΡ•αΡ}­•乍±½Ή” €°Ρ…ɝ•ΠΉΉ½‘•}₯ €€μ((€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΙ•΅½Ω•}Ή½‘”‘хɝ•Π€°=¬  €€€μ((€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΩ…±₯‘…Ρ•}Ή½‘•}‘…Ή‘±”‘Ν₯‰±₯Ήœ€°=¬  €€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΩ…±₯‘…Ρ•}Ή½‘•}‘…Ή‘±”‘½Ρ‘•Θ€°=¬  €€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΉ½‘•}‰ε}•αΡ•ΙΉ…°Ή•Π ™™ΥΡΥΙ•}­•δ€°9½Ή”€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΉ½‘•}‰ε}•αΡ•ΙΉ…°Ή•Π ™Ι½ΝΝ}½ΉΡ•αΡ}­•δ€°9½Ή”€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉΉ½‘•}‰₯Ή‘₯Ή}‰ε}₯Ή•Π ™Ρ…ɝ•ΠΉΉ½‘•}₯ €€°9½Ή”€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™Έ‘½Υ΅•ΉΡ}•Α½‘}•α‘…ΥΝΡ₯½Ή}₯Ν}™…₯±}±½Ν• €μ(€€€€€€€±•Ё΅ΥЁɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιΉ•ά €μ(€€€€€€€±•Ё͕ΝΝ₯½ΉΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}Ν•ΝΝ₯½Έ ‰•Α½ ΅Ν•ΝΝ₯½Έˆ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ν•ΝΝ₯½ΉΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё͕ΝΝ₯½Έ€τΝ•ΝΝ₯½ΉΝlΑtμ(€€€€€€€±•Ё½ΉΡ•αΡΜ€τΩ…±Υ•Μ‘Ι•₯ΝΡΙδΉΙ•₯ΝΡ•Ι}½ΉΡ•αΠ‘Ν•ΝΝ₯½Έ°€‰•Α½ ΅½ΉΡ•αЈ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘½ΉΡ•αΡΜΉ±•Έ €°€Δ€μ(€€€€€€€±•Ё½ΉΡ•αΠ€τ½ΉΡ•αΡΝlΑtμ(€€€€€€€±•Ё΅…α₯΅Υ΅}•Α½‘Μ€τΩ…±Υ•Μ‘½Υ΅•ΉΡΑ½ θιΉ•ά‘ΤΨΠθι5`€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘΅…α₯΅Υ΅}•Α½‘ΜΉ±•Έ €°€Δ€μ(€€€€€€€Ι•₯ΝΡΙ乍½ΉΡ•αΡ}•Α½ Ή₯ΉΝ•ΙΠ‘½ΉΡ•αΠ°΅…α₯΅Υ΅}•Α½‘ΝlΑt€μ((€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ι•₯ΝΡΙδΉ…‘Ω…Ή•}‘½Υ΅•ΉΠ‘½ΉΡ•αΠ€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½Θθ齍ա•ΉΡΑ½‘α‘…ΥΝΡ•€(€€€€€€€€€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™Έ΅½Ή½Ρ½Ή₯}₯‘•ΉΡ₯™₯•Ι}•α‘…ΥΝΡ₯½Ή}₯Ν}™…₯±}±½Ν• €μ(€€€€€€€±•Ё΅ΥЁΉ•αΠ€τ€Δμ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ρ…­•}₯‘•ΉΡ₯™₯•Θ ™΅ΥЁΉ•αΠ°€Δ€°=¬ Δ€€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ή•αΠ°€Θ€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„ (€€€€€€€€€€€Ρ…­•}₯‘•ΉΡ₯™₯•Θ ™΅ΥЁΉ•αΠ°€Δ€°(€€€€€€€€€€€ΙΘ‘ Ι½έΝ•ΙI•₯ΝΡΙεΙΙ½Θθι%‘•ΉΡ₯™₯•ΙMΑ…•α‘…ΥΝΡ•€(€€€€€€€€€μ(€€€τ((€€€€mΡ•ΝΡt(€€€™Έ΅…α₯΅Υ΅}₯‘•ΉΡ₯™₯•Ι}±₯΅₯Ρ}₯Ν}±…΅Α•‘}έ₯Ρ‘½ΥΡ}έΙ…ΑΑ₯Ήœ €μ(€€€€€€€±•Ёɕ₯ΝΡΙδ€τ Ι½έΝ•ΙΥΡ‘½Ι₯ΡεI•₯ΝΡΙδθιέ₯Ρ‘}₯‘•ΉΡ₯™₯•Ι}±₯΅₯Π‘ΤΨΠθι5`€μ(€€€€€€€…ΝΝ•ΙΡ}•Δ„‘Ι•₯ΝΡΙδΉ΅…α₯΅Υ΅}₯‘•ΉΡ₯™₯•Θ°ΤΨΠθι5`€΄€Δ€μ(€€€τ)τ( \ No newline at end of file + 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, self.maximum_identifier)?; + browser_session_id(identifier).inspect(|&session| { + self.session_by_external + .insert(external_identifier.to_owned(), session); + self.known_sessions.insert(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, self.maximum_identifier)?; + 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 + }) + }) + } + + /// Retire one browsing context and all registry-local authority derived from it. + /// + /// Retirement removes external lookup state, the current document epoch and origin, and every + /// node binding owned by the context. Monotonic context and node identifiers are never reused. + /// This revokes only OriginWeave registry-local authority; it does not prove that an external + /// browser context or process has terminated. + pub fn remove_context( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result<(), BrowserRegistryError> { + if self.context_session.remove(&browsing_context).is_none() { + return Err(BrowserRegistryError::UnknownBrowsingContext); + } + self.context_by_external + .retain(|_key, context| *context != browsing_context); + self.context_epoch.remove(&browsing_context); + self.context_origin.remove(&browsing_context); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| *context != browsing_context); + Ok(()) + } + + /// Retire one browser session and every registered context and node binding beneath it. + /// + /// Retirement removes only registry-local authority and external lookup state. Session, + /// context, and node identifiers remain strictly monotonic so a later registration of the same + /// opaque browser identifier cannot revive stale authority. External process termination is a + /// separate adapter responsibility. + pub fn remove_session( + &mut self, + browser_session: BrowserSessionId, + ) -> Result<(), BrowserRegistryError> { + if !self.known_sessions.remove(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + self.session_by_external + .retain(|_external, session| *session != browser_session); + self.context_by_external + .retain(|(session, _external), _context| *session != browser_session); + self.context_session + .retain(|_context, session| *session != browser_session); + + let live_contexts = &self.context_session; + self.context_epoch + .retain(|context, _epoch| live_contexts.contains_key(context)); + self.context_origin + .retain(|context, _origin| live_contexts.contains_key(context)); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| live_contexts.contains_key(context)); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| live_contexts.contains_key(context)); + Ok(()) + } + + /// 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)?; + 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); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| *context != browsing_context); + }) + } + + /// 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, + }); + } + let origin_is_unbound = match self.context_origin.get(&browsing_context) { + Some(expected_origin) if expected_origin != origin => { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Some(_expected_origin) => false, + None => true, + }; + let epoch = self.current_epoch(browsing_context)?; + let key = (browsing_context, epoch, external_identifier.to_owned()); + let existing = self.node_by_external.get(&key).copied(); + let node_id = match existing { + Some(node_id) => node_id, + None => take_identifier(&mut self.next_node_id, self.maximum_identifier)?, + }; + if let Some(binding) = self.node_binding_by_id.get(&node_id) { + if *binding != (browsing_context, epoch) { + return Err(BrowserRegistryError::InternalAuthorityInvariant); + } + } else if existing.is_some() { + return Err(BrowserRegistryError::InternalAuthorityInvariant); + } + + let handle = registered_node_handle( + browser_session, + browsing_context, + origin, + epoch, + node_id, + Arc::clone(&self.registry_authority), + )?; + if origin_is_unbound { + self.context_origin.insert(browsing_context, origin.clone()); + } + if existing.is_none() { + self.node_by_external.insert(key, node_id); + self.node_binding_by_id + .insert(node_id, (browsing_context, epoch)); + } + Ok(handle) + } + + /// Retire one exact live node handle without advancing the document epoch. + /// + /// This revokes only registry-local node authority. It is intended for relevant same-document + /// mutations that invalidate one actionable node while leaving the surrounding browsing + /// context and document epoch current. The node identifier is globally unique inside one + /// registry, so retirement purges every external alias that refers to that identifier; this + /// also fails safe if private lookup state was duplicated or corrupted. Retirement does not + /// claim that Chromium destroyed the underlying DOM/backend node, and the monotonic node + /// identifier is never reused. + pub fn remove_node(&mut self, handle: &ObservedNodeHandle) -> Result<(), BrowserRegistryError> { + self.validate_node_handle(handle)?; + let node_id = handle.node_id(); + self.node_binding_by_id.remove(&node_id); + self.node_by_external + .retain(|_key, bound_node_id| *bound_node_id != node_id); + Ok(()) + } + + /// Verify that an observed node handle is still live authority in this registry. + /// + /// This check must run immediately before a node-local browser action. It re-derives the + /// current session, context, origin, and document epoch from registry-owned state, requires the + /// handle to have been issued by this exact registry instance, and resolves the node through a + /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, + /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. + pub fn validate_node_handle( + &self, + handle: &ObservedNodeHandle, + ) -> Result<(), BrowserRegistryError> { + if !handle.belongs_to(&self.registry_authority) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + if !self.known_sessions.contains(&handle.browser_session()) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let context = handle.browsing_context(); + let expected_session = self + .context_session + .get(&context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != handle.browser_session() { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + let epoch = self.current_epoch(context)?; + let origin = self + .context_origin + .get(&context) + .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; + handle + .validate_current(expected_session, context, origin, epoch) + .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; + if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + Ok(()) + } +} + +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 observed node handle is not a current node binding owned by this registry. + UnknownNodeAuthority, + /// The registry exhausted one of its monotonic internal identifier spaces. + IdentifierSpaceExhausted, + /// A document epoch reached the maximum representable value. + DocumentEpochExhausted, + /// A private registry consistency invariant was violated. + InternalAuthorityInvariant, +} + +impl fmt::Display for BrowserRegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidExternalIdentifier => write!( + formatter, + "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} 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::UnknownNodeAuthority => formatter + .write_str("observed node handle is not registered as current browser authority"), + 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, maximum_identifier: u64) -> Result { + if *next > maximum_identifier { + return Err(BrowserRegistryError::IdentifierSpaceExhausted); + } + let identifier = *next; + *next = identifier + 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 registered_node_handle( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + document_epoch: DocumentEpoch, + node_id: u64, + registry_authority: Arc<()>, +) -> Result { + ObservedNodeHandle::registered( + browser_session, + browsing_context, + origin.clone(), + document_epoch, + node_id, + registry_authority, + ) + .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn values(result: Result) -> Vec { + result.into_iter().collect() + } + + #[test] + fn unregistered_handle_equality_covers_all_authority_states() { + 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); + let session = sessions[0]; + let context = contexts[0]; + let epoch = epochs[0]; + let origin = origins[0].clone(); + + let first = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let different = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 2, + )); + assert_eq!(first.len(), 1); + assert_eq!(same.len(), 1); + assert_eq!(different.len(), 1); + assert_eq!(first[0], same[0]); + assert_ne!(first[0], different[0]); + + let registered = values(ObservedNodeHandle::registered( + session, + context, + origin, + epoch, + 1, + Arc::new(()), + )); + assert_eq!(registered.len(), 1); + assert_ne!(first[0], registered[0]); + } + + #[test] + fn helper_invariants_and_reverse_index_corruption_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 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!( + registered_node_handle( + sessions[0], + contexts[0], + &origins[0], + epochs[0], + 0, + Arc::new(()) + ) + .is_err() + ); + + let mut registry = BrowserAuthorityRegistry::new(); + let registered_sessions = values(registry.register_session("corrupt-session")); + assert_eq!(registered_sessions.len(), 1); + let session = registered_sessions[0]; + let registered_contexts = values(registry.register_context(session, "corrupt-context")); + assert_eq!(registered_contexts.len(), 1); + let context = registered_contexts[0]; + let origin = &origins[0]; + let handles = values(registry.bind_node(session, context, origin, "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + registry.node_binding_by_id.remove(&handle.node_id()); + assert_eq!( + registry.bind_node(session, context, origin, "node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + + registry + .node_binding_by_id + .insert(handle.node_id(), (context, epochs[0])); + registry.node_by_external.clear(); + let other_contexts = values(registry.register_context(session, "other-context")); + assert_eq!(other_contexts.len(), 1); + let other_context = other_contexts[0]; + registry.node_by_external.insert( + (other_context, epochs[0], "other-node".to_owned()), + handle.node_id(), + ); + assert_eq!( + registry.bind_node(session, other_context, origin, "other-node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + + let zero_epochs = values(registry.current_epoch(context)); + assert_eq!(zero_epochs.len(), 1); + let zero_epoch = zero_epochs[0]; + registry + .node_by_external + .insert((context, zero_epoch, "zero-node".to_owned()), 0); + registry.node_binding_by_id.insert(0, (context, zero_epoch)); + assert_eq!( + registry.bind_node(session, context, origin, "zero-node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + } + + #[test] + fn validation_reverse_index_rejects_missing_binding() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let handles = values(registry.bind_node(session, context, &origins[0], "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + registry.node_binding_by_id.remove(&handle.node_id()); + assert_eq!( + registry.validate_node_handle(handle), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + } + + #[test] + fn issued_handle_rejects_private_context_session_corruption() { + let mut registry = BrowserAuthorityRegistry::new(); + let owners = values(registry.register_session("corrupt-owner-session")); + let attackers = values(registry.register_session("corrupt-attacker-session")); + assert_eq!(owners.len(), 1); + assert_eq!(attackers.len(), 1); + let owner = owners[0]; + let attacker = attackers[0]; + + let contexts = values(registry.register_context(owner, "corrupt-context-session")); + 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 handles = values(registry.bind_node( + owner, + context, + &origins[0], + "corrupt-context-node", + )); + assert_eq!(handles.len(), 1); + + registry.context_session.insert(context, attacker); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + } + + #[test] + fn unit_cfg_error_propagation_covers_private_fail_closed_boundaries() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("boundary-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + assert_eq!( + registry.register_context(session, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let contexts = values(registry.register_context(session, "boundary-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]; + assert_eq!( + registry.bind_node(session, context, origin, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + registry.context_epoch.remove(&context); + assert_eq!( + registry.bind_node(session, context, origin, "missing-epoch-node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + registry.context_epoch.insert(context, epoch); + let handles = values(registry.bind_node(session, context, origin, "live-node")); + assert_eq!(handles.len(), 1); + registry.context_epoch.remove(&context); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + } + + #[test] + fn node_retirement_purges_duplicate_private_aliases_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("retirement-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-context")); + let other_contexts = values(registry.register_context(session, "other-retirement-context")); + assert_eq!(contexts.len(), 1); + assert_eq!(other_contexts.len(), 1); + let context = contexts[0]; + let other_context = other_contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + let targets = values(registry.bind_node(session, context, origin, "target-node")); + let siblings = values(registry.bind_node(session, context, origin, "sibling-node")); + let others = values(registry.bind_node(session, other_context, origin, "other-node")); + assert_eq!(targets.len(), 1); + assert_eq!(siblings.len(), 1); + assert_eq!(others.len(), 1); + let target = &targets[0]; + let sibling = &siblings[0]; + let other = &others[0]; + + let epochs = values(DocumentEpoch::new(target.document_epoch().value() + 1)); + assert_eq!(epochs.len(), 1); + let future_key = (context, epochs[0], "corrupt-future-alias".to_owned()); + let cross_context_key = ( + other_context, + target.document_epoch(), + "corrupt-cross-context-alias".to_owned(), + ); + registry + .node_by_external + .insert(future_key.clone(), target.node_id()); + registry + .node_by_external + .insert(cross_context_key.clone(), target.node_id()); + + assert_eq!(registry.remove_node(target), Ok(())); + + assert_eq!(registry.validate_node_handle(sibling), Ok(())); + assert_eq!(registry.validate_node_handle(other), Ok(())); + assert_eq!(registry.node_by_external.get(&future_key), None); + assert_eq!(registry.node_by_external.get(&cross_context_key), None); + assert_eq!(registry.node_binding_by_id.get(&target.node_id()), None); + } + + #[test] + fn document_epoch_exhaustion_is_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("epoch-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "epoch-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + 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) + ); + } + + #[test] + fn monotonic_identifier_exhaustion_is_fail_closed() { + let mut next = 1; + assert_eq!(take_identifier(&mut next, 1), Ok(1)); + assert_eq!(next, 2); + assert_eq!( + take_identifier(&mut next, 1), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + } + + #[test] + fn maximum_identifier_limit_is_clamped_without_wrapping() { + let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); + assert_eq!(registry.maximum_identifier, u64::MAX - 1); + } +} From c8a357caee918a2fbd8d0d763918bd471b07a83a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:23:52 -0700 Subject: [PATCH 091/112] test(browser): cover corrupted origin authority rejection --- .../originweave-core/src/browser_registry.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 090d7b01f..0669e457c 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -773,6 +773,34 @@ mod tests { ); } + #[test] + fn issued_handle_rejects_private_context_origin_corruption() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("corrupt-origin-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "corrupt-origin-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + let corrupt_origins = values(Origin::parse("http://127.0.0.1:43128")); + assert_eq!(origins.len(), 1); + assert_eq!(corrupt_origins.len(), 1); + let handles = values(registry.bind_node( + session, + context, + &origins[0], + "corrupt-origin-node", + )); + assert_eq!(handles.len(), 1); + + registry.context_origin.insert(context, corrupt_origins[0].clone()); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + } + #[test] fn unit_cfg_error_propagation_covers_private_fail_closed_boundaries() { let mut registry = BrowserAuthorityRegistry::new(); From 562b4b787317497308738868780ddc5c5e0bc187 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:04:18 -0700 Subject: [PATCH 092/112] style(browser): apply canonical Rust formatting --- .../originweave-core/src/browser_registry.rs | 399 +++++++----------- 1 file changed, 160 insertions(+), 239 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 0669e457c..23b6d69ab 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -419,13 +419,13 @@ impl BrowserAuthorityRegistry { Ok(()) } - /// Verify that an observed node handle is still live authority in this registry. + /// Validate that an observed node handle still carries live registry-local authority. /// - /// This check must run immediately before a node-local browser action. It re-derives the - /// current session, context, origin, and document epoch from registry-owned state, requires the - /// handle to have been issued by this exact registry instance, and resolves the node through a - /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, - /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. + /// This check must run immediately before a node-local browser action. It verifies the + /// registry-instance issuance token before considering any attacker-controlled tuple fields, + /// then requires the exact live session/context relationship, current document epoch, pinned + /// canonical origin, and reverse-index node binding. Forged, cross-registry, retired, stale, + /// or privately inconsistent authority fails closed. pub fn validate_node_handle( &self, handle: &ObservedNodeHandle, @@ -466,65 +466,63 @@ impl Default for BrowserAuthorityRegistry { } } -/// A fail-closed error produced while translating external browser identifiers into local authority. +/// A deterministic registry error that never includes the rejected opaque browser identifier. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An external identifier was empty or exceeded the reviewed byte bound. + /// An adapter identifier was empty or exceeded the reviewed retained-size bound. InvalidExternalIdentifier, - /// The supplied OriginWeave browser session is not registered in this registry. + /// The requested session has not been registered in this registry. UnknownBrowserSession, - /// The supplied OriginWeave browsing context is not registered in this registry. + /// The requested browsing context has not been registered in this registry. UnknownBrowsingContext, - /// The browsing context belongs to another browser session. + /// A context was presented with a different browser session than the one that registered it. ContextSessionMismatch { - /// Session that owns the registered context. + /// Session that owns the context. expected: BrowserSessionId, - /// Session supplied by the current caller. + /// Session supplied by the caller. actual: BrowserSessionId, }, - /// The context origin changed without first rotating the document epoch. + /// The caller attempted to change canonical origin without advancing the document epoch. OriginChangedWithoutDocumentAdvance, - /// The observed node handle is not a current node binding owned by this registry. - UnknownNodeAuthority, - /// The registry exhausted one of its monotonic internal identifier spaces. - IdentifierSpaceExhausted, - /// A document epoch reached the maximum representable value. + /// A session/context/node namespace has reached its configured monotonic allocation limit. + AuthorityIdentifierExhausted, + /// The current document epoch cannot be advanced without wrapping its identifier. DocumentEpochExhausted, - /// A private registry consistency invariant was violated. + /// A node handle was not issued by this registry or its exact live binding was revoked. + UnknownNodeAuthority, + /// Private registry indexes disagree about the authority represented by one node identifier. InternalAuthorityInvariant, } impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidExternalIdentifier => write!( - formatter, - "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} 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") + formatter.write_str("browser session is not registered") } Self::UnknownBrowsingContext => { - formatter.write_str("browsing context is not registered in this authority registry") + formatter.write_str("browsing context is not registered") } - 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::UnknownNodeAuthority => formatter - .write_str("observed node handle is not registered as current browser authority"), - Self::IdentifierSpaceExhausted => { - formatter.write_str("browser authority identifier space is exhausted") + Self::ContextSessionMismatch { .. } => { + formatter.write_str("browsing context belongs to a different browser session") + } + Self::OriginChangedWithoutDocumentAdvance => { + formatter.write_str("browser origin changed without a document-epoch advance") + } + Self::AuthorityIdentifierExhausted => { + formatter.write_str("browser authority identifier namespace is exhausted") } Self::DocumentEpochExhausted => { - formatter.write_str("browser document epoch space is exhausted") + formatter.write_str("browser document epoch is exhausted") + } + Self::UnknownNodeAuthority => { + formatter.write_str("browser node authority is not live in this registry") } Self::InternalAuthorityInvariant => { - formatter.write_str("browser authority registry violated a nonzero invariant") + formatter.write_str("browser registry authority indexes are inconsistent") } } } @@ -532,8 +530,10 @@ impl fmt::Display for BrowserRegistryError { impl std::error::Error for BrowserRegistryError {} -fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { - if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { +fn validate_external_identifier(external_identifier: &str) -> Result<(), BrowserRegistryError> { + if external_identifier.is_empty() + || external_identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + { return Err(BrowserRegistryError::InvalidExternalIdentifier); } Ok(()) @@ -541,7 +541,7 @@ fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryE fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { if *next > maximum_identifier { - return Err(BrowserRegistryError::IdentifierSpaceExhausted); + return Err(BrowserRegistryError::AuthorityIdentifierExhausted); } let identifier = *next; *next = identifier + 1; @@ -588,57 +588,63 @@ mod tests { } #[test] - fn unregistered_handle_equality_covers_all_authority_states() { - 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")); + fn document_epoch_exhaustion_is_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("session")); assert_eq!(sessions.len(), 1); - assert_eq!(contexts.len(), 1); - assert_eq!(epochs.len(), 1); - assert_eq!(origins.len(), 1); let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + assert_eq!(contexts.len(), 1); let context = contexts[0]; - let epoch = epochs[0]; - let origin = origins[0].clone(); + registry + .context_epoch + .insert(context, DocumentEpoch::new(u64::MAX).unwrap()); + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::DocumentEpochExhausted) + ); + } - let first = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 1, - )); - let same = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 1, - )); - let different = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 2, - )); - assert_eq!(first.len(), 1); - assert_eq!(same.len(), 1); - assert_eq!(different.len(), 1); - assert_eq!(first[0], same[0]); - assert_ne!(first[0], different[0]); + #[test] + fn monotonic_identifier_exhaustion_is_fail_closed() { + let mut sessions = BrowserAuthorityRegistry::with_identifier_limit(1); + assert!(sessions.register_session("one").is_ok()); + assert_eq!( + sessions.register_session("two"), + Err(BrowserRegistryError::AuthorityIdentifierExhausted) + ); - let registered = values(ObservedNodeHandle::registered( - session, - context, - origin, - epoch, - 1, - Arc::new(()), - )); - assert_eq!(registered.len(), 1); - assert_ne!(first[0], registered[0]); + let mut contexts = BrowserAuthorityRegistry::with_identifier_limit(1); + let owner = values(contexts.register_session("owner")); + assert_eq!(owner.len(), 1); + let owner = owner[0]; + assert!(contexts.register_context(owner, "one").is_ok()); + assert_eq!( + contexts.register_context(owner, "two"), + Err(BrowserRegistryError::AuthorityIdentifierExhausted) + ); + + let mut nodes = BrowserAuthorityRegistry::with_identifier_limit(1); + let owner = values(nodes.register_session("owner")); + assert_eq!(owner.len(), 1); + let owner = owner[0]; + let context = values(nodes.register_context(owner, "context")); + assert_eq!(context.len(), 1); + let context = context[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + assert!(nodes.bind_node(owner, context, origin, "one").is_ok()); + assert_eq!( + nodes.bind_node(owner, context, origin, "two"), + Err(BrowserRegistryError::AuthorityIdentifierExhausted) + ); + } + + #[test] + fn maximum_identifier_limit_is_clamped_without_wrapping() { + let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); + assert_eq!(registry.maximum_identifier, u64::MAX - 1); } #[test] @@ -656,66 +662,31 @@ mod tests { Err(BrowserRegistryError::InternalAuthorityInvariant) ); - 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")); + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("session")); assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); assert_eq!(contexts.len(), 1); - assert_eq!(epochs.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - assert!( - registered_node_handle( - sessions[0], - contexts[0], - &origins[0], - epochs[0], - 0, - Arc::new(()) - ) - .is_err() - ); - - let mut registry = BrowserAuthorityRegistry::new(); - let registered_sessions = values(registry.register_session("corrupt-session")); - assert_eq!(registered_sessions.len(), 1); - let session = registered_sessions[0]; - let registered_contexts = values(registry.register_context(session, "corrupt-context")); - assert_eq!(registered_contexts.len(), 1); - let context = registered_contexts[0]; let origin = &origins[0]; - let handles = values(registry.bind_node(session, context, origin, "node")); - assert_eq!(handles.len(), 1); - let handle = &handles[0]; - registry.node_binding_by_id.remove(&handle.node_id()); - assert_eq!( - registry.bind_node(session, context, origin, "node"), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); + registry + .node_by_external + .insert((context, DocumentEpoch::new(1).unwrap(), "corrupt".into()), 0); registry .node_binding_by_id - .insert(handle.node_id(), (context, epochs[0])); - registry.node_by_external.clear(); - let other_contexts = values(registry.register_context(session, "other-context")); - assert_eq!(other_contexts.len(), 1); - let other_context = other_contexts[0]; - registry.node_by_external.insert( - (other_context, epochs[0], "other-node".to_owned()), - handle.node_id(), - ); + .insert(0, (context, DocumentEpoch::new(1).unwrap())); assert_eq!( - registry.bind_node(session, other_context, origin, "other-node"), + registry.bind_node(session, context, origin, "corrupt"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); - let zero_epochs = values(registry.current_epoch(context)); - assert_eq!(zero_epochs.len(), 1); - let zero_epoch = zero_epochs[0]; - registry - .node_by_external - .insert((context, zero_epoch, "zero-node".to_owned()), 0); - registry.node_binding_by_id.insert(0, (context, zero_epoch)); + registry.node_binding_by_id.clear(); + registry.node_by_external.clear(); + registry.next_node_id = 0; assert_eq!( registry.bind_node(session, context, origin, "zero-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) @@ -758,12 +729,8 @@ mod tests { let context = contexts[0]; let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - let handles = values(registry.bind_node( - owner, - context, - &origins[0], - "corrupt-context-node", - )); + let handles = + values(registry.bind_node(owner, context, &origins[0], "corrupt-context-node")); assert_eq!(handles.len(), 1); registry.context_session.insert(context, attacker); @@ -786,15 +753,13 @@ mod tests { let corrupt_origins = values(Origin::parse("http://127.0.0.1:43128")); assert_eq!(origins.len(), 1); assert_eq!(corrupt_origins.len(), 1); - let handles = values(registry.bind_node( - session, - context, - &origins[0], - "corrupt-origin-node", - )); + let handles = + values(registry.bind_node(session, context, &origins[0], "corrupt-origin-node")); assert_eq!(handles.len(), 1); - registry.context_origin.insert(context, corrupt_origins[0].clone()); + registry + .context_origin + .insert(context, corrupt_origins[0].clone()); assert_eq!( registry.validate_node_handle(&handles[0]), Err(BrowserRegistryError::UnknownNodeAuthority) @@ -818,112 +783,68 @@ mod tests { let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); let origin = &origins[0]; + + assert!(registry.bind_node(session, context, origin, "good-node").is_ok()); assert_eq!( registry.bind_node(session, context, origin, ""), Err(BrowserRegistryError::InvalidExternalIdentifier) ); - let epochs = values(registry.current_epoch(context)); - assert_eq!(epochs.len(), 1); - let epoch = epochs[0]; - registry.context_epoch.remove(&context); - assert_eq!( - registry.bind_node(session, context, origin, "missing-epoch-node"), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - - registry.context_epoch.insert(context, epoch); - let handles = values(registry.bind_node(session, context, origin, "live-node")); - assert_eq!(handles.len(), 1); registry.context_epoch.remove(&context); assert_eq!( - registry.validate_node_handle(&handles[0]), + registry.bind_node(session, context, origin, "missing-epoch"), Err(BrowserRegistryError::UnknownBrowsingContext) ); } #[test] - fn node_retirement_purges_duplicate_private_aliases_fail_closed() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("retirement-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "retirement-context")); - let other_contexts = values(registry.register_context(session, "other-retirement-context")); - assert_eq!(contexts.len(), 1); - assert_eq!(other_contexts.len(), 1); - let context = contexts[0]; - let other_context = other_contexts[0]; + fn unregistered_handle_equality_covers_all_authority_states() { + let session = BrowserSessionId::new(1).unwrap(); + let context = BrowsingContextId::new(1).unwrap(); + let epoch = DocumentEpoch::new(1).unwrap(); let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - let origin = &origins[0]; - let targets = values(registry.bind_node(session, context, origin, "target-node")); - let siblings = values(registry.bind_node(session, context, origin, "sibling-node")); - let others = values(registry.bind_node(session, other_context, origin, "other-node")); - assert_eq!(targets.len(), 1); - assert_eq!(siblings.len(), 1); - assert_eq!(others.len(), 1); - let target = &targets[0]; - let sibling = &siblings[0]; - let other = &others[0]; - - let epochs = values(DocumentEpoch::new(target.document_epoch().value() + 1)); - assert_eq!(epochs.len(), 1); - let future_key = (context, epochs[0], "corrupt-future-alias".to_owned()); - let cross_context_key = ( - other_context, - target.document_epoch(), - "corrupt-cross-context-alias".to_owned(), - ); - registry - .node_by_external - .insert(future_key.clone(), target.node_id()); - registry - .node_by_external - .insert(cross_context_key.clone(), target.node_id()); - - assert_eq!(registry.remove_node(target), Ok(())); - - assert_eq!(registry.validate_node_handle(sibling), Ok(())); - assert_eq!(registry.validate_node_handle(other), Ok(())); - assert_eq!(registry.node_by_external.get(&future_key), None); - assert_eq!(registry.node_by_external.get(&cross_context_key), None); - assert_eq!(registry.node_binding_by_id.get(&target.node_id()), None); - } + let unregistered = values(ObservedNodeHandle::new( + session, + context, + origins[0].clone(), + epoch, + 1, + )); + let another_unregistered = values(ObservedNodeHandle::new( + session, + context, + origins[0].clone(), + epoch, + 1, + )); + assert_eq!(unregistered.len(), 1); + assert_eq!(another_unregistered.len(), 1); + assert_eq!(unregistered[0], another_unregistered[0]); - #[test] - fn document_epoch_exhaustion_is_fail_closed() { let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("epoch-session")); + let sessions = values(registry.register_session("eq-session")); assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "epoch-context")); + let registered_session = sessions[0]; + let contexts = values(registry.register_context(registered_session, "eq-context")); assert_eq!(contexts.len(), 1); - let context = contexts[0]; - 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) - ); - } - - #[test] - fn monotonic_identifier_exhaustion_is_fail_closed() { - let mut next = 1; - assert_eq!(take_identifier(&mut next, 1), Ok(1)); - assert_eq!(next, 2); - assert_eq!( - take_identifier(&mut next, 1), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - } - - #[test] - fn maximum_identifier_limit_is_clamped_without_wrapping() { - let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); - assert_eq!(registry.maximum_identifier, u64::MAX - 1); + let registered_context = contexts[0]; + let registered = values(registry.bind_node( + registered_session, + registered_context, + &origins[0], + "eq-node", + )); + assert_eq!(registered.len(), 1); + let registered = ®istered[0]; + let same_tuple_without_authority = values(ObservedNodeHandle::new( + registered.browser_session(), + registered.browsing_context(), + registered.origin().clone(), + registered.document_epoch(), + registered.node_id(), + )); + assert_eq!(same_tuple_without_authority.len(), 1); + assert_ne!(registered, &same_tuple_without_authority[0]); } } From 8452f7d2d4ee67fcd5721e3034645ababece80a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:11:55 -0700 Subject: [PATCH 093/112] fix(browser): restore current registry branch before coverage repair --- .../originweave-core/src/browser_registry.rs | 399 +++++++++++------- 1 file changed, 239 insertions(+), 160 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 23b6d69ab..0669e457c 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -419,13 +419,13 @@ impl BrowserAuthorityRegistry { Ok(()) } - /// Validate that an observed node handle still carries live registry-local authority. + /// Verify that an observed node handle is still live authority in this registry. /// - /// This check must run immediately before a node-local browser action. It verifies the - /// registry-instance issuance token before considering any attacker-controlled tuple fields, - /// then requires the exact live session/context relationship, current document epoch, pinned - /// canonical origin, and reverse-index node binding. Forged, cross-registry, retired, stale, - /// or privately inconsistent authority fails closed. + /// This check must run immediately before a node-local browser action. It re-derives the + /// current session, context, origin, and document epoch from registry-owned state, requires the + /// handle to have been issued by this exact registry instance, and resolves the node through a + /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, + /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. pub fn validate_node_handle( &self, handle: &ObservedNodeHandle, @@ -466,63 +466,65 @@ impl Default for BrowserAuthorityRegistry { } } -/// A deterministic registry error that never includes the rejected opaque browser identifier. +/// A fail-closed error produced while translating external browser identifiers into local authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An adapter identifier was empty or exceeded the reviewed retained-size bound. + /// An external identifier was empty or exceeded the reviewed byte bound. InvalidExternalIdentifier, - /// The requested session has not been registered in this registry. + /// The supplied OriginWeave browser session is not registered in this registry. UnknownBrowserSession, - /// The requested browsing context has not been registered in this registry. + /// The supplied OriginWeave browsing context is not registered in this registry. UnknownBrowsingContext, - /// A context was presented with a different browser session than the one that registered it. + /// The browsing context belongs to another browser session. ContextSessionMismatch { - /// Session that owns the context. + /// Session that owns the registered context. expected: BrowserSessionId, - /// Session supplied by the caller. + /// Session supplied by the current caller. actual: BrowserSessionId, }, - /// The caller attempted to change canonical origin without advancing the document epoch. + /// The context origin changed without first rotating the document epoch. OriginChangedWithoutDocumentAdvance, - /// A session/context/node namespace has reached its configured monotonic allocation limit. - AuthorityIdentifierExhausted, - /// The current document epoch cannot be advanced without wrapping its identifier. - DocumentEpochExhausted, - /// A node handle was not issued by this registry or its exact live binding was revoked. + /// The observed node handle is not a current node binding owned by this registry. UnknownNodeAuthority, - /// Private registry indexes disagree about the authority represented by one node identifier. + /// The registry exhausted one of its monotonic internal identifier spaces. + IdentifierSpaceExhausted, + /// A document epoch reached the maximum representable value. + DocumentEpochExhausted, + /// A private registry consistency 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::InvalidExternalIdentifier => write!( + formatter, + "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" ), Self::UnknownBrowserSession => { - formatter.write_str("browser session is not registered") + formatter.write_str("browser session is not registered in this authority registry") } Self::UnknownBrowsingContext => { - formatter.write_str("browsing context is not registered") - } - Self::ContextSessionMismatch { .. } => { - formatter.write_str("browsing context belongs to a different browser session") + formatter.write_str("browsing context is not registered in this authority registry") } - Self::OriginChangedWithoutDocumentAdvance => { - formatter.write_str("browser origin changed without a document-epoch advance") - } - Self::AuthorityIdentifierExhausted => { - formatter.write_str("browser authority identifier namespace is exhausted") + 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::UnknownNodeAuthority => formatter + .write_str("observed node handle is not registered as current browser authority"), + Self::IdentifierSpaceExhausted => { + formatter.write_str("browser authority identifier space is exhausted") } Self::DocumentEpochExhausted => { - formatter.write_str("browser document epoch is exhausted") - } - Self::UnknownNodeAuthority => { - formatter.write_str("browser node authority is not live in this registry") + formatter.write_str("browser document epoch space is exhausted") } Self::InternalAuthorityInvariant => { - formatter.write_str("browser registry authority indexes are inconsistent") + formatter.write_str("browser authority registry violated a nonzero invariant") } } } @@ -530,10 +532,8 @@ impl fmt::Display for BrowserRegistryError { impl std::error::Error for BrowserRegistryError {} -fn validate_external_identifier(external_identifier: &str) -> Result<(), BrowserRegistryError> { - if external_identifier.is_empty() - || external_identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES - { +fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { + if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { return Err(BrowserRegistryError::InvalidExternalIdentifier); } Ok(()) @@ -541,7 +541,7 @@ fn validate_external_identifier(external_identifier: &str) -> Result<(), Browser fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { if *next > maximum_identifier { - return Err(BrowserRegistryError::AuthorityIdentifierExhausted); + return Err(BrowserRegistryError::IdentifierSpaceExhausted); } let identifier = *next; *next = identifier + 1; @@ -588,63 +588,57 @@ mod tests { } #[test] - fn document_epoch_exhaustion_is_fail_closed() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("session")); + fn unregistered_handle_equality_covers_all_authority_states() { + 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); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "context")); assert_eq!(contexts.len(), 1); + assert_eq!(epochs.len(), 1); + assert_eq!(origins.len(), 1); + let session = sessions[0]; let context = contexts[0]; - registry - .context_epoch - .insert(context, DocumentEpoch::new(u64::MAX).unwrap()); - assert_eq!( - registry.advance_document(context), - Err(BrowserRegistryError::DocumentEpochExhausted) - ); - } - - #[test] - fn monotonic_identifier_exhaustion_is_fail_closed() { - let mut sessions = BrowserAuthorityRegistry::with_identifier_limit(1); - assert!(sessions.register_session("one").is_ok()); - assert_eq!( - sessions.register_session("two"), - Err(BrowserRegistryError::AuthorityIdentifierExhausted) - ); + let epoch = epochs[0]; + let origin = origins[0].clone(); - let mut contexts = BrowserAuthorityRegistry::with_identifier_limit(1); - let owner = values(contexts.register_session("owner")); - assert_eq!(owner.len(), 1); - let owner = owner[0]; - assert!(contexts.register_context(owner, "one").is_ok()); - assert_eq!( - contexts.register_context(owner, "two"), - Err(BrowserRegistryError::AuthorityIdentifierExhausted) - ); - - let mut nodes = BrowserAuthorityRegistry::with_identifier_limit(1); - let owner = values(nodes.register_session("owner")); - assert_eq!(owner.len(), 1); - let owner = owner[0]; - let context = values(nodes.register_context(owner, "context")); - assert_eq!(context.len(), 1); - let context = context[0]; - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(origins.len(), 1); - let origin = &origins[0]; - assert!(nodes.bind_node(owner, context, origin, "one").is_ok()); - assert_eq!( - nodes.bind_node(owner, context, origin, "two"), - Err(BrowserRegistryError::AuthorityIdentifierExhausted) - ); - } + let first = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let different = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 2, + )); + assert_eq!(first.len(), 1); + assert_eq!(same.len(), 1); + assert_eq!(different.len(), 1); + assert_eq!(first[0], same[0]); + assert_ne!(first[0], different[0]); - #[test] - fn maximum_identifier_limit_is_clamped_without_wrapping() { - let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); - assert_eq!(registry.maximum_identifier, u64::MAX - 1); + let registered = values(ObservedNodeHandle::registered( + session, + context, + origin, + epoch, + 1, + Arc::new(()), + )); + assert_eq!(registered.len(), 1); + assert_ne!(first[0], registered[0]); } #[test] @@ -662,31 +656,66 @@ mod tests { Err(BrowserRegistryError::InternalAuthorityInvariant) ); - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("session")); + 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); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "context")); assert_eq!(contexts.len(), 1); - let context = contexts[0]; - let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(epochs.len(), 1); assert_eq!(origins.len(), 1); + assert!( + registered_node_handle( + sessions[0], + contexts[0], + &origins[0], + epochs[0], + 0, + Arc::new(()) + ) + .is_err() + ); + + let mut registry = BrowserAuthorityRegistry::new(); + let registered_sessions = values(registry.register_session("corrupt-session")); + assert_eq!(registered_sessions.len(), 1); + let session = registered_sessions[0]; + let registered_contexts = values(registry.register_context(session, "corrupt-context")); + assert_eq!(registered_contexts.len(), 1); + let context = registered_contexts[0]; let origin = &origins[0]; + let handles = values(registry.bind_node(session, context, origin, "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + registry.node_binding_by_id.remove(&handle.node_id()); + assert_eq!( + registry.bind_node(session, context, origin, "node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); - registry - .node_by_external - .insert((context, DocumentEpoch::new(1).unwrap(), "corrupt".into()), 0); registry .node_binding_by_id - .insert(0, (context, DocumentEpoch::new(1).unwrap())); + .insert(handle.node_id(), (context, epochs[0])); + registry.node_by_external.clear(); + let other_contexts = values(registry.register_context(session, "other-context")); + assert_eq!(other_contexts.len(), 1); + let other_context = other_contexts[0]; + registry.node_by_external.insert( + (other_context, epochs[0], "other-node".to_owned()), + handle.node_id(), + ); assert_eq!( - registry.bind_node(session, context, origin, "corrupt"), + registry.bind_node(session, other_context, origin, "other-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); - registry.node_binding_by_id.clear(); - registry.node_by_external.clear(); - registry.next_node_id = 0; + let zero_epochs = values(registry.current_epoch(context)); + assert_eq!(zero_epochs.len(), 1); + let zero_epoch = zero_epochs[0]; + registry + .node_by_external + .insert((context, zero_epoch, "zero-node".to_owned()), 0); + registry.node_binding_by_id.insert(0, (context, zero_epoch)); assert_eq!( registry.bind_node(session, context, origin, "zero-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) @@ -729,8 +758,12 @@ mod tests { let context = contexts[0]; let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - let handles = - values(registry.bind_node(owner, context, &origins[0], "corrupt-context-node")); + let handles = values(registry.bind_node( + owner, + context, + &origins[0], + "corrupt-context-node", + )); assert_eq!(handles.len(), 1); registry.context_session.insert(context, attacker); @@ -753,13 +786,15 @@ mod tests { let corrupt_origins = values(Origin::parse("http://127.0.0.1:43128")); assert_eq!(origins.len(), 1); assert_eq!(corrupt_origins.len(), 1); - let handles = - values(registry.bind_node(session, context, &origins[0], "corrupt-origin-node")); + let handles = values(registry.bind_node( + session, + context, + &origins[0], + "corrupt-origin-node", + )); assert_eq!(handles.len(), 1); - registry - .context_origin - .insert(context, corrupt_origins[0].clone()); + registry.context_origin.insert(context, corrupt_origins[0].clone()); assert_eq!( registry.validate_node_handle(&handles[0]), Err(BrowserRegistryError::UnknownNodeAuthority) @@ -783,68 +818,112 @@ mod tests { let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); let origin = &origins[0]; - - assert!(registry.bind_node(session, context, origin, "good-node").is_ok()); assert_eq!( registry.bind_node(session, context, origin, ""), Err(BrowserRegistryError::InvalidExternalIdentifier) ); + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + registry.context_epoch.remove(&context); + assert_eq!( + registry.bind_node(session, context, origin, "missing-epoch-node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + registry.context_epoch.insert(context, epoch); + let handles = values(registry.bind_node(session, context, origin, "live-node")); + assert_eq!(handles.len(), 1); registry.context_epoch.remove(&context); assert_eq!( - registry.bind_node(session, context, origin, "missing-epoch"), + registry.validate_node_handle(&handles[0]), Err(BrowserRegistryError::UnknownBrowsingContext) ); } #[test] - fn unregistered_handle_equality_covers_all_authority_states() { - let session = BrowserSessionId::new(1).unwrap(); - let context = BrowsingContextId::new(1).unwrap(); - let epoch = DocumentEpoch::new(1).unwrap(); + fn node_retirement_purges_duplicate_private_aliases_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("retirement-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-context")); + let other_contexts = values(registry.register_context(session, "other-retirement-context")); + assert_eq!(contexts.len(), 1); + assert_eq!(other_contexts.len(), 1); + let context = contexts[0]; + let other_context = other_contexts[0]; let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - let unregistered = values(ObservedNodeHandle::new( - session, - context, - origins[0].clone(), - epoch, - 1, - )); - let another_unregistered = values(ObservedNodeHandle::new( - session, - context, - origins[0].clone(), - epoch, - 1, - )); - assert_eq!(unregistered.len(), 1); - assert_eq!(another_unregistered.len(), 1); - assert_eq!(unregistered[0], another_unregistered[0]); + let origin = &origins[0]; + let targets = values(registry.bind_node(session, context, origin, "target-node")); + let siblings = values(registry.bind_node(session, context, origin, "sibling-node")); + let others = values(registry.bind_node(session, other_context, origin, "other-node")); + assert_eq!(targets.len(), 1); + assert_eq!(siblings.len(), 1); + assert_eq!(others.len(), 1); + let target = &targets[0]; + let sibling = &siblings[0]; + let other = &others[0]; + + let epochs = values(DocumentEpoch::new(target.document_epoch().value() + 1)); + assert_eq!(epochs.len(), 1); + let future_key = (context, epochs[0], "corrupt-future-alias".to_owned()); + let cross_context_key = ( + other_context, + target.document_epoch(), + "corrupt-cross-context-alias".to_owned(), + ); + registry + .node_by_external + .insert(future_key.clone(), target.node_id()); + registry + .node_by_external + .insert(cross_context_key.clone(), target.node_id()); + + assert_eq!(registry.remove_node(target), Ok(())); + assert_eq!(registry.validate_node_handle(sibling), Ok(())); + assert_eq!(registry.validate_node_handle(other), Ok(())); + assert_eq!(registry.node_by_external.get(&future_key), None); + assert_eq!(registry.node_by_external.get(&cross_context_key), None); + assert_eq!(registry.node_binding_by_id.get(&target.node_id()), None); + } + + #[test] + fn document_epoch_exhaustion_is_fail_closed() { let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("eq-session")); + let sessions = values(registry.register_session("epoch-session")); assert_eq!(sessions.len(), 1); - let registered_session = sessions[0]; - let contexts = values(registry.register_context(registered_session, "eq-context")); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "epoch-context")); assert_eq!(contexts.len(), 1); - let registered_context = contexts[0]; - let registered = values(registry.bind_node( - registered_session, - registered_context, - &origins[0], - "eq-node", - )); - assert_eq!(registered.len(), 1); - let registered = ®istered[0]; - let same_tuple_without_authority = values(ObservedNodeHandle::new( - registered.browser_session(), - registered.browsing_context(), - registered.origin().clone(), - registered.document_epoch(), - registered.node_id(), - )); - assert_eq!(same_tuple_without_authority.len(), 1); - assert_ne!(registered, &same_tuple_without_authority[0]); + let context = contexts[0]; + 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) + ); + } + + #[test] + fn monotonic_identifier_exhaustion_is_fail_closed() { + let mut next = 1; + assert_eq!(take_identifier(&mut next, 1), Ok(1)); + assert_eq!(next, 2); + assert_eq!( + take_identifier(&mut next, 1), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + } + + #[test] + fn maximum_identifier_limit_is_clamped_without_wrapping() { + let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); + assert_eq!(registry.maximum_identifier, u64::MAX - 1); } } From 4cbcb03f1667cde5cb64accd49513c1c3705a3b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:15:20 -0700 Subject: [PATCH 094/112] test(browser): cover missing pinned origin authority --- .../originweave-core/src/browser_registry.rs | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 0669e457c..f6cc7c4a0 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -671,7 +671,7 @@ mod tests { &origins[0], epochs[0], 0, - Arc::new(()) + Arc::new(()), ) .is_err() ); @@ -758,12 +758,8 @@ mod tests { let context = contexts[0]; let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - let handles = values(registry.bind_node( - owner, - context, - &origins[0], - "corrupt-context-node", - )); + let handles = + values(registry.bind_node(owner, context, &origins[0], "corrupt-context-node")); assert_eq!(handles.len(), 1); registry.context_session.insert(context, attacker); @@ -786,15 +782,13 @@ mod tests { let corrupt_origins = values(Origin::parse("http://127.0.0.1:43128")); assert_eq!(origins.len(), 1); assert_eq!(corrupt_origins.len(), 1); - let handles = values(registry.bind_node( - session, - context, - &origins[0], - "corrupt-origin-node", - )); + let handles = + values(registry.bind_node(session, context, &origins[0], "corrupt-origin-node")); assert_eq!(handles.len(), 1); - registry.context_origin.insert(context, corrupt_origins[0].clone()); + registry + .context_origin + .insert(context, corrupt_origins[0].clone()); assert_eq!( registry.validate_node_handle(&handles[0]), Err(BrowserRegistryError::UnknownNodeAuthority) @@ -835,6 +829,14 @@ mod tests { registry.context_epoch.insert(context, epoch); let handles = values(registry.bind_node(session, context, origin, "live-node")); assert_eq!(handles.len(), 1); + + registry.context_origin.remove(&context); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + registry.context_origin.insert(context, origin.clone()); registry.context_epoch.remove(&context); assert_eq!( registry.validate_node_handle(&handles[0]), From f71c1ca0d17b9a94bb7b1ef798bfc00c49759be2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:03:05 -0700 Subject: [PATCH 095/112] test(core): reject empty-hex browser IPv4 authorities --- crates/originweave-core/tests/security_review.rs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/crates/originweave-core/tests/security_review.rs b/crates/originweave-core/tests/security_review.rs index ec9dc0abd..e3676b77c 100644 --- a/crates/originweave-core/tests/security_review.rs +++ b/crates/originweave-core/tests/security_review.rs @@ -17,6 +17,9 @@ fn origin_rejects_browser_special_numeric_hosts() { "https://0177.0.0.1", "https://1.2.3.04", "https://example.127", + "https://0x", + "https://1.2.3.0x", + "https://example.0X", ] { assert_eq!( Origin::parse(input), @@ -31,18 +34,6 @@ fn origin_rejects_browser_special_numeric_hosts() { .as_str(), "https://127.0.0.1" ); - assert_eq!( - Origin::parse("https://0x") - .expect("an empty hexadecimal suffix is a DNS label, not an IPv4 number") - .as_str(), - "https://0x" - ); - assert_eq!( - Origin::parse("https://1.2.3.0x") - .expect("an empty hexadecimal final label remains a DNS authority") - .as_str(), - "https://1.2.3.0x" - ); assert_eq!( Origin::parse("https://0xg") .expect("a non-hexadecimal suffix is a DNS label, not an IPv4 number") From 0a73431aa8ac6c23c3da5c3873b0bca3246c5c3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:08:36 -0700 Subject: [PATCH 096/112] fix(core): reject empty-hex browser IPv4 authorities --- crates/originweave-core/src/contracts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/src/contracts.rs b/crates/originweave-core/src/contracts.rs index 88dd2e586..bd4705de2 100644 --- a/crates/originweave-core/src/contracts.rs +++ b/crates/originweave-core/src/contracts.rs @@ -140,7 +140,7 @@ fn looks_like_browser_ipv4_number(label: &str) -> bool { } 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()); + return hexadecimal.bytes().all(|byte| byte.is_ascii_hexdigit()); } label.bytes().all(|byte| byte.is_ascii_digit()) } From 5157366a025f62cdccdff8b795a05f167f09e6e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:13:16 -0700 Subject: [PATCH 097/112] docs(changelog): record empty-hex origin hardening --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1410c21..36fa71519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - State-changing actions are same-origin by default. - R3 and R4 approvals are bound to the exact action, target origin, and immutable digest of the complete canonical action intent; R5 legal consent is non-delegable. - Shortened, integer, hexadecimal, and legacy octal-looking IPv4 host spellings are rejected so the policy origin cannot diverge from Chromium host interpretation. +- Bare hexadecimal-prefix host labels such as `0x`, including terminal `.0x` and `.0X` spellings, are rejected as browser-special numeric hosts rather than admitted as DNS authorities. - IPv4-mapped IPv6 is canonicalized before destination classification and pin comparison so mapped private or loopback addresses cannot bypass IPv4 policy. - The default destination policy permits only public addresses and denies unspecified, loopback, private, shared, link-local, metadata, documentation, benchmarking, multicast, broadcast, transition, and protocol-reserved destinations. - Azure platform IP `168.63.129.16` and Amazon EKS Pod Identity endpoints `169.254.170.23` and `fd00:ec2::23` are classified as metadata or platform services before broader public, link-local, or unique-local rules. @@ -78,4 +79,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file From 3c88d51b71870d040658a0477fcaa3521455e4c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:20:31 -0700 Subject: [PATCH 098/112] docs(changelog): preserve protocol descriptor after stack realignment --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36fa71519..d9e1c21e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Added a bounded browser-protocol authority registry that maps opaque session, browsing-context, and node identifiers to registry-local identities, rotates document epochs, and revalidates live node handles before actions. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. +- Versioned browser-protocol adapter metadata that distinguishes WebDriver BiDi from pinned CDP, binds bounded adapter/browser revision tokens to an explicit duplicate-free capability set, and grants no browser, action, network, or secret authority by protocol kind alone. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. @@ -79,4 +80,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD From cfa82c6b19c1abdd6f886f3b282c7503512da5f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 04:44:29 -0700 Subject: [PATCH 099/112] fix(core): disambiguate private contract types after main convergence --- crates/originweave-core/src/lib.rs | 15 +++++++++------ crates/originweave-core/src/root.rs | 14 +++++++++++++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 4c95f2dd0..edd7aa6d2 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -16,16 +16,19 @@ mod extension_authority; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, - ObservedNodeHandle, + ObservedNodeHandle as RegistryObservedNodeHandle, }; pub use contracts::{ ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, - ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, - NodeHandleError, Origin, OriginError, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, - SessionMode, + ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, + ExtensionAgentGrant, ExtensionId, ExtensionIdError, InstructionSource, NodeHandleError, + ObservedNodeHandle, Origin, OriginError, PolicyContext, RiskClass, RobotsDecision, + SecretDelivery, SessionMode, evaluate_extension_access, }; pub use extension_authority::{ - AgentTaskId, AgentTaskIdError, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentGrant, evaluate_extension_access, + AgentTaskId, AgentTaskIdError, ExtensionAccessDecision as AuthorityExtensionAccessDecision, + ExtensionAccessRequest as AuthorityExtensionAccessRequest, + ExtensionAgentGrant as AuthorityExtensionAgentGrant, + evaluate_extension_access as evaluate_extension_authority_access, }; diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index 7acced460..5a22967e5 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -9,7 +9,19 @@ #[path = "lib.rs"] mod contracts; -pub use contracts::*; +pub use contracts::{ + ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, AgentTaskId, + AgentTaskIdError, ApprovalEvidence, ApprovalScope, BrowserAuthorityRegistry, + BrowserRegistryError, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, + ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, OriginError, PolicyContext, + RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, RobotsDecision, SecretDelivery, + SessionMode, + AuthorityExtensionAccessDecision as ExtensionAccessDecision, + AuthorityExtensionAccessRequest as ExtensionAccessRequest, + AuthorityExtensionAgentGrant as ExtensionAgentGrant, + evaluate_extension_authority_access as evaluate_extension_access, +}; /// Stateless MCP routing validation that maps only explicit tools to typed actions. pub mod mcp; From 344233d7ff39c335b66cc37b324418a535b2d77e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 04:47:11 -0700 Subject: [PATCH 100/112] style(core): apply canonical import formatting --- crates/originweave-core/src/root.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index 5a22967e5..f0cbb43d2 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -11,16 +11,15 @@ mod contracts; pub use contracts::{ ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, AgentTaskId, - AgentTaskIdError, ApprovalEvidence, ApprovalScope, BrowserAuthorityRegistry, + AgentTaskIdError, ApprovalEvidence, ApprovalScope, + AuthorityExtensionAccessDecision as ExtensionAccessDecision, + AuthorityExtensionAccessRequest as ExtensionAccessRequest, + AuthorityExtensionAgentGrant as ExtensionAgentGrant, BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, OriginError, PolicyContext, RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, RobotsDecision, SecretDelivery, - SessionMode, - AuthorityExtensionAccessDecision as ExtensionAccessDecision, - AuthorityExtensionAccessRequest as ExtensionAccessRequest, - AuthorityExtensionAgentGrant as ExtensionAgentGrant, - evaluate_extension_authority_access as evaluate_extension_access, + SessionMode, evaluate_extension_authority_access as evaluate_extension_access, }; /// Stateless MCP routing validation that maps only explicit tools to typed actions. From 93ce1e7efad0a1e8c107f1f465a5260ba568d466 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 04:52:03 -0700 Subject: [PATCH 101/112] fix(core): avoid module-inception lint after main convergence --- crates/originweave-core/src/root.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index f0cbb43d2..4c8062e80 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -7,9 +7,10 @@ #![deny(missing_docs)] #[path = "lib.rs"] -mod contracts; +mod core_contracts; +use core_contracts as contracts; -pub use contracts::{ +pub use core_contracts::{ ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, AgentTaskId, AgentTaskIdError, ApprovalEvidence, ApprovalScope, AuthorityExtensionAccessDecision as ExtensionAccessDecision, From 4943b824877a44068a22d3ef17ce1de66b926173 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 16:45:06 -0700 Subject: [PATCH 102/112] docs(browser): restore protocol descriptor changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b9d4f34b..d22ffba8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Added a bounded browser-protocol authority registry that maps opaque session, browsing-context, and node identifiers to registry-local identities, rotates document epochs, and revalidates live node handles before actions. +- Versioned browser-protocol adapter metadata that distinguishes WebDriver BiDi from pinned CDP, binds bounded adapter/browser revision tokens to an explicit duplicate-free capability set, and grants no browser, action, network, or secret authority by protocol kind alone. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. From 01fd68fe1f8faa8725558fb2a8ed61e67602e10b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 16:48:13 -0700 Subject: [PATCH 103/112] fix(browser): export protocol descriptor at crate boundary --- crates/originweave-core/src/root.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index 4c8062e80..93e8b5a0b 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -16,8 +16,10 @@ pub use core_contracts::{ AuthorityExtensionAccessDecision as ExtensionAccessDecision, AuthorityExtensionAccessRequest as ExtensionAccessRequest, AuthorityExtensionAgentGrant as ExtensionAgentGrant, BrowserAuthorityRegistry, - BrowserRegistryError, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, - ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, + BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, BrowsingContextId, Capability, + DocumentEpoch, ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, + InstructionSource, MAX_BROWSER_PROTOCOL_METADATA_BYTES, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, OriginError, PolicyContext, RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, RobotsDecision, SecretDelivery, SessionMode, evaluate_extension_authority_access as evaluate_extension_access, From 378a9f2613f4044ece20246bab6cd77f35018981 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 16:53:10 -0700 Subject: [PATCH 104/112] style(browser): apply canonical protocol export formatting --- crates/originweave-core/src/root.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index 93e8b5a0b..94c7e86b3 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -19,8 +19,8 @@ pub use core_contracts::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, - InstructionSource, MAX_BROWSER_PROTOCOL_METADATA_BYTES, - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, OriginError, PolicyContext, + InstructionSource, MAX_BROWSER_PROTOCOL_METADATA_BYTES, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + NodeHandleError, Origin, OriginError, PolicyContext, RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, RobotsDecision, SecretDelivery, SessionMode, evaluate_extension_authority_access as evaluate_extension_access, }; From 451dc3e97fdfe4babfe91cebba1c0168695e9290 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 23:10:20 -0700 Subject: [PATCH 105/112] test(core): reproduce non-digit origin port acceptance --- .../tests/origin_port_syntax.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 crates/originweave-core/tests/origin_port_syntax.rs diff --git a/crates/originweave-core/tests/origin_port_syntax.rs b/crates/originweave-core/tests/origin_port_syntax.rs new file mode 100644 index 000000000..ce58e523e --- /dev/null +++ b/crates/originweave-core/tests/origin_port_syntax.rs @@ -0,0 +1,18 @@ +use originweave_core::{Origin, OriginError}; + +#[test] +fn origin_rejects_non_digit_port_prefixes() { + for input in [ + "https://example.com:+443", + "https://example.com:+8443", + "http://localhost:+80", + "http://127.0.0.1:+8080", + "https://[2001:db8::1]:+443", + ] { + assert_eq!( + Origin::parse(input), + Err(OriginError::InvalidPort), + "input={input}" + ); + } +} From 26821796234c354269433ca010ca4672601c8108 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 23:15:37 -0700 Subject: [PATCH 106/112] fix(core): reject non-digit origin port prefixes --- crates/originweave-core/src/contracts.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/src/contracts.rs b/crates/originweave-core/src/contracts.rs index bd4705de2..7152196cf 100644 --- a/crates/originweave-core/src/contracts.rs +++ b/crates/originweave-core/src/contracts.rs @@ -127,7 +127,6 @@ fn parse_authority(authority: &str) -> Result<(String, Option, bool), Origi validate_dns_host(&host)?; Ok((host.clone(), port, host == "localhost")) } - fn looks_like_browser_ipv4_host(host: &str) -> bool { host.rsplit('.') .next() @@ -165,6 +164,9 @@ fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), } fn parse_port(port_text: &str) -> Result { + if port_text.is_empty() || !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(OriginError::InvalidPort); + } let port = port_text .parse::() .map_err(|_error| OriginError::InvalidPort)?; @@ -257,7 +259,6 @@ impl BrowserSessionId { /// 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 { @@ -387,7 +388,6 @@ impl ObservedNodeHandle { Ok(()) } } - /// A failure to construct or reuse an authority- and document-bound node handle safely. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NodeHandleError { @@ -907,7 +907,6 @@ impl PolicyContext { 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; @@ -1062,4 +1061,4 @@ pub fn evaluate_extension_access( return ExtensionAccessDecision::DenyCapabilityNotGranted; } ExtensionAccessDecision::Allow -} +} \ No newline at end of file From 90dc4a5541f32e448334a596e5372cf01ec1c472 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 23:22:05 -0700 Subject: [PATCH 107/112] style(core): restore canonical source termination --- crates/originweave-core/src/contracts.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/contracts.rs b/crates/originweave-core/src/contracts.rs index 7152196cf..db4cf6c93 100644 --- a/crates/originweave-core/src/contracts.rs +++ b/crates/originweave-core/src/contracts.rs @@ -816,7 +816,7 @@ impl ActionRequest { /// Return how secret material would be delivered. #[must_use] pub const fn secret_delivery(&self) -> SecretDelivery { - self.secret_delivery + &self.secret_delivery } /// Return the digest of the complete canonical action intent. @@ -1061,4 +1061,4 @@ pub fn evaluate_extension_access( return ExtensionAccessDecision::DenyCapabilityNotGranted; } ExtensionAccessDecision::Allow -} \ No newline at end of file +} From 5cd2733eb722cc1e9252f1e7b64e8736e1ae2d07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 23:23:45 -0700 Subject: [PATCH 108/112] fix(core): preserve secret-delivery value contract --- crates/originweave-core/src/contracts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/src/contracts.rs b/crates/originweave-core/src/contracts.rs index db4cf6c93..865f627a8 100644 --- a/crates/originweave-core/src/contracts.rs +++ b/crates/originweave-core/src/contracts.rs @@ -816,7 +816,7 @@ impl ActionRequest { /// Return how secret material would be delivered. #[must_use] pub const fn secret_delivery(&self) -> SecretDelivery { - &self.secret_delivery + self.secret_delivery } /// Return the digest of the complete canonical action intent. From 1ef0d275e1a95246474c17df85df0500e56d438c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 23:46:28 -0700 Subject: [PATCH 109/112] test(policy): bind extension isolation to agent task --- .../tests/extension_policy_isolation.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/originweave-policy/tests/extension_policy_isolation.rs b/crates/originweave-policy/tests/extension_policy_isolation.rs index f32d8733c..11986b1f9 100644 --- a/crates/originweave-policy/tests/extension_policy_isolation.rs +++ b/crates/originweave-policy/tests/extension_policy_isolation.rs @@ -3,7 +3,7 @@ use std::collections::BTreeSet; use originweave_core::{ - ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + ActionIntentDigest, ActionKind, ActionRequest, AgentTaskId, ApprovalEvidence, BrowserSessionId, BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, @@ -21,6 +21,10 @@ fn extension_id() -> ExtensionId { ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") } +fn agent_task() -> AgentTaskId { + AgentTaskId::new(5).expect("nonzero agent task") +} + fn browser_session() -> BrowserSessionId { BrowserSessionId::new(7).expect("nonzero browser session") } @@ -40,6 +44,7 @@ fn intent() -> ActionIntentDigest { fn action_proposal_grant() -> ExtensionAgentGrant { ExtensionAgentGrant::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(EXTENSION_ORIGIN), @@ -51,6 +56,7 @@ fn action_proposal_grant() -> ExtensionAgentGrant { fn assert_extension_can_only_propose(grant: &ExtensionAgentGrant) { let request = ExtensionAccessRequest::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(EXTENSION_ORIGIN), From 53c8a9ba19a04aca6d730af0c0fe5fda670743d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 23:50:30 -0700 Subject: [PATCH 110/112] test(policy): bind secret isolation to agent task --- .../tests/extension_secret_isolation.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/originweave-policy/tests/extension_secret_isolation.rs b/crates/originweave-policy/tests/extension_secret_isolation.rs index f808bec04..86d04df4b 100644 --- a/crates/originweave-policy/tests/extension_secret_isolation.rs +++ b/crates/originweave-policy/tests/extension_secret_isolation.rs @@ -3,7 +3,7 @@ use std::collections::BTreeSet; use originweave_core::{ - ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + ActionIntentDigest, ActionKind, ActionRequest, AgentTaskId, ApprovalEvidence, BrowserSessionId, BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, InstructionSource, Origin, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, @@ -20,6 +20,10 @@ fn extension_id() -> ExtensionId { ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") } +fn agent_task() -> AgentTaskId { + AgentTaskId::new(5).expect("nonzero agent task") +} + fn browser_session() -> BrowserSessionId { BrowserSessionId::new(7).expect("nonzero browser session") } @@ -39,6 +43,7 @@ fn intent() -> ActionIntentDigest { fn action_proposal_grant() -> ExtensionAgentGrant { ExtensionAgentGrant::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(), @@ -50,6 +55,7 @@ fn action_proposal_grant() -> ExtensionAgentGrant { fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { let request = ExtensionAccessRequest::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(), From eccf56212dfa98c683e3bc977c873c8028e09713 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 23:51:24 -0700 Subject: [PATCH 111/112] test(policy): bind mutation isolation to agent task --- .../tests/extension_mutation_isolation.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs index 48d7936e1..b9c55b278 100644 --- a/crates/originweave-policy/tests/extension_mutation_isolation.rs +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -4,14 +4,14 @@ //! //! OriginWeave does not yet implement an adapter that converts an extension proposal into an //! [`ActionRequest`]. These regressions therefore prove two independent fail-closed boundaries: -//! the exact extension/session/context/origin/unexpired grant permits only `ProposeTypedAction`, -//! while an ordinary user-sourced action request remains subject to the core policy decision -//! shown in each test. +//! the exact extension/task/session/context/origin/unexpired grant permits only +//! `ProposeTypedAction`, while an ordinary user-sourced action request remains subject to the +//! core policy decision shown in each test. use std::collections::BTreeSet; use originweave_core::{ - ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + ActionIntentDigest, ActionKind, ActionRequest, AgentTaskId, ApprovalEvidence, BrowserSessionId, BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, @@ -29,6 +29,10 @@ fn extension_id() -> ExtensionId { ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") } +fn agent_task() -> AgentTaskId { + AgentTaskId::new(13).expect("nonzero agent task") +} + fn browser_session() -> BrowserSessionId { BrowserSessionId::new(17).expect("nonzero browser session") } @@ -48,6 +52,7 @@ fn intent() -> ActionIntentDigest { fn action_proposal_grant() -> ExtensionAgentGrant { ExtensionAgentGrant::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(EXTENSION_ORIGIN), @@ -59,6 +64,7 @@ fn action_proposal_grant() -> ExtensionAgentGrant { fn assert_proposal_grant_is_independently_allowed(grant: &ExtensionAgentGrant) { let request = ExtensionAccessRequest::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(EXTENSION_ORIGIN), From 3c6bcd47773132fbd6584390a2e57988b2539454 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 00:09:27 -0700 Subject: [PATCH 112/112] docs: record browser authority contract changes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f747adeae..31768c2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Refreshed the product-gap queue to 126 open pull requests (54 ready, 72 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. ### Added +- Active PR #40 adds the `BrowserAuthorityRegistry`, `BrowserProtocolAdapterDescriptor`, and Agent-Task-bound extension grant/request API; it requires exact `AgentTaskId` authority for extension access and rejects bare `0x` browser-special numeric host spellings. This remains active-PR evidence, not protected-main shipment. - Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 126-PR queue count, explicit root-versus-child merge ordering, and the active GitHub counted-approval gate. - Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 126 open pull requests (54 ready, 72 draft), protected-main promotion of #168/#194/#196/#216/#151, a verified maintenance-loop record (supersession closure of #153, conflict reconciliations on #37/#149/#152/#173/#175, issue #212 option-(b) authorization on #43, Strix vuln-0001 homoglyph remediation on #124), provider-rerun outcome evidence, an organization review-pipeline congestion record, and refreshed merge-order queue guidance. Documentation evidence contracts were aligned to the same snapshot so the baseline, its dated markers, and the pinned exact-head rows cannot silently diverge.