feat: bind Host interfaces to scope Object lifetimes
Register explicit scope/name coordinates independently of Definition type. Require provider-attested current scope snapshots for fetch and dispatch, compare optional scope_ref at membership precedence, and retain the same Descriptor through result validation. Migrate FS pinned-root boundaries and public self-scope example; document provider atomicity/lifecycle and root-only FS support. Add lifecycle, ref, replacement-race, root-lineage and compile-time exposure regressions. Validation: cargo fmt --all --check; git diff --check; cargo check --workspace --all-targets; cargo test --workspace --all-targets and --doc (232 passing test-harness cases including doctests); nix develop --command cargo clippy --workspace --all-targets -- -D warnings; RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-deps. Narrow wip-host suite also passed.
This commit is contained in:
+8
-2
@@ -8,8 +8,14 @@ All notable changes to the published WIP Rust crates are documented here.
|
||||
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
||||
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
||||
without changing routes, validators or the wire error-code set.
|
||||
- Mechanically migrates workspace consumers and fixtures. See [MIGRATION.md](MIGRATION.md)
|
||||
for API/wire examples and the deferred Host/Client lifecycle semantics.
|
||||
- Breaking Host registration migration to explicit Scope publication lifetimes and
|
||||
local names, with coherent provider-attested scope Object binding for fetch and
|
||||
dispatch. Supplied scope_ref is checked at membership precedence; scope deletion
|
||||
retires retained tokens, while ordinary Object state updates preserve lifetime.
|
||||
- Migrates FS root publication and public-example self-scopes to the Host API,
|
||||
including coherent backend observations and explicit built-in names. Non-root
|
||||
FS semantics and Client cache recovery remain follow-up work. See
|
||||
[MIGRATION.md](MIGRATION.md) for API/wire examples and provider responsibilities.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
|
||||
+76
-14
@@ -1,4 +1,4 @@
|
||||
# Structured Interface reference migration
|
||||
# Structured Interface reference and scope publication migration
|
||||
|
||||
This unreleased API/wire change follows `wip-reference` canonical Core Model,
|
||||
Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`.
|
||||
@@ -46,9 +46,9 @@ still contains only format, documentation, types and operations, not identity.
|
||||
field `scope_ref: Option<String>` in struct literals. Use `None` when omitted,
|
||||
not an invented identity. This is an opaque scope Object ref observation/call
|
||||
precondition, **not** a third Interface identity component or a validator.
|
||||
The scope-ref lifecycle/atomic Host enforcement and Client cache-recovery
|
||||
semantics are follow-up work (T-21/T-22); this migration supplies the data model
|
||||
and codec, not a completed lifecycle implementation.
|
||||
Host now enforces scope publication lifetimes and optional scope-ref identity
|
||||
inside the provider boundary (T-21). Client cache-recovery semantics remain
|
||||
follow-up work (T-22); do not mistake registry artifact retention for publication.
|
||||
|
||||
## Validation boundaries
|
||||
|
||||
@@ -116,15 +116,77 @@ malformed success responses retain Client-local `InvalidResponse`. Standard
|
||||
`POST v1/observe`, `v1/fetch_interface`, `v1/call_operation`, error-code/status
|
||||
mapping and Transport failure classification are unchanged.
|
||||
|
||||
## Host registration and provider migration
|
||||
|
||||
Registration now requires an explicit canonical Scope and local name:
|
||||
|
||||
```rust,ignore
|
||||
let mut registry = wip_host::InterfaceRegistry::new();
|
||||
let scope = wip_host::Scope::new("/service")?;
|
||||
let api = registry.register(&scope, "catalog", CatalogDefinition, None)?;
|
||||
|
||||
// INSIDE the backend transaction/lock/stable-handle boundary:
|
||||
let current = wip_host::ScopeObservation::new(&scope, ¤t_scope_object);
|
||||
let publication = wip_host::PublishedObject::new(current_target_object)
|
||||
.expose(&api)?
|
||||
.bind_scope(current);
|
||||
```
|
||||
|
||||
Collision keys are exact `(scope, name)` coordinates, not Rust Definition TypeId.
|
||||
The same Definition can be registered in different scopes or under different
|
||||
names. Root, self and ancestor scopes are valid, but ancestor compatibility and
|
||||
explicit target membership remain independent checks. Descriptors still have
|
||||
no publication identity fields.
|
||||
|
||||
A Scope carries a private publication lifetime, not a generated Protocol ref.
|
||||
Clone it only while the same scope Object remains published: ordinary Object
|
||||
state/validator updates do not retire it. Deletion, replacement or movement
|
||||
requires retiring the old lifetime with `registry.remove_scope(&scope)` and
|
||||
creating a fresh Scope before republication, even at the same path or ref.
|
||||
Retained registration tokens cannot fetch or start calls after retirement.
|
||||
Ref-less replacement cannot be tracked by a Client identity precondition; refs
|
||||
and scope_ref remain optional, not forcibly generated.
|
||||
|
||||
`ScopeObservation::new(&scope, &object)` is a provider attestation that this
|
||||
lifetime currently belongs to the Object at the scope path. Construct and attach
|
||||
it with `PublishedObject::bind_scope` inside the same coherent boundary as target
|
||||
resolution, membership, Descriptor/validator selection, implementation and
|
||||
backend handles, through dispatch and commit/rollback. A matching ref or an
|
||||
independent scope prelookup is not sufficient. The scope Object need not expose
|
||||
any interfaces. Providers own atomicity and external-writer limitations; the
|
||||
runtime checks the attached lifetime and optional scope_ref, and validates both
|
||||
arguments and results against the same frozen Descriptor. A supplied scope_ref
|
||||
mismatch, including a missing current ref, is `InterfaceMismatch` at membership
|
||||
precedence, after target existence and a supplied Object validator mismatch.
|
||||
Omission applies no scope identity precondition.
|
||||
|
||||
Artifact-only `registry.fetch_interface(reference)` is removed. Prefer
|
||||
`ObjectProvider::fetch_interface(&provider, ®istry, &reference)`, which resolves
|
||||
the visible scope Object with `with_object` and uses its attached current binding.
|
||||
Low-level `registry.fetch_interface(&reference, ¤t_observation)` requires
|
||||
the same coherent current attestation, not a cached snapshot. Fetch returns
|
||||
Descriptor, Interface validator and optional scope Object ref together; Clients
|
||||
need not additionally fetch an ancestor Object. `registry.scopes()` may repeat
|
||||
a handle for several names; do not treat it as a unique-scope iterator.
|
||||
|
||||
`PublishedObject::protocol_object()` is a path-free composition projection, not
|
||||
publication authority. For current observations use `ObjectProvider::observe_object`
|
||||
or `publication.protocol_object_at(path)` inside the coherent provider boundary;
|
||||
the latter also validates compatible live scope associations and response shape.
|
||||
|
||||
## Downstream status
|
||||
|
||||
The workspace Client, Host, filesystem server, TUI and public-API example
|
||||
are mechanically migrated to structured protocol values and updated fixtures.
|
||||
The existing Host's generated opaque registration handle is bridged explicitly
|
||||
to scope `/` with its full handle as the name; no delimiter parsing or scope
|
||||
registration/lifetime model is introduced here. The existing Client keys use
|
||||
structured values; this change does not implement scope deletion/replacement
|
||||
cache recovery. TUI formatting is only the type-compatibility adaptation, not
|
||||
the full canonical Text View work (T-24). Filesystem/TUI semantics integration
|
||||
remains T-23. Interface authoring/macros are descriptor-only and require no
|
||||
identity fields or declaration changes.
|
||||
The filesystem consumer registers `file.read`, `symlink.inspect` and `file.write`
|
||||
at its deployment-pinned root Scope. It binds that lifetime from the refreshed
|
||||
retained root lineage inside the backend execution boundary for calls, generic
|
||||
Object observations and fetch. A textual Linux root rename/replacement does not
|
||||
substitute the pinned root Object. The root has no Object ref, so fetch omits
|
||||
scope_ref; the private lifetime check still applies. Non-root FS registrations
|
||||
fail closed pending broader FS/TUI semantics integration (T-23).
|
||||
|
||||
The public API example self-scopes `catalog`, `audit` and `diagnostics` at
|
||||
`/article-service`, fetches through its provider, and carries the observed scope
|
||||
ref in calls. Client keys use structured values; scope deletion/replacement
|
||||
cache recovery remains T-22. This Host change does not modify TUI formatting;
|
||||
canonical Text View work is tracked separately in T-24. Interface authoring/macros remain
|
||||
descriptor-only and need no identity fields or declaration changes.
|
||||
|
||||
@@ -226,25 +226,44 @@ 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 scope = wip_host::Scope::new("/service")?;
|
||||
let messenger = interfaces.register(&scope, "messenger", MessengerDefinition, None)?;
|
||||
|
||||
// Inside the provider coherent backend boundary, not a stale scope prelookup:
|
||||
let current = wip_host::ScopeObservation::new(&scope, ¤t_scope_object);
|
||||
|
||||
let published = wip_host::PublishedObject::new(request_scoped_object)
|
||||
.expose(&messenger)?;
|
||||
let protocol_object = published.protocol_object();
|
||||
.expose(&messenger)?
|
||||
.bind_scope(current);
|
||||
let protocol_object = published.protocol_object_at("/service/item")?;
|
||||
```
|
||||
|
||||
`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 and remain outside the
|
||||
descriptor; generated descriptors contain neither those references nor Rust
|
||||
trait identity.
|
||||
references are rejected. Registry collisions use exact `(scope, name)` pairs,
|
||||
not Rust Definition identity; the same Definition may be registered at multiple
|
||||
coordinates. Structured references remain outside the Descriptor.
|
||||
|
||||
`Scope` carries a private Object publication lifetime. Keep it for ordinary state
|
||||
updates; retire it with `remove_scope` and create a fresh Scope on deletion,
|
||||
replacement or movement, even at the same path. Providers attach a current
|
||||
`ScopeObservation` from the scope Object inside the coherent target/backend
|
||||
boundary, never from a separate prelookup. The scope Object need not expose any
|
||||
interfaces. Fetch uses `ObjectProvider::fetch_interface(&provider, ®istry,
|
||||
&reference)` to observe the visible scope Object and return its optional ref,
|
||||
Descriptor and Interface validator together; retained artifacts alone are not
|
||||
publication. Calls require the current attached lifetime and check supplied
|
||||
`scope_ref` at membership precedence; omission adds no identity precondition.
|
||||
Refs, Object validators and Interface validators retain distinct responsibilities.
|
||||
See [MIGRATION.md](MIGRATION.md) for the provider atomicity and lifecycle contract.
|
||||
|
||||
Dispatch is qualified by interface reference and operation name and targets a
|
||||
canonical Worldspace path. An `ObjectProvider` validates that path before
|
||||
resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
|
||||
exactly once inside `with_object`; the host checks the supplied object validator,
|
||||
current interface membership, interface validator, descriptor operation,
|
||||
current scope lifetime/optional scope_ref and interface membership, Interface
|
||||
validator, Descriptor operation,
|
||||
operation-specific object-validator policy, arguments, and authorization in
|
||||
canonical precedence. Path and children remain provider resolution context and
|
||||
are never projected by `WipObject`; host-private fields and backend handles are
|
||||
|
||||
@@ -26,9 +26,13 @@ For an external backend/composition:
|
||||
protected state. Implement `FilesystemBackend` to resolve one connected
|
||||
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
|
||||
bounded children, and execute against that already-resolved lineage.
|
||||
2. Register generated `#[wip_interface::interface]` definitions using the existing
|
||||
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies
|
||||
reusable built-in tokens; using those tokens does not force their publication.
|
||||
2. Create `wip_host::Scope::new("/")` for the deployment-pinned root, then
|
||||
register generated definitions with `registry.register(&scope, name,
|
||||
Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)`
|
||||
optionally supplies reusable built-in tokens named `file.read`,
|
||||
`symlink.inspect`, and `file.write`; tokens do not force publication. The
|
||||
current engine binds only the root scope and fails closed for non-root
|
||||
registrations; broader FS scope semantics remain follow-up work.
|
||||
3. Implement `Composition<Entry>` on a local type. Its local Object wraps the entry
|
||||
and implements both `WipObject` and the chosen generated traits, avoiding orphan
|
||||
rules. `PublishedObject::expose` proves each implementation at compile time.
|
||||
@@ -61,7 +65,26 @@ from one connected backend traversal/snapshot, not independent path-prefix
|
||||
resolutions. A root target has no ancestors; other targets retain the root first,
|
||||
then intermediate parent containers. This is backend-private context, never wire
|
||||
metadata. Backend `resolve` and `execute` signatures both carry this type;
|
||||
compositions still receive only the exact scoped target `Entry`.
|
||||
compositions receive exact retained entries, including publication of the root
|
||||
scope Object while another target is executing, never independent raw-path
|
||||
lookups. Root publication must be deterministic and side-effect-free.
|
||||
|
||||
Every lineage must retain the same deployment-pinned root Object. Replacing that
|
||||
Object requires a new projection and fresh `Scope`, not reuse of registrations
|
||||
at the same path. The engine constructs `ScopeObservation` from the published
|
||||
root Object's metadata using the refreshed retained root capability **inside**
|
||||
`FilesystemBackend::execute`, then attaches it with `PublishedObject::bind_scope`.
|
||||
It never independently looks up the raw configured root path. Root Objects need
|
||||
not expose any interfaces. Calls check both the private lifetime and optional
|
||||
`scope_ref`; normal root state updates do not retire the lifetime.
|
||||
|
||||
`with_object` (including fetch) and individual tree Object publications also
|
||||
enter this backend boundary. Generic read callbacks use a `NotStarted` attempt
|
||||
as an adapter to the existing execution API; no typed operation runs and no
|
||||
commit is implied. Tree observations are bounded traversals, not one global tree
|
||||
transaction. Fetch resolves the visible scope Object through the provider and
|
||||
returns its ref, Descriptor and Interface validator together; retained registry
|
||||
artifacts alone never establish publication.
|
||||
|
||||
`FilesystemBackend::execute` must validate ancestor membership/identity and
|
||||
refresh **all** lineage state/validators inside its transaction or lock before
|
||||
@@ -196,14 +219,16 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
|
||||
```
|
||||
|
||||
A regular-file observation returns a structured read interface reference and an
|
||||
object validator. Built-in legacy registrations use scope `/` and copy the opaque
|
||||
registration string verbatim into `name`; they do not emit `scope_ref`. Copy the
|
||||
complete reference from the observation to retrieve that descriptor:
|
||||
object validator. Built-ins explicitly use scope `/` and the local names
|
||||
`file.read`, `symlink.inspect`, and `file.write`. The Linux root does not issue an
|
||||
Object ref, so fetch omits `scope_ref`; its private publication lifetime still
|
||||
binds fetch and call to the pinned deployment root. Copy the complete reference
|
||||
from the observation to retrieve that descriptor:
|
||||
|
||||
```console
|
||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{"interface":{"scope":"/","name":"<read-interface-name-from-observation>"}}'
|
||||
--data '{"interface":{"scope":"/","name":"file.read"}}'
|
||||
```
|
||||
|
||||
Then call a bounded range read:
|
||||
@@ -213,7 +238,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":{"scope":"/","name":"<read-interface-name>"}},
|
||||
"interface":{"reference":{"scope":"/","name":"file.read"}},
|
||||
"operation":"read",
|
||||
"arguments":{"offset":0,"length":4096}
|
||||
}'
|
||||
@@ -227,7 +252,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":{"scope":"/","name":"<write-interface-name>"}},
|
||||
"interface":{"reference":{"scope":"/","name":"file.write"}},
|
||||
"operation":"replace",
|
||||
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||
}'
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::projection::{LinuxBackend, LinuxEntry};
|
||||
use crate::{AccessMode, ResourceLimits};
|
||||
use wip_host::{
|
||||
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
|
||||
RegisteredInterface, RegistrationError, Validator, WipObject,
|
||||
RegisteredInterface, RegistrationError, Scope, Validator, WipObject,
|
||||
};
|
||||
use wip_interface as wip;
|
||||
|
||||
@@ -22,12 +22,17 @@ pub struct DefaultInterfaces {
|
||||
pub write: RegisteredInterface<FileWriteDefinition>,
|
||||
}
|
||||
impl DefaultInterfaces {
|
||||
/// Register reusable default descriptors without publishing any objects.
|
||||
pub fn register(registry: &mut InterfaceRegistry) -> Result<Self, RegistrationError> {
|
||||
/// Register reusable descriptors at an explicit publication scope.
|
||||
///
|
||||
/// The current FS engine binds only its deployment root scope (`/`).
|
||||
pub fn register(
|
||||
registry: &mut InterfaceRegistry,
|
||||
scope: &Scope,
|
||||
) -> Result<Self, RegistrationError> {
|
||||
Ok(Self {
|
||||
read: registry.register(FileReadDefinition, None)?,
|
||||
symlink: registry.register(SymlinkInspectDefinition, None)?,
|
||||
write: registry.register(FileWriteDefinition, None)?,
|
||||
read: registry.register(scope, "file.read", FileReadDefinition, None)?,
|
||||
symlink: registry.register(scope, "symlink.inspect", SymlinkInspectDefinition, None)?,
|
||||
write: registry.register(scope, "file.write", FileWriteDefinition, None)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -135,7 +140,9 @@ pub fn default_projection(
|
||||
) -> Result<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>, std::io::Error> {
|
||||
let backend = LinuxBackend::open(root, access, limits)?;
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let interfaces = DefaultInterfaces::register(&mut registry).map_err(std::io::Error::other)?;
|
||||
let scope = Scope::new("/").map_err(std::io::Error::other)?;
|
||||
let interfaces =
|
||||
DefaultInterfaces::register(&mut registry, &scope).map_err(std::io::Error::other)?;
|
||||
Ok(Projection::new(
|
||||
backend,
|
||||
registry,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::ResourceLimits;
|
||||
use wip_host::{
|
||||
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
|
||||
OperationAttempt, ProviderResult, PublishedObject, WipObject,
|
||||
OperationAttempt, ProviderResult, PublishedObject, ScopeObservation, WipObject,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
|
||||
@@ -72,6 +72,12 @@ impl<E> ResolvedEntry<E> {
|
||||
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
||||
/// backend paths, credentials or hidden metadata.
|
||||
///
|
||||
/// The root is a deployment-pinned Object: every resolution/execution lineage
|
||||
/// must retain that same root, even if its textual backend path is renamed or
|
||||
/// replaced. Replacing the deployment root requires a new projection and Scope
|
||||
/// lifetime. The engine currently binds only `/`; non-root registrations are
|
||||
/// not published until a backend can attest their Object lifetimes.
|
||||
///
|
||||
/// `execute` must retain the same target and complete lineage and serialize
|
||||
/// ancestor identity/membership checks, policy metadata, validator observation,
|
||||
/// publication, validation, typed dispatch and commit/rollback. Changed/detached
|
||||
@@ -123,7 +129,10 @@ pub trait Composition<E: BackendEntry> {
|
||||
/// Build a request-scoped publication using this exact capability/context.
|
||||
///
|
||||
/// Publication and policy hooks must be side-effect-free with respect to the
|
||||
/// backend. A publication failure is a pre-dispatch rejection, never a
|
||||
/// backend. This hook may also publish the retained root to observe its scope
|
||||
/// metadata while another target is executing. Root publication must be
|
||||
/// deterministic within the view and use this same retained capability.
|
||||
/// A publication failure is a pre-dispatch rejection, never a
|
||||
/// started Operation. Delegate metadata/validators to the boundary snapshot.
|
||||
fn publish(
|
||||
&self,
|
||||
@@ -286,12 +295,21 @@ where
|
||||
fn publication(
|
||||
&self,
|
||||
entry: B::Entry,
|
||||
root: &B::Entry,
|
||||
context: &ExecutionContext,
|
||||
) -> Result<PublishedObject<C::Object>, HostError> {
|
||||
let is_root = entry.name().is_empty();
|
||||
let publication = self
|
||||
.projection
|
||||
.composition
|
||||
.publish(entry.clone(), context)?;
|
||||
// Scope refs describe the published root Object, not an independently
|
||||
// resolved raw path or the backend entry's possibly different metadata.
|
||||
let root_publication = if is_root {
|
||||
None
|
||||
} else {
|
||||
Some(self.projection.composition.publish(root.clone(), context)?)
|
||||
};
|
||||
let available = publication
|
||||
.protocol_object()
|
||||
.interfaces
|
||||
@@ -308,17 +326,81 @@ where
|
||||
.policy
|
||||
.interfaces(self.principal, &entry, &available);
|
||||
selected.retain(|reference| {
|
||||
self.projection
|
||||
// Non-root lifetimes are not attested by this engine yet. Never
|
||||
// publish an artifact merely because the registry retains it.
|
||||
reference.protocol_reference().scope == "/"
|
||||
&& self
|
||||
.projection
|
||||
.policy
|
||||
.interface_visible(self.principal, reference)
|
||||
});
|
||||
publication
|
||||
let mut publication = publication
|
||||
.project(&selected, |reference, operation| {
|
||||
self.projection
|
||||
.policy
|
||||
.operation_visible(self.principal, reference, operation)
|
||||
})
|
||||
.map_err(|_| internal())
|
||||
.map_err(|_| internal())?;
|
||||
// Called only inside backend.execute, with its refreshed pinned lineage.
|
||||
// scopes() may repeat the handle for different names; bind_scope replaces
|
||||
// the same-path snapshot rather than treating repetition as a collision.
|
||||
for scope in self
|
||||
.projection
|
||||
.registry
|
||||
.scopes()
|
||||
.filter(|scope| scope.path() == "/")
|
||||
{
|
||||
let object = root_publication
|
||||
.as_ref()
|
||||
.map_or(publication.implementation(), |root| root.implementation());
|
||||
let current = ScopeObservation::new(scope, object);
|
||||
publication = publication.bind_scope(current);
|
||||
}
|
||||
Ok(publication)
|
||||
}
|
||||
|
||||
// Adapt read-only callbacks to the existing backend execution boundary. The
|
||||
// sentinel is NotStarted: no typed operation ran and no commit/rollback may
|
||||
// be inferred. The actual callback result never escapes until execute exits.
|
||||
fn with_publication<R>(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
resolved: ResolvedEntry<B::Entry>,
|
||||
operation: impl FnOnce(&PublishedObject<C::Object>) -> R,
|
||||
) -> ProviderResult<R> {
|
||||
let mut observed = None;
|
||||
let boundary = self
|
||||
.projection
|
||||
.backend
|
||||
.execute(context, resolved, |resolved| {
|
||||
if !lineage_matches_path(&resolved, path) {
|
||||
observed = Some(ProviderResult::Failure(internal()));
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::Internal);
|
||||
}
|
||||
if !self.lineage_visible(&resolved) {
|
||||
observed = Some(ProviderResult::NotFound);
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||
}
|
||||
let root = resolved.ancestors().first().unwrap_or(resolved.target());
|
||||
observed = Some(
|
||||
match preflight(context).and_then(|()| {
|
||||
self.publication(resolved.target().clone(), root, context)
|
||||
.map(|publication| operation(&publication))
|
||||
}) {
|
||||
Ok(result) => ProviderResult::Found(result),
|
||||
Err(error) => ProviderResult::Failure(error),
|
||||
},
|
||||
);
|
||||
OperationAttempt::reject(wip_host::CallErrorKind::Internal)
|
||||
});
|
||||
match boundary {
|
||||
ProviderResult::Found(_) => {
|
||||
observed.unwrap_or_else(|| ProviderResult::Failure(internal()))
|
||||
}
|
||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve the descriptor with precisely this principal's visible operations.
|
||||
@@ -333,10 +415,8 @@ where
|
||||
&self,
|
||||
request: &FetchInterfaceRequest,
|
||||
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||
let observation = self
|
||||
.projection
|
||||
.registry
|
||||
.fetch_interface(&request.interface)?;
|
||||
let observation =
|
||||
ObjectProvider::fetch_interface(self, &self.projection.registry, &request.interface)?;
|
||||
if !self
|
||||
.projection
|
||||
.policy
|
||||
@@ -387,10 +467,10 @@ where
|
||||
}
|
||||
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||
};
|
||||
let (_ancestors, entry) = entry.into_parts();
|
||||
let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
|
||||
.min(context.resource_budget().unwrap_or(u64::MAX));
|
||||
let response = self.observe_entry(context, entry, request.depth, &mut remaining)?;
|
||||
let response =
|
||||
self.observe_entry(context, &request.path, entry, request.depth, &mut remaining)?;
|
||||
request.validate_response(&response).map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::Internal,
|
||||
@@ -403,7 +483,8 @@ where
|
||||
fn observe_entry(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
entry: B::Entry,
|
||||
path: &str,
|
||||
resolved: ResolvedEntry<B::Entry>,
|
||||
depth: u32,
|
||||
remaining: &mut u64,
|
||||
) -> Result<ObjectObservation, ProtocolError> {
|
||||
@@ -412,10 +493,19 @@ where
|
||||
return Err(resource_protocol());
|
||||
}
|
||||
*remaining -= 1;
|
||||
let object = self
|
||||
.publication(entry.clone(), context)
|
||||
.map_err(read_error)?
|
||||
.protocol_object();
|
||||
let object = match self.with_publication(context, path, resolved.clone(), |publication| {
|
||||
publication.protocol_object_at(path)
|
||||
}) {
|
||||
ProviderResult::Found(object) => object.map_err(|error| error.protocol_error())?,
|
||||
ProviderResult::NotFound => {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::NotFound,
|
||||
"target path is not published",
|
||||
));
|
||||
}
|
||||
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||
};
|
||||
let (mut ancestors, entry) = resolved.into_parts();
|
||||
if depth == 0 {
|
||||
return Ok(ObjectObservation {
|
||||
object,
|
||||
@@ -436,13 +526,25 @@ where
|
||||
if entries.len() as u64 > *remaining {
|
||||
return Err(resource_protocol());
|
||||
}
|
||||
ancestors.push(entry.clone());
|
||||
for child in entries {
|
||||
if self
|
||||
.projection
|
||||
.policy
|
||||
.object_visible(self.principal, &child)
|
||||
{
|
||||
children.push(self.observe_entry(context, child, depth - 1, remaining)?);
|
||||
let child_path = if path == "/" {
|
||||
format!("/{}", child.name())
|
||||
} else {
|
||||
format!("{path}/{}", child.name())
|
||||
};
|
||||
children.push(self.observe_entry(
|
||||
context,
|
||||
&child_path,
|
||||
ResolvedEntry::new(ancestors.clone(), child),
|
||||
depth - 1,
|
||||
remaining,
|
||||
)?);
|
||||
} else {
|
||||
if *remaining == 0 {
|
||||
return Err(resource_protocol());
|
||||
@@ -473,11 +575,7 @@ where
|
||||
) -> ProviderResult<R> {
|
||||
match self.resolve(context, path) {
|
||||
ProviderResult::Found(resolved) => {
|
||||
let (_ancestors, entry) = resolved.into_parts();
|
||||
match self.publication(entry, context) {
|
||||
Ok(publication) => ProviderResult::Found(operation(&publication)),
|
||||
Err(error) => ProviderResult::Failure(error),
|
||||
}
|
||||
self.with_publication(context, path, resolved, operation)
|
||||
}
|
||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||
@@ -503,7 +601,8 @@ where
|
||||
if !self.lineage_visible(&resolved) {
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||
}
|
||||
let (_ancestors, entry) = resolved.into_parts();
|
||||
let root = resolved.ancestors().first().unwrap_or(resolved.target());
|
||||
let entry = resolved.target().clone();
|
||||
if let Err(error) = preflight(context) {
|
||||
return OperationAttempt::reject(match error.kind() {
|
||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||
@@ -512,7 +611,7 @@ where
|
||||
}
|
||||
});
|
||||
}
|
||||
match self.publication(entry, context) {
|
||||
match self.publication(entry, root, context) {
|
||||
Ok(publication) => operation(&publication),
|
||||
Err(error) => OperationAttempt::reject(match error.kind() {
|
||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||
|
||||
@@ -17,6 +17,18 @@
|
||||
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
|
||||
//! existing standard HTTP binding to a deployment-established principal view.
|
||||
//!
|
||||
//! # Scope publication migration
|
||||
//!
|
||||
//! Registration uses `registry.register(&scope, name, Definition, validator)`.
|
||||
//! [`DefaultInterfaces::register`] takes an explicit [`wip_host::Scope`]; built-ins
|
||||
//! use root-local names `file.read`, `symlink.inspect` and `file.write`. The engine
|
||||
//! binds only its deployment-pinned root lifetime, inside the backend execution
|
||||
//! boundary, using the current published root Object from the retained lineage.
|
||||
//! Observation/fetch callbacks enter that same coherent boundary. Non-root
|
||||
//! registrations fail closed until their Object lifetimes can be attested.
|
||||
//! The Linux root survives textual root renames and does not issue an Object ref;
|
||||
//! fetch therefore omits `scope_ref` without weakening the private lifetime check.
|
||||
//!
|
||||
//! Backends and Operations are trusted code, responsible for truthful effects,
|
||||
//! public-only metadata and ongoing execution-context bounds. Policies must be
|
||||
//! stable within a view; changing descriptor visibility requires new reference
|
||||
|
||||
@@ -1612,6 +1612,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scoped_interfaces_follow_the_pinned_deployment_not_textual_replacement() {
|
||||
let temporary = tempdir().expect("temporary parent");
|
||||
let configured = temporary.path().join("root");
|
||||
let moved = temporary.path().join("moved");
|
||||
fs::create_dir(&configured).unwrap();
|
||||
fs::write(configured.join("file"), b"original").unwrap();
|
||||
let engine = projection(&configured, AccessMode::ReadOnly);
|
||||
let object = engine.observe_object("/file").unwrap();
|
||||
let reference = object.interfaces[0].clone();
|
||||
assert_eq!(reference.scope, "/");
|
||||
assert_eq!(reference.name, "file.read");
|
||||
fs::rename(&configured, &moved).unwrap();
|
||||
fs::create_dir(&configured).unwrap();
|
||||
fs::write(configured.join("file"), b"replacement").unwrap();
|
||||
let fetched = engine
|
||||
.fetch_interface(&wip_protocol::FetchInterfaceRequest {
|
||||
interface: reference.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(fetched.scope_ref.is_none());
|
||||
let result = call(
|
||||
&engine,
|
||||
"/file",
|
||||
object.validator,
|
||||
reference.clone(),
|
||||
"read",
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.result, Value::Bytes(b"original".to_vec()));
|
||||
let error = engine
|
||||
.call_operation(wip_host::CallRequest {
|
||||
target: wip_host::Target {
|
||||
path: "/file".into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: wip_host::InterfaceTarget {
|
||||
reference,
|
||||
scope_ref: Some("invented-root-ref".into()),
|
||||
validator: None,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_lock_wait_observes_the_operation_deadline() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
@@ -1622,8 +1671,6 @@ mod tests {
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.expect("open lock owner");
|
||||
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
||||
|
||||
let limits = ResourceLimits {
|
||||
request_timeout: std::time::Duration::from_millis(20),
|
||||
..ResourceLimits::default()
|
||||
@@ -1631,6 +1678,9 @@ mod tests {
|
||||
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
|
||||
.expect("open projection");
|
||||
let observed = projection.observe_object("/file").expect("observe file");
|
||||
// Observation also enters the coherent backend boundary now. Acquire
|
||||
// the interfering lock only after obtaining Client-known metadata.
|
||||
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
||||
let started = std::time::Instant::now();
|
||||
let error = call(
|
||||
&projection,
|
||||
|
||||
@@ -10,7 +10,7 @@ use wip_fs_server::{
|
||||
use wip_host::{
|
||||
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
||||
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
|
||||
RegisteredInterface, Validator, WipObject,
|
||||
RegisteredInterface, Scope, Validator, WipObject,
|
||||
};
|
||||
use wip_http::{
|
||||
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
|
||||
@@ -101,6 +101,9 @@ impl WipObject for Entry {
|
||||
fn description(&self) -> Option<&str> {
|
||||
Some("public memory entry")
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
(self.path == "/").then_some("entry-root")
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
Some(&self.validator)
|
||||
}
|
||||
@@ -157,7 +160,13 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct MemoryBackend(Arc<Shared>);
|
||||
struct MemoryBackend(Arc<Shared>, Arc<Node>);
|
||||
impl MemoryBackend {
|
||||
fn new(shared: Arc<Shared>) -> Self {
|
||||
let root = shared.node("/");
|
||||
Self(shared, root)
|
||||
}
|
||||
}
|
||||
fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
||||
if context.cancellation().is_cancelled()
|
||||
|| context
|
||||
@@ -193,6 +202,12 @@ impl FilesystemBackend for MemoryBackend {
|
||||
// One connected map snapshot, serialized with participating mutations.
|
||||
let _transaction = self.0.transaction.lock().unwrap();
|
||||
let nodes = self.0.nodes.lock().unwrap();
|
||||
if !nodes
|
||||
.get("/")
|
||||
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
|
||||
{
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
let Some(node) = nodes.get(path) else {
|
||||
return ProviderResult::NotFound;
|
||||
};
|
||||
@@ -320,6 +335,12 @@ impl FilesystemBackend for MemoryBackend {
|
||||
// Verify lineage identity/membership within the same transaction. The
|
||||
// callback cannot see a substituted ancestor, nor a redirected target.
|
||||
let nodes = self.0.nodes.lock().unwrap();
|
||||
if !nodes
|
||||
.get("/")
|
||||
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
|
||||
{
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
for ancestor in &ancestors {
|
||||
if !nodes
|
||||
.get(&ancestor.path)
|
||||
@@ -371,6 +392,11 @@ impl WipObject for Object {
|
||||
fn description(&self) -> Option<&str> {
|
||||
self.entry.description()
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
// Public representation identity need not be the backend entry ref.
|
||||
// ScopeObservation must capture this published Object's ref.
|
||||
(self.entry.path == "/").then_some("memory-root")
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
self.entry.validator()
|
||||
}
|
||||
@@ -574,11 +600,19 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
|
||||
for panic_object in [true, false] {
|
||||
let fixture = Fixture::new();
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
||||
let custom = registry.register(MemoryOperationsDefinition, None).unwrap();
|
||||
let scope = Scope::new("/").unwrap();
|
||||
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
|
||||
let custom = registry
|
||||
.register(
|
||||
&scope,
|
||||
"memory.operations",
|
||||
MemoryOperationsDefinition,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reference = custom.reference().protocol_reference();
|
||||
let engine = Projection::new(
|
||||
MemoryBackend(fixture.shared.clone()),
|
||||
MemoryBackend::new(fixture.shared.clone()),
|
||||
registry,
|
||||
MemoryComposition {
|
||||
defaults,
|
||||
@@ -619,6 +653,8 @@ struct Fixture {
|
||||
custom: wip_protocol::InterfaceReference,
|
||||
read: wip_protocol::InterfaceReference,
|
||||
limits: ResourceLimits,
|
||||
// Client-known descriptors acquired coherently before execution fault injection.
|
||||
descriptors: BTreeMap<(bool, wip_protocol::InterfaceReference), InterfaceDescriptor>,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
@@ -669,9 +705,15 @@ impl Fixture {
|
||||
seen: Mutex::new(vec![]),
|
||||
});
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
||||
let scope = Scope::new("/").unwrap();
|
||||
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
|
||||
let custom = registry
|
||||
.register(MemoryOperationsDefinition, interface_validator)
|
||||
.register(
|
||||
&scope,
|
||||
"memory.operations",
|
||||
MemoryOperationsDefinition,
|
||||
interface_validator,
|
||||
)
|
||||
.unwrap();
|
||||
let read_ref = defaults.read.reference().clone();
|
||||
let custom_ref = custom.reference().clone();
|
||||
@@ -687,18 +729,38 @@ impl Fixture {
|
||||
custom,
|
||||
shared: shared.clone(),
|
||||
};
|
||||
Self {
|
||||
engine: Projection::new(
|
||||
MemoryBackend(shared.clone()),
|
||||
let engine = Projection::new(
|
||||
MemoryBackend::new(shared.clone()),
|
||||
registry,
|
||||
composition,
|
||||
policy,
|
||||
limits,
|
||||
),
|
||||
);
|
||||
let mut descriptors = BTreeMap::new();
|
||||
if !invalid_selection {
|
||||
for principal in [Principal::Admin, Principal::Limited] {
|
||||
for reference in [&read_ref, &custom_ref] {
|
||||
let reference = reference.protocol_reference();
|
||||
if let Ok(observation) = engine
|
||||
.view(&principal)
|
||||
.fetch_interface(&fetch_request(&reference))
|
||||
{
|
||||
descriptors.insert(
|
||||
(matches!(principal, Principal::Admin), reference),
|
||||
observation.descriptor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shared.seen.lock().unwrap().clear();
|
||||
Self {
|
||||
engine,
|
||||
shared,
|
||||
custom: custom_ref.protocol_reference(),
|
||||
read: read_ref.protocol_reference(),
|
||||
limits,
|
||||
descriptors,
|
||||
}
|
||||
}
|
||||
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
|
||||
@@ -712,11 +774,7 @@ impl Fixture {
|
||||
principal: &Principal,
|
||||
reference: &wip_protocol::InterfaceReference,
|
||||
) -> InterfaceDescriptor {
|
||||
self.engine
|
||||
.view(principal)
|
||||
.fetch_interface(&fetch_request(reference))
|
||||
.unwrap()
|
||||
.descriptor
|
||||
self.descriptors[&(matches!(principal, Principal::Admin), reference.clone())].clone()
|
||||
}
|
||||
fn request(
|
||||
&self,
|
||||
@@ -930,7 +988,10 @@ fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_cond
|
||||
Some("public memory entry")
|
||||
);
|
||||
assert!(observation.children.is_none());
|
||||
assert!(observation.object.r#ref.is_none());
|
||||
assert_eq!(
|
||||
observation.object.r#ref.as_deref(),
|
||||
(path == "/").then_some("memory-root")
|
||||
);
|
||||
for reference in &observation.object.interfaces {
|
||||
let fetched = fixture
|
||||
.engine
|
||||
@@ -1383,10 +1444,12 @@ fn policy_cannot_add_an_unimplemented_interface() {
|
||||
ProtocolErrorCode::Internal
|
||||
);
|
||||
let request = fixture.request("/plain", &fixture.custom, "denied", None);
|
||||
// This deliberately invalid publication cannot supply a fetch response.
|
||||
// Use the application's authored schema solely to encode the test request.
|
||||
let (response, decoded) = fixture.http_call(
|
||||
&Principal::Admin,
|
||||
&request,
|
||||
&fixture.descriptor(&Principal::Admin, &fixture.custom),
|
||||
&MemoryOperationsDefinition::descriptor(),
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
wire_error(decoded, ProtocolErrorCode::Internal);
|
||||
@@ -2036,3 +2099,150 @@ fn observation_depth_node_and_context_budgets_fail_without_truncating_the_tree()
|
||||
);
|
||||
no_secrets(&response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scope_fetch_and_call_use_the_current_boundary_snapshot() {
|
||||
let fixture = Fixture::new();
|
||||
let view = fixture.engine.view(&Principal::Admin);
|
||||
let fetched = view
|
||||
.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap();
|
||||
assert_eq!(fetched.scope_ref.as_deref(), Some("memory-root"));
|
||||
assert!(
|
||||
fixture
|
||||
.observe(&Principal::Admin, "/", 0)
|
||||
.object
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let mut request = fixture.request("/added", &fixture.custom, "denied", None);
|
||||
request.interface.scope_ref = Some("wrong-root".into());
|
||||
assert_eq!(
|
||||
view.call_operation(request.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node("/added")
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// Ordinary state updates do not replace the publication lifetime or ref.
|
||||
fixture.shared.node("/").data.lock().unwrap().revision += 1;
|
||||
request.interface.scope_ref = fetched.scope_ref;
|
||||
view.call_operation(request).unwrap();
|
||||
|
||||
// Fetch cannot attest a stale prelookup: visibility is refreshed under the
|
||||
// same boundary even though resolve initially sees a visible root.
|
||||
fixture.shared.controls.lock().unwrap().hide_before_execute = true;
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scope_replacement_during_call_rejects_the_prelookup() {
|
||||
let fixture = Fixture::new();
|
||||
let view = fixture.engine.view(&Principal::Admin);
|
||||
// Simulate a retained non-root lineage whose root is replaced before call.
|
||||
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
|
||||
let request = fixture.request("/added", &fixture.custom, "denied", None);
|
||||
assert_eq!(
|
||||
view.call_operation(request).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::NotFound
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node("/added")
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
|
||||
let fixture = Fixture::new();
|
||||
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
|
||||
assert_eq!(
|
||||
fixture
|
||||
.engine
|
||||
.view(&Principal::Admin)
|
||||
.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonroot_registrations_fail_closed_until_the_engine_can_attest_their_lifetimes() {
|
||||
let fixture = Fixture::new();
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let root = Scope::new("/").unwrap();
|
||||
let local = Scope::new("/added").unwrap();
|
||||
let defaults = DefaultInterfaces::register(&mut registry, &root).unwrap();
|
||||
let custom = registry
|
||||
.register(
|
||||
&local,
|
||||
"memory.operations",
|
||||
MemoryOperationsDefinition,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reference = custom.reference().protocol_reference();
|
||||
let policy = Policy {
|
||||
custom: custom.reference().clone(),
|
||||
read: defaults.read.reference().clone(),
|
||||
hide_read: false,
|
||||
invalid_selection: false,
|
||||
panic_authorize: false,
|
||||
};
|
||||
let engine = Projection::new(
|
||||
MemoryBackend::new(fixture.shared.clone()),
|
||||
registry,
|
||||
MemoryComposition {
|
||||
defaults,
|
||||
custom,
|
||||
shared: fixture.shared.clone(),
|
||||
},
|
||||
policy,
|
||||
fixture.limits,
|
||||
);
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let object = view.observe_object("/added").unwrap();
|
||||
assert!(!object.interfaces.contains(&reference));
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&reference))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let request = fixture.request("/added", &reference, "denied", None);
|
||||
assert_eq!(
|
||||
view.call_operation(request).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node("/added")
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
+306
-109
@@ -3,7 +3,7 @@
|
||||
//! # Responsibility boundaries
|
||||
//!
|
||||
//! [`InterfaceRegistry::register`] validates and freezes a generated interface
|
||||
//! descriptor, assigns an opaque [`InterfaceRef`], and leaves that reference
|
||||
//! descriptor at an explicit scope and local name, and leaves that reference
|
||||
//! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
|
||||
//! request-scoped Rust object with a registered interface; its
|
||||
//! [`InterfaceImplementation`] bound proves at compile time that the object
|
||||
@@ -11,7 +11,8 @@
|
||||
//! and a publication with no interfaces is valid.
|
||||
//!
|
||||
//! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
|
||||
//! keeps the resulting object, interface membership, descriptor, implementation,
|
||||
//! keeps the resulting object, current scope Object association/ref, interface
|
||||
//! membership, descriptor, implementation,
|
||||
//! and backend target fixed within the provider's [`ObjectProvider::with_object`]
|
||||
//! callback. Dispatch is qualified by `(interface reference, operation name)`.
|
||||
//! The same frozen operation declaration validates both arguments and results.
|
||||
@@ -29,14 +30,13 @@
|
||||
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use std::any::TypeId;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::error::Error;
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
use std::marker::PhantomData;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use wip_interface::DispatchError;
|
||||
@@ -187,36 +187,104 @@ impl ExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque host-assigned reference to one registered interface artifact.
|
||||
/// One publication lifetime of the Object at a canonical scope path.
|
||||
///
|
||||
/// Clone while the same Object remains published, including state updates. Create
|
||||
/// a new handle after deletion, replacement or movement, even with the same ref.
|
||||
/// This private lifetime is not a Protocol identity component or generated ref.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Scope {
|
||||
path: String,
|
||||
lifetime: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
/// Creates a fresh lifetime without implicitly publishing an Object.
|
||||
pub fn new(path: impl Into<String>) -> Result<Self, ValidationError> {
|
||||
let path = path.into();
|
||||
wip_protocol::validate_path(&path)?;
|
||||
Ok(Self {
|
||||
path,
|
||||
lifetime: Arc::new(AtomicBool::new(true)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the client-facing canonical scope path.
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn same_lifetime(&self, other: &Self) -> bool {
|
||||
self.path == other.path && Arc::ptr_eq(&self.lifetime, &other.lifetime)
|
||||
}
|
||||
|
||||
fn active(&self) -> bool {
|
||||
self.lifetime.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-attested current scope Object association.
|
||||
///
|
||||
/// Construct only inside a coherent boundary that proves this lifetime belongs
|
||||
/// to the Object at `scope.path()`. A prelookup or matching ref alone is not proof.
|
||||
/// The Object need not expose interfaces. Its validator is not scope identity.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScopeObservation {
|
||||
scope: Scope,
|
||||
reference: Option<String>,
|
||||
}
|
||||
|
||||
impl ScopeObservation {
|
||||
/// Attests the association and captures the current Object's optional ref.
|
||||
#[must_use]
|
||||
pub fn new(scope: &Scope, object: &impl WipObject) -> Self {
|
||||
Self {
|
||||
scope: scope.clone(),
|
||||
reference: object.reference().map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the observed optional ref.
|
||||
#[must_use]
|
||||
pub fn reference(&self) -> Option<&str> {
|
||||
self.reference.as_deref()
|
||||
}
|
||||
|
||||
/// Returns the associated scope publication lifetime.
|
||||
#[must_use]
|
||||
pub fn scope(&self) -> &Scope {
|
||||
&self.scope
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured client-facing reference to a scoped Interface publication.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct InterfaceRef(String);
|
||||
pub struct InterfaceRef(InterfaceReference);
|
||||
|
||||
impl InterfaceRef {
|
||||
/// Returns the legacy opaque registration reference.
|
||||
/// Returns the local name without parsing or adding display delimiters.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
&self.0.name
|
||||
}
|
||||
|
||||
/// Maps the legacy registration reference to a root-scoped protocol reference.
|
||||
///
|
||||
/// The opaque registration string is copied as the name without parsing it.
|
||||
/// Returns the structured Protocol reference.
|
||||
#[must_use]
|
||||
pub fn protocol_reference(&self) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: self.0.clone(),
|
||||
}
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// One atomic observation of an interface reference, descriptor, and validator.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct InterfaceObservation {
|
||||
/// Opaque reference selected by published objects and operation calls.
|
||||
/// Structured reference selected by published objects and operation calls.
|
||||
pub reference: InterfaceRef,
|
||||
/// Canonical descriptor frozen when the interface was registered.
|
||||
pub descriptor: InterfaceDescriptor,
|
||||
/// Optional ref of the scope Object from this same observation.
|
||||
pub scope_ref: Option<String>,
|
||||
/// Optional validator. Its presence makes it a required call precondition.
|
||||
pub validator: Option<Validator>,
|
||||
}
|
||||
@@ -242,7 +310,7 @@ impl InterfaceObservation {
|
||||
let response = FetchInterfaceResponse {
|
||||
interface: self.reference.protocol_reference(),
|
||||
descriptor: self.descriptor.clone(),
|
||||
scope_ref: None,
|
||||
scope_ref: self.scope_ref.clone(),
|
||||
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
||||
};
|
||||
response
|
||||
@@ -259,6 +327,7 @@ impl InterfaceObservation {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InterfaceArtifact {
|
||||
scope: Scope,
|
||||
reference: InterfaceRef,
|
||||
descriptor: InterfaceDescriptor,
|
||||
validator: Option<Validator>,
|
||||
@@ -267,7 +336,7 @@ struct InterfaceArtifact {
|
||||
/// 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.
|
||||
/// scoped reference and a private publication lifetime, not a Rust address.
|
||||
pub struct RegisteredInterface<D> {
|
||||
artifact: Arc<InterfaceArtifact>,
|
||||
definition: PhantomData<fn() -> D>,
|
||||
@@ -283,170 +352,161 @@ impl<D> Clone for RegisteredInterface<D> {
|
||||
}
|
||||
|
||||
impl<D> RegisteredInterface<D> {
|
||||
/// Returns the opaque protocol reference assigned by the registry.
|
||||
/// Returns the explicit scope/name reference.
|
||||
#[must_use]
|
||||
pub fn reference(&self) -> &InterfaceRef {
|
||||
&self.artifact.reference
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry of validated, immutable interface artifacts.
|
||||
/// Registry of frozen definition artifacts and explicitly scoped registrations.
|
||||
///
|
||||
/// Generated descriptors are never rewritten to contain their assigned
|
||||
/// 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 rather than silently changing
|
||||
/// the observation associated with an existing reference.
|
||||
#[derive(Debug)]
|
||||
/// Artifact retention is not proof of current publication. Fetch requires a live
|
||||
/// provider-attested ScopeObservation; execution requires the same lifetime in
|
||||
/// the target's atomic boundary. Collision keys are exact (scope, name) pairs,
|
||||
/// not Rust TypeId: the same Definition can be registered at multiple coordinates.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InterfaceRegistry {
|
||||
namespace: u64,
|
||||
next_reference: u64,
|
||||
definitions: HashSet<TypeId>,
|
||||
artifacts: HashMap<InterfaceRef, Arc<InterfaceArtifact>>,
|
||||
}
|
||||
|
||||
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.
|
||||
/// Creates an empty artifact registry without publishing any Object.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers and freezes one generated interface definition.
|
||||
/// Freezes a Definition at an explicit scope and local name.
|
||||
pub fn register<D>(
|
||||
&mut self,
|
||||
scope: &Scope,
|
||||
name: impl Into<String>,
|
||||
_definition: D,
|
||||
validator: Option<Validator>,
|
||||
) -> Result<RegisteredInterface<D>, RegistrationError>
|
||||
where
|
||||
D: InterfaceDefinition + 'static,
|
||||
{
|
||||
let definition = TypeId::of::<D>();
|
||||
if self.definitions.contains(&definition) {
|
||||
let reference = InterfaceRef(InterfaceReference {
|
||||
scope: scope.path.clone(),
|
||||
name: name.into(),
|
||||
});
|
||||
reference
|
||||
.0
|
||||
.validate()
|
||||
.map_err(RegistrationError::InvalidReference)?;
|
||||
if !scope.active() {
|
||||
return Err(RegistrationError::RetiredScope);
|
||||
}
|
||||
if self.artifacts.contains_key(&reference) {
|
||||
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 {
|
||||
scope: scope.clone(),
|
||||
reference: reference.clone(),
|
||||
descriptor,
|
||||
validator,
|
||||
});
|
||||
self.definitions.insert(definition);
|
||||
self.artifacts.insert(reference, Arc::clone(&artifact));
|
||||
|
||||
Ok(RegisteredInterface {
|
||||
artifact,
|
||||
definition: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves a structured protocol reference to a legacy registration token.
|
||||
/// Ends a scope lifetime and releases its registry entries.
|
||||
///
|
||||
/// Legacy artifacts are published only at root scope. The name is looked up
|
||||
/// verbatim; no scope or name parsing is performed.
|
||||
/// Retained tokens cannot start new calls/fetches. Started calls retain their
|
||||
/// frozen Descriptor. Re-publication requires a fresh Scope.
|
||||
pub fn remove_scope(&mut self, scope: &Scope) {
|
||||
scope.lifetime.store(false, Ordering::Release);
|
||||
self.artifacts
|
||||
.retain(|_, artifact| !artifact.scope.same_lifetime(scope));
|
||||
}
|
||||
|
||||
/// Resolves a stored artifact, not evidence of current publication.
|
||||
#[must_use]
|
||||
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
|
||||
if reference.scope != "/" {
|
||||
return None;
|
||||
}
|
||||
let reference = InterfaceRef(reference.name.clone());
|
||||
let reference = InterfaceRef(reference.clone());
|
||||
self.artifacts.contains_key(&reference).then_some(reference)
|
||||
}
|
||||
|
||||
/// Observes a registered interface by its host-side reference.
|
||||
#[must_use]
|
||||
pub fn observe_object(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
|
||||
self.artifacts.get(reference).map(Self::observation)
|
||||
/// Iterates registered scope handles, possibly repeated for multiple names.
|
||||
pub fn scopes(&self) -> impl Iterator<Item = &Scope> {
|
||||
self.artifacts.values().map(|artifact| &artifact.scope)
|
||||
}
|
||||
|
||||
/// Implements canonical `fetch_interface` semantics for a structured reference.
|
||||
///
|
||||
/// Malformed references map to `InvalidRequest`. Unknown and currently
|
||||
/// unpublished references (including non-root scopes) map to `InterfaceNotFound`.
|
||||
/// Fetches only with a coherent current scope Object attestation.
|
||||
pub fn fetch_interface(
|
||||
&self,
|
||||
reference: &InterfaceReference,
|
||||
current: &ScopeObservation,
|
||||
) -> Result<InterfaceObservation, ProtocolError> {
|
||||
reference.validate().map_err(|error| ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
self.resolve(reference)
|
||||
.and_then(|reference| self.artifacts.get(&reference))
|
||||
.map(Self::observation)
|
||||
.ok_or_else(|| ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceNotFound,
|
||||
message: "interface reference is not currently published".into(),
|
||||
self.artifacts
|
||||
.get(&InterfaceRef(reference.clone()))
|
||||
.filter(|artifact| {
|
||||
artifact.scope.active() && artifact.scope.same_lifetime(¤t.scope)
|
||||
})
|
||||
}
|
||||
|
||||
fn observation(artifact: &Arc<InterfaceArtifact>) -> InterfaceObservation {
|
||||
InterfaceObservation {
|
||||
.map(|artifact| InterfaceObservation {
|
||||
reference: artifact.reference.clone(),
|
||||
descriptor: artifact.descriptor.clone(),
|
||||
scope_ref: current.reference.clone(),
|
||||
validator: artifact.validator.clone(),
|
||||
}
|
||||
})
|
||||
.ok_or_else(interface_not_found)
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure while registering a generated interface definition.
|
||||
fn interface_not_found() -> ProtocolError {
|
||||
ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceNotFound,
|
||||
message: "interface is not currently published".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure while registering a generated interface Definition.
|
||||
#[derive(Debug)]
|
||||
pub enum RegistrationError {
|
||||
/// The same generated definition was already assigned a reference.
|
||||
/// The exact scope/name coordinate is already occupied.
|
||||
AlreadyRegistered,
|
||||
/// The generated descriptor is structurally invalid.
|
||||
/// The generated Descriptor is invalid.
|
||||
InvalidDescriptor(ValidationError),
|
||||
/// The registry cannot assign another opaque reference.
|
||||
ReferenceSpaceExhausted,
|
||||
/// The scope or local name is invalid.
|
||||
InvalidReference(ValidationError),
|
||||
/// The lifetime ended; re-publication needs a fresh Scope.
|
||||
RetiredScope,
|
||||
}
|
||||
|
||||
impl Display for RegistrationError {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::AlreadyRegistered => {
|
||||
formatter.write_str("interface definition already registered")
|
||||
formatter.write_str("interface scope/name already registered")
|
||||
}
|
||||
Self::InvalidDescriptor(error) => {
|
||||
write!(formatter, "invalid interface descriptor: {error}")
|
||||
}
|
||||
Self::ReferenceSpaceExhausted => {
|
||||
formatter.write_str("interface reference space exhausted")
|
||||
Self::InvalidReference(error) => {
|
||||
write!(formatter, "invalid interface reference: {error}")
|
||||
}
|
||||
Self::RetiredScope => formatter.write_str("scope publication lifetime has ended"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RegistrationError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::InvalidDescriptor(error) => Some(error),
|
||||
Self::AlreadyRegistered | Self::ReferenceSpaceExhausted => None,
|
||||
Self::InvalidDescriptor(error) | Self::InvalidReference(error) => Some(error),
|
||||
Self::AlreadyRegistered | Self::RetiredScope => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,6 +613,7 @@ struct BoundInterface<T> {
|
||||
pub struct PublishedObject<T: WipObject> {
|
||||
implementation: T,
|
||||
interfaces: Vec<BoundInterface<T>>,
|
||||
scopes: Vec<ScopeObservation>,
|
||||
}
|
||||
|
||||
impl<T: WipObject> PublishedObject<T> {
|
||||
@@ -562,13 +623,45 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
Self {
|
||||
implementation,
|
||||
interfaces: Vec::new(),
|
||||
scopes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches a current scope snapshot inside the provider's atomic boundary.
|
||||
///
|
||||
/// Never populate this from a stale prelookup. Target, scope association,
|
||||
/// Descriptor and backend handles must remain coherent through dispatch and
|
||||
/// commit. A later same-path binding replaces the earlier snapshot.
|
||||
#[must_use]
|
||||
pub fn bind_scope(mut self, current: ScopeObservation) -> Self {
|
||||
self.scopes
|
||||
.retain(|old| old.scope.path != current.scope.path);
|
||||
self.scopes.push(current);
|
||||
self
|
||||
}
|
||||
|
||||
fn current_scope(&self, scope: &Scope) -> Option<&ScopeObservation> {
|
||||
self.scopes
|
||||
.iter()
|
||||
.find(|current| scope.active() && scope.same_lifetime(¤t.scope))
|
||||
}
|
||||
|
||||
fn scopes_valid_at(&self, path: &str) -> bool {
|
||||
self.interfaces.iter().all(|bound| {
|
||||
bound.artifact.reference.0.validate_for_path(path).is_ok()
|
||||
&& self
|
||||
.current_scope(&bound.artifact.scope)
|
||||
.is_some_and(|current| {
|
||||
current.scope.path != path
|
||||
|| current.reference() == self.implementation.reference()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Explicitly exposes a registered generated interface in insertion order.
|
||||
///
|
||||
/// The `InterfaceImplementation<T>` bound is generated only when `T`
|
||||
/// implements the corresponding Rust interface trait. Duplicate opaque
|
||||
/// implements the corresponding Rust interface trait. Duplicate scoped
|
||||
/// references are rejected.
|
||||
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
|
||||
where
|
||||
@@ -621,6 +714,7 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
let mut descriptor = bound.artifact.descriptor.clone();
|
||||
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
|
||||
bound.artifact = Arc::new(InterfaceArtifact {
|
||||
scope: bound.artifact.scope.clone(),
|
||||
reference: reference.clone(),
|
||||
descriptor,
|
||||
validator: bound.artifact.validator.clone(),
|
||||
@@ -637,9 +731,13 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
&self.implementation
|
||||
}
|
||||
|
||||
/// Builds canonical protocol object metadata in interface insertion order.
|
||||
/// Builds metadata from the explicit exposure set in insertion order.
|
||||
///
|
||||
/// The observation contains no path, children, descriptor, backend handle,
|
||||
/// This path-free projection is useful while composing a publication, but is
|
||||
/// not proof that its scopes are currently published. Use
|
||||
/// [`ObjectProvider::observe_object`] for a validated current observation, or
|
||||
/// [`Self::protocol_object_at`] inside a provider's coherent boundary.
|
||||
/// The metadata contains no path, children, descriptor, backend handle,
|
||||
/// credential, or provider resolution state.
|
||||
#[must_use]
|
||||
pub fn protocol_object(&self) -> Object {
|
||||
@@ -659,6 +757,30 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a current Object observation inside the provider's boundary.
|
||||
///
|
||||
/// All exposed interfaces must have compatible, live scope associations. The
|
||||
/// provider must still establish those associations atomically; this method
|
||||
/// cannot verify backend lifetime assertions or lock external writers.
|
||||
pub fn protocol_object_at(&self, path: &str) -> Result<Object, CallError> {
|
||||
wip_protocol::validate_path(path)
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
if !self.scopes_valid_at(path) {
|
||||
return Err(CallError::new(
|
||||
CallErrorKind::Internal,
|
||||
"Host published interfaces without current compatible scope Objects",
|
||||
));
|
||||
}
|
||||
let object = self.protocol_object();
|
||||
object.validate_at_path(path).map_err(|_| {
|
||||
CallError::new(
|
||||
CallErrorKind::Internal,
|
||||
"Host produced an invalid object response",
|
||||
)
|
||||
})?;
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
fn dispatch_resolved(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
@@ -720,6 +842,31 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
);
|
||||
};
|
||||
|
||||
let Some(current_scope) = self.current_scope(&bound.artifact.scope) else {
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::InterfaceMismatch,
|
||||
"interface scope Object is not currently associated with this publication",
|
||||
);
|
||||
};
|
||||
if current_scope.scope.path == target.path
|
||||
&& current_scope.reference() != self.implementation.reference()
|
||||
{
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::Internal,
|
||||
"provider produced inconsistent self-scope metadata",
|
||||
);
|
||||
}
|
||||
if interface
|
||||
.scope_ref
|
||||
.as_deref()
|
||||
.is_some_and(|supplied| current_scope.reference() != Some(supplied))
|
||||
{
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::InterfaceMismatch,
|
||||
"scope Object ref does not match the current association",
|
||||
);
|
||||
}
|
||||
|
||||
match (
|
||||
bound.artifact.validator.as_ref(),
|
||||
interface.validator.as_deref(),
|
||||
@@ -958,7 +1105,12 @@ pub enum ProviderResult<T> {
|
||||
///
|
||||
/// `with_object` must resolve the current object at `path` exactly once and pass
|
||||
/// a publication that pins its current interface set, implementations, and
|
||||
/// backend target for the duration of `operation`. The execution context is the
|
||||
/// backend target and all current scope Object associations for the duration of
|
||||
/// `operation`. Attach them with PublishedObject::bind_scope inside this same
|
||||
/// boundary, never via an independent scope prelookup. A new Object at the same
|
||||
/// path needs a fresh Scope; ordinary state changes retain the handle. Providers
|
||||
/// own lifetime/atomicity enforcement, including external writers and backend
|
||||
/// commit/rollback. The execution context is the
|
||||
/// runtime-neutral carrier for cancellation, deadline, and resource controls.
|
||||
/// The borrowed publication cannot escape the callback.
|
||||
///
|
||||
@@ -1022,7 +1174,9 @@ pub trait ObjectProvider {
|
||||
wip_protocol::validate_path(path)
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
let resolved = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.with_object(context, path, PublishedObject::protocol_object)
|
||||
self.with_object(context, path, |publication| {
|
||||
publication.protocol_object_at(path)
|
||||
})
|
||||
}))
|
||||
.map_err(|_| {
|
||||
CallError::new(
|
||||
@@ -1031,7 +1185,7 @@ pub trait ObjectProvider {
|
||||
)
|
||||
})?;
|
||||
let object = match resolved {
|
||||
ProviderResult::Found(object) => object,
|
||||
ProviderResult::Found(object) => object?,
|
||||
ProviderResult::NotFound => {
|
||||
return Err(CallError::new(
|
||||
CallErrorKind::NotFound,
|
||||
@@ -1040,15 +1194,58 @@ pub trait ObjectProvider {
|
||||
}
|
||||
ProviderResult::Failure(error) => return Err(CallError::from_read_failure(error)),
|
||||
};
|
||||
object.validate_at_path(path).map_err(|_| {
|
||||
CallError::new(
|
||||
CallErrorKind::Internal,
|
||||
"Host produced an invalid object response",
|
||||
)
|
||||
})?;
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
/// Fetches Descriptor, scope Object ref and Interface validator atomically.
|
||||
///
|
||||
/// The scope Object may expose no interfaces. Its publication must still
|
||||
/// bind the live ScopeObservation. Registry retention is not publication.
|
||||
fn fetch_interface(
|
||||
&self,
|
||||
registry: &InterfaceRegistry,
|
||||
reference: &InterfaceReference,
|
||||
) -> Result<InterfaceObservation, ProtocolError> {
|
||||
reference.validate().map_err(|error| ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let observed = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.with_object(
|
||||
&ExecutionContext::default(),
|
||||
&reference.scope,
|
||||
|publication| {
|
||||
publication
|
||||
.scopes
|
||||
.iter()
|
||||
.find(|current| current.scope.path == reference.scope)
|
||||
.ok_or_else(interface_not_found)
|
||||
.and_then(|current| {
|
||||
if current.reference() != publication.implementation.reference() {
|
||||
return Err(ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
message: "provider produced inconsistent scope Object metadata"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
registry.fetch_interface(reference, current)
|
||||
})
|
||||
},
|
||||
)
|
||||
}))
|
||||
.map_err(|_| ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
message: "provider panicked while observing an interface".into(),
|
||||
})?;
|
||||
match observed {
|
||||
ProviderResult::Found(observation) => observation,
|
||||
ProviderResult::NotFound => Err(interface_not_found()),
|
||||
ProviderResult::Failure(error) => {
|
||||
Err(CallError::from_read_failure(error).protocol_error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves and executes a canonical path-targeted operation request.
|
||||
///
|
||||
/// Structural path validation precedes provider resolution. For a valid
|
||||
@@ -1168,7 +1365,7 @@ pub trait ObjectProvider {
|
||||
/// 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.
|
||||
/// The same scope/name Interface reference was exposed more than once.
|
||||
DuplicateInterface(InterfaceRef),
|
||||
/// Projection attempted to add an interface without an implementation.
|
||||
UnexposedInterface(InterfaceRef),
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
|
||||
use wip_host::{
|
||||
CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef,
|
||||
InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult,
|
||||
PublishedObject, RegisteredInterface, Target, Validator, WipObject,
|
||||
PublishedObject, RegisteredInterface, Scope, ScopeObservation, Target, Validator, WipObject,
|
||||
};
|
||||
use wip_interface::{
|
||||
CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value,
|
||||
@@ -17,9 +17,16 @@ use wip_protocol::ProtocolErrorCode;
|
||||
#[test]
|
||||
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let read = registry.register(ReaderDefinition, None).unwrap();
|
||||
let write = registry.register(WriterDefinition, None).unwrap();
|
||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
||||
let root = Scope::new("/").unwrap();
|
||||
let read = registry
|
||||
.register(&root, "reader", ReaderDefinition, None)
|
||||
.unwrap();
|
||||
let write = registry
|
||||
.register(&root, "writer::#日本語", WriterDefinition, None)
|
||||
.unwrap();
|
||||
let hidden = registry
|
||||
.register(&root, "hidden", HiddenDefinition, None)
|
||||
.unwrap();
|
||||
let publication = || {
|
||||
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
|
||||
.expose(&read)
|
||||
@@ -61,8 +68,9 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
let current = ScopeObservation::new(&root, &Document::open("", None, Rc::new(Cell::new(0))));
|
||||
let full = registry
|
||||
.fetch_interface(&read.reference().protocol_reference())
|
||||
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||
.unwrap();
|
||||
let filtered = full
|
||||
.clone()
|
||||
@@ -71,7 +79,7 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
||||
assert_eq!(full.descriptor.operations.len(), 3);
|
||||
assert_eq!(
|
||||
registry
|
||||
.fetch_interface(&read.reference().protocol_reference())
|
||||
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||
.unwrap(),
|
||||
full
|
||||
);
|
||||
@@ -309,6 +317,7 @@ impl Broken for Document {
|
||||
}
|
||||
|
||||
struct TestProvider {
|
||||
root: Scope,
|
||||
reader: RegisteredInterface<ReaderDefinition>,
|
||||
writer: RegisteredInterface<WriterDefinition>,
|
||||
broken: RegisteredInterface<BrokenDefinition>,
|
||||
@@ -326,11 +335,13 @@ struct TestProvider {
|
||||
|
||||
impl TestProvider {
|
||||
fn new(
|
||||
root: Scope,
|
||||
reader: RegisteredInterface<ReaderDefinition>,
|
||||
writer: RegisteredInterface<WriterDefinition>,
|
||||
broken: RegisteredInterface<BrokenDefinition>,
|
||||
) -> Self {
|
||||
Self {
|
||||
root,
|
||||
reader,
|
||||
writer,
|
||||
broken,
|
||||
@@ -362,7 +373,10 @@ impl ObjectProvider for TestProvider {
|
||||
self.observed_cancelled
|
||||
.set(context.cancellation().is_cancelled());
|
||||
self.resolutions.set(self.resolutions.get() + 1);
|
||||
let scope_object = Document::open("", None, Rc::clone(&self.calls));
|
||||
let current_scope = ScopeObservation::new(&self.root, &scope_object);
|
||||
let (document, interfaces) = match path {
|
||||
"/" => (scope_object, 0),
|
||||
"/document" => (
|
||||
Document::strict(
|
||||
"document",
|
||||
@@ -420,6 +434,7 @@ impl ObjectProvider for TestProvider {
|
||||
3 => publication.expose(&self.broken).unwrap(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let publication = publication.bind_scope(current_scope);
|
||||
ProviderResult::Found(operation(&publication))
|
||||
}
|
||||
|
||||
@@ -461,13 +476,25 @@ fn registry_and_provider() -> (
|
||||
RegisteredInterface<HiddenDefinition>,
|
||||
) {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let root = Scope::new("/").unwrap();
|
||||
let reader = registry
|
||||
.register(ReaderDefinition, Some(Validator::new(b"schema-v1")))
|
||||
.register(
|
||||
&root,
|
||||
"reader",
|
||||
ReaderDefinition,
|
||||
Some(Validator::new(b"schema-v1")),
|
||||
)
|
||||
.unwrap();
|
||||
let writer = registry.register(WriterDefinition, None).unwrap();
|
||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
||||
let broken = registry.register(BrokenDefinition, None).unwrap();
|
||||
let provider = TestProvider::new(reader, writer, broken);
|
||||
let writer = registry
|
||||
.register(&root, "writer::#日本語", WriterDefinition, None)
|
||||
.unwrap();
|
||||
let hidden = registry
|
||||
.register(&root, "hidden", HiddenDefinition, None)
|
||||
.unwrap();
|
||||
let broken = registry
|
||||
.register(&root, "broken", BrokenDefinition, None)
|
||||
.unwrap();
|
||||
let provider = TestProvider::new(root, reader, writer, broken);
|
||||
(registry, provider, hidden)
|
||||
}
|
||||
|
||||
@@ -505,8 +532,8 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
let reference = provider.reader.reference();
|
||||
|
||||
assert_ne!(reference.as_str(), "Reader");
|
||||
let observation = registry
|
||||
.fetch_interface(&reference.protocol_reference())
|
||||
let observation = provider
|
||||
.fetch_interface(®istry, &reference.protocol_reference())
|
||||
.unwrap();
|
||||
assert_eq!(observation.reference, *reference);
|
||||
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
|
||||
@@ -518,8 +545,8 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
||||
response.descriptor.validate().unwrap();
|
||||
|
||||
let mut invalid_observation = registry
|
||||
.fetch_interface(&reference.protocol_reference())
|
||||
let mut invalid_observation = provider
|
||||
.fetch_interface(®istry, &reference.protocol_reference())
|
||||
.unwrap();
|
||||
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
||||
invalid_observation
|
||||
@@ -529,11 +556,14 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
||||
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
||||
|
||||
let error = registry
|
||||
.fetch_interface(&wip_protocol::InterfaceReference {
|
||||
let error = provider
|
||||
.fetch_interface(
|
||||
®istry,
|
||||
&wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: "unknown".into(),
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
||||
}
|
||||
@@ -1146,14 +1176,13 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
||||
fn registry_boundaries_preserve_names_verbatim_and_match_exact_scope() {
|
||||
let (registry, provider, _) = registry_and_provider();
|
||||
let legacy = provider.writer.reference();
|
||||
let reference = legacy.protocol_reference();
|
||||
assert_eq!(reference.scope, "/");
|
||||
assert_eq!(reference.name, legacy.as_str());
|
||||
// The registry's colon-delimited name remains opaque, not parsed.
|
||||
assert!(reference.name.starts_with("wip-interface:"));
|
||||
assert_eq!(reference.name, "writer::#日本語");
|
||||
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
|
||||
let nested = wip_protocol::InterfaceReference {
|
||||
scope: "/open".into(),
|
||||
@@ -1161,7 +1190,10 @@ fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
||||
};
|
||||
assert!(registry.resolve(&nested).is_none());
|
||||
assert_eq!(
|
||||
registry.fetch_interface(&nested).unwrap_err().code,
|
||||
provider
|
||||
.fetch_interface(®istry, &nested)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let malformed = wip_protocol::InterfaceReference {
|
||||
@@ -1169,7 +1201,10 @@ fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
||||
name: reference.name,
|
||||
};
|
||||
assert_eq!(
|
||||
registry.fetch_interface(&malformed).unwrap_err().code,
|
||||
provider
|
||||
.fetch_interface(®istry, &malformed)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wip_host::{
|
||||
CallErrorKind, CallRequest, ExecutionContext, InterfaceRegistry, InterfaceTarget,
|
||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, RegistrationError, Scope,
|
||||
ScopeObservation, Target, Validator, WipObject,
|
||||
};
|
||||
use wip_interface::{OperationResult, interface};
|
||||
use wip_protocol::{InterfaceReference, ProtocolErrorCode, Value};
|
||||
|
||||
#[interface]
|
||||
pub trait Read {
|
||||
fn read(&self, value: String) -> OperationResult<String>;
|
||||
}
|
||||
|
||||
#[interface]
|
||||
pub trait Count {
|
||||
fn read(&self, value: i64) -> OperationResult<i64>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Metadata {
|
||||
name: String,
|
||||
reference: Option<String>,
|
||||
validator: Option<Validator>,
|
||||
}
|
||||
impl WipObject for Metadata {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
self.reference.as_deref()
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
self.validator.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
type OnStart = Rc<RefCell<Option<Box<dyn Fn()>>>>;
|
||||
|
||||
struct Implementation {
|
||||
metadata: Metadata,
|
||||
calls: Rc<Cell<usize>>,
|
||||
on_start: OnStart,
|
||||
}
|
||||
impl WipObject for Implementation {
|
||||
fn name(&self) -> &str {
|
||||
self.metadata.name()
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
self.metadata.reference()
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
self.metadata.validator()
|
||||
}
|
||||
}
|
||||
impl Read for Implementation {
|
||||
fn read(&self, value: String) -> OperationResult<String> {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
if let Some(action) = self.on_start.borrow_mut().take() {
|
||||
action();
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
struct Provider {
|
||||
interface: RegisteredInterface<ReadDefinition>,
|
||||
current: Rc<RefCell<Option<(Scope, Metadata)>>>,
|
||||
target_path: String,
|
||||
target_exists: Cell<bool>,
|
||||
member: Cell<bool>,
|
||||
calls: Rc<Cell<usize>>,
|
||||
resolutions: Cell<usize>,
|
||||
on_start: OnStart,
|
||||
}
|
||||
impl Provider {
|
||||
fn new(
|
||||
scope: &Scope,
|
||||
interface: RegisteredInterface<ReadDefinition>,
|
||||
target: &str,
|
||||
reference: Option<&str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
interface,
|
||||
current: Rc::new(RefCell::new(Some((
|
||||
scope.clone(),
|
||||
Metadata {
|
||||
name: scope.path().rsplit('/').next().unwrap().into(),
|
||||
reference: reference.map(str::to_owned),
|
||||
validator: Some(Validator::new(b"scope-state-v1")),
|
||||
},
|
||||
)))),
|
||||
target_path: target.into(),
|
||||
target_exists: Cell::new(true),
|
||||
member: Cell::new(true),
|
||||
calls: Rc::new(Cell::new(0)),
|
||||
resolutions: Cell::new(0),
|
||||
on_start: Rc::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
fn request(&self, scope_ref: Option<&str>) -> CallRequest {
|
||||
CallRequest {
|
||||
target: Target {
|
||||
path: self.target_path.clone(),
|
||||
validator: None,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: self.interface.reference().protocol_reference(),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
validator: Some(b"descriptor-v1".to_vec()),
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::from([("value".into(), Value::String("original backend".into()))]),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ObjectProvider for Provider {
|
||||
type Object = Implementation;
|
||||
fn with_object<R>(
|
||||
&self,
|
||||
_: &ExecutionContext,
|
||||
path: &str,
|
||||
callback: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
||||
) -> ProviderResult<R> {
|
||||
self.resolutions.set(self.resolutions.get() + 1);
|
||||
// A single boundary captures both the target and the live scope association.
|
||||
let current = self.current.borrow().clone();
|
||||
let target = path == self.target_path;
|
||||
let metadata = if target {
|
||||
if !self.target_exists.get() {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
if let Some((scope, object)) = ¤t
|
||||
&& scope.path() == path
|
||||
{
|
||||
object.clone()
|
||||
} else {
|
||||
Metadata {
|
||||
name: path.rsplit('/').next().unwrap().into(),
|
||||
reference: None,
|
||||
validator: Some(Validator::new(b"target-v1")),
|
||||
}
|
||||
}
|
||||
} else if let Some((scope, object)) = ¤t {
|
||||
if path != scope.path() {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
object.clone()
|
||||
} else {
|
||||
return ProviderResult::NotFound;
|
||||
};
|
||||
let mut publication = PublishedObject::new(Implementation {
|
||||
metadata,
|
||||
calls: self.calls.clone(),
|
||||
on_start: self.on_start.clone(),
|
||||
});
|
||||
if target && self.member.get() {
|
||||
publication = publication.expose(&self.interface).unwrap();
|
||||
}
|
||||
if let Some((scope, object)) = current {
|
||||
publication = publication.bind_scope(ScopeObservation::new(&scope, &object));
|
||||
}
|
||||
ProviderResult::Found(callback(&publication))
|
||||
}
|
||||
}
|
||||
fn setup(
|
||||
scope: &str,
|
||||
target: &str,
|
||||
reference: Option<&str>,
|
||||
) -> (InterfaceRegistry, Scope, Provider) {
|
||||
let scope = Scope::new(scope).unwrap();
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let interface = registry
|
||||
.register(
|
||||
&scope,
|
||||
"read::#日本語",
|
||||
ReadDefinition,
|
||||
Some(Validator::new(b"descriptor-v1")),
|
||||
)
|
||||
.unwrap();
|
||||
let provider = Provider::new(&scope, interface, target, reference);
|
||||
(registry, scope, provider)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registrations_use_scope_name_not_definition_type_or_delimiters() {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let left = Scope::new("/left").unwrap();
|
||||
let right = Scope::new("/right").unwrap();
|
||||
let a = registry
|
||||
.register(&left, "read::#日本語", ReadDefinition, None)
|
||||
.unwrap();
|
||||
let b = registry
|
||||
.register(&right, "read::#日本語", ReadDefinition, None)
|
||||
.unwrap();
|
||||
assert_ne!(a.reference(), b.reference());
|
||||
assert_eq!(
|
||||
a.reference().protocol_reference(),
|
||||
InterfaceReference {
|
||||
scope: "/left".into(),
|
||||
name: "read::#日本語".into(),
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
registry.register(&left, "read::#日本語", CountDefinition, None),
|
||||
Err(RegistrationError::AlreadyRegistered)
|
||||
));
|
||||
assert!(matches!(
|
||||
registry.register(&left, "", ReadDefinition, None),
|
||||
Err(RegistrationError::InvalidReference(_))
|
||||
));
|
||||
assert!(Scope::new("/invalid/").is_err());
|
||||
registry
|
||||
.register(&left, "another", ReadDefinition, None)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_root_ancestor_and_membership_are_independent() {
|
||||
for (scope, target) in [
|
||||
("/", "/"),
|
||||
("/", "/target"),
|
||||
("/scope", "/scope"),
|
||||
("/scope", "/scope/target"),
|
||||
] {
|
||||
let (registry, _, provider) = setup(scope, target, Some("scope-id"));
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(Some("scope-id")))
|
||||
.unwrap()
|
||||
.result,
|
||||
Value::String("original backend".into())
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("scope-id")
|
||||
);
|
||||
provider.member.set(false);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
// Non-self scope Objects need not implement or expose any Interface.
|
||||
let (registry, _, provider) = setup("/scope", "/scope/target", None);
|
||||
assert!(
|
||||
provider
|
||||
.observe_object("/scope")
|
||||
.unwrap()
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap();
|
||||
for target in ["/sibling/target", "/scopelong", "/"] {
|
||||
let (_, _, provider) = setup("/scope", target, None);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 0);
|
||||
assert_eq!(
|
||||
provider.observe_object(target).unwrap_err().kind(),
|
||||
CallErrorKind::Internal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_scope_ref_and_state_validator_have_distinct_responsibilities() {
|
||||
for current in [Some("scope-id"), None, Some("")] {
|
||||
let (registry, _, provider) = setup("/scope", "/scope/target", current);
|
||||
let observation = provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
observation
|
||||
.protocol_response()
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
current
|
||||
);
|
||||
assert_eq!(
|
||||
observation.validator,
|
||||
Some(Validator::new(b"descriptor-v1"))
|
||||
);
|
||||
provider.call_operation(provider.request(None)).unwrap();
|
||||
if let Some(reference) = current {
|
||||
provider
|
||||
.call_operation(provider.request(Some(reference)))
|
||||
.unwrap();
|
||||
}
|
||||
let before = provider.calls.get();
|
||||
for supplied in ["other", "missing"] {
|
||||
let mut call = provider.request(Some(supplied));
|
||||
call.interface.validator = None;
|
||||
call.operation = "missing".into();
|
||||
assert_eq!(
|
||||
provider.call_operation(call).unwrap_err().kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
assert_eq!(provider.calls.get(), before);
|
||||
provider.current.borrow_mut().as_mut().unwrap().1.validator =
|
||||
Some(Validator::new(b"scope-state-v2"));
|
||||
provider.call_operation(provider.request(current)).unwrap();
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap(),
|
||||
observation
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_precondition_is_membership_stage_in_both_dispatch_paths() {
|
||||
let (_, _, provider) = setup("/scope", "/scope/target", Some("current"));
|
||||
let mut call = provider.request(Some("stale"));
|
||||
call.target.validator = Some(b"wrong".to_vec());
|
||||
assert_eq!(
|
||||
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||
CallErrorKind::ValidatorMismatch
|
||||
);
|
||||
provider.target_exists.set(false);
|
||||
assert_eq!(
|
||||
provider.call_operation(call).unwrap_err().kind(),
|
||||
CallErrorKind::NotFound
|
||||
);
|
||||
provider.target_exists.set(true);
|
||||
let call = provider.request(Some("stale"));
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::new(),
|
||||
call.target,
|
||||
call.interface,
|
||||
call.operation,
|
||||
|_| -> Result<BTreeMap<String, Value>, ()> { panic!("decoder must not run") }
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_republication_and_movement_never_inherit_retained_artifacts() {
|
||||
let (mut registry, scope, provider) = setup("/scope", "/scope/target", Some("old"));
|
||||
let reference = provider.request(None).interface.reference;
|
||||
provider.fetch_interface(®istry, &reference).unwrap();
|
||||
*provider.current.borrow_mut() = None;
|
||||
provider.target_exists.set(false);
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &reference)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::NotFound
|
||||
);
|
||||
provider.target_exists.set(true);
|
||||
// Even the same ref is not proof that a replacement inherits registration.
|
||||
let replacement = Scope::new("/scope").unwrap();
|
||||
*provider.current.borrow_mut() = Some((
|
||||
replacement.clone(),
|
||||
Metadata {
|
||||
name: "scope".into(),
|
||||
reference: Some("old".into()),
|
||||
validator: None,
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &reference)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(Some("old")))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
registry.remove_scope(&scope);
|
||||
assert!(matches!(
|
||||
registry.register(&scope, "new", ReadDefinition, None),
|
||||
Err(RegistrationError::RetiredScope)
|
||||
));
|
||||
let fresh = registry
|
||||
.register(&replacement, reference.name.clone(), ReadDefinition, None)
|
||||
.unwrap();
|
||||
provider.fetch_interface(®istry, &reference).unwrap();
|
||||
// Old target membership still binds the old lifetime, even after new publication.
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
let new_provider = Provider::new(&replacement, fresh, "/scope/target", Some("new"));
|
||||
let mut new_call = new_provider.request(Some("new"));
|
||||
new_call.interface.validator = None;
|
||||
new_provider.call_operation(new_call).unwrap();
|
||||
let moved = Scope::new("/moved").unwrap();
|
||||
new_provider.target_exists.set(false);
|
||||
*new_provider.current.borrow_mut() = Some((
|
||||
moved.clone(),
|
||||
Metadata {
|
||||
name: "moved".into(),
|
||||
reference: Some("new".into()),
|
||||
validator: None,
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
new_provider
|
||||
.fetch_interface(®istry, &reference)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
assert_eq!(
|
||||
new_provider
|
||||
.call_operation(new_provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::NotFound
|
||||
);
|
||||
let moved_interface = registry
|
||||
.register(&moved, reference.name, ReadDefinition, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
moved_interface.reference().protocol_reference().scope,
|
||||
"/moved"
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_after_fetch_rejects_old_ref_but_omission_and_refless_are_allowed() {
|
||||
for previous_ref in [Some("old"), None] {
|
||||
let (mut registry, old_scope, old_provider) =
|
||||
setup("/scope", "/scope/target", previous_ref);
|
||||
let observed = old_provider
|
||||
.fetch_interface(®istry, &old_provider.request(None).interface.reference)
|
||||
.unwrap();
|
||||
registry.remove_scope(&old_scope);
|
||||
let new_scope = Scope::new("/scope").unwrap();
|
||||
let token = registry
|
||||
.register(
|
||||
&new_scope,
|
||||
"read::#日本語",
|
||||
ReadDefinition,
|
||||
Some(Validator::new(b"descriptor-v1")),
|
||||
)
|
||||
.unwrap();
|
||||
let new_provider = Provider::new(&new_scope, token, "/scope/target", None);
|
||||
if let Some(reference) = observed.scope_ref {
|
||||
assert_eq!(
|
||||
new_provider
|
||||
.call_operation(new_provider.request(Some(&reference)))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(new_provider.calls.get(), 0);
|
||||
}
|
||||
new_provider
|
||||
.call_operation(new_provider.request(None))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn started_execution_retains_target_and_same_descriptor_through_replacement() {
|
||||
let (registry, old_scope, provider) = setup("/scope", "/scope/target", Some("old"));
|
||||
let registry = Rc::new(RefCell::new(registry));
|
||||
let current = provider.current.clone();
|
||||
let replacement = Scope::new("/scope").unwrap();
|
||||
let registry_for_action = registry.clone();
|
||||
let replacement_for_action = replacement.clone();
|
||||
*provider.on_start.borrow_mut() = Some(Box::new(move || {
|
||||
let mut registry = registry_for_action.borrow_mut();
|
||||
registry.remove_scope(&old_scope);
|
||||
registry
|
||||
.register(
|
||||
&replacement_for_action,
|
||||
"read::#日本語",
|
||||
CountDefinition,
|
||||
Some(Validator::new(b"descriptor-v2")),
|
||||
)
|
||||
.unwrap();
|
||||
*current.borrow_mut() = Some((
|
||||
replacement_for_action.clone(),
|
||||
Metadata {
|
||||
name: "scope".into(),
|
||||
reference: Some("new".into()),
|
||||
validator: None,
|
||||
},
|
||||
));
|
||||
}));
|
||||
let call = provider.request(Some("old"));
|
||||
let result = provider
|
||||
.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::new(),
|
||||
call.target,
|
||||
call.interface,
|
||||
call.operation,
|
||||
|descriptor| {
|
||||
assert_eq!(descriptor, &ReadDefinition::descriptor());
|
||||
Ok::<_, ()>(call.arguments)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.result, Value::String("original backend".into()));
|
||||
assert_eq!(provider.calls.get(), 1);
|
||||
assert_eq!(provider.resolutions.get(), 1);
|
||||
let new = provider
|
||||
.fetch_interface(
|
||||
®istry.borrow(),
|
||||
&provider.request(None).interface.reference,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(new.descriptor, CountDefinition::descriptor());
|
||||
assert_eq!(new.scope_ref.as_deref(), Some("new"));
|
||||
// The old bound target can no longer start a call against the new descriptor.
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 1);
|
||||
}
|
||||
@@ -16,6 +16,6 @@ impl WipObject for Object {
|
||||
|
||||
fn main() {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let search = registry.register(SearchDefinition, None).unwrap();
|
||||
let search = registry.register(&wip_host::Scope::new("/").unwrap(), "search", SearchDefinition, None).unwrap();
|
||||
let _published = PublishedObject::new(Object).expose(&search).unwrap();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
//!
|
||||
//! The canonical API targets operations by `target.path` rather than a mandatory
|
||||
//! object identifier. Object `ref` and object/interface validators are separate,
|
||||
//! optional observation fields. Interface references are structured scope/name values observed
|
||||
//! optional observation fields. This demo registers explicit `catalog`, `audit`
|
||||
//! and `diagnostics` names at the service Object self-scope. A private [`Scope`]
|
||||
//! lifetime and coherent [`ScopeObservation`] bind fetch and call to that Object;
|
||||
//! successful calls carry its optional `scope_ref` identity precondition.
|
||||
//! Interface references are structured scope/name values observed
|
||||
//! separately from descriptors, and descriptors contain neither a Rust trait
|
||||
//! name nor an opaque-reference `name` field. Application records, enums,
|
||||
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
||||
@@ -41,8 +45,8 @@ use std::io;
|
||||
|
||||
use wip_host::{
|
||||
CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget,
|
||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Target, Validator,
|
||||
WipObject,
|
||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Scope, ScopeObservation,
|
||||
Target, Validator, WipObject,
|
||||
};
|
||||
use wip_interface as wip;
|
||||
use wip_interface::WipType;
|
||||
@@ -136,14 +140,20 @@ pub struct DemoReport {
|
||||
pub object_validator: Option<Vec<u8>>,
|
||||
/// Structured interface references published by the object, in exposure order.
|
||||
pub published_interfaces: Vec<InterfaceReference>,
|
||||
/// Root-scoped reference assigned to the catalog descriptor.
|
||||
/// Self-scoped reference assigned to the catalog descriptor.
|
||||
pub catalog_interface_reference: InterfaceReference,
|
||||
/// Root-scoped reference assigned to the audit descriptor.
|
||||
/// Self-scoped reference assigned to the audit descriptor.
|
||||
pub audit_interface_reference: InterfaceReference,
|
||||
/// Descriptor format observed separately for the catalog reference.
|
||||
pub catalog_descriptor_format: String,
|
||||
/// Descriptor format observed separately for the audit reference.
|
||||
pub audit_descriptor_format: String,
|
||||
/// Scope Object ref observed together with the catalog Descriptor.
|
||||
pub catalog_scope_ref: Option<String>,
|
||||
/// Scope Object ref observed together with the audit Descriptor.
|
||||
pub audit_scope_ref: Option<String>,
|
||||
/// Membership rejection when the supplied scope Object ref is stale.
|
||||
pub scope_mismatch: ProtocolErrorCode,
|
||||
/// Typed successful application result decoded from a validated [`Value`].
|
||||
pub published: PublishOutcome,
|
||||
/// Typed application-domain rejection decoded from a validated [`Value`].
|
||||
@@ -182,25 +192,34 @@ pub struct DemoReport {
|
||||
/// application-value decoding fails unexpectedly.
|
||||
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
|
||||
let audit = registry.register(AuditDefinition, None)?;
|
||||
let diagnostics = registry.register(DiagnosticsDefinition, None)?;
|
||||
let scope = Scope::new(OBJECT_PATH)?;
|
||||
let catalog = registry.register(
|
||||
&scope,
|
||||
"catalog",
|
||||
CatalogDefinition,
|
||||
Some(Validator::new(CATALOG_VALIDATOR)),
|
||||
)?;
|
||||
let audit = registry.register(&scope, "audit", AuditDefinition, None)?;
|
||||
let diagnostics = registry.register(&scope, "diagnostics", DiagnosticsDefinition, None)?;
|
||||
let provider = ApplicationProvider {
|
||||
scope,
|
||||
catalog: catalog.clone(),
|
||||
audit: audit.clone(),
|
||||
};
|
||||
|
||||
let object = provider.observe_object(OBJECT_PATH)?;
|
||||
let catalog_response = registry
|
||||
let catalog_response = provider
|
||||
.fetch_interface(
|
||||
®istry,
|
||||
object
|
||||
.interfaces
|
||||
.first()
|
||||
.ok_or_else(|| io::Error::other("catalog interface was not published"))?,
|
||||
)?
|
||||
.protocol_response()?;
|
||||
let audit_response = registry
|
||||
let audit_response = provider
|
||||
.fetch_interface(
|
||||
®istry,
|
||||
object
|
||||
.interfaces
|
||||
.get(1)
|
||||
@@ -319,14 +338,31 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
.protocol_error()
|
||||
.code;
|
||||
|
||||
let interface_not_found = registry
|
||||
.fetch_interface(&InterfaceReference {
|
||||
scope: "/".into(),
|
||||
let interface_not_found = provider
|
||||
.fetch_interface(
|
||||
®istry,
|
||||
&InterfaceReference {
|
||||
scope: OBJECT_PATH.into(),
|
||||
name: "missing-interface".into(),
|
||||
})
|
||||
},
|
||||
)
|
||||
.expect_err("unknown interface references must not resolve")
|
||||
.code;
|
||||
|
||||
let mut stale_scope = request(
|
||||
OBJECT_PATH,
|
||||
catalog.reference(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"status",
|
||||
BTreeMap::new(),
|
||||
);
|
||||
stale_scope.interface.scope_ref = Some("replaced-service-instance".into());
|
||||
let scope_mismatch = provider
|
||||
.call_operation(stale_scope)
|
||||
.expect_err("stale scope identity must reject before operation dispatch")
|
||||
.protocol_error()
|
||||
.code;
|
||||
|
||||
let precedence_failure = provider
|
||||
.call_operation(CallRequest {
|
||||
target: Target {
|
||||
@@ -354,6 +390,9 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
audit_interface_reference: audit.reference().protocol_reference(),
|
||||
catalog_descriptor_format: catalog_response.descriptor.format,
|
||||
audit_descriptor_format: audit_response.descriptor.format,
|
||||
catalog_scope_ref: catalog_response.scope_ref,
|
||||
audit_scope_ref: audit_response.scope_ref,
|
||||
scope_mismatch,
|
||||
published,
|
||||
rejected,
|
||||
catalog_status,
|
||||
@@ -417,7 +456,7 @@ fn request(
|
||||
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
scope_ref: Some("article-service-instance".into()),
|
||||
reference: interface.protocol_reference(),
|
||||
validator: interface_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
@@ -427,6 +466,7 @@ fn request(
|
||||
}
|
||||
|
||||
struct ApplicationProvider {
|
||||
scope: Scope,
|
||||
catalog: RegisteredInterface<CatalogDefinition>,
|
||||
audit: RegisteredInterface<AuditDefinition>,
|
||||
}
|
||||
@@ -443,7 +483,12 @@ impl ObjectProvider for ApplicationProvider {
|
||||
if path != OBJECT_PATH {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
let publication = PublishedObject::new(ApplicationService::new())
|
||||
// The immutable demo has no mutable backend actors. A real backend
|
||||
// must retain its transaction/lock across association and operation.
|
||||
let service = ApplicationService::new();
|
||||
let current = ScopeObservation::new(&self.scope, &service);
|
||||
let publication = PublishedObject::new(service)
|
||||
.bind_scope(current)
|
||||
.expose(&self.catalog)
|
||||
.expect("ApplicationService implements Catalog")
|
||||
.expose(&self.audit)
|
||||
|
||||
@@ -28,10 +28,20 @@ fn public_crates_support_the_complete_qualified_end_to_end_scenario() {
|
||||
);
|
||||
assert_ne!(
|
||||
report.catalog_interface_reference, report.audit_interface_reference,
|
||||
"interfaces form an ordered set of distinct opaque references"
|
||||
"interfaces form an ordered set of distinct scope/name references"
|
||||
);
|
||||
assert_eq!(report.catalog_interface_reference.scope, "/article-service");
|
||||
assert_eq!(report.catalog_interface_reference.name, "catalog");
|
||||
assert_eq!(report.audit_interface_reference.scope, "/article-service");
|
||||
assert_eq!(report.audit_interface_reference.name, "audit");
|
||||
assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1);
|
||||
assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1);
|
||||
assert_eq!(
|
||||
report.catalog_scope_ref.as_deref(),
|
||||
Some("article-service-instance")
|
||||
);
|
||||
assert_eq!(report.audit_scope_ref, report.catalog_scope_ref);
|
||||
assert_eq!(report.scope_mismatch, ProtocolErrorCode::InterfaceMismatch);
|
||||
assert_eq!(
|
||||
report.published,
|
||||
PublishOutcome::Published("article-1".into())
|
||||
|
||||
Reference in New Issue
Block a user