diff --git a/CHANGELOG.md b/CHANGELOG.md index 31768c2e..5f370178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. +- Authority-bound, bounded semantic node observations with typed node-local action evidence and explicit observation-channel provenance for the first Chromium vertical slice; observation metadata grants no execution authority. - Versioned schema-bound extraction contracts with bounded identifiers and field counts, typed value/cardinality metadata, explicit duplicate-free reviewed source channels, fail-closed schema validation, and deterministic `Display`/`std::error::Error` contracts for public schema failures. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. @@ -103,4 +104,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 diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index f5791fb5..8b45cca9 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -2,7 +2,8 @@ //! //! This crate keeps the long-lived value contracts in `contracts`, the //! browser protocol/identifier boundaries and extension authority in focused -//! modules so browser adapters can evolve without turning raw CDP or WebDriver +//! modules, and bounded semantic observations in a separate authority-preserving +//! module so browser adapters can evolve without turning raw CDP or WebDriver //! metadata into OriginWeave authority. #![forbid(unsafe_code)] @@ -15,6 +16,7 @@ mod browser_registry_coverage; mod contract_errors; mod contracts; mod extension_authority; +mod semantic_observation; pub use browser_protocol::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, @@ -38,3 +40,8 @@ pub use extension_authority::{ ExtensionAgentGrant as AuthorityExtensionAgentGrant, evaluate_extension_access as evaluate_extension_authority_access, }; +pub use semantic_observation::{ + MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, + MAX_VISIBLE_TEXT_BYTES, NodeActionKind, ObservationChannel, SemanticNodeObservation, + SemanticNodeObservationError, SemanticNodeObservationInput, +}; diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index 933641bb..bd516460 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -19,10 +19,13 @@ 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, - RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, RobotsDecision, SecretDelivery, - SessionMode, evaluate_extension_authority_access as evaluate_extension_access, + InstructionSource, MAX_ACCESSIBLE_NAME_BYTES, MAX_BROWSER_PROTOCOL_METADATA_BYTES, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, + MAX_VISIBLE_TEXT_BYTES, NodeActionKind, NodeHandleError, ObservationChannel, Origin, + OriginError, PolicyContext, RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, + RobotsDecision, SecretDelivery, SemanticNodeObservation, SemanticNodeObservationError, + SemanticNodeObservationInput, SessionMode, + evaluate_extension_authority_access as evaluate_extension_access, }; /// Stateless MCP routing validation that maps only explicit tools to typed actions. diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs new file mode 100644 index 00000000..3a7fffba --- /dev/null +++ b/crates/originweave-core/src/semantic_observation.rs @@ -0,0 +1,306 @@ +use std::collections::BTreeSet; +use std::fmt; + +use crate::{BrowserAuthorityRegistry, ObservedNodeHandle}; + +/// Maximum UTF-8 byte length retained for one semantic node role. +pub const MAX_SEMANTIC_ROLE_BYTES: usize = 64; +/// Maximum UTF-8 byte length retained for one semantic node accessible name. +pub const MAX_ACCESSIBLE_NAME_BYTES: usize = 512; +/// Maximum UTF-8 byte length retained for one semantic node visible-text excerpt. +pub const MAX_VISIBLE_TEXT_BYTES: usize = 4_096; +/// Maximum number of child relationships retained for one semantic node observation. +pub const MAX_SEMANTIC_CHILDREN: usize = 128; + +/// A node-local typed action advertised by an observation adapter. +/// +/// This is descriptive evidence only and never grants execution authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NodeActionKind { + /// Activate the node using browser-native click semantics. + Click, + /// Insert bounded non-secret text using browser-native input semantics. + TypeText, + /// Select one option using browser-native selection semantics. + SelectOption, + /// Set a checkable control to an explicit checked state. + SetChecked, + /// Scroll the node into the viewport without activating it. + ScrollIntoView, +} + +/// A structured evidence channel that contributed to a semantic observation. +/// +/// Channel provenance never converts page-provided content into trusted instruction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ObservationChannel { + /// Experimental structured browser tool metadata, such as WebMCP when available. + WebMcp, + /// Structured data interpreted by a versioned adapter. + StructuredData, + /// Browser accessibility-tree evidence. + Accessibility, + /// Browser DOM evidence used through a bounded adapter. + Dom, + /// Browser layout evidence used through a bounded adapter. + Layout, + /// Bounded visual evidence used when structured channels are insufficient. + Visual, +} + +/// Caller-owned fields used to construct one bounded semantic node observation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNodeObservationInput { + /// Exact OriginWeave authority handle for the observed node. + pub handle: ObservedNodeHandle, + /// Optional exact-authority parent relationship. + pub parent: Option, + /// Bounded exact-authority child relationships in adapter-observed order. + pub children: Vec, + /// Bounded semantic or accessibility role. + pub role: String, + /// Bounded accessible name; an empty name is valid. + pub accessible_name: String, + /// Optional bounded visible-text excerpt. + pub visible_text: Option, + /// Whether the adapter observed the node as enabled. + pub enabled: bool, + /// Whether the adapter observed the node as visible. + pub visible: bool, + /// Optional selected state when that concept applies. + pub selected: Option, + /// Finite typed actions the adapter reports as meaningful for this node. + pub supported_actions: BTreeSet, + /// Finite evidence channels that contributed to this observation. + pub evidence_channels: BTreeSet, +} + +/// A bounded semantic view of one authority-bound browser node. +/// +/// The value carries no raw HTML, protocol-local identifier, or independent authorization. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNodeObservation { + handle: ObservedNodeHandle, + parent: Option, + children: Vec, + role: String, + accessible_name: String, + visible_text: Option, + enabled: bool, + visible: bool, + selected: Option, + supported_actions: BTreeSet, + evidence_channels: BTreeSet, +} + +impl SemanticNodeObservation { + /// Validate reviewed text, relationship, authority, and provenance bounds. + /// + /// Every primary or related node handle must still be live authority owned by + /// `registry`. Caller-constructed, retired, stale, or otherwise unbound handles + /// fail closed before page-derived semantic metadata can become an observation. + pub fn new( + input: SemanticNodeObservationInput, + registry: &BrowserAuthorityRegistry, + ) -> Result { + if input.role.is_empty() { + return Err(SemanticNodeObservationError::EmptyRole); + } + if input.role.len() > MAX_SEMANTIC_ROLE_BYTES { + return Err(SemanticNodeObservationError::RoleTooLong); + } + if input.accessible_name.len() > MAX_ACCESSIBLE_NAME_BYTES { + return Err(SemanticNodeObservationError::AccessibleNameTooLong); + } + if input + .visible_text + .as_ref() + .is_some_and(|text| text.len() > MAX_VISIBLE_TEXT_BYTES) + { + return Err(SemanticNodeObservationError::VisibleTextTooLong); + } + if input.evidence_channels.is_empty() { + return Err(SemanticNodeObservationError::MissingEvidenceChannel); + } + if input.children.len() > MAX_SEMANTIC_CHILDREN { + return Err(SemanticNodeObservationError::TooManyChildren); + } + validate_live_node(registry, &input.handle)?; + if let Some(parent) = input.parent.as_ref() { + validate_live_node(registry, parent)?; + validate_relationship(&input.handle, parent)?; + } + for (index, child) in input.children.iter().enumerate() { + validate_live_node(registry, child)?; + validate_relationship(&input.handle, child)?; + if input.children[..index].contains(child) { + return Err(SemanticNodeObservationError::DuplicateChild); + } + } + Ok(Self { + handle: input.handle, + parent: input.parent, + children: input.children, + role: input.role, + accessible_name: input.accessible_name, + visible_text: input.visible_text, + enabled: input.enabled, + visible: input.visible, + selected: input.selected, + supported_actions: input.supported_actions, + evidence_channels: input.evidence_channels, + }) + } + + /// Return the exact authority-bound node handle. + #[must_use] + pub const fn handle(&self) -> &ObservedNodeHandle { + &self.handle + } + + /// Return the optional exact-authority parent relationship. + #[must_use] + pub const fn parent(&self) -> Option<&ObservedNodeHandle> { + self.parent.as_ref() + } + + /// Return the bounded exact-authority child relationships in observed order. + #[must_use] + pub fn children(&self) -> &[ObservedNodeHandle] { + &self.children + } + + /// Return the bounded semantic role. + #[must_use] + pub fn role(&self) -> &str { + &self.role + } + + /// Return the bounded accessible name. + #[must_use] + pub fn accessible_name(&self) -> &str { + &self.accessible_name + } + + /// Return the optional bounded visible-text excerpt. + #[must_use] + pub fn visible_text(&self) -> Option<&str> { + self.visible_text.as_deref() + } + + /// Return whether the node was observed as enabled. + #[must_use] + pub const fn is_enabled(&self) -> bool { + self.enabled + } + + /// Return whether the node was observed as visible. + #[must_use] + pub const fn is_visible(&self) -> bool { + self.visible + } + + /// Return the optional selected state. + #[must_use] + pub const fn is_selected(&self) -> Option { + self.selected + } + + /// Return the adapter-advertised node action set. + #[must_use] + pub const fn supported_actions(&self) -> &BTreeSet { + &self.supported_actions + } + + /// Return the non-empty evidence-channel provenance set. + #[must_use] + pub const fn evidence_channels(&self) -> &BTreeSet { + &self.evidence_channels + } +} + +fn validate_live_node( + registry: &BrowserAuthorityRegistry, + handle: &ObservedNodeHandle, +) -> Result<(), SemanticNodeObservationError> { + registry + .validate_node_handle(handle) + .map_err(|_error| SemanticNodeObservationError::UnknownNodeAuthority) +} + +fn validate_relationship( + handle: &ObservedNodeHandle, + related: &ObservedNodeHandle, +) -> Result<(), SemanticNodeObservationError> { + if handle == related { + return Err(SemanticNodeObservationError::SelfRelationship); + } + // Both handles have already passed `validate_live_node` against the same registry. A live + // browsing context has exactly one current origin and document epoch, so matching session and + // context authority necessarily implies matching origin and epoch. Rechecking those implied + // dimensions would create unreachable branch states rather than additional defense in depth. + if handle.browser_session() != related.browser_session() + || handle.browsing_context() != related.browsing_context() + { + return Err(SemanticNodeObservationError::RelationshipAuthorityMismatch); + } + Ok(()) +} + +/// A bounded validation failure for one semantic node observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticNodeObservationError { + /// The semantic role was empty. + EmptyRole, + /// The role exceeded [`MAX_SEMANTIC_ROLE_BYTES`]. + RoleTooLong, + /// The accessible name exceeded [`MAX_ACCESSIBLE_NAME_BYTES`]. + AccessibleNameTooLong, + /// The visible-text excerpt exceeded [`MAX_VISIBLE_TEXT_BYTES`]. + VisibleTextTooLong, + /// No evidence channel was supplied for the observation. + MissingEvidenceChannel, + /// The child relationship list exceeded [`MAX_SEMANTIC_CHILDREN`]. + TooManyChildren, + /// A supplied node handle is not current authority owned by the active browser registry. + UnknownNodeAuthority, + /// A relationship crossed the observation's session, context, origin, or document authority. + RelationshipAuthorityMismatch, + /// The observation attempted to relate the node to itself. + SelfRelationship, + /// The child relationship list contained the same exact handle more than once. + DuplicateChild, +} + +impl fmt::Display for SemanticNodeObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyRole => formatter.write_str("semantic node role must not be empty"), + Self::RoleTooLong => formatter.write_str("semantic node role exceeds 64 UTF-8 bytes"), + Self::AccessibleNameTooLong => { + formatter.write_str("semantic node accessible name exceeds 512 UTF-8 bytes") + } + Self::VisibleTextTooLong => { + formatter.write_str("semantic node visible text exceeds 4096 UTF-8 bytes") + } + Self::MissingEvidenceChannel => formatter + .write_str("semantic node observation requires at least one evidence channel"), + Self::TooManyChildren => { + formatter.write_str("semantic node observation exceeds 128 child relationships") + } + Self::UnknownNodeAuthority => formatter.write_str( + "semantic node observation contains node authority not owned by the active browser registry", + ), + Self::RelationshipAuthorityMismatch => formatter.write_str( + "semantic node relationship crosses its session, context, origin, or document authority", + ), + Self::SelfRelationship => { + formatter.write_str("semantic node observation cannot relate the node to itself") + } + Self::DuplicateChild => formatter + .write_str("semantic node observation contains a duplicate child relationship"), + } + } +} + +impl std::error::Error for SemanticNodeObservationError {} diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs new file mode 100644 index 00000000..6466abab --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -0,0 +1,340 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserSessionId, BrowsingContextId, MAX_ACCESSIBLE_NAME_BYTES, + MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, + ObservationChannel, ObservedNodeHandle, Origin, SemanticNodeObservation, + SemanticNodeObservationError, SemanticNodeObservationInput, +}; + +struct Fixture { + registry: BrowserAuthorityRegistry, + session: BrowserSessionId, + context: BrowsingContextId, + origin: Origin, + next_external: u64, +} + +impl Fixture { + fn new() -> Result { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry + .register_session("semantic-session") + .map_err(|error| error.to_string())?; + let context = registry + .register_context(session, "semantic-context") + .map_err(|error| error.to_string())?; + let origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; + Ok(Self { + registry, + session, + context, + origin, + next_external: 1, + }) + } + + fn bind_named(&mut self, external_identifier: &str) -> Result { + self.registry + .bind_node( + self.session, + self.context, + &self.origin, + external_identifier, + ) + .map_err(|error| error.to_string()) + } + + fn bind_next(&mut self) -> Result { + let external_identifier = format!("semantic-node-{}", self.next_external); + self.next_external += 1; + self.bind_named(&external_identifier) + } + + fn input( + &mut self, + role: String, + accessible_name: String, + visible_text: Option, + ) -> Result { + Ok(SemanticNodeObservationInput { + handle: self.bind_next()?, + parent: None, + children: Vec::new(), + role, + accessible_name, + visible_text, + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]), + evidence_channels: BTreeSet::from([ + ObservationChannel::Accessibility, + ObservationChannel::Dom, + ]), + }) + } +} + +#[test] +fn semantic_node_preserves_live_authority_and_bounded_surface() -> Result<(), String> { + let mut fixture = Fixture::new()?; + let mut input = fixture.input( + "textbox".to_owned(), + "Email address".to_owned(), + Some("name@example.test".to_owned()), + )?; + let handle = input.handle.clone(); + let parent = fixture.bind_next()?; + let first_child = fixture.bind_next()?; + let second_child = fixture.bind_next()?; + input.parent = Some(parent.clone()); + input.children = vec![first_child.clone(), second_child.clone()]; + input.selected = Some(false); + + let observation = SemanticNodeObservation::new(input, &fixture.registry) + .map_err(|error| error.to_string())?; + + assert_eq!(observation.handle(), &handle); + assert_eq!(observation.parent(), Some(&parent)); + assert_eq!(observation.children(), &[first_child, second_child]); + assert_eq!(observation.role(), "textbox"); + assert_eq!(observation.accessible_name(), "Email address"); + assert_eq!(observation.visible_text(), Some("name@example.test")); + assert!(observation.is_enabled()); + assert!(observation.is_visible()); + assert_eq!(observation.is_selected(), Some(false)); + assert_eq!( + observation.supported_actions(), + &BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]) + ); + assert_eq!( + observation.evidence_channels(), + &BTreeSet::from([ObservationChannel::Accessibility, ObservationChannel::Dom]) + ); + Ok(()) +} + +#[test] +fn semantic_node_bounds_child_relationship_count() -> Result<(), String> { + let mut fixture = Fixture::new()?; + let mut boundary = fixture.input("list".to_owned(), "Items".to_owned(), None)?; + let mut children = Vec::with_capacity(MAX_SEMANTIC_CHILDREN); + for _ in 0..MAX_SEMANTIC_CHILDREN { + children.push(fixture.bind_next()?); + } + boundary.children = children; + let observation = SemanticNodeObservation::new(boundary, &fixture.registry) + .map_err(|error| error.to_string())?; + assert_eq!(observation.children().len(), MAX_SEMANTIC_CHILDREN); + + let mut overflow = fixture.input("list".to_owned(), "Items".to_owned(), None)?; + overflow.children = vec![overflow.handle.clone(); MAX_SEMANTIC_CHILDREN + 1]; + assert_eq!( + SemanticNodeObservation::new(overflow, &fixture.registry).err(), + Some(SemanticNodeObservationError::TooManyChildren) + ); + Ok(()) +} + +#[test] +fn semantic_node_rejects_live_relationships_from_other_authority() -> Result<(), String> { + let mut fixture = Fixture::new()?; + let mut parent_input = fixture.input("group".to_owned(), "Account".to_owned(), None)?; + let other_context = fixture + .registry + .register_context(fixture.session, "other-context") + .map_err(|error| error.to_string())?; + let origin = fixture.origin.clone(); + let other_context_node = fixture + .registry + .bind_node( + fixture.session, + other_context, + &origin, + "other-context-node", + ) + .map_err(|error| error.to_string())?; + parent_input.parent = Some(other_context_node); + assert_eq!( + SemanticNodeObservation::new(parent_input, &fixture.registry).err(), + Some(SemanticNodeObservationError::RelationshipAuthorityMismatch) + ); + + let mut child_input = fixture.input("group".to_owned(), "Account".to_owned(), None)?; + let other_session = fixture + .registry + .register_session("other-session") + .map_err(|error| error.to_string())?; + let other_session_context = fixture + .registry + .register_context(other_session, "other-session-context") + .map_err(|error| error.to_string())?; + let other_origin = + Origin::parse("https://other.example").map_err(|error| format!("{error:?}"))?; + let other_session_node = fixture + .registry + .bind_node( + other_session, + other_session_context, + &other_origin, + "other-session-node", + ) + .map_err(|error| error.to_string())?; + child_input.children = vec![other_session_node]; + assert_eq!( + SemanticNodeObservation::new(child_input, &fixture.registry).err(), + Some(SemanticNodeObservationError::RelationshipAuthorityMismatch) + ); + Ok(()) +} + +#[test] +fn semantic_node_rejects_self_and_duplicate_child_relationships() -> Result<(), String> { + let mut fixture = Fixture::new()?; + let mut self_parent = fixture.input("group".to_owned(), "Account".to_owned(), None)?; + self_parent.parent = Some(self_parent.handle.clone()); + assert_eq!( + SemanticNodeObservation::new(self_parent, &fixture.registry).err(), + Some(SemanticNodeObservationError::SelfRelationship) + ); + + let mut self_child = fixture.input("group".to_owned(), "Account".to_owned(), None)?; + self_child.children = vec![self_child.handle.clone()]; + assert_eq!( + SemanticNodeObservation::new(self_child, &fixture.registry).err(), + Some(SemanticNodeObservationError::SelfRelationship) + ); + + let child = fixture.bind_next()?; + let mut duplicate = fixture.input("group".to_owned(), "Account".to_owned(), None)?; + duplicate.children = vec![child.clone(), child]; + assert_eq!( + SemanticNodeObservation::new(duplicate, &fixture.registry).err(), + Some(SemanticNodeObservationError::DuplicateChild) + ); + Ok(()) +} + +#[test] +fn reviewed_text_bounds_are_inclusive_and_visible_text_is_optional() -> Result<(), String> { + let mut fixture = Fixture::new()?; + let boundary_input = fixture.input( + "r".repeat(MAX_SEMANTIC_ROLE_BYTES), + "n".repeat(MAX_ACCESSIBLE_NAME_BYTES), + Some("v".repeat(MAX_VISIBLE_TEXT_BYTES)), + )?; + let boundary = SemanticNodeObservation::new(boundary_input, &fixture.registry) + .map_err(|error| error.to_string())?; + assert_eq!(boundary.role().len(), MAX_SEMANTIC_ROLE_BYTES); + assert_eq!(boundary.accessible_name().len(), MAX_ACCESSIBLE_NAME_BYTES); + assert_eq!( + boundary.visible_text().map(str::len), + Some(MAX_VISIBLE_TEXT_BYTES) + ); + + let without_text_input = fixture.input("button".to_owned(), String::new(), None)?; + let without_text = SemanticNodeObservation::new(without_text_input, &fixture.registry) + .map_err(|error| error.to_string())?; + assert_eq!(without_text.visible_text(), None); + Ok(()) +} + +#[test] +fn semantic_node_rejects_missing_provenance_and_unbounded_text() -> Result<(), String> { + let mut fixture = Fixture::new()?; + + let mut missing_provenance = fixture.input("button".to_owned(), "Submit".to_owned(), None)?; + missing_provenance.evidence_channels.clear(); + assert_eq!( + SemanticNodeObservation::new(missing_provenance, &fixture.registry).err(), + Some(SemanticNodeObservationError::MissingEvidenceChannel) + ); + + let empty_role = fixture.input(String::new(), "name".to_owned(), None)?; + assert_eq!( + SemanticNodeObservation::new(empty_role, &fixture.registry).err(), + Some(SemanticNodeObservationError::EmptyRole) + ); + + let long_role = fixture.input( + "r".repeat(MAX_SEMANTIC_ROLE_BYTES + 1), + "name".to_owned(), + None, + )?; + assert_eq!( + SemanticNodeObservation::new(long_role, &fixture.registry).err(), + Some(SemanticNodeObservationError::RoleTooLong) + ); + + let long_name = fixture.input( + "button".to_owned(), + "n".repeat(MAX_ACCESSIBLE_NAME_BYTES + 1), + None, + )?; + assert_eq!( + SemanticNodeObservation::new(long_name, &fixture.registry).err(), + Some(SemanticNodeObservationError::AccessibleNameTooLong) + ); + + let long_visible_text = fixture.input( + "button".to_owned(), + "name".to_owned(), + Some("v".repeat(MAX_VISIBLE_TEXT_BYTES + 1)), + )?; + assert_eq!( + SemanticNodeObservation::new(long_visible_text, &fixture.registry).err(), + Some(SemanticNodeObservationError::VisibleTextTooLong) + ); + Ok(()) +} + +#[test] +fn semantic_node_errors_are_stable_and_credential_free() { + let expected = [ + ( + SemanticNodeObservationError::EmptyRole, + "semantic node role must not be empty", + ), + ( + SemanticNodeObservationError::RoleTooLong, + "semantic node role exceeds 64 UTF-8 bytes", + ), + ( + SemanticNodeObservationError::AccessibleNameTooLong, + "semantic node accessible name exceeds 512 UTF-8 bytes", + ), + ( + SemanticNodeObservationError::VisibleTextTooLong, + "semantic node visible text exceeds 4096 UTF-8 bytes", + ), + ( + SemanticNodeObservationError::MissingEvidenceChannel, + "semantic node observation requires at least one evidence channel", + ), + ( + SemanticNodeObservationError::TooManyChildren, + "semantic node observation exceeds 128 child relationships", + ), + ( + SemanticNodeObservationError::UnknownNodeAuthority, + "semantic node observation contains node authority not owned by the active browser registry", + ), + ( + SemanticNodeObservationError::RelationshipAuthorityMismatch, + "semantic node relationship crosses its session, context, origin, or document authority", + ), + ( + SemanticNodeObservationError::SelfRelationship, + "semantic node observation cannot relate the node to itself", + ), + ( + SemanticNodeObservationError::DuplicateChild, + "semantic node observation contains a duplicate child relationship", + ), + ]; + + for (error, message) in expected { + assert_eq!(error.to_string(), message); + } +} diff --git a/crates/originweave-core/tests/semantic_observation_registry_authority.rs b/crates/originweave-core/tests/semantic_observation_registry_authority.rs new file mode 100644 index 00000000..ea787767 --- /dev/null +++ b/crates/originweave-core/tests/semantic_observation_registry_authority.rs @@ -0,0 +1,93 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + BrowserAuthorityRegistry, NodeActionKind, ObservationChannel, ObservedNodeHandle, Origin, + SemanticNodeObservation, SemanticNodeObservationError, SemanticNodeObservationInput, +}; + +fn bound_observation_fixture() +-> Result<(BrowserAuthorityRegistry, ObservedNodeHandle), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("semantic-session")?; + let context = registry.register_context(session, "semantic-context")?; + let origin = Origin::parse("https://example.com")?; + let handle = registry.bind_node(session, context, &origin, "semantic-node")?; + Ok((registry, handle)) +} + +fn input(handle: ObservedNodeHandle) -> SemanticNodeObservationInput { + SemanticNodeObservationInput { + handle, + parent: None, + children: Vec::new(), + role: "button".to_owned(), + accessible_name: "Submit".to_owned(), + visible_text: None, + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([NodeActionKind::Click]), + evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]), + } +} + +#[test] +fn semantic_observation_rejects_forged_primary_node_authority() +-> Result<(), Box> { + let (registry, bound) = bound_observation_fixture()?; + let forged = ObservedNodeHandle::new( + bound.browser_session(), + bound.browsing_context(), + bound.origin().clone(), + bound.document_epoch(), + bound.node_id() + 10_000, + )?; + + assert_eq!( + SemanticNodeObservation::new(input(forged), ®istry).err(), + Some(SemanticNodeObservationError::UnknownNodeAuthority) + ); + Ok(()) +} + +#[test] +fn semantic_observation_rejects_forged_parent_node_authority() +-> Result<(), Box> { + let (registry, bound) = bound_observation_fixture()?; + let forged_parent = ObservedNodeHandle::new( + bound.browser_session(), + bound.browsing_context(), + bound.origin().clone(), + bound.document_epoch(), + bound.node_id() + 10_000, + )?; + let mut observation_input = input(bound); + observation_input.parent = Some(forged_parent); + + assert_eq!( + SemanticNodeObservation::new(observation_input, ®istry).err(), + Some(SemanticNodeObservationError::UnknownNodeAuthority) + ); + Ok(()) +} + +#[test] +fn semantic_observation_rejects_forged_related_node_authority() +-> Result<(), Box> { + let (registry, bound) = bound_observation_fixture()?; + let forged_child = ObservedNodeHandle::new( + bound.browser_session(), + bound.browsing_context(), + bound.origin().clone(), + bound.document_epoch(), + bound.node_id() + 10_000, + )?; + let mut observation_input = input(bound); + observation_input.children.push(forged_child); + + assert_eq!( + SemanticNodeObservation::new(observation_input, ®istry).err(), + Some(SemanticNodeObservationError::UnknownNodeAuthority) + ); + Ok(()) +}