From 32f3fb6fb14eb757e5269c0c05e61f3138d9871a Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 28 Sep 2026 23:36:50 +0900 Subject: [PATCH] feat(host): add object publication and dispatch --- Cargo.lock | 9 + Cargo.toml | 1 + README.md | 32 + crates/wip-host/Cargo.toml | 13 + crates/wip-host/src/lib.rs | 623 ++++++++++++++++++ crates/wip-host/tests/compile_fail.rs | 5 + crates/wip-host/tests/host.rs | 488 ++++++++++++++ .../tests/ui/unimplemented_interface.rs | 22 + .../tests/ui/unimplemented_interface.stderr | 34 + crates/wip-interface-macros/src/lib.rs | 33 +- crates/wip-interface/src/lib.rs | 15 + 11 files changed, 1267 insertions(+), 8 deletions(-) create mode 100644 crates/wip-host/Cargo.toml create mode 100644 crates/wip-host/src/lib.rs create mode 100644 crates/wip-host/tests/compile_fail.rs create mode 100644 crates/wip-host/tests/host.rs create mode 100644 crates/wip-host/tests/ui/unimplemented_interface.rs create mode 100644 crates/wip-host/tests/ui/unimplemented_interface.stderr diff --git a/Cargo.lock b/Cargo.lock index 2c1107e..c755088 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,6 +238,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "wip-host" +version = "0.1.0" +dependencies = [ + "trybuild", + "wip-interface", + "wip-protocol", +] + [[package]] name = "wip-interface" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2c5e304..9e5498a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ ".", + "crates/wip-host", "crates/wip-interface", "crates/wip-interface-macros", "crates/wip-protocol", diff --git a/README.md b/README.md index 220bc93..34da9d9 100644 --- a/README.md +++ b/README.md @@ -65,3 +65,35 @@ The supported interface slice is deliberately non-generic: public traits, `&self`, named parameters, and `OperationResult`. Generic traits or methods, associated items, custom lifetimes, inheritance, default method bodies, and other receiver forms are rejected at their source spans. + +## Host publication and dispatch + +Applications use `wip-host` to register generated definitions and explicitly +choose the ordered interface set for each object publication: + +```rust,ignore +let mut interfaces = wip_host::InterfaceRegistry::new(); +let messenger = interfaces.register(MessengerDefinition, None)?; + +let published = wip_host::PublishedObject::new(request_scoped_object) + .expose(&messenger)?; +let protocol_object = published.protocol_object(); +``` + +`expose` compiles only when the object implements the generated definition's +interface trait. Implementing a trait does not expose it automatically, empty +interface sets are valid, insertion order is preserved, and duplicate +references are rejected. Registry references are opaque; descriptor names and +Rust type names are not used as wire identity. + +Dispatch is qualified by interface reference and operation name. The host checks +current object identity and validator policy, current interface membership, +interface validator, descriptor operation, arguments, and result while retaining +the selected object and registered artifact. Host-private fields and backend +handles are never projected by `WipObject`. + +Providers remain responsible for backend atomicity. Enter a transaction, lock, +stable-handle scope, or conditional-update primitive before constructing and +dispatching the request-scoped `PublishedObject`, and retain it until dispatch +returns. The runtime never validates one object and then resolves a second +snapshot, but it cannot make an arbitrary external backend atomic by itself. diff --git a/crates/wip-host/Cargo.toml b/crates/wip-host/Cargo.toml new file mode 100644 index 0000000..c6b0776 --- /dev/null +++ b/crates/wip-host/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "wip-host" +version = "0.1.0" +edition = "2024" +description = "Host-side object publication and operation dispatch for WIP" +license = "MIT OR Apache-2.0" + +[dependencies] +wip-interface = { path = "../wip-interface" } +wip-protocol = { path = "../wip-protocol" } + +[dev-dependencies] +trybuild = "1" diff --git a/crates/wip-host/src/lib.rs b/crates/wip-host/src/lib.rs new file mode 100644 index 0000000..c049dcb --- /dev/null +++ b/crates/wip-host/src/lib.rs @@ -0,0 +1,623 @@ +//! Host-side publication and operation dispatch for Web Interface Protocol objects. +//! +//! # Responsibility boundaries +//! +//! [`InterfaceRegistry::register`] validates and freezes a generated interface +//! descriptor, assigns an opaque [`InterfaceRef`], and makes the same descriptor +//! observation available to clients and dispatch. [`PublishedObject::expose`] +//! explicitly combines a request-scoped Rust object with a registered interface; +//! its [`InterfaceImplementation`] bound proves at compile time that the object +//! implements that interface. Implementing a Rust trait alone never publishes it, +//! and a publication with no interfaces is valid. +//! +//! [`PublishedObject::dispatch`] selects by `(InterfaceRef, operation name)`, +//! checks object and interface validators, current membership, the frozen +//! descriptor, and then invokes the matching generated typed dispatcher on the +//! same object instance. Arguments and results cross the protocol [`Value`] +//! boundary only through generated validation and codecs. Rust fields that are +//! not returned by [`WipObject`] are host-private and are never serialized by +//! this crate. +//! +//! The call method is deliberately an execution boundary rather than a global +//! object registry: a provider may resolve and publish an object per request and +//! drop it afterward. The provider must enter any backend-specific transaction, +//! lock, stable-handle scope, or conditional-update primitive *before* creating +//! or dispatching the publication and retain it until dispatch returns. This +//! crate keeps validator checks, descriptor and implementation selection, and +//! invocation in one borrow of that publication; it does not claim that an +//! arbitrary backend stays stable, and it never reopens a path or resolves a +//! second implementation snapshot after validation. + +#![deny(missing_docs)] + +use std::any::TypeId; +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::fmt::{self, Display, Formatter}; +use std::marker::PhantomData; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use wip_interface::DispatchError; +pub use wip_interface::{InterfaceDefinition, InterfaceImplementation}; +pub use wip_protocol; +use wip_protocol::{ + InterfaceDescriptor, NamedValue, Object, ObjectId, OperationCall, OperationTarget, + ProtocolError, ProtocolErrorCategory, ValidationError, Value, +}; + +/// Opaque bytes used as a conditional execution precondition. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Validator(Vec); + +impl Validator { + /// Creates a validator without assigning meaning to its bytes. + #[must_use] + pub fn new(bytes: impl Into>) -> Self { + Self(bytes.into()) + } + + /// Returns the opaque bytes for transport encoding or comparison. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +/// Opaque host-assigned reference to one registered interface artifact. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct InterfaceRef(String); + +impl InterfaceRef { + /// Returns the opaque reference for a protocol transport. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// An immutable observation returned for an opaque interface reference. +#[derive(Debug, Clone, PartialEq)] +pub struct InterfaceObservation { + /// Opaque reference selected by published objects and operation calls. + pub reference: InterfaceRef, + /// Optional validator. Its presence makes it a required call precondition. + pub validator: Option, + /// Validated descriptor frozen when the interface was registered. + pub descriptor: InterfaceDescriptor, +} + +#[derive(Debug)] +struct InterfaceArtifact { + reference: InterfaceRef, + validator: Option, + descriptor: InterfaceDescriptor, +} + +/// A typed registration token for a generated interface definition. +/// +/// Tokens can be reused by any number of request-scoped objects. They expose an +/// opaque reference, not the Rust type name or an address. +pub struct RegisteredInterface { + artifact: Arc, + definition: PhantomData D>, +} + +impl Clone for RegisteredInterface { + fn clone(&self) -> Self { + Self { + artifact: Arc::clone(&self.artifact), + definition: PhantomData, + } + } +} + +impl RegisteredInterface { + /// Returns the opaque protocol reference assigned by the registry. + #[must_use] + pub fn reference(&self) -> &InterfaceRef { + &self.artifact.reference + } +} + +/// Registry of validated, immutable interface artifacts. +/// +/// This registry stores interface definitions, not object instances. Generated +/// definitions are registered explicitly and receive process-local opaque +/// references. A validator-free artifact is immutable for the lifetime of its +/// reference; a validator-bearing artifact requires that exact validator on a +/// call. Re-registering a definition is rejected instead of silently changing +/// the descriptor associated with an existing reference. +#[derive(Debug)] +pub struct InterfaceRegistry { + namespace: u64, + next_reference: u64, + definitions: HashSet, + artifacts: HashMap>, +} + +static NEXT_REGISTRY_NAMESPACE: AtomicU64 = AtomicU64::new(1); + +impl Default for InterfaceRegistry { + fn default() -> Self { + Self { + namespace: NEXT_REGISTRY_NAMESPACE.fetch_add(1, Ordering::Relaxed), + next_reference: 0, + definitions: HashSet::new(), + artifacts: HashMap::new(), + } + } +} + +impl InterfaceRegistry { + /// Creates an empty interface registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Registers and freezes one generated interface definition. + /// + /// `validator` should be present when the host intends the reference to + /// represent a versioned descriptor. Its presence makes the same value a + /// mandatory precondition for dispatch. + pub fn register( + &mut self, + _definition: D, + validator: Option, + ) -> Result, RegistrationError> + where + D: InterfaceDefinition + 'static, + { + let definition = TypeId::of::(); + if self.definitions.contains(&definition) { + return Err(RegistrationError::AlreadyRegistered); + } + + let descriptor = D::descriptor(); + descriptor + .validate() + .map_err(RegistrationError::InvalidDescriptor)?; + + let reference = InterfaceRef(format!( + "wip-interface:{}:{}", + self.namespace, self.next_reference + )); + self.next_reference = self + .next_reference + .checked_add(1) + .ok_or(RegistrationError::ReferenceSpaceExhausted)?; + let artifact = Arc::new(InterfaceArtifact { + reference: reference.clone(), + validator, + descriptor, + }); + self.definitions.insert(definition); + self.artifacts.insert(reference, Arc::clone(&artifact)); + + Ok(RegisteredInterface { + artifact, + definition: PhantomData, + }) + } + + /// Observes the descriptor and optional validator for an opaque reference. + #[must_use] + pub fn observe(&self, reference: &InterfaceRef) -> Option { + self.artifacts + .get(reference) + .map(|artifact| InterfaceObservation { + reference: artifact.reference.clone(), + validator: artifact.validator.clone(), + descriptor: artifact.descriptor.clone(), + }) + } +} + +/// Failure while registering a generated interface definition. +#[derive(Debug)] +pub enum RegistrationError { + /// The same generated definition was already assigned a reference. + AlreadyRegistered, + /// The generated descriptor is structurally invalid. + InvalidDescriptor(ValidationError), + /// The registry cannot assign another opaque reference. + ReferenceSpaceExhausted, +} + +impl Display for RegistrationError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::AlreadyRegistered => { + formatter.write_str("interface definition already registered") + } + Self::InvalidDescriptor(error) => { + write!(formatter, "invalid interface descriptor: {error}") + } + Self::ReferenceSpaceExhausted => { + formatter.write_str("interface reference space exhausted") + } + } + } +} + +impl Error for RegistrationError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::InvalidDescriptor(error) => Some(error), + Self::AlreadyRegistered | Self::ReferenceSpaceExhausted => None, + } + } +} + +/// Public metadata and conditional-execution policy for a host-private object. +/// +/// Only values returned by this trait are projected into protocol metadata. +/// Backend handles, filesystem paths, credentials, and other private fields on +/// the implementing type are neither inspected nor serialized. +pub trait WipObject { + /// Returns the current logical identity of this object. + fn id(&self) -> ObjectId; + + /// Returns the current opaque object validator, if any. + fn validator(&self) -> Option<&Validator> { + None + } + + /// Whether a call must include the current object validator. + fn requires_validator(&self) -> bool { + false + } +} + +type DispatchFn = fn(&T, &str, Vec) -> Result, DispatchError>; + +struct BoundInterface { + artifact: Arc, + dispatch: DispatchFn, +} + +/// A host-private object instance combined with an explicit ordered interface set. +/// +/// The instance is owned by this value and can therefore be request-scoped. No +/// global or pre-populated object registry is required. +pub struct PublishedObject { + implementation: T, + interfaces: Vec>, +} + +impl PublishedObject { + /// Starts a publication with no exposed interfaces. + #[must_use] + pub fn new(implementation: T) -> Self { + Self { + implementation, + interfaces: Vec::new(), + } + } + + /// Explicitly exposes a registered generated interface in insertion order. + /// + /// The `InterfaceImplementation` bound is generated only when `T` + /// implements the corresponding Rust interface trait. Duplicate opaque + /// references are rejected. + pub fn expose(mut self, interface: &RegisteredInterface) -> Result + where + D: InterfaceImplementation, + { + if self + .interfaces + .iter() + .any(|bound| bound.artifact.reference == interface.artifact.reference) + { + return Err(PublicationError::DuplicateInterface( + interface.artifact.reference.clone(), + )); + } + + self.interfaces.push(BoundInterface { + artifact: Arc::clone(&interface.artifact), + dispatch: D::dispatch, + }); + Ok(self) + } + + /// Returns the host-private implementation without projecting its fields. + #[must_use] + pub fn implementation(&self) -> &T { + &self.implementation + } + + /// Builds the protocol object metadata with interface insertion order intact. + #[must_use] + pub fn protocol_object(&self) -> Object { + let object = Object { + id: self.implementation.id(), + interfaces: self + .interfaces + .iter() + .map(|bound| bound.artifact.reference.0.clone()) + .collect(), + }; + debug_assert!(object.validate().is_ok()); + object + } + + /// Dispatches one qualified operation against this current publication. + /// + /// Preconditions are checked before generated operation code runs. The + /// selected artifact and function pointer are borrowed once and are not + /// resolved again after validator checks. + pub fn dispatch(&self, request: CallRequest) -> Result { + if request.object != self.implementation.id() { + return Err(CallError::new( + CallErrorKind::ObjectNotFound, + "call target does not identify this published object", + )); + } + + let current_object_validator = self.implementation.validator(); + match request.object_validator.as_ref() { + Some(supplied) if Some(supplied) != current_object_validator => { + return Err(CallError::new( + CallErrorKind::ValidatorMismatch, + "object validator does not match current object state", + )); + } + None if self.implementation.requires_validator() => { + return Err(CallError::new( + CallErrorKind::ValidatorRequired, + "this object requires a validator", + )); + } + _ => {} + } + + let Some(bound) = self + .interfaces + .iter() + .find(|bound| bound.artifact.reference == request.interface.reference) + else { + return Err(CallError::new( + CallErrorKind::InterfaceMismatch, + "interface is not a member of the current object publication", + )); + }; + + match ( + bound.artifact.validator.as_ref(), + request.interface.validator.as_ref(), + ) { + (Some(_), None) => { + return Err(CallError::new( + CallErrorKind::InterfaceValidatorRequired, + "registered interface requires a validator", + )); + } + (expected, Some(actual)) if expected != Some(actual) => { + return Err(CallError::new( + CallErrorKind::InterfaceValidatorMismatch, + "interface validator does not match the registered descriptor", + )); + } + _ => {} + } + + if !bound + .artifact + .descriptor + .operations + .iter() + .any(|operation| operation.name == request.operation) + { + return Err(CallError::new( + CallErrorKind::OperationNotFound, + format!("operation `{}` is not declared", request.operation), + )); + } + + bound + .artifact + .descriptor + .validate_call(&OperationCall { + target: OperationTarget { + object: request.object.clone(), + interface: bound.artifact.descriptor.name.clone(), + operation: request.operation.clone(), + }, + arguments: request.arguments.clone(), + }) + .map_err(|error| CallError::new(CallErrorKind::InvalidArguments, error.to_string()))?; + + let value = (bound.dispatch)(&self.implementation, &request.operation, request.arguments) + .map_err(CallError::from_dispatch)?; + + bound + .artifact + .descriptor + .validate_return(&request.operation, value.as_ref()) + .map_err(|error| CallError::new(CallErrorKind::InvalidResult, error.to_string()))?; + + Ok(CallResult { + value, + validator: self.implementation.validator().cloned(), + }) + } + + /// Consumes the publication and returns its host-private implementation. + #[must_use] + pub fn into_inner(self) -> T { + self.implementation + } +} + +/// Failure to construct an explicit object publication. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PublicationError { + /// The same opaque interface reference was exposed more than once. + DuplicateInterface(InterfaceRef), +} + +impl Display for PublicationError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateInterface(reference) => write!( + formatter, + "interface `{}` is already exposed by this object", + reference.as_str() + ), + } + } +} + +impl Error for PublicationError {} + +/// Interface portion of a qualified operation target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InterfaceTarget { + /// Opaque reference selected from the current object's interface set. + pub reference: InterfaceRef, + /// Validator copied from the descriptor observation, when one was present. + pub validator: Option, +} + +/// One transport-independent request to a published object operation. +#[derive(Debug, Clone, PartialEq)] +pub struct CallRequest { + /// Current logical object identity. + pub object: ObjectId, + /// Optional current object-state precondition. + pub object_validator: Option, + /// Qualified interface reference and descriptor precondition. + pub interface: InterfaceTarget, + /// Operation name within the selected interface descriptor. + pub operation: String, + /// Named protocol arguments. + pub arguments: Vec, +} + +/// Successful host dispatch result. +#[derive(Debug, Clone, PartialEq)] +pub struct CallResult { + /// Encoded application value, or `None` for a no-return operation. + pub value: Option, + /// Object validator observed after the implementation returned. + pub validator: Option, +} + +/// Stable host dispatch failure kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CallErrorKind { + /// The call names a different or unavailable object. + ObjectNotFound, + /// The selected interface is not exposed by the current object. + InterfaceMismatch, + /// The operation is absent from the selected descriptor. + OperationNotFound, + /// Supplied named values do not satisfy the operation declaration. + InvalidArguments, + /// The encoded implementation result violates its declaration. + InvalidResult, + /// This object requires a validator and the call omitted it. + ValidatorRequired, + /// The supplied object validator is stale or belongs to another object. + ValidatorMismatch, + /// The selected mutable interface requires its observed validator. + InterfaceValidatorRequired, + /// The supplied interface validator does not identify the current descriptor. + InterfaceValidatorMismatch, + /// The typed implementation reported a host/backend failure. + Implementation, +} + +/// A dispatch failure with a stable kind and non-normative diagnostic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallError { + kind: CallErrorKind, + message: String, +} + +impl CallError { + fn new(kind: CallErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + fn from_dispatch(error: DispatchError) -> Self { + match error { + DispatchError::UnknownOperation(operation) => Self::new( + CallErrorKind::OperationNotFound, + format!("operation `{operation}` is not declared"), + ), + DispatchError::InvalidArguments(error) => { + Self::new(CallErrorKind::InvalidArguments, error.to_string()) + } + DispatchError::Decode(error) => { + Self::new(CallErrorKind::InvalidArguments, error.to_string()) + } + DispatchError::Host(error) => { + Self::new(CallErrorKind::Implementation, error.to_string()) + } + DispatchError::Encode(error) => { + Self::new(CallErrorKind::InvalidResult, error.to_string()) + } + DispatchError::InvalidResult(error) => { + Self::new(CallErrorKind::InvalidResult, error.to_string()) + } + } + } + + /// Returns the stable failure kind. + #[must_use] + pub fn kind(&self) -> CallErrorKind { + self.kind + } + + /// Returns the human-readable diagnostic. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// Maps this host failure to the existing protocol error taxonomy. + /// + /// Validator details remain available through [`CallError::kind`], while + /// the current protocol model represents conditional-request failures as + /// `InvalidRequest`. Result/codec and implementation failures are internal, + /// because exposing malformed host output as an application failure would + /// be unsafe. + #[must_use] + pub fn protocol_error(&self) -> ProtocolError { + let category = match self.kind { + CallErrorKind::ObjectNotFound => ProtocolErrorCategory::ObjectNotFound, + CallErrorKind::InterfaceMismatch => ProtocolErrorCategory::InterfaceNotFound, + CallErrorKind::OperationNotFound => ProtocolErrorCategory::OperationNotFound, + CallErrorKind::InvalidArguments => ProtocolErrorCategory::InvalidArguments, + CallErrorKind::ValidatorRequired + | CallErrorKind::ValidatorMismatch + | CallErrorKind::InterfaceValidatorRequired + | CallErrorKind::InterfaceValidatorMismatch => ProtocolErrorCategory::InvalidRequest, + CallErrorKind::InvalidResult | CallErrorKind::Implementation => { + ProtocolErrorCategory::Internal + } + }; + ProtocolError { + category, + message: self.message.clone(), + } + } +} + +impl Display for CallError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!( + formatter, + "host dispatch failed ({:?}): {}", + self.kind, self.message + ) + } +} + +impl Error for CallError {} diff --git a/crates/wip-host/tests/compile_fail.rs b/crates/wip-host/tests/compile_fail.rs new file mode 100644 index 0000000..f9b0990 --- /dev/null +++ b/crates/wip-host/tests/compile_fail.rs @@ -0,0 +1,5 @@ +#[test] +fn rejects_exposing_an_unimplemented_interface() { + let tests = trybuild::TestCases::new(); + tests.compile_fail("tests/ui/unimplemented_interface.rs"); +} diff --git a/crates/wip-host/tests/host.rs b/crates/wip-host/tests/host.rs new file mode 100644 index 0000000..2106b46 --- /dev/null +++ b/crates/wip-host/tests/host.rs @@ -0,0 +1,488 @@ +use std::cell::{Cell, RefCell}; + +use wip_host::{ + CallErrorKind, CallRequest, InterfaceRegistry, InterfaceTarget, PublishedObject, Validator, + WipObject, +}; +use wip_interface::{ + CodecError, HostError, NamedValue, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, + interface, +}; +use wip_protocol::{ObjectId, ProtocolErrorCategory}; + +#[interface] +pub trait Reader { + /// Returns the reader-qualified label. + fn label(&self) -> OperationResult; + + /// Adds two integers. + fn add(&self, left: i64, right: i64) -> OperationResult; +} + +#[interface] +pub trait Writer { + /// Returns the writer-qualified label. + fn label(&self) -> OperationResult; +} + +#[interface] +pub trait Hidden { + /// Returns a hidden value. + fn hidden(&self) -> OperationResult; +} + +#[interface] +pub trait Broken { + /// Returns a deliberately invalid encoded value. + fn broken(&self) -> OperationResult; +} + +#[derive(Debug)] +pub struct BadValue; + +impl WipType for BadValue { + fn type_expr() -> TypeExpr { + TypeExpr::Integer + } + + fn collect_declarations(_: &mut Vec) {} + + fn encode(self) -> Result { + Ok(Value::String("not an integer".into())) + } + + fn decode(_: Value) -> Result { + Ok(Self) + } +} + +struct Document { + id: String, + validator: Option, + require_validator: bool, + calls: Cell, + private_backend_handle: RefCell, +} + +impl Document { + fn new(id: &str) -> Self { + Self { + id: id.into(), + validator: None, + require_validator: false, + calls: Cell::new(0), + private_backend_handle: RefCell::new("/private/backend/path".into()), + } + } + + fn strict(id: &str, validator: &[u8]) -> Self { + Self { + id: id.into(), + validator: Some(Validator::new(validator)), + require_validator: true, + calls: Cell::new(0), + private_backend_handle: RefCell::new("secret-handle".into()), + } + } + + fn record_call(&self) { + self.calls.set(self.calls.get() + 1); + } +} + +impl WipObject for Document { + fn id(&self) -> ObjectId { + ObjectId(self.id.clone()) + } + + fn validator(&self) -> Option<&Validator> { + self.validator.as_ref() + } + + fn requires_validator(&self) -> bool { + self.require_validator + } +} + +impl Reader for Document { + fn label(&self) -> OperationResult { + self.record_call(); + Ok("reader".into()) + } + + fn add(&self, left: i64, right: i64) -> OperationResult { + self.record_call(); + Ok(left + right) + } +} + +impl Writer for Document { + fn label(&self) -> OperationResult { + self.record_call(); + Ok("writer".into()) + } +} + +impl Hidden for Document { + fn hidden(&self) -> OperationResult { + self.record_call(); + Ok("hidden".into()) + } +} + +impl Broken for Document { + fn broken(&self) -> OperationResult { + self.record_call(); + Ok(BadValue) + } +} + +fn request( + object: &str, + object_validator: Option<&[u8]>, + reference: &wip_host::InterfaceRef, + interface_validator: Option<&[u8]>, + operation: &str, + arguments: Vec, +) -> CallRequest { + CallRequest { + object: ObjectId(object.into()), + object_validator: object_validator.map(Validator::new), + interface: InterfaceTarget { + reference: reference.clone(), + validator: interface_validator.map(Validator::new), + }, + operation: operation.into(), + arguments, + } +} + +#[test] +fn registry_exposes_opaque_validated_descriptor_observations() { + let mut registry = InterfaceRegistry::new(); + let reader = registry.register(ReaderDefinition, None).unwrap(); + + assert_ne!(reader.reference().as_str(), "Reader"); + let observation = registry.observe(reader.reference()).unwrap(); + assert_eq!(observation.reference, *reader.reference()); + assert_eq!(observation.descriptor.name, "Reader"); + assert_eq!(observation.validator, None); + observation.descriptor.validate().unwrap(); + + assert!(registry.register(ReaderDefinition, None).is_err()); + + let mut other_registry = InterfaceRegistry::new(); + let other_reader = other_registry.register(ReaderDefinition, None).unwrap(); + assert_ne!(reader.reference(), other_reader.reference()); +} + +#[test] +fn publication_is_explicit_ordered_qualified_and_request_scoped() { + let mut registry = InterfaceRegistry::new(); + let reader = registry.register(ReaderDefinition, None).unwrap(); + let writer = registry.register(WriterDefinition, None).unwrap(); + let _hidden = registry.register(HiddenDefinition, None).unwrap(); + + let publication = PublishedObject::new(Document::new("document-1")) + .expose(&writer) + .unwrap() + .expose(&reader) + .unwrap(); + + let object = publication.protocol_object(); + assert_eq!( + object.interfaces, + vec![ + writer.reference().as_str().to_owned(), + reader.reference().as_str().to_owned() + ] + ); + assert!(!format!("{object:?}").contains("private/backend/path")); + + let writer_result = publication + .dispatch(request( + "document-1", + None, + writer.reference(), + None, + "label", + vec![], + )) + .unwrap(); + let reader_result = publication + .dispatch(request( + "document-1", + None, + reader.reference(), + None, + "label", + vec![], + )) + .unwrap(); + assert_eq!(writer_result.value, Some(Value::String("writer".into()))); + assert_eq!(reader_result.value, Some(Value::String("reader".into()))); + assert_eq!(publication.implementation().calls.get(), 2); + assert_eq!( + publication + .implementation() + .private_backend_handle + .borrow() + .as_str(), + "/private/backend/path" + ); +} + +#[test] +fn implemented_but_unexposed_interface_is_not_a_member() { + let mut registry = InterfaceRegistry::new(); + let reader = registry.register(ReaderDefinition, None).unwrap(); + let hidden = registry.register(HiddenDefinition, None).unwrap(); + let publication = PublishedObject::new(Document::new("document-2")) + .expose(&reader) + .unwrap(); + + let error = publication + .dispatch(request( + "document-2", + None, + hidden.reference(), + None, + "hidden", + vec![], + )) + .unwrap_err(); + assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch); + assert_eq!( + error.protocol_error().category, + ProtocolErrorCategory::InterfaceNotFound + ); + assert_eq!(publication.implementation().calls.get(), 0); +} + +#[test] +fn zero_interface_objects_are_valid() { + let publication = PublishedObject::new(Document::new("empty")); + let object = publication.protocol_object(); + assert_eq!(object.id, ObjectId("empty".into())); + assert!(object.interfaces.is_empty()); + object.validate().unwrap(); +} + +#[test] +fn duplicate_exposure_is_rejected_without_reordering() { + let mut registry = InterfaceRegistry::new(); + let reader = registry.register(ReaderDefinition, None).unwrap(); + let publication = PublishedObject::new(Document::new("duplicate")) + .expose(&reader) + .unwrap(); + let error = match publication.expose(&reader) { + Ok(_) => panic!("duplicate exposure unexpectedly succeeded"), + Err(error) => error, + }; + assert!(error.to_string().contains(reader.reference().as_str())); +} + +#[test] +fn operation_and_argument_failures_are_mapped_without_execution() { + let mut registry = InterfaceRegistry::new(); + let reader = registry.register(ReaderDefinition, None).unwrap(); + let publication = PublishedObject::new(Document::new("validation")) + .expose(&reader) + .unwrap(); + + let missing = publication + .dispatch(request( + "validation", + None, + reader.reference(), + None, + "missing", + vec![], + )) + .unwrap_err(); + assert_eq!(missing.kind(), CallErrorKind::OperationNotFound); + assert_eq!( + missing.protocol_error().category, + ProtocolErrorCategory::OperationNotFound + ); + + let invalid = publication + .dispatch(request( + "validation", + None, + reader.reference(), + None, + "add", + vec![NamedValue { + name: "left".into(), + value: Value::String("wrong".into()), + }], + )) + .unwrap_err(); + assert_eq!(invalid.kind(), CallErrorKind::InvalidArguments); + assert_eq!( + invalid.protocol_error().category, + ProtocolErrorCategory::InvalidArguments + ); + assert_eq!(publication.implementation().calls.get(), 0); +} + +#[test] +fn invalid_implementation_result_is_internal_and_not_returned() { + let mut registry = InterfaceRegistry::new(); + let broken = registry.register(BrokenDefinition, None).unwrap(); + let publication = PublishedObject::new(Document::new("broken")) + .expose(&broken) + .unwrap(); + + let error = publication + .dispatch(request( + "broken", + None, + broken.reference(), + None, + "broken", + vec![], + )) + .unwrap_err(); + assert_eq!(error.kind(), CallErrorKind::InvalidResult); + assert_eq!( + error.protocol_error().category, + ProtocolErrorCategory::Internal + ); + assert_eq!(publication.implementation().calls.get(), 1); +} + +#[test] +fn object_validator_missing_and_mismatch_are_distinct_and_prevent_execution() { + let mut registry = InterfaceRegistry::new(); + let reader = registry.register(ReaderDefinition, None).unwrap(); + let publication = PublishedObject::new(Document::strict("strict", b"object-v1")) + .expose(&reader) + .unwrap(); + + let missing = publication + .dispatch(request( + "strict", + None, + reader.reference(), + None, + "label", + vec![], + )) + .unwrap_err(); + assert_eq!(missing.kind(), CallErrorKind::ValidatorRequired); + + let mismatch = publication + .dispatch(request( + "strict", + Some(b"stale"), + reader.reference(), + None, + "label", + vec![], + )) + .unwrap_err(); + assert_eq!(mismatch.kind(), CallErrorKind::ValidatorMismatch); + assert_eq!(publication.implementation().calls.get(), 0); + + let result = publication + .dispatch(request( + "strict", + Some(b"object-v1"), + reader.reference(), + None, + "label", + vec![], + )) + .unwrap(); + assert_eq!(result.validator, Some(Validator::new(b"object-v1"))); + assert_eq!(publication.implementation().calls.get(), 1); +} + +#[test] +fn interface_validator_missing_and_mismatch_are_distinct_and_prevent_execution() { + let mut registry = InterfaceRegistry::new(); + let writer = registry + .register(WriterDefinition, Some(Validator::new(b"schema-v1"))) + .unwrap(); + let publication = PublishedObject::new(Document::new("mutable-interface")) + .expose(&writer) + .unwrap(); + + let observation = registry.observe(writer.reference()).unwrap(); + assert_eq!(observation.validator, Some(Validator::new(b"schema-v1"))); + + let missing = publication + .dispatch(request( + "mutable-interface", + None, + writer.reference(), + None, + "label", + vec![], + )) + .unwrap_err(); + assert_eq!(missing.kind(), CallErrorKind::InterfaceValidatorRequired); + + let mismatch = publication + .dispatch(request( + "mutable-interface", + None, + writer.reference(), + Some(b"schema-v0"), + "label", + vec![], + )) + .unwrap_err(); + assert_eq!(mismatch.kind(), CallErrorKind::InterfaceValidatorMismatch); + assert_eq!(publication.implementation().calls.get(), 0); + + let result = publication + .dispatch(request( + "mutable-interface", + None, + writer.reference(), + Some(b"schema-v1"), + "label", + vec![], + )) + .unwrap(); + assert_eq!(result.value, Some(Value::String("writer".into()))); +} + +#[test] +fn host_failures_map_to_internal_without_becoming_domain_values() { + #[interface] + pub trait Failing { + /// Always fails in the host implementation. + fn fail(&self) -> OperationResult; + } + + impl Failing for Document { + fn fail(&self) -> OperationResult { + Err(HostError::new("backend unavailable")) + } + } + + let mut registry = InterfaceRegistry::new(); + let failing = registry.register(FailingDefinition, None).unwrap(); + let publication = PublishedObject::new(Document::new("failure")) + .expose(&failing) + .unwrap(); + let error = publication + .dispatch(request( + "failure", + None, + failing.reference(), + None, + "fail", + vec![], + )) + .unwrap_err(); + assert_eq!(error.kind(), CallErrorKind::Implementation); + assert_eq!( + error.protocol_error().category, + ProtocolErrorCategory::Internal + ); +} diff --git a/crates/wip-host/tests/ui/unimplemented_interface.rs b/crates/wip-host/tests/ui/unimplemented_interface.rs new file mode 100644 index 0000000..4622450 --- /dev/null +++ b/crates/wip-host/tests/ui/unimplemented_interface.rs @@ -0,0 +1,22 @@ +use wip_host::{InterfaceRegistry, PublishedObject, WipObject}; +use wip_interface::{OperationResult, interface}; +use wip_protocol::ObjectId; + +#[interface] +pub trait Search { + fn search(&self) -> OperationResult; +} + +struct Object; + +impl WipObject for Object { + fn id(&self) -> ObjectId { + ObjectId("object".into()) + } +} + +fn main() { + let mut registry = InterfaceRegistry::new(); + let search = registry.register(SearchDefinition, None).unwrap(); + let _published = PublishedObject::new(Object).expose(&search).unwrap(); +} diff --git a/crates/wip-host/tests/ui/unimplemented_interface.stderr b/crates/wip-host/tests/ui/unimplemented_interface.stderr new file mode 100644 index 0000000..8b817ad --- /dev/null +++ b/crates/wip-host/tests/ui/unimplemented_interface.stderr @@ -0,0 +1,34 @@ +error[E0277]: the trait bound `Object: Search` is not satisfied + --> tests/ui/unimplemented_interface.rs:21:58 + | +21 | let _published = PublishedObject::new(Object).expose(&search).unwrap(); + | ------ ^^^^^^^ unsatisfied trait bound + | | + | required by a bound introduced by this call + | +help: the trait `Search` is not implemented for `Object` + --> tests/ui/unimplemented_interface.rs:10:1 + | +10 | struct Object; + | ^^^^^^^^^^^^^ +help: this trait has no implementations, consider adding one + --> tests/ui/unimplemented_interface.rs:6:1 + | + 6 | pub trait Search { + | ^^^^^^^^^^^^^^^^ +note: required for `SearchDefinition` to implement `InterfaceImplementation` + --> tests/ui/unimplemented_interface.rs:5:1 + | + 5 | #[interface] + | ^^^^^^^^^^^^ + 6 | pub trait Search { + | ^^^^^^ +note: required by a bound in `PublishedObject::::expose` + --> src/lib.rs + | + | pub fn expose(mut self, interface: &RegisteredInterface) -> Result + | ------ required by a bound in this associated function + | where + | D: InterfaceImplementation, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `PublishedObject::::expose` + = note: this error originates in the attribute macro `interface` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/wip-interface-macros/src/lib.rs b/crates/wip-interface-macros/src/lib.rs index 8615dac..bf08a5c 100644 --- a/crates/wip-interface-macros/src/lib.rs +++ b/crates/wip-interface-macros/src/lib.rs @@ -170,14 +170,8 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result ::wip_interface::wip_protocol::InterfaceDescriptor { - ::descriptor() - } - - /// Dispatches one validated logical operation call to a typed implementation. - pub fn dispatch( + impl ::wip_interface::InterfaceImplementation for #definition_ident { + fn dispatch( implementation: &T, operation: &str, arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, @@ -197,6 +191,29 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result ::wip_interface::wip_protocol::InterfaceDescriptor { + ::descriptor() + } + + /// Dispatches one validated logical operation call to a typed implementation. + pub fn dispatch( + implementation: &T, + operation: &str, + arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, + ) -> ::std::result::Result< + ::std::option::Option<::wip_interface::wip_protocol::Value>, + ::wip_interface::DispatchError, + > { + >::dispatch( + implementation, + operation, + arguments, + ) + } + } + #(#argument_items)* }) } diff --git a/crates/wip-interface/src/lib.rs b/crates/wip-interface/src/lib.rs index 6602160..4ee4e0d 100644 --- a/crates/wip-interface/src/lib.rs +++ b/crates/wip-interface/src/lib.rs @@ -92,6 +92,21 @@ pub trait InterfaceDefinition { fn descriptor() -> InterfaceDescriptor; } +/// Typed dispatch implemented by every generated interface companion. +/// +/// `T` must implement the Rust interface trait associated with this definition. +/// Host runtimes use this bound when an interface is explicitly exposed, so an +/// object that does not implement that interface is rejected at compile time. +pub trait InterfaceImplementation: InterfaceDefinition { + /// Validates, decodes, and dispatches one operation to `implementation`, + /// then encodes and validates its result. + fn dispatch( + implementation: &T, + operation: &str, + arguments: Vec, + ) -> Result, DispatchError>; +} + /// Result convention required for generated interface operations. /// /// `Ok(T)` is an application result, including domain-failure union cases.