diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f804f749..99d8d6ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: --branch --text --show-missing-lines - > missing-lines.txt + | tee missing-lines.txt - name: Upload exact coverage diagnostics uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs new file mode 100644 index 00000000..85e4af3a --- /dev/null +++ b/crates/originweave-core/src/browser_registry.rs @@ -0,0 +1,545 @@ +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; + +/// 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 +/// 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>, + maximum_identifier: u64, + next_session_id: u64, + next_context_id: u64, + next_node_id: u64, +} + +impl BrowserAuthorityRegistry { + /// 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(), + context_by_external: BTreeMap::new(), + context_session: BTreeMap::new(), + 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, + } + } + + /// 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, 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 + }) + }) + } + + /// 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); + }) + } + + /// 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()); + } + } + 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) + }) + } +} + +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, 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 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 values(result: Result) -> Vec { + result.into_iter().collect() + } + + #[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 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(sessions[0], contexts[0], &origins[0], epochs[0], 0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + } + + #[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 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::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/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs new file mode 100644 index 00000000..1860bc7b --- /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/contracts.rs b/crates/originweave-core/src/contracts.rs new file mode 100644 index 00000000..88dd2e58 --- /dev/null +++ b/crates/originweave-core/src/contracts.rs @@ -0,0 +1,1065 @@ +//! Shared security and governance contracts for OriginWeave. +//! +//! The crate deliberately contains no browser-engine integration. It defines +//! small, deterministic value types that can be reused by the browser shell, +//! headless runtime, MCP adapter, and enterprise policy service. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use std::collections::BTreeSet; +use std::fmt; +use std::net::{Ipv4Addr, Ipv6Addr}; + +/// A normalized web origin accepted by the OriginWeave trust boundary. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Origin { + canonical: String, +} + +impl Origin { + /// Parse one origin and reject paths, credentials, fragments, insecure + /// remote HTTP endpoints, and browser-special numeric host spellings. + pub fn parse(input: &str) -> Result { + if input.trim() != input + || input + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return Err(OriginError::InvalidAuthority); + } + + let Some((raw_scheme, authority)) = input.split_once("://") else { + return Err(OriginError::MissingScheme); + }; + let scheme = raw_scheme.to_ascii_lowercase(); + if scheme != "https" && scheme != "http" { + return Err(OriginError::UnsupportedScheme); + } + if authority.is_empty() { + return Err(OriginError::MissingAuthority); + } + if authority.contains('@') { + return Err(OriginError::UserInfoNotAllowed); + } + if authority + .chars() + .any(|character| matches!(character, '/' | '?' | '#')) + { + return Err(OriginError::PathNotAllowed); + } + + let (host, port, is_loopback) = parse_authority(authority)?; + if scheme == "http" && !is_loopback { + return Err(OriginError::InsecureRemoteOrigin); + } + let normalized_port = normalize_default_port(&scheme, port); + let canonical = match normalized_port { + Some(port_number) => format!("{scheme}://{host}:{port_number}"), + None => format!("{scheme}://{host}"), + }; + Ok(Self { canonical }) + } + + /// Return the normalized origin string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } + + /// Return the validated lowercase origin scheme. + #[must_use] + pub fn scheme(&self) -> &str { + if self.canonical.starts_with("https://") { + "https" + } else { + "http" + } + } + + /// Return the validated canonical host without IPv6 brackets. + #[must_use] + pub fn host(&self) -> &str { + let authority = &self.canonical[self.scheme().len() + 3..]; + let bracketed = authority.starts_with('['); + let host_start = usize::from(bracketed); + let host_end = if bracketed { + authority.find(']').unwrap_or(authority.len()) + } else { + authority.find(':').unwrap_or(authority.len()) + }; + &authority[host_start..host_end] + } +} + +impl fmt::Display for Origin { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +fn normalize_default_port(scheme: &str, port: Option) -> Option { + match (scheme, port) { + ("https", Some(443)) | ("http", Some(80)) => None, + (_, other) => other, + } +} + +fn parse_authority(authority: &str) -> Result<(String, Option, bool), OriginError> { + if authority.starts_with('[') { + return parse_bracketed_ipv6(authority); + } + if authority.matches(':').count() > 1 { + return Err(OriginError::InvalidAuthority); + } + + let (host_text, port) = match authority.rsplit_once(':') { + Some((host, port_text)) => (host, Some(parse_port(port_text)?)), + None => (authority, None), + }; + let host = host_text.to_ascii_lowercase(); + if let Ok(address) = host.parse::() { + return Ok((host, port, address.is_loopback())); + } + if looks_like_browser_ipv4_host(&host) { + return Err(OriginError::AmbiguousNumericHost); + } + validate_dns_host(&host)?; + Ok((host.clone(), port, host == "localhost")) +} + +fn looks_like_browser_ipv4_host(host: &str) -> bool { + host.rsplit('.') + .next() + .is_some_and(looks_like_browser_ipv4_number) +} + +fn looks_like_browser_ipv4_number(label: &str) -> bool { + if label.is_empty() { + return false; + } + let lowercase = label.to_ascii_lowercase(); + if let Some(hexadecimal) = lowercase.strip_prefix("0x") { + return !hexadecimal.is_empty() && hexadecimal.bytes().all(|byte| byte.is_ascii_hexdigit()); + } + label.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), OriginError> { + let Some(close_index) = authority.find(']') else { + return Err(OriginError::InvalidAuthority); + }; + let address_text = &authority[1..close_index]; + let address = address_text + .parse::() + .map_err(|_error| OriginError::InvalidAuthority)?; + let remainder = &authority[close_index + 1..]; + let port = if remainder.is_empty() { + None + } else if let Some(port_text) = remainder.strip_prefix(':') { + Some(parse_port(port_text)?) + } else { + return Err(OriginError::InvalidAuthority); + }; + Ok((format!("[{address}]"), port, address.is_loopback())) +} + +fn parse_port(port_text: &str) -> Result { + let port = port_text + .parse::() + .map_err(|_error| OriginError::InvalidPort)?; + if port == 0 { + return Err(OriginError::InvalidPort); + } + Ok(port) +} + +fn validate_dns_host(host: &str) -> Result<(), OriginError> { + if host.is_empty() { + return Err(OriginError::InvalidAuthority); + } + if host.len() > 253 { + return Err(OriginError::InvalidAuthority); + } + if !host.is_ascii() { + return Err(OriginError::InvalidAuthority); + } + if host.starts_with('.') || host.ends_with('.') { + return Err(OriginError::InvalidAuthority); + } + for label in host.split('.') { + if label.is_empty() { + return Err(OriginError::InvalidAuthority); + } + if label.len() > 63 { + return Err(OriginError::InvalidAuthority); + } + let bytes = label.as_bytes(); + if !bytes[0].is_ascii_alphanumeric() { + return Err(OriginError::InvalidAuthority); + } + if !bytes[bytes.len() - 1].is_ascii_alphanumeric() { + return Err(OriginError::InvalidAuthority); + } + if !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + { + return Err(OriginError::InvalidAuthority); + } + } + Ok(()) +} + +/// A reason that an origin string could not enter the trust boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OriginError { + /// The input did not contain a `scheme://` separator. + MissingScheme, + /// The scheme was neither HTTPS nor locally scoped HTTP. + UnsupportedScheme, + /// HTTP was requested for a non-loopback host. + InsecureRemoteOrigin, + /// No authority followed the scheme. + MissingAuthority, + /// User information appeared before the host. + UserInfoNotAllowed, + /// A path, query, or fragment was supplied where only an origin is valid. + PathNotAllowed, + /// The host or authority syntax was ambiguous or malformed. + InvalidAuthority, + /// A browser could reinterpret the host as a non-canonical IPv4 address. + AmbiguousNumericHost, + /// The explicit port was outside `1..=65535` or was not numeric. + InvalidPort, +} + +/// A nonzero identity for one active browser automation session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowserSessionId(u64); + +impl BrowserSessionId { + /// Validate one adapter-supplied browser-session identifier. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidBrowserSessionId); + } + Ok(Self(value)) + } + + /// Return the validated browser-session identifier. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A nonzero identity for one independently navigable browser context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowsingContextId(u64); + +impl BrowsingContextId { + /// Validate one adapter-supplied browsing-context identifier. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidBrowsingContextId); + } + Ok(Self(value)) + } + + /// Return the validated browsing-context identifier. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A nonzero identity for one observed browser document lifetime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DocumentEpoch(u64); + +impl DocumentEpoch { + /// Validate one adapter-supplied document epoch. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidDocumentEpoch); + } + Ok(Self(value)) + } + + /// Return the validated document epoch value. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A node identity bound to the exact session, context, origin, and document that produced it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservedNodeHandle { + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, +} + +impl ObservedNodeHandle { + /// Create one authority-bound observed node handle from a nonzero adapter node identifier. + pub fn new( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, + ) -> Result { + if node_id == 0 { + return Err(NodeHandleError::InvalidNodeId); + } + Ok(Self { + browser_session, + browsing_context, + origin, + document_epoch, + node_id, + }) + } + + /// Return the browser session that produced the node observation. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the browsing context that produced the node observation. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } + + /// Return the canonical origin that produced the node observation. + #[must_use] + pub const fn origin(&self) -> &Origin { + &self.origin + } + + /// Return the document epoch that produced the node observation. + #[must_use] + pub const fn document_epoch(&self) -> DocumentEpoch { + self.document_epoch + } + + /// Return the adapter-local nonzero node identifier. + #[must_use] + pub const fn node_id(&self) -> u64 { + self.node_id + } + + /// Reject use when the session, browsing context, origin, or document epoch has changed. + pub fn validate_current( + &self, + current_session: BrowserSessionId, + current_context: BrowsingContextId, + current_origin: &Origin, + current_epoch: DocumentEpoch, + ) -> Result<(), NodeHandleError> { + if self.browser_session != current_session { + return Err(NodeHandleError::BrowserSessionMismatch { + observed: self.browser_session, + current: current_session, + }); + } + if self.browsing_context != current_context { + return Err(NodeHandleError::BrowsingContextMismatch { + observed: self.browsing_context, + current: current_context, + }); + } + if &self.origin != current_origin { + return Err(NodeHandleError::OriginMismatch); + } + if self.document_epoch != current_epoch { + return Err(NodeHandleError::StaleDocumentEpoch { + observed: self.document_epoch, + current: current_epoch, + }); + } + Ok(()) + } +} + +/// A failure to construct or reuse an authority- and document-bound node handle safely. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeHandleError { + /// Browser-session identifiers are one-based and zero was supplied. + InvalidBrowserSessionId, + /// Browsing-context identifiers are one-based and zero was supplied. + InvalidBrowsingContextId, + /// Document epochs are one-based and zero was supplied. + InvalidDocumentEpoch, + /// Adapter-local node identifiers are one-based and zero was supplied. + InvalidNodeId, + /// The node handle belongs to a different browser automation session. + BrowserSessionMismatch { + /// Session that originally produced the node handle. + observed: BrowserSessionId, + /// Session currently active for the requested action. + current: BrowserSessionId, + }, + /// The node handle belongs to a different independently navigable context. + BrowsingContextMismatch { + /// Context that originally produced the node handle. + observed: BrowsingContextId, + /// Context currently active for the requested action. + current: BrowsingContextId, + }, + /// The browser context is now at a different canonical origin. + OriginMismatch, + /// The browser context is now at a different document epoch. + StaleDocumentEpoch { + /// Epoch that originally produced the node handle. + observed: DocumentEpoch, + /// Epoch currently active in the browser context. + current: DocumentEpoch, + }, +} + +impl fmt::Display for NodeHandleError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidBrowserSessionId => { + formatter.write_str("browser session identifier must be nonzero") + } + Self::InvalidBrowsingContextId => { + formatter.write_str("browsing context identifier must be nonzero") + } + Self::InvalidDocumentEpoch => formatter.write_str("document epoch must be nonzero"), + Self::InvalidNodeId => formatter.write_str("observed node identifier must be nonzero"), + Self::BrowserSessionMismatch { observed, current } => write!( + formatter, + "observed node browser session {} does not match current session {}", + observed.value(), + current.value() + ), + Self::BrowsingContextMismatch { observed, current } => write!( + formatter, + "observed node browsing context {} does not match current context {}", + observed.value(), + current.value() + ), + Self::OriginMismatch => { + formatter.write_str("observed node origin does not match the current origin") + } + Self::StaleDocumentEpoch { observed, current } => write!( + formatter, + "observed node document epoch {} is stale; current epoch is {}", + observed.value(), + current.value() + ), + } + } +} + +impl std::error::Error for NodeHandleError {} + +/// An immutable digest of the complete canonical action intent. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ActionIntentDigest { + canonical: String, +} + +impl ActionIntentDigest { + /// Parse a lowercase `sha256:` digest of the complete canonical intent. + pub fn parse(input: &str) -> Result { + let Some(hexadecimal) = input.strip_prefix("sha256:") else { + return Err(ActionIntentDigestError::InvalidFormat); + }; + if hexadecimal.len() != 64 + || !hexadecimal + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ActionIntentDigestError::InvalidFormat); + } + Ok(Self { + canonical: input.to_owned(), + }) + } + + /// Return the canonical lowercase digest. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } +} + +/// A validation error for an action-intent digest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionIntentDigestError { + /// The value was not `sha256:` followed by 64 lowercase hexadecimal digits. + InvalidFormat, +} + +/// The browser execution mode that owns an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SessionMode { + /// A person controls the browser without agent execution privileges. + Human, + /// An agent assists a person while write actions remain governed. + Assist, + /// An isolated task session is delegated to an agent. + AgentTask, + /// A read-only crawler performs policy-bounded collection. + Crawler, +} + +/// The declared business purpose of one browser execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExecutionPurpose { + /// Public content is collected under crawler policy. + PublicCrawl, + /// A person delegated a bounded task in their own context. + UserDelegatedTask, + /// An enterprise policy authorized a managed task. + EnterpriseAuthorizedTask, + /// The action is running in a non-production test environment. + TestingEnvironment, +} + +/// The trust class of the instruction that proposed an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum InstructionSource { + /// A human user supplied the instruction. + User, + /// A managed enterprise policy supplied the instruction. + EnterprisePolicy, + /// Untrusted page or document content supplied the instruction. + WebContent, +} + +/// The result of applying a robots-exclusion policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RobotsDecision { + /// The requested crawl is explicitly allowed. + Allowed, + /// The requested crawl is explicitly disallowed. + Disallowed, + /// The policy could not be fetched or interpreted safely. + Unknown, + /// Robots policy was not evaluated for this execution purpose. + NotApplicable, +} + +/// How secret material is delivered to a browser action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SecretDelivery { + /// The action carries no secret material. + None, + /// A trusted broker resolves an opaque secret handle outside the model. + BrokerHandle, + /// A raw secret value would be exposed directly to the caller. + RawValue, +} + +/// The ordered risk class assigned to an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RiskClass { + /// Read-only observation with no state change. + R0, + /// Low-risk navigation or local retrieval. + R1, + /// Reversible preparation such as creating a draft. + R2, + /// External submission or sensitive interaction requiring approval. + R3, + /// High-impact purchase, deletion, or permission change. + R4, + /// Legal or similarly non-delegable consent. + R5, +} + +impl RiskClass { + /// Return whether the risk class requires approval before execution. + #[must_use] + pub const fn requires_approval(self) -> bool { + matches!(self, Self::R3 | Self::R4 | Self::R5) + } +} + +/// A capability that may be granted to an isolated agent session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Capability { + /// Observe a page's governed semantic representation. + Observe, + /// Extract structured information from allowed evidence. + Extract, + /// Navigate to an allowed origin. + Navigate, + /// Download a resource from an allowed origin. + Download, + /// Prepare a reversible draft. + Draft, + /// Submit data to an allowed origin. + Submit, + /// Upload a pre-approved artifact. + Upload, + /// Fill a secret through the trusted secret broker. + FillSecret, + /// Complete a purchase after approval. + Purchase, + /// Delete a remote object after approval. + Delete, + /// Change a permission after approval. + ManagePermission, + /// Record legal consent, which agents cannot perform autonomously. + LegalConsent, +} + +/// A typed browser action exposed to policy evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ActionKind { + /// Observe governed page state. + Observe, + /// Extract structured data. + Extract, + /// Navigate the browser. + Navigate, + /// Download a resource. + Download, + /// Create or update a reversible draft. + Draft, + /// Submit data externally. + Submit, + /// Upload an approved file. + Upload, + /// Fill a secret using an opaque broker handle. + FillSecret, + /// Complete a purchase. + Purchase, + /// Delete remote state. + Delete, + /// Change access permissions. + ManagePermission, + /// Accept legally binding terms. + LegalConsent, +} + +impl ActionKind { + /// Return the action's fixed risk classification. + #[must_use] + pub const fn risk_class(self) -> RiskClass { + match self { + Self::Observe | Self::Extract => RiskClass::R0, + Self::Navigate | Self::Download => RiskClass::R1, + Self::Draft => RiskClass::R2, + Self::Submit | Self::Upload | Self::FillSecret => RiskClass::R3, + Self::Purchase | Self::Delete | Self::ManagePermission => RiskClass::R4, + Self::LegalConsent => RiskClass::R5, + } + } + + /// Return the capability required to request this action. + #[must_use] + pub const fn required_capability(self) -> Capability { + match self { + Self::Observe => Capability::Observe, + Self::Extract => Capability::Extract, + Self::Navigate => Capability::Navigate, + Self::Download => Capability::Download, + Self::Draft => Capability::Draft, + Self::Submit => Capability::Submit, + Self::Upload => Capability::Upload, + Self::FillSecret => Capability::FillSecret, + Self::Purchase => Capability::Purchase, + Self::Delete => Capability::Delete, + Self::ManagePermission => Capability::ManagePermission, + Self::LegalConsent => Capability::LegalConsent, + } + } + + /// Return whether execution can mutate browser or remote state. + #[must_use] + pub const fn mutates_state(self) -> bool { + !matches!( + self, + Self::Observe | Self::Extract | Self::Navigate | Self::Download + ) + } + + /// Return whether this action is designed to resolve a brokered secret. + #[must_use] + pub const fn uses_secret(self) -> bool { + matches!(self, Self::FillSecret) + } +} + +/// The exact action, target origin, and complete intent covered by an approval. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalScope { + action: ActionKind, + target_origin: Origin, + intent_digest: ActionIntentDigest, +} + +impl ApprovalScope { + /// Create one exact approval scope. + #[must_use] + pub const fn new( + action: ActionKind, + target_origin: Origin, + intent_digest: ActionIntentDigest, + ) -> Self { + Self { + action, + target_origin, + intent_digest, + } + } + + /// Return the approved action kind. + #[must_use] + pub const fn action(&self) -> ActionKind { + self.action + } + + /// Return the approved target origin. + #[must_use] + pub const fn target_origin(&self) -> &Origin { + &self.target_origin + } + + /// Return the approved complete-intent digest. + #[must_use] + pub const fn intent_digest(&self) -> &ActionIntentDigest { + &self.intent_digest + } +} + +/// Evidence that a high-risk action was approved for an exact scope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApprovalEvidence { + /// No approval was supplied. + None, + /// A person confirmed the exact action, target, and complete intent. + UserConfirmed(ApprovalScope), + /// A managed policy approved the exact action, target, and complete intent. + EnterprisePolicy(ApprovalScope), +} + +impl ApprovalEvidence { + /// Return whether this evidence authorizes the exact required scope. + #[must_use] + pub fn authorizes(&self, required: &ApprovalScope) -> bool { + match self { + Self::None => false, + Self::UserConfirmed(scope) | Self::EnterprisePolicy(scope) => scope == required, + } + } +} + +/// A complete typed request presented to the policy engine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionRequest { + action: ActionKind, + source_origin: Origin, + target_origin: Origin, + instruction_source: InstructionSource, + secret_delivery: SecretDelivery, + intent_digest: ActionIntentDigest, +} + +impl ActionRequest { + /// Create one action request without executing it. + #[must_use] + pub const fn new( + action: ActionKind, + source_origin: Origin, + target_origin: Origin, + instruction_source: InstructionSource, + secret_delivery: SecretDelivery, + intent_digest: ActionIntentDigest, + ) -> Self { + Self { + action, + source_origin, + target_origin, + instruction_source, + secret_delivery, + intent_digest, + } + } + + /// Return the requested action. + #[must_use] + pub const fn action(&self) -> ActionKind { + self.action + } + + /// Return the origin that currently owns the browser context. + #[must_use] + pub const fn source_origin(&self) -> &Origin { + &self.source_origin + } + + /// Return the origin affected by the action. + #[must_use] + pub const fn target_origin(&self) -> &Origin { + &self.target_origin + } + + /// Return the trust class of the proposing instruction. + #[must_use] + pub const fn instruction_source(&self) -> InstructionSource { + self.instruction_source + } + + /// Return how secret material would be delivered. + #[must_use] + pub const fn secret_delivery(&self) -> SecretDelivery { + self.secret_delivery + } + + /// Return the digest of the complete canonical action intent. + #[must_use] + pub const fn intent_digest(&self) -> &ActionIntentDigest { + &self.intent_digest + } +} + +/// Immutable grants and mutable evidence used for one policy decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyContext { + mode: SessionMode, + purpose: ExecutionPurpose, + capabilities: BTreeSet, + read_origins: BTreeSet, + write_origins: BTreeSet, + robots_decision: RobotsDecision, + approval: ApprovalEvidence, +} + +impl PolicyContext { + /// Create one policy context from explicitly granted capabilities and origins. + #[must_use] + pub const fn new( + mode: SessionMode, + purpose: ExecutionPurpose, + capabilities: BTreeSet, + read_origins: BTreeSet, + write_origins: BTreeSet, + robots_decision: RobotsDecision, + approval: ApprovalEvidence, + ) -> Self { + Self { + mode, + purpose, + capabilities, + read_origins, + write_origins, + robots_decision, + approval, + } + } + + /// Return the browser execution mode. + #[must_use] + pub const fn mode(&self) -> SessionMode { + self.mode + } + + /// Return the declared execution purpose. + #[must_use] + pub const fn purpose(&self) -> ExecutionPurpose { + self.purpose + } + + /// Return the granted capabilities. + #[must_use] + pub const fn capabilities(&self) -> &BTreeSet { + &self.capabilities + } + + /// Return the origins that may be read. + #[must_use] + pub const fn read_origins(&self) -> &BTreeSet { + &self.read_origins + } + + /// Return the origins that may be mutated. + #[must_use] + pub const fn write_origins(&self) -> &BTreeSet { + &self.write_origins + } + + /// Return the robots-exclusion decision. + #[must_use] + pub const fn robots_decision(&self) -> RobotsDecision { + self.robots_decision + } + + /// Replace robots evidence after a fresh policy lookup. + pub const fn set_robots_decision(&mut self, decision: RobotsDecision) { + self.robots_decision = decision; + } + + /// Return the supplied approval evidence. + #[must_use] + pub const fn approval(&self) -> &ApprovalEvidence { + &self.approval + } + + /// Replace approval evidence after a user or enterprise decision. + pub fn set_approval(&mut self, approval: ApprovalEvidence) { + self.approval = approval; + } +} + +/// A canonical Chromium extension identifier admitted to OriginWeave policy. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExtensionId { + canonical: String, +} + +impl ExtensionId { + /// Parse one canonical 32-character lowercase Chromium extension identifier. + /// + /// Chromium extension identifiers use only the lowercase `a` through `p` + /// alphabet. OriginWeave rejects any non-canonical spelling rather than + /// normalizing caller-controlled identity text. + pub fn parse(input: &str) -> Result { + if input.len() != 32 { + return Err(ExtensionIdError::InvalidExtensionId); + } + if !input.bytes().all(|byte| (b'a'..=b'p').contains(&byte)) { + return Err(ExtensionIdError::InvalidExtensionId); + } + Ok(Self { + canonical: input.to_owned(), + }) + } + + /// Return the canonical extension identifier. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } +} + +/// A validation error for a Chromium extension identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionIdError { + /// The value was not exactly 32 lowercase characters from `a` through `p`. + InvalidExtensionId, +} + +/// An OriginWeave Agent capability that a browser extension may request explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtensionAgentCapability { + /// Observe the governed semantic representation of the exact current context. + ObserveCurrentContext, + /// Propose a typed action for independent OriginWeave policy evaluation. + ProposeTypedAction, +} + +/// An explicit host-originated grant from one extension to bounded Agent capabilities. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAgentGrant { + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capabilities: BTreeSet, +} + +impl ExtensionAgentGrant { + /// Build an exact extension-to-Agent grant for one browser session and context. + #[must_use] + pub fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capabilities: I, + ) -> Self + where + I: IntoIterator, + { + Self { + extension_id, + browser_session, + browsing_context, + capabilities: capabilities.into_iter().collect(), + } + } +} + +/// One extension request to use a bounded OriginWeave Agent capability. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAccessRequest { + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capability: ExtensionAgentCapability, +} + +impl ExtensionAccessRequest { + /// Build one exact extension capability request without granting authority. + #[must_use] + pub const fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capability: ExtensionAgentCapability, + ) -> Self { + Self { + extension_id, + browser_session, + browsing_context, + capability, + } + } +} + +/// Result of evaluating an extension request against one explicit Agent grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionAccessDecision { + /// The exact extension, session, context, and capability are explicitly granted. + Allow, + /// No explicit extension-to-Agent grant was supplied. + DenyMissingGrant, + /// The request belongs to a different extension identity. + DenyExtensionMismatch, + /// The request belongs to a different browser automation session. + DenyBrowserSessionMismatch, + /// The request belongs to a different independently navigable browser context. + DenyBrowsingContextMismatch, + /// The extension grant does not contain the requested OriginWeave capability. + DenyCapabilityNotGranted, +} + +/// Evaluate extension Agent access without inheriting ambient Chrome permissions. +/// +/// A Chrome extension permission, installation state, or page capability is never +/// consulted here. A future Chromium adapter must construct a host-originated +/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session/context +/// request at the boundary where Agent authority would otherwise cross. +#[must_use] +pub fn evaluate_extension_access( + request: &ExtensionAccessRequest, + grant: Option<&ExtensionAgentGrant>, +) -> ExtensionAccessDecision { + let Some(grant) = grant else { + return ExtensionAccessDecision::DenyMissingGrant; + }; + if request.extension_id != grant.extension_id { + return ExtensionAccessDecision::DenyExtensionMismatch; + } + if request.browser_session != grant.browser_session { + return ExtensionAccessDecision::DenyBrowserSessionMismatch; + } + if request.browsing_context != grant.browsing_context { + return ExtensionAccessDecision::DenyBrowsingContextMismatch; + } + if !grant.capabilities.contains(&request.capability) { + return ExtensionAccessDecision::DenyCapabilityNotGranted; + } + ExtensionAccessDecision::Allow +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 88dd2e58..bdd1b2aa 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1,1065 +1,18 @@ //! 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; +#[cfg(test)] +mod browser_registry_coverage; +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::*; diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs new file mode 100644 index 00000000..f55c69f7 --- /dev/null +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -0,0 +1,217 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, +}; + +fn loopback_origin() -> Origin { + Origin::parse("http://127.0.0.1:43127").expect("valid loopback fixture origin") +} + +#[test] +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")?; + 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")?; + 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)?, + DocumentEpoch::new(1)? + ); + 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(); + 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)?; + 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")?; + 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() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "shared-looking-context")?; + let origin = loopback_origin(); + + assert_eq!( + registry.bind_node(attacker, context, &origin, "node"), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); + 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").expect("valid loopback fixture origin"); + + 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> +{ + let mut registry = BrowserAuthorityRegistry::new(); + + assert_eq!( + registry.register_session(""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session(&"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1)), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let unicode = registry.register_session("μ„Έμ…˜-opaque-βœ“")?; + assert!(unicode.value() > 0); + 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(); + let unknown = BrowserSessionId::new(999)?; + + assert_eq!( + 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(()) +}