Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
827bef8478 | ||
|
|
b2be41d540 | ||
|
|
cc0ec0ccc8 | ||
|
|
755e6aeb88 | ||
|
|
fe6dccda83 | ||
|
|
8b8041cba1 | ||
|
|
4544bc0d8b |
+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
|
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
||||||
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
||||||
without changing routes, validators or the wire error-code set.
|
without changing routes, validators or the wire error-code set.
|
||||||
- Mechanically migrates workspace consumers and fixtures. See [MIGRATION.md](MIGRATION.md)
|
- Breaking Host registration migration to explicit Scope publication lifetimes and
|
||||||
for API/wire examples and the deferred Host/Client lifecycle semantics.
|
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
|
## 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,
|
This unreleased API/wire change follows `wip-reference` canonical Core Model,
|
||||||
Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`.
|
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,
|
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
|
not an invented identity. This is an opaque scope Object ref observation/call
|
||||||
precondition, **not** a third Interface identity component or a validator.
|
precondition, **not** a third Interface identity component or a validator.
|
||||||
The scope-ref lifecycle/atomic Host enforcement and Client cache-recovery
|
Host now enforces scope publication lifetimes and optional scope-ref identity
|
||||||
semantics are follow-up work (T-21/T-22); this migration supplies the data model
|
inside the provider boundary (T-21). Client cache-recovery semantics remain
|
||||||
and codec, not a completed lifecycle implementation.
|
follow-up work (T-22); do not mistake registry artifact retention for publication.
|
||||||
|
|
||||||
## Validation boundaries
|
## 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
|
`POST v1/observe`, `v1/fetch_interface`, `v1/call_operation`, error-code/status
|
||||||
mapping and Transport failure classification are unchanged.
|
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
|
## Downstream status
|
||||||
|
|
||||||
The workspace Client, Host, filesystem server, TUI and public-API example
|
The filesystem consumer registers `file.read`, `symlink.inspect` and `file.write`
|
||||||
are mechanically migrated to structured protocol values and updated fixtures.
|
at its deployment-pinned root Scope. It binds that lifetime from the refreshed
|
||||||
The existing Host's generated opaque registration handle is bridged explicitly
|
retained root lineage inside the backend execution boundary for calls, generic
|
||||||
to scope `/` with its full handle as the name; no delimiter parsing or scope
|
Object observations and fetch. A textual Linux root rename/replacement does not
|
||||||
registration/lifetime model is introduced here. The existing Client keys use
|
substitute the pinned root Object. The root has no Object ref, so fetch omits
|
||||||
structured values; this change does not implement scope deletion/replacement
|
scope_ref; the private lifetime check still applies. Non-root FS registrations
|
||||||
cache recovery. TUI formatting is only the type-compatibility adaptation, not
|
fail closed pending broader FS/TUI semantics integration (T-23).
|
||||||
the full canonical Text View work (T-24). Filesystem/TUI semantics integration
|
|
||||||
remains T-23. Interface authoring/macros are descriptor-only and require no
|
The public API example self-scopes `catalog`, `audit` and `diagnostics` at
|
||||||
identity fields or declaration changes.
|
`/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
|
```rust,ignore
|
||||||
let mut interfaces = wip_host::InterfaceRegistry::new();
|
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)
|
let published = wip_host::PublishedObject::new(request_scoped_object)
|
||||||
.expose(&messenger)?;
|
.expose(&messenger)?
|
||||||
let protocol_object = published.protocol_object();
|
.bind_scope(current);
|
||||||
|
let protocol_object = published.protocol_object_at("/service/item")?;
|
||||||
```
|
```
|
||||||
|
|
||||||
`expose` compiles only when the object implements the generated definition's
|
`expose` compiles only when the object implements the generated definition's
|
||||||
interface trait. Implementing a trait does not expose it automatically, empty
|
interface trait. Implementing a trait does not expose it automatically, empty
|
||||||
interface sets are valid, insertion order is preserved, and duplicate
|
interface sets are valid, insertion order is preserved, and duplicate
|
||||||
references are rejected. Registry references are opaque and remain outside the
|
references are rejected. Registry collisions use exact `(scope, name)` pairs,
|
||||||
descriptor; generated descriptors contain neither those references nor Rust
|
not Rust Definition identity; the same Definition may be registered at multiple
|
||||||
trait identity.
|
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
|
Dispatch is qualified by interface reference and operation name and targets a
|
||||||
canonical Worldspace path. An `ObjectProvider` validates that path before
|
canonical Worldspace path. An `ObjectProvider` validates that path before
|
||||||
resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
|
resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
|
||||||
exactly once inside `with_object`; the host checks the supplied object validator,
|
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
|
operation-specific object-validator policy, arguments, and authorization in
|
||||||
canonical precedence. Path and children remain provider resolution context and
|
canonical precedence. Path and children remain provider resolution context and
|
||||||
are never projected by `WipObject`; host-private fields and backend handles are
|
are never projected by `WipObject`; host-private fields and backend handles are
|
||||||
|
|||||||
@@ -11,7 +11,25 @@ wip-client = "0.1.0"
|
|||||||
|
|
||||||
Applications provide the HTTP adapter and presentation layer, send each prepared request exactly as owned by the runtime, then return the corresponding response for completion.
|
Applications provide the HTTP adapter and presentation layer, send each prepared request exactly as owned by the runtime, then return the corresponding response for completion.
|
||||||
|
|
||||||
Interface acquisition and call APIs use `wip_protocol::InterfaceReference { scope, name }` directly. Interface caches and Object membership compare the complete structured value, so equal names in different scopes remain distinct. Interface observations retain the optional `scope_ref`, and calls copy it into `InterfaceTarget` unchanged. Scope-identity recovery is not implemented by this mechanical migration.
|
## Interface identity and scope lifetime
|
||||||
|
|
||||||
|
Interface acquisition and call APIs use `wip_protocol::InterfaceReference { scope, name }` directly. Within an endpoint/security-context session, the complete scoped pair is the cache key and the Object membership key: equal names in different scopes remain distinct. A scope is a canonical absolute Worldspace path and must be an ancestor of, or equal to, the call target path. Root scope (`/`) and self scope are valid; clients must not rewrite either into a different reference.
|
||||||
|
|
||||||
|
`InterfaceObservation::scope_ref` is optional opaque metadata identifying the scope Object, not a third reference component, a path, or an Interface Descriptor validator. Its absence does not prevent acquisition or calls. Calls copy the exact observed reference, optional `scope_ref`, and optional Interface Descriptor validator into `InterfaceTarget`; the target Object validator is copied separately. The `CallContext` fixes those observations, the descriptor, and the operation declaration before dispatch.
|
||||||
|
|
||||||
|
A descriptor without an Interface Descriptor validator is immutable only within the lifetime of its scope Object, not forever at that path. Descriptors with validators are also bound to that scope lifetime. When accepted observations establish that a known scope binding has changed or been lost, dependent cached interface state is invalidated and cannot authorize a new call until explicitly reacquired. A superseded interface response must not restore an observation from the old lifetime. A previously prepared call retains its original context rather than silently adopting a new scope identity or descriptor.
|
||||||
|
|
||||||
|
Scope binding evidence is shared across Interface names at the same scope path: a new binding learned by fetching one name retires older descriptors for the other names too. Separately observed Object refs also provide lifetime evidence even when a descriptor's `scope_ref` was omitted. This corroboration is private bounded cache state, not an invented identity, and never fills in missing call metadata. Same-lifetime descriptor validation still applies; a detected ref change or loss retires the old lifetime instead.
|
||||||
|
|
||||||
|
Object deletion, entry-edge removal, and local cache eviction are different events. A direct observe `NotFound` confirms unavailability at the requested scope path and removes cached interfaces scoped to that path; it does not guarantee identity-wide Object deletion or unavailability through every alias. Removing one entry edge likewise does not prove that the Object was deleted or invalidate every remaining alias. Cache eviction is local loss of retained knowledge, not evidence of Host deletion. Losing a known binding at an interface's scope path nevertheless ends safe reuse of descriptors dependent on that binding, even if the same Object remains reachable elsewhere. Optional `scope_ref` can provide identity evidence, but clients do not fabricate it when it is absent.
|
||||||
|
|
||||||
|
Without Object `ref` or `scope_ref` identity evidence, a replacement at the same scope path can be indistinguishable from the previous Object. The runtime cannot detect every ref-less replacement from path and descriptor contents alone; absence of detectable change is not proof that the scope lifetime continued.
|
||||||
|
|
||||||
|
## Explicit recovery and operation outcomes
|
||||||
|
|
||||||
|
`ensure_observed` and `ensure_interface` avoid duplicate fresh or loading work; they do not automatically refresh retained stale or failed observations. Use the explicit object/children refresh APIs and `prepare_interface` to recover the observations required for a new call. An operation `InterfaceMismatch` invalidates both sides of the call's assumptions: it marks the target Object observation stale and invalidates the selected cached interface observation, so recovery requires reobserving membership and reacquiring the descriptor. Reobserving an Object and reacquiring its interface are retrievals, not a retry of an operation.
|
||||||
|
|
||||||
|
The runtime never automatically retries an operation. Mark a call dispatched when handing it to the transport and report completion or transport failure with its original `RequestId`. A timeout, disconnect, or invalid response after dispatch can leave the outcome unknown: execution or commit cannot be ruled out. Refreshing stale state does not resolve that uncertainty and must not automatically resubmit the call; any new operation is an explicit application decision.
|
||||||
|
|
||||||
See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs.
|
See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs.
|
||||||
|
|
||||||
|
|||||||
+197
-11
@@ -223,14 +223,20 @@ pub struct TreeObservation {
|
|||||||
pub state: ObservationState,
|
pub state: ObservationState,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached interface descriptor observation.
|
/// Cached interface descriptor observation for one structured `(scope, name)` pair.
|
||||||
|
///
|
||||||
|
/// A known lost scope binding retires the descriptor rather than replacing only
|
||||||
|
/// its [`Self::scope_ref`]. A direct scope `observe` returning `NotFound` may remove
|
||||||
|
/// this cache entry; edge omission and local Object eviction do not prove deletion.
|
||||||
|
/// Prepared calls retain their own snapshot until result validation completes.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct InterfaceObservation {
|
pub struct InterfaceObservation {
|
||||||
/// Structured interface reference.
|
/// Structured interface reference.
|
||||||
pub reference: InterfaceReference,
|
pub reference: InterfaceReference,
|
||||||
/// Last accepted descriptor, if one has been received.
|
/// Last accepted descriptor, if one has been received.
|
||||||
pub descriptor: Option<InterfaceDescriptor>,
|
pub descriptor: Option<InterfaceDescriptor>,
|
||||||
/// Optional interface validator. Absence makes the reference immutable.
|
/// Optional interface validator. Absence makes the descriptor immutable only
|
||||||
|
/// while the scope binding remains valid.
|
||||||
pub validator: Option<Vec<u8>>,
|
pub validator: Option<Vec<u8>>,
|
||||||
/// Optional opaque identity of the interface scope Object.
|
/// Optional opaque identity of the interface scope Object.
|
||||||
pub scope_ref: Option<String>,
|
pub scope_ref: Option<String>,
|
||||||
@@ -245,6 +251,7 @@ pub struct CallContext {
|
|||||||
object_key: u64,
|
object_key: u64,
|
||||||
object: ObjectObservation,
|
object: ObjectObservation,
|
||||||
interface: InterfaceObservation,
|
interface: InterfaceObservation,
|
||||||
|
interface_revision: RequestId,
|
||||||
descriptor: InterfaceDescriptor,
|
descriptor: InterfaceDescriptor,
|
||||||
operation: OperationDeclaration,
|
operation: OperationDeclaration,
|
||||||
request: CallOperationRequest,
|
request: CallOperationRequest,
|
||||||
@@ -390,6 +397,9 @@ struct EntryRecord {
|
|||||||
object_key: Option<u64>,
|
object_key: Option<u64>,
|
||||||
revision: RequestId,
|
revision: RequestId,
|
||||||
state: ObservationState,
|
state: ObservationState,
|
||||||
|
// Retrieval state can change without losing the last accepted binding.
|
||||||
|
// Only confirmed unavailability ends that path's lifetime evidence.
|
||||||
|
confirmed_missing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -416,9 +426,28 @@ struct Session {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct InterfaceRecord {
|
struct InterfaceRecord {
|
||||||
observation: InterfaceObservation,
|
observation: InterfaceObservation,
|
||||||
|
// Ownership of this exact fetch/observation, not a Protocol identity.
|
||||||
|
revision: RequestId,
|
||||||
|
// Starting a refresh does not rebind its retained Descriptor. Scope evidence
|
||||||
|
// must still be able to retire that old lifetime while the refresh loads.
|
||||||
|
accepted_revision: RequestId,
|
||||||
|
// Scope evidence is shared across names and can also come from Observe.
|
||||||
|
// This private corroboration is never substituted for wire scope_ref and is
|
||||||
|
// retained only with the bounded Interface record, not as a new identity/key.
|
||||||
|
scope_binding: Option<String>,
|
||||||
|
binding_revision: RequestId,
|
||||||
touched: u64,
|
touched: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl InterfaceRecord {
|
||||||
|
fn retire(&mut self, revision: RequestId) {
|
||||||
|
self.revision = self.revision.max(revision);
|
||||||
|
self.observation.descriptor = None;
|
||||||
|
self.observation.validator = None;
|
||||||
|
self.observation.state = ObservationState::Stale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -596,7 +625,8 @@ impl Client {
|
|||||||
self.sessions.get(session)?.trees.get(path)
|
self.sessions.get(session)?.trees.get(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current interface observation.
|
/// Returns the current interface observation, isolated by session and the
|
||||||
|
/// complete structured reference (never by display text or serialized JSON).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn interface(
|
pub fn interface(
|
||||||
&self,
|
&self,
|
||||||
@@ -724,6 +754,7 @@ impl Client {
|
|||||||
object_key: None,
|
object_key: None,
|
||||||
revision: id,
|
revision: id,
|
||||||
state: ObservationState::Loading(id),
|
state: ObservationState::Loading(id),
|
||||||
|
confirmed_missing: false,
|
||||||
});
|
});
|
||||||
entry.revision = id;
|
entry.revision = id;
|
||||||
entry.state = ObservationState::Loading(id);
|
entry.state = ObservationState::Loading(id);
|
||||||
@@ -802,6 +833,8 @@ impl Client {
|
|||||||
let maximum = self.limits.interfaces_per_session;
|
let maximum = self.limits.interfaces_per_session;
|
||||||
let state = self.session_mut(session)?;
|
let state = self.session_mut(session)?;
|
||||||
ensure_interface_slot(state, &request.interface, maximum)?;
|
ensure_interface_slot(state, &request.interface, maximum)?;
|
||||||
|
let (scope_binding, binding_revision) =
|
||||||
|
latest_scope_binding(state, &request.interface.scope);
|
||||||
let touched = state.tick();
|
let touched = state.tick();
|
||||||
let record = state
|
let record = state
|
||||||
.interfaces
|
.interfaces
|
||||||
@@ -814,8 +847,13 @@ impl Client {
|
|||||||
validator: None,
|
validator: None,
|
||||||
state: ObservationState::Loading(id),
|
state: ObservationState::Loading(id),
|
||||||
},
|
},
|
||||||
|
revision: id,
|
||||||
|
accepted_revision: RequestId(0),
|
||||||
|
scope_binding,
|
||||||
|
binding_revision,
|
||||||
touched,
|
touched,
|
||||||
});
|
});
|
||||||
|
record.revision = id;
|
||||||
record.observation.state = ObservationState::Loading(id);
|
record.observation.state = ObservationState::Loading(id);
|
||||||
record.touched = touched;
|
record.touched = touched;
|
||||||
self.pending.insert(
|
self.pending.insert(
|
||||||
@@ -833,8 +871,11 @@ impl Client {
|
|||||||
|
|
||||||
/// Constructs an operation from fresh object and interface observations.
|
/// Constructs an operation from fresh object and interface observations.
|
||||||
///
|
///
|
||||||
/// Membership, both validators, the descriptor, and the operation declaration
|
/// Membership, scope binding, both validators, the descriptor, and the operation
|
||||||
/// are fixed in the returned request's immutable [`CallContext`].
|
/// declaration are fixed in the returned request's immutable [`CallContext`].
|
||||||
|
/// The descriptor's optional `scope_ref` is copied unchanged, never substituted
|
||||||
|
/// from a separate Object observation. No ancestor fetch is required. Without
|
||||||
|
/// a scope ref, same-path replacement cannot always be detected.
|
||||||
pub fn prepare_call(
|
pub fn prepare_call(
|
||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
@@ -885,6 +926,7 @@ impl Client {
|
|||||||
"interface observation is not fresh",
|
"interface observation is not fresh",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let interface_revision = state.interfaces[interface].revision;
|
||||||
let descriptor = interface_observation
|
let descriptor = interface_observation
|
||||||
.descriptor
|
.descriptor
|
||||||
.clone()
|
.clone()
|
||||||
@@ -921,6 +963,7 @@ impl Client {
|
|||||||
object_key,
|
object_key,
|
||||||
object: object_observation,
|
object: object_observation,
|
||||||
interface: interface_observation,
|
interface: interface_observation,
|
||||||
|
interface_revision,
|
||||||
descriptor,
|
descriptor,
|
||||||
operation: operation_declaration,
|
operation: operation_declaration,
|
||||||
request,
|
request,
|
||||||
@@ -1138,6 +1181,11 @@ impl Client {
|
|||||||
error.to_string(),
|
error.to_string(),
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
|
if error.code == ProtocolErrorCode::NotFound {
|
||||||
|
let state = self.session_mut(session)?;
|
||||||
|
retire_scope_subtree(state, &request.path, id);
|
||||||
|
mark_subtree_stale(state, &request.path, id);
|
||||||
|
}
|
||||||
set_observe_subject_state(
|
set_observe_subject_state(
|
||||||
self.session_mut(session)?,
|
self.session_mut(session)?,
|
||||||
&request,
|
&request,
|
||||||
@@ -1165,8 +1213,37 @@ impl Client {
|
|||||||
match decode_fetch_interface_response(&request, &response, self.wire_limits) {
|
match decode_fetch_interface_response(&request, &response, self.wire_limits) {
|
||||||
Ok(DecodedResponse::Success(value)) => {
|
Ok(DecodedResponse::Success(value)) => {
|
||||||
let state = self.session_mut(session)?;
|
let state = self.session_mut(session)?;
|
||||||
let existing = &state.interfaces[&request.interface].observation;
|
// Newer scope evidence (Observe or another Interface name) can
|
||||||
|
// supersede even the first fetch for this pair.
|
||||||
|
let (scope_binding, binding_revision) =
|
||||||
|
latest_scope_binding(state, &request.interface.scope);
|
||||||
|
if binding_revision > id
|
||||||
|
&& value.scope_ref.is_some()
|
||||||
|
&& value.scope_ref != scope_binding
|
||||||
|
{
|
||||||
|
state
|
||||||
|
.interfaces
|
||||||
|
.get_mut(&request.interface)
|
||||||
|
.expect("interface exists")
|
||||||
|
.retire(binding_revision);
|
||||||
|
return self.reject_stale(session, id);
|
||||||
|
}
|
||||||
|
let existing_record = &state.interfaces[&request.interface];
|
||||||
|
let existing = &existing_record.observation;
|
||||||
|
let lost_wire_binding = existing.scope_ref.is_some() && value.scope_ref.is_none();
|
||||||
|
// Omission stays omission in the public observation and call. A
|
||||||
|
// known Object/sibling binding can still supply private lifetime
|
||||||
|
// evidence unless this response explicitly lost its prior ref.
|
||||||
|
let current_binding = if lost_wire_binding || value.scope_ref.is_some() {
|
||||||
|
value.scope_ref.clone()
|
||||||
|
} else {
|
||||||
|
scope_binding
|
||||||
|
};
|
||||||
|
let same_lifetime = existing.scope_ref == value.scope_ref
|
||||||
|
|| (current_binding.is_some()
|
||||||
|
&& existing_record.scope_binding == current_binding);
|
||||||
if let Some(descriptor) = &existing.descriptor
|
if let Some(descriptor) = &existing.descriptor
|
||||||
|
&& same_lifetime
|
||||||
&& ((existing.validator.is_none()
|
&& ((existing.validator.is_none()
|
||||||
&& (value.validator.is_some() || descriptor != &value.descriptor))
|
&& (value.validator.is_some() || descriptor != &value.descriptor))
|
||||||
|| (existing.validator.is_some()
|
|| (existing.validator.is_some()
|
||||||
@@ -1186,6 +1263,14 @@ impl Client {
|
|||||||
self.push_failure_diagnostic(session, id, &error)?;
|
self.push_failure_diagnostic(session, id, &error)?;
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
|
update_scope_binding(
|
||||||
|
state,
|
||||||
|
&request.interface.scope,
|
||||||
|
current_binding.clone(),
|
||||||
|
id,
|
||||||
|
false,
|
||||||
|
Some(&request.interface),
|
||||||
|
);
|
||||||
let touched = state.tick();
|
let touched = state.tick();
|
||||||
let observation = InterfaceObservation {
|
let observation = InterfaceObservation {
|
||||||
reference: value.interface,
|
reference: value.interface,
|
||||||
@@ -1199,6 +1284,9 @@ impl Client {
|
|||||||
.get_mut(&request.interface)
|
.get_mut(&request.interface)
|
||||||
.expect("interface exists");
|
.expect("interface exists");
|
||||||
record.observation = observation.clone();
|
record.observation = observation.clone();
|
||||||
|
record.accepted_revision = id;
|
||||||
|
record.scope_binding = current_binding;
|
||||||
|
record.binding_revision = id;
|
||||||
record.touched = touched;
|
record.touched = touched;
|
||||||
Ok(Completion::Interface(observation))
|
Ok(Completion::Interface(observation))
|
||||||
}
|
}
|
||||||
@@ -1285,14 +1373,28 @@ impl Client {
|
|||||||
match error.code {
|
match error.code {
|
||||||
ProtocolErrorCode::ValidatorRequired
|
ProtocolErrorCode::ValidatorRequired
|
||||||
| ProtocolErrorCode::ValidatorMismatch
|
| ProtocolErrorCode::ValidatorMismatch
|
||||||
| ProtocolErrorCode::InterfaceMismatch => {
|
| ProtocolErrorCode::NotFound => {
|
||||||
state.mark_object_stale(context.object_key, id);
|
state.mark_object_stale(context.object_key, id);
|
||||||
}
|
}
|
||||||
|
ProtocolErrorCode::InterfaceMismatch => {
|
||||||
|
state.mark_object_stale(context.object_key, id);
|
||||||
|
if let Some(interface) =
|
||||||
|
state.interfaces.get_mut(&context.interface.reference)
|
||||||
|
&& interface.revision == context.interface_revision
|
||||||
|
{
|
||||||
|
// The call cannot reuse this observation, but mismatch
|
||||||
|
// alone does not prove the scope lifetime ended. Retain
|
||||||
|
// its Descriptor for same-binding consistency checks.
|
||||||
|
interface.revision = interface.revision.max(id);
|
||||||
|
interface.observation.state = ObservationState::Stale;
|
||||||
|
}
|
||||||
|
}
|
||||||
ProtocolErrorCode::InterfaceValidatorRequired
|
ProtocolErrorCode::InterfaceValidatorRequired
|
||||||
| ProtocolErrorCode::InterfaceValidatorMismatch => {
|
| ProtocolErrorCode::InterfaceValidatorMismatch => {
|
||||||
if let Some(interface) = state
|
if let Some(interface) = state
|
||||||
.interfaces
|
.interfaces
|
||||||
.get_mut(&context.request.interface.reference)
|
.get_mut(&context.request.interface.reference)
|
||||||
|
&& interface.revision == context.interface_revision
|
||||||
{
|
{
|
||||||
interface.observation.state = ObservationState::Stale;
|
interface.observation.state = ObservationState::Stale;
|
||||||
}
|
}
|
||||||
@@ -1762,6 +1864,12 @@ fn insert_object(
|
|||||||
let existing_path_key = existing_path_entry
|
let existing_path_key = existing_path_entry
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|entry| entry.object_key);
|
.and_then(|entry| entry.object_key);
|
||||||
|
let previous_scope_ref = existing_path_entry
|
||||||
|
.as_ref()
|
||||||
|
.filter(|entry| !entry.confirmed_missing)
|
||||||
|
.and_then(|entry| entry.object_key)
|
||||||
|
.and_then(|key| state.objects.get(&key))
|
||||||
|
.and_then(|record| record.object.r#ref.clone());
|
||||||
let replaceable_key = existing_path_key.filter(|key| {
|
let replaceable_key = existing_path_key.filter(|key| {
|
||||||
state
|
state
|
||||||
.objects
|
.objects
|
||||||
@@ -1839,6 +1947,7 @@ fn insert_object(
|
|||||||
object_key: Some(key),
|
object_key: Some(key),
|
||||||
revision: ownership_revision,
|
revision: ownership_revision,
|
||||||
state: ObservationState::Stale,
|
state: ObservationState::Stale,
|
||||||
|
confirmed_missing: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1846,9 +1955,15 @@ fn insert_object(
|
|||||||
object_key: Some(key),
|
object_key: Some(key),
|
||||||
revision,
|
revision,
|
||||||
state: ObservationState::Fresh,
|
state: ObservationState::Fresh,
|
||||||
|
confirmed_missing: false,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
state.entries.insert(path.to_owned(), entry);
|
state.entries.insert(path.to_owned(), entry);
|
||||||
|
if !suppressed {
|
||||||
|
let scope_ref = state.objects[&key].object.r#ref.clone();
|
||||||
|
let changed = previous_scope_ref.is_some() && previous_scope_ref != scope_ref;
|
||||||
|
update_scope_binding(state, path, scope_ref, revision, changed, None);
|
||||||
|
}
|
||||||
Ok(key)
|
Ok(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1997,15 +2112,86 @@ fn reserve_observation_metadata(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) {
|
// Only retained observations contribute scope evidence. No unbounded scope
|
||||||
let prefix = format!("{root}/");
|
// tombstones or synthetic identity are needed: evidence is kept with bounded
|
||||||
|
// Interface records, survives Object eviction, and disappears with cache cleanup.
|
||||||
|
fn latest_scope_binding(state: &Session, scope: &str) -> (Option<String>, RequestId) {
|
||||||
|
let object_binding = state
|
||||||
|
.entries
|
||||||
|
.get(scope)
|
||||||
|
.filter(|entry| !entry.confirmed_missing)
|
||||||
|
.and_then(|entry| entry.object_key)
|
||||||
|
.and_then(|key| state.objects.get(&key))
|
||||||
|
.map(|record| (record.object.r#ref.clone(), record.revision));
|
||||||
|
state
|
||||||
|
.interfaces
|
||||||
|
.iter()
|
||||||
|
.filter(|(reference, _)| reference.scope == scope)
|
||||||
|
.map(|(_, record)| (record.scope_binding.clone(), record.binding_revision))
|
||||||
|
.chain(object_binding)
|
||||||
|
.max_by_key(|(_, revision)| *revision)
|
||||||
|
.unwrap_or((None, RequestId(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_scope_binding(
|
||||||
|
state: &mut Session,
|
||||||
|
scope: &str,
|
||||||
|
scope_ref: Option<String>,
|
||||||
|
revision: RequestId,
|
||||||
|
confirmed_change: bool,
|
||||||
|
except: Option<&InterfaceReference>,
|
||||||
|
) {
|
||||||
|
for (reference, interface) in &mut state.interfaces {
|
||||||
|
if reference.scope != scope
|
||||||
|
|| except == Some(reference)
|
||||||
|
|| interface.accepted_revision > revision
|
||||||
|
|| interface.binding_revision > revision
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if confirmed_change
|
||||||
|
|| (interface.scope_binding.is_some() && interface.scope_binding != scope_ref)
|
||||||
|
{
|
||||||
|
interface.retire(revision);
|
||||||
|
}
|
||||||
|
// Corroboration is not wire metadata: never update observation.scope_ref
|
||||||
|
// on a retained Descriptor, even when the first known ref is learned.
|
||||||
|
interface.scope_binding = scope_ref.clone();
|
||||||
|
interface.binding_revision = revision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_subtree_path(root: &str, path: &str) -> bool {
|
||||||
|
root == "/"
|
||||||
|
|| path == root
|
||||||
|
|| path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct NotFound confirms that this scope path is no longer available. Edge
|
||||||
|
// omission and local eviction deliberately do not call this function.
|
||||||
|
fn retire_scope_subtree(state: &mut Session, root: &str, revision: RequestId) {
|
||||||
for (path, entry) in &mut state.entries {
|
for (path, entry) in &mut state.entries {
|
||||||
if (path == root || path.starts_with(&prefix)) && entry.revision <= request_id {
|
if is_subtree_path(root, path) && entry.revision <= revision {
|
||||||
|
entry.confirmed_missing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.interfaces.retain(|reference, record| {
|
||||||
|
!is_subtree_path(root, &reference.scope) || record.accepted_revision > revision
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) {
|
||||||
|
for (path, entry) in &mut state.entries {
|
||||||
|
if is_subtree_path(root, path) && entry.revision <= request_id {
|
||||||
|
entry.revision = request_id;
|
||||||
entry.state = ObservationState::Stale;
|
entry.state = ObservationState::Stale;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (path, tree) in &mut state.trees {
|
for (path, tree) in &mut state.trees {
|
||||||
if (path == root || path.starts_with(&prefix)) && tree.revision <= request_id {
|
if is_subtree_path(root, path) && tree.revision <= request_id {
|
||||||
|
tree.revision = request_id;
|
||||||
tree.state = ObservationState::Stale;
|
tree.state = ObservationState::Stale;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ use wip_client::{
|
|||||||
};
|
};
|
||||||
use wip_http::http::{Response, StatusCode};
|
use wip_http::http::{Response, StatusCode};
|
||||||
use wip_http::{
|
use wip_http::{
|
||||||
Limits, encode_fetch_interface_response, encode_observe_response,
|
Limits, decode_call_operation_request, decode_fetch_interface_request,
|
||||||
encode_protocol_error_response,
|
encode_fetch_interface_response, encode_observe_response, encode_protocol_error_response,
|
||||||
};
|
};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
|
||||||
Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration,
|
InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object, ObjectObservation,
|
||||||
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
|
ObserveRequest, OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||||
|
ProtocolInteraction, ReturnDeclaration, Target, TypeExpr, Value,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn limits() -> Limits {
|
fn limits() -> Limits {
|
||||||
@@ -74,6 +75,352 @@ fn descriptor(format: &str) -> InterfaceDescriptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe(client: &mut Client, session: &wip_client::SessionId, path: &str, value: Object) {
|
||||||
|
let request = client.refresh_object(session, path).unwrap();
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
encode_observe_response(
|
||||||
|
&ObserveRequest {
|
||||||
|
path: path.into(),
|
||||||
|
depth: 0,
|
||||||
|
},
|
||||||
|
&ObjectObservation {
|
||||||
|
object: value,
|
||||||
|
children: None,
|
||||||
|
},
|
||||||
|
limits(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch(
|
||||||
|
client: &mut Client,
|
||||||
|
session: &wip_client::SessionId,
|
||||||
|
value: FetchInterfaceResponse,
|
||||||
|
) -> Completion {
|
||||||
|
let request = client
|
||||||
|
.prepare_interface(session, value.interface.clone())
|
||||||
|
.unwrap();
|
||||||
|
let logical = FetchInterfaceRequest {
|
||||||
|
interface: value.interface.clone(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_request(request.request.body(), limits()).unwrap(),
|
||||||
|
logical
|
||||||
|
);
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
encode_fetch_interface_response(&logical, &value, limits()).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observe_accepts_same_name_interfaces_in_different_scopes() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let request = client.refresh_object(&session, "/item").unwrap();
|
||||||
|
let completion = client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
response(
|
||||||
|
StatusCode::OK,
|
||||||
|
br#"{"object":{"name":"item","interfaces":[{"scope":"/","name":"math"},{"scope":"/item","name":"math"}]}}"#,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let expected = vec![root_reference("math"), reference("/item", "math")];
|
||||||
|
assert!(matches!(
|
||||||
|
completion,
|
||||||
|
Completion::Object(observation)
|
||||||
|
if observation.state == ObservationState::Fresh
|
||||||
|
&& observation.object.as_ref().unwrap().interfaces == expected
|
||||||
|
));
|
||||||
|
let cached = client.object(&session, "/item").unwrap();
|
||||||
|
assert_eq!(cached.state, ObservationState::Fresh);
|
||||||
|
assert_eq!(cached.object.unwrap().interfaces, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_name_different_scopes_have_independent_requests_and_cached_descriptors() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let root = root_reference("math");
|
||||||
|
let scoped = reference("/area", "math");
|
||||||
|
let root_request = client
|
||||||
|
.ensure_interface(&session, root.clone())
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let scoped_request = client
|
||||||
|
.ensure_interface(&session, scoped.clone())
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(root_request.id, scoped_request.id);
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_request(root_request.request.body(), limits()).unwrap(),
|
||||||
|
FetchInterfaceRequest {
|
||||||
|
interface: root.clone(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_request(scoped_request.request.body(), limits()).unwrap(),
|
||||||
|
FetchInterfaceRequest {
|
||||||
|
interface: scoped.clone(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
for selected in [&root, &scoped] {
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_interface(&session, selected.clone())
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(client.in_flight_len(), 2);
|
||||||
|
|
||||||
|
// Reverse completion order must not supersede the other scope's request.
|
||||||
|
for (request, selected, documentation, scope_ref, validator) in [
|
||||||
|
(
|
||||||
|
scoped_request,
|
||||||
|
&scoped,
|
||||||
|
"area math",
|
||||||
|
Some("area-object"),
|
||||||
|
Some(b"area-v1".to_vec()),
|
||||||
|
),
|
||||||
|
(root_request, &root, "root math", None, None),
|
||||||
|
] {
|
||||||
|
let mut expected_descriptor = descriptor(INTERFACE_FORMAT_V1);
|
||||||
|
expected_descriptor.documentation = Some(wip_protocol::Documentation {
|
||||||
|
summary: documentation.into(),
|
||||||
|
details: None,
|
||||||
|
});
|
||||||
|
let value = FetchInterfaceResponse {
|
||||||
|
interface: selected.clone(),
|
||||||
|
scope_ref: scope_ref.map(str::to_owned),
|
||||||
|
descriptor: expected_descriptor.clone(),
|
||||||
|
validator: validator.clone(),
|
||||||
|
};
|
||||||
|
let completion = client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
encode_fetch_interface_response(
|
||||||
|
&FetchInterfaceRequest {
|
||||||
|
interface: selected.clone(),
|
||||||
|
},
|
||||||
|
&value,
|
||||||
|
limits(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(completion, Completion::Interface(_)));
|
||||||
|
let cached = client.interface(&session, selected).unwrap();
|
||||||
|
assert_eq!(cached.reference, *selected);
|
||||||
|
assert_eq!(cached.descriptor, Some(expected_descriptor));
|
||||||
|
assert_eq!(cached.scope_ref, value.scope_ref);
|
||||||
|
assert_eq!(cached.validator, validator);
|
||||||
|
assert_eq!(cached.state, ObservationState::Fresh);
|
||||||
|
}
|
||||||
|
for selected in [&root, &scoped] {
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_interface(&session, selected.clone())
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.interface(&session, &root)
|
||||||
|
.unwrap()
|
||||||
|
.descriptor
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.documentation
|
||||||
|
.as_ref()
|
||||||
|
.map(|documentation| documentation.summary.as_str()),
|
||||||
|
Some("root math")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.interface(&session, &scoped)
|
||||||
|
.unwrap()
|
||||||
|
.descriptor
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.documentation
|
||||||
|
.as_ref()
|
||||||
|
.map(|documentation| documentation.summary.as_str()),
|
||||||
|
Some("area math")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interface_completion_retains_optional_scope_ref_independently_of_validator() {
|
||||||
|
for scope_ref in [None, Some("opaque scope identity / not a path")] {
|
||||||
|
for validator in [None, Some(b"interface-v1".to_vec())] {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let selected = reference("/area", "math");
|
||||||
|
let expected = wip_client::InterfaceObservation {
|
||||||
|
reference: selected.clone(),
|
||||||
|
descriptor: Some(descriptor(INTERFACE_FORMAT_V1)),
|
||||||
|
scope_ref: scope_ref.map(str::to_owned),
|
||||||
|
validator: validator.clone(),
|
||||||
|
state: ObservationState::Fresh,
|
||||||
|
};
|
||||||
|
let completion = fetch(
|
||||||
|
&mut client,
|
||||||
|
&session,
|
||||||
|
FetchInterfaceResponse {
|
||||||
|
interface: selected.clone(),
|
||||||
|
descriptor: expected.descriptor.clone().unwrap(),
|
||||||
|
scope_ref: expected.scope_ref.clone(),
|
||||||
|
validator,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(completion, Completion::Interface(expected.clone()));
|
||||||
|
assert_eq!(client.interface(&session, &selected), Some(&expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_membership_requires_the_complete_scope_and_name_pair() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let member = reference("/item", "math");
|
||||||
|
let same_name = root_reference("math");
|
||||||
|
let mut value = object();
|
||||||
|
value.interfaces = vec![member.clone()];
|
||||||
|
observe(&mut client, &session, "/item", value);
|
||||||
|
for selected in [&member, &same_name] {
|
||||||
|
fetch(
|
||||||
|
&mut client,
|
||||||
|
&session,
|
||||||
|
FetchInterfaceResponse {
|
||||||
|
interface: selected.clone(),
|
||||||
|
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||||
|
scope_ref: None,
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let arguments = BTreeMap::from([("value".into(), Value::Integer(2))]);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&same_name,
|
||||||
|
"calculate",
|
||||||
|
arguments.clone(),
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
|
let call = client
|
||||||
|
.prepare_call(&session, "/item", &member, "calculate", arguments)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_request(
|
||||||
|
call.request.body(),
|
||||||
|
&descriptor(INTERFACE_FORMAT_V1),
|
||||||
|
limits(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.interface
|
||||||
|
.reference,
|
||||||
|
member
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calls_copy_exact_observed_references_metadata_and_validators_for_root_ancestor_and_self_scopes()
|
||||||
|
{
|
||||||
|
for (path, scope) in [
|
||||||
|
("/", "/"),
|
||||||
|
("/item", "/"),
|
||||||
|
("/item", "/item"),
|
||||||
|
("/area/item", "/area"),
|
||||||
|
("/area/item", "/area/item"),
|
||||||
|
] {
|
||||||
|
for scope_ref in [None, Some("opaque scope identity / not a path")] {
|
||||||
|
for interface_validator in [None, Some(b"interface-v1".to_vec())] {
|
||||||
|
for object_validator in [None, Some(b"object-v1".to_vec())] {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let selected = reference(scope, "math");
|
||||||
|
let expected_descriptor = descriptor(INTERFACE_FORMAT_V1);
|
||||||
|
let mut value = object();
|
||||||
|
value.name = path.rsplit('/').next().unwrap().into();
|
||||||
|
value.interfaces = vec![selected.clone()];
|
||||||
|
value.validator = object_validator.clone();
|
||||||
|
if path == scope {
|
||||||
|
value.r#ref = scope_ref.map(str::to_owned);
|
||||||
|
}
|
||||||
|
observe(&mut client, &session, path, value.clone());
|
||||||
|
fetch(
|
||||||
|
&mut client,
|
||||||
|
&session,
|
||||||
|
FetchInterfaceResponse {
|
||||||
|
interface: selected.clone(),
|
||||||
|
descriptor: expected_descriptor.clone(),
|
||||||
|
scope_ref: scope_ref.map(str::to_owned),
|
||||||
|
validator: interface_validator.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let expected_interface = client.interface(&session, &selected).unwrap().clone();
|
||||||
|
let expected_object = client.object(&session, path).unwrap();
|
||||||
|
let arguments = BTreeMap::from([("value".into(), Value::Integer(2))]);
|
||||||
|
let expected = CallOperationRequest {
|
||||||
|
target: Target {
|
||||||
|
path: path.into(),
|
||||||
|
validator: object_validator,
|
||||||
|
},
|
||||||
|
interface: InterfaceTarget {
|
||||||
|
reference: selected.clone(),
|
||||||
|
scope_ref: scope_ref.map(str::to_owned),
|
||||||
|
validator: interface_validator.clone(),
|
||||||
|
},
|
||||||
|
operation: "calculate".into(),
|
||||||
|
arguments: arguments.clone(),
|
||||||
|
};
|
||||||
|
let call = client
|
||||||
|
.prepare_call(&session, path, &selected, "calculate", arguments)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_request(
|
||||||
|
call.request.body(),
|
||||||
|
&expected_descriptor,
|
||||||
|
limits(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
let context = client.pending_call_context(call.id).unwrap();
|
||||||
|
assert_eq!(context.request(), &expected);
|
||||||
|
assert_eq!(context.interface(), &expected_interface);
|
||||||
|
assert_eq!(context.object(), &expected_object);
|
||||||
|
assert_eq!(context.object().object.as_ref(), Some(&value));
|
||||||
|
assert_eq!(context.descriptor(), &expected_descriptor);
|
||||||
|
assert_eq!(context.operation(), &expected_descriptor.operations[0]);
|
||||||
|
assert_eq!(context.session(), &session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() {
|
fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() {
|
||||||
let cases: &[&[u8]] = &[
|
let cases: &[&[u8]] = &[
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,9 +26,13 @@ For an external backend/composition:
|
|||||||
protected state. Implement `FilesystemBackend` to resolve one connected
|
protected state. Implement `FilesystemBackend` to resolve one connected
|
||||||
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
|
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
|
||||||
bounded children, and execute against that already-resolved lineage.
|
bounded children, and execute against that already-resolved lineage.
|
||||||
2. Register generated `#[wip_interface::interface]` definitions using the existing
|
2. Create `wip_host::Scope::new("/")` for the deployment-pinned root, then
|
||||||
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies
|
register generated definitions with `registry.register(&scope, name,
|
||||||
reusable built-in tokens; using those tokens does not force their publication.
|
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
|
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
|
and implements both `WipObject` and the chosen generated traits, avoiding orphan
|
||||||
rules. `PublishedObject::expose` proves each implementation at compile time.
|
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,
|
resolutions. A root target has no ancestors; other targets retain the root first,
|
||||||
then intermediate parent containers. This is backend-private context, never wire
|
then intermediate parent containers. This is backend-private context, never wire
|
||||||
metadata. Backend `resolve` and `execute` signatures both carry this type;
|
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
|
`FilesystemBackend::execute` must validate ancestor membership/identity and
|
||||||
refresh **all** lineage state/validators inside its transaction or lock before
|
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
|
A regular-file observation returns a structured read interface reference and an
|
||||||
object validator. Built-in legacy registrations use scope `/` and copy the opaque
|
object validator. Built-ins explicitly use scope `/` and the local names
|
||||||
registration string verbatim into `name`; they do not emit `scope_ref`. Copy the
|
`file.read`, `symlink.inspect`, and `file.write`. The Linux root does not issue an
|
||||||
complete reference from the observation to retrieve that descriptor:
|
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
|
```console
|
||||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
||||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
-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:
|
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' \
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
--data '{
|
--data '{
|
||||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||||
"interface":{"reference":{"scope":"/","name":"<read-interface-name>"}},
|
"interface":{"reference":{"scope":"/","name":"file.read"}},
|
||||||
"operation":"read",
|
"operation":"read",
|
||||||
"arguments":{"offset":0,"length":4096}
|
"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' \
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
--data '{
|
--data '{
|
||||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||||
"interface":{"reference":{"scope":"/","name":"<write-interface-name>"}},
|
"interface":{"reference":{"scope":"/","name":"file.write"}},
|
||||||
"operation":"replace",
|
"operation":"replace",
|
||||||
"arguments":{"contents":"dXBkYXRlZA=="}
|
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||||
}'
|
}'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::projection::{LinuxBackend, LinuxEntry};
|
|||||||
use crate::{AccessMode, ResourceLimits};
|
use crate::{AccessMode, ResourceLimits};
|
||||||
use wip_host::{
|
use wip_host::{
|
||||||
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
|
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
|
||||||
RegisteredInterface, RegistrationError, Validator, WipObject,
|
RegisteredInterface, RegistrationError, Scope, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_interface as wip;
|
use wip_interface as wip;
|
||||||
|
|
||||||
@@ -22,12 +22,17 @@ pub struct DefaultInterfaces {
|
|||||||
pub write: RegisteredInterface<FileWriteDefinition>,
|
pub write: RegisteredInterface<FileWriteDefinition>,
|
||||||
}
|
}
|
||||||
impl DefaultInterfaces {
|
impl DefaultInterfaces {
|
||||||
/// Register reusable default descriptors without publishing any objects.
|
/// Register reusable descriptors at an explicit publication scope.
|
||||||
pub fn register(registry: &mut InterfaceRegistry) -> Result<Self, RegistrationError> {
|
///
|
||||||
|
/// The current FS engine binds only its deployment root scope (`/`).
|
||||||
|
pub fn register(
|
||||||
|
registry: &mut InterfaceRegistry,
|
||||||
|
scope: &Scope,
|
||||||
|
) -> Result<Self, RegistrationError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
read: registry.register(FileReadDefinition, None)?,
|
read: registry.register(scope, "file.read", FileReadDefinition, None)?,
|
||||||
symlink: registry.register(SymlinkInspectDefinition, None)?,
|
symlink: registry.register(scope, "symlink.inspect", SymlinkInspectDefinition, None)?,
|
||||||
write: registry.register(FileWriteDefinition, 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> {
|
) -> Result<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>, std::io::Error> {
|
||||||
let backend = LinuxBackend::open(root, access, limits)?;
|
let backend = LinuxBackend::open(root, access, limits)?;
|
||||||
let mut registry = InterfaceRegistry::new();
|
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(
|
Ok(Projection::new(
|
||||||
backend,
|
backend,
|
||||||
registry,
|
registry,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use crate::ResourceLimits;
|
use crate::ResourceLimits;
|
||||||
use wip_host::{
|
use wip_host::{
|
||||||
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
|
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
|
||||||
OperationAttempt, ProviderResult, PublishedObject, WipObject,
|
OperationAttempt, ProviderResult, PublishedObject, ScopeObservation, WipObject,
|
||||||
};
|
};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
|
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
|
||||||
@@ -72,6 +72,12 @@ impl<E> ResolvedEntry<E> {
|
|||||||
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
||||||
/// backend paths, credentials or hidden metadata.
|
/// 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
|
/// `execute` must retain the same target and complete lineage and serialize
|
||||||
/// ancestor identity/membership checks, policy metadata, validator observation,
|
/// ancestor identity/membership checks, policy metadata, validator observation,
|
||||||
/// publication, validation, typed dispatch and commit/rollback. Changed/detached
|
/// 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.
|
/// Build a request-scoped publication using this exact capability/context.
|
||||||
///
|
///
|
||||||
/// Publication and policy hooks must be side-effect-free with respect to the
|
/// 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.
|
/// started Operation. Delegate metadata/validators to the boundary snapshot.
|
||||||
fn publish(
|
fn publish(
|
||||||
&self,
|
&self,
|
||||||
@@ -286,12 +295,21 @@ where
|
|||||||
fn publication(
|
fn publication(
|
||||||
&self,
|
&self,
|
||||||
entry: B::Entry,
|
entry: B::Entry,
|
||||||
|
root: &B::Entry,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
) -> Result<PublishedObject<C::Object>, HostError> {
|
) -> Result<PublishedObject<C::Object>, HostError> {
|
||||||
|
let is_root = entry.name().is_empty();
|
||||||
let publication = self
|
let publication = self
|
||||||
.projection
|
.projection
|
||||||
.composition
|
.composition
|
||||||
.publish(entry.clone(), context)?;
|
.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
|
let available = publication
|
||||||
.protocol_object()
|
.protocol_object()
|
||||||
.interfaces
|
.interfaces
|
||||||
@@ -308,17 +326,81 @@ where
|
|||||||
.policy
|
.policy
|
||||||
.interfaces(self.principal, &entry, &available);
|
.interfaces(self.principal, &entry, &available);
|
||||||
selected.retain(|reference| {
|
selected.retain(|reference| {
|
||||||
self.projection
|
// Non-root lifetimes are not attested by this engine yet. Never
|
||||||
.policy
|
// publish an artifact merely because the registry retains it.
|
||||||
.interface_visible(self.principal, reference)
|
reference.protocol_reference().scope == "/"
|
||||||
|
&& self
|
||||||
|
.projection
|
||||||
|
.policy
|
||||||
|
.interface_visible(self.principal, reference)
|
||||||
});
|
});
|
||||||
publication
|
let mut publication = publication
|
||||||
.project(&selected, |reference, operation| {
|
.project(&selected, |reference, operation| {
|
||||||
self.projection
|
self.projection
|
||||||
.policy
|
.policy
|
||||||
.operation_visible(self.principal, reference, operation)
|
.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.
|
/// Retrieve the descriptor with precisely this principal's visible operations.
|
||||||
@@ -333,10 +415,8 @@ where
|
|||||||
&self,
|
&self,
|
||||||
request: &FetchInterfaceRequest,
|
request: &FetchInterfaceRequest,
|
||||||
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||||
let observation = self
|
let observation =
|
||||||
.projection
|
ObjectProvider::fetch_interface(self, &self.projection.registry, &request.interface)?;
|
||||||
.registry
|
|
||||||
.fetch_interface(&request.interface)?;
|
|
||||||
if !self
|
if !self
|
||||||
.projection
|
.projection
|
||||||
.policy
|
.policy
|
||||||
@@ -387,10 +467,10 @@ where
|
|||||||
}
|
}
|
||||||
ProviderResult::Failure(error) => return Err(read_error(error)),
|
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)
|
let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
|
||||||
.min(context.resource_budget().unwrap_or(u64::MAX));
|
.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(|_| {
|
request.validate_response(&response).map_err(|_| {
|
||||||
protocol(
|
protocol(
|
||||||
ProtocolErrorCode::Internal,
|
ProtocolErrorCode::Internal,
|
||||||
@@ -403,7 +483,8 @@ where
|
|||||||
fn observe_entry(
|
fn observe_entry(
|
||||||
&self,
|
&self,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
entry: B::Entry,
|
path: &str,
|
||||||
|
resolved: ResolvedEntry<B::Entry>,
|
||||||
depth: u32,
|
depth: u32,
|
||||||
remaining: &mut u64,
|
remaining: &mut u64,
|
||||||
) -> Result<ObjectObservation, ProtocolError> {
|
) -> Result<ObjectObservation, ProtocolError> {
|
||||||
@@ -412,10 +493,19 @@ where
|
|||||||
return Err(resource_protocol());
|
return Err(resource_protocol());
|
||||||
}
|
}
|
||||||
*remaining -= 1;
|
*remaining -= 1;
|
||||||
let object = self
|
let object = match self.with_publication(context, path, resolved.clone(), |publication| {
|
||||||
.publication(entry.clone(), context)
|
publication.protocol_object_at(path)
|
||||||
.map_err(read_error)?
|
}) {
|
||||||
.protocol_object();
|
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 {
|
if depth == 0 {
|
||||||
return Ok(ObjectObservation {
|
return Ok(ObjectObservation {
|
||||||
object,
|
object,
|
||||||
@@ -436,13 +526,25 @@ where
|
|||||||
if entries.len() as u64 > *remaining {
|
if entries.len() as u64 > *remaining {
|
||||||
return Err(resource_protocol());
|
return Err(resource_protocol());
|
||||||
}
|
}
|
||||||
|
ancestors.push(entry.clone());
|
||||||
for child in entries {
|
for child in entries {
|
||||||
if self
|
if self
|
||||||
.projection
|
.projection
|
||||||
.policy
|
.policy
|
||||||
.object_visible(self.principal, &child)
|
.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 {
|
} else {
|
||||||
if *remaining == 0 {
|
if *remaining == 0 {
|
||||||
return Err(resource_protocol());
|
return Err(resource_protocol());
|
||||||
@@ -473,11 +575,7 @@ where
|
|||||||
) -> ProviderResult<R> {
|
) -> ProviderResult<R> {
|
||||||
match self.resolve(context, path) {
|
match self.resolve(context, path) {
|
||||||
ProviderResult::Found(resolved) => {
|
ProviderResult::Found(resolved) => {
|
||||||
let (_ancestors, entry) = resolved.into_parts();
|
self.with_publication(context, path, resolved, operation)
|
||||||
match self.publication(entry, context) {
|
|
||||||
Ok(publication) => ProviderResult::Found(operation(&publication)),
|
|
||||||
Err(error) => ProviderResult::Failure(error),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||||
@@ -503,7 +601,8 @@ where
|
|||||||
if !self.lineage_visible(&resolved) {
|
if !self.lineage_visible(&resolved) {
|
||||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
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) {
|
if let Err(error) = preflight(context) {
|
||||||
return OperationAttempt::reject(match error.kind() {
|
return OperationAttempt::reject(match error.kind() {
|
||||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
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),
|
Ok(publication) => operation(&publication),
|
||||||
Err(error) => OperationAttempt::reject(match error.kind() {
|
Err(error) => OperationAttempt::reject(match error.kind() {
|
||||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||||
|
|||||||
@@ -17,6 +17,18 @@
|
|||||||
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
|
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
|
||||||
//! existing standard HTTP binding to a deployment-established principal view.
|
//! 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,
|
//! Backends and Operations are trusted code, responsible for truthful effects,
|
||||||
//! public-only metadata and ongoing execution-context bounds. Policies must be
|
//! public-only metadata and ongoing execution-context bounds. Policies must be
|
||||||
//! stable within a view; changing descriptor visibility requires new reference
|
//! 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]
|
#[test]
|
||||||
fn file_lock_wait_observes_the_operation_deadline() {
|
fn file_lock_wait_observes_the_operation_deadline() {
|
||||||
let temporary = tempdir().expect("temporary root");
|
let temporary = tempdir().expect("temporary root");
|
||||||
@@ -1622,8 +1671,6 @@ mod tests {
|
|||||||
.write(true)
|
.write(true)
|
||||||
.open(&path)
|
.open(&path)
|
||||||
.expect("open lock owner");
|
.expect("open lock owner");
|
||||||
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
|
||||||
|
|
||||||
let limits = ResourceLimits {
|
let limits = ResourceLimits {
|
||||||
request_timeout: std::time::Duration::from_millis(20),
|
request_timeout: std::time::Duration::from_millis(20),
|
||||||
..ResourceLimits::default()
|
..ResourceLimits::default()
|
||||||
@@ -1631,6 +1678,9 @@ mod tests {
|
|||||||
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
|
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
|
||||||
.expect("open projection");
|
.expect("open projection");
|
||||||
let observed = projection.observe_object("/file").expect("observe file");
|
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 started = std::time::Instant::now();
|
||||||
let error = call(
|
let error = call(
|
||||||
&projection,
|
&projection,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use wip_fs_server::{
|
|||||||
use wip_host::{
|
use wip_host::{
|
||||||
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
||||||
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
|
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
|
||||||
RegisteredInterface, Validator, WipObject,
|
RegisteredInterface, Scope, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_http::{
|
use wip_http::{
|
||||||
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
|
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
|
||||||
@@ -101,6 +101,9 @@ impl WipObject for Entry {
|
|||||||
fn description(&self) -> Option<&str> {
|
fn description(&self) -> Option<&str> {
|
||||||
Some("public memory entry")
|
Some("public memory entry")
|
||||||
}
|
}
|
||||||
|
fn reference(&self) -> Option<&str> {
|
||||||
|
(self.path == "/").then_some("entry-root")
|
||||||
|
}
|
||||||
fn validator(&self) -> Option<&Validator> {
|
fn validator(&self) -> Option<&Validator> {
|
||||||
Some(&self.validator)
|
Some(&self.validator)
|
||||||
}
|
}
|
||||||
@@ -157,7 +160,13 @@ impl Shared {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[derive(Clone)]
|
#[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> {
|
fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
||||||
if context.cancellation().is_cancelled()
|
if context.cancellation().is_cancelled()
|
||||||
|| context
|
|| context
|
||||||
@@ -193,6 +202,12 @@ impl FilesystemBackend for MemoryBackend {
|
|||||||
// One connected map snapshot, serialized with participating mutations.
|
// One connected map snapshot, serialized with participating mutations.
|
||||||
let _transaction = self.0.transaction.lock().unwrap();
|
let _transaction = self.0.transaction.lock().unwrap();
|
||||||
let nodes = self.0.nodes.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 {
|
let Some(node) = nodes.get(path) else {
|
||||||
return ProviderResult::NotFound;
|
return ProviderResult::NotFound;
|
||||||
};
|
};
|
||||||
@@ -320,6 +335,12 @@ impl FilesystemBackend for MemoryBackend {
|
|||||||
// Verify lineage identity/membership within the same transaction. The
|
// Verify lineage identity/membership within the same transaction. The
|
||||||
// callback cannot see a substituted ancestor, nor a redirected target.
|
// callback cannot see a substituted ancestor, nor a redirected target.
|
||||||
let nodes = self.0.nodes.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;
|
||||||
|
}
|
||||||
for ancestor in &ancestors {
|
for ancestor in &ancestors {
|
||||||
if !nodes
|
if !nodes
|
||||||
.get(&ancestor.path)
|
.get(&ancestor.path)
|
||||||
@@ -371,6 +392,11 @@ impl WipObject for Object {
|
|||||||
fn description(&self) -> Option<&str> {
|
fn description(&self) -> Option<&str> {
|
||||||
self.entry.description()
|
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> {
|
fn validator(&self) -> Option<&Validator> {
|
||||||
self.entry.validator()
|
self.entry.validator()
|
||||||
}
|
}
|
||||||
@@ -574,11 +600,19 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
|
|||||||
for panic_object in [true, false] {
|
for panic_object in [true, false] {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
let scope = Scope::new("/").unwrap();
|
||||||
let custom = registry.register(MemoryOperationsDefinition, None).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 reference = custom.reference().protocol_reference();
|
||||||
let engine = Projection::new(
|
let engine = Projection::new(
|
||||||
MemoryBackend(fixture.shared.clone()),
|
MemoryBackend::new(fixture.shared.clone()),
|
||||||
registry,
|
registry,
|
||||||
MemoryComposition {
|
MemoryComposition {
|
||||||
defaults,
|
defaults,
|
||||||
@@ -619,6 +653,8 @@ struct Fixture {
|
|||||||
custom: wip_protocol::InterfaceReference,
|
custom: wip_protocol::InterfaceReference,
|
||||||
read: wip_protocol::InterfaceReference,
|
read: wip_protocol::InterfaceReference,
|
||||||
limits: ResourceLimits,
|
limits: ResourceLimits,
|
||||||
|
// Client-known descriptors acquired coherently before execution fault injection.
|
||||||
|
descriptors: BTreeMap<(bool, wip_protocol::InterfaceReference), InterfaceDescriptor>,
|
||||||
}
|
}
|
||||||
impl Fixture {
|
impl Fixture {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
@@ -669,9 +705,15 @@ impl Fixture {
|
|||||||
seen: Mutex::new(vec![]),
|
seen: Mutex::new(vec![]),
|
||||||
});
|
});
|
||||||
let mut registry = InterfaceRegistry::new();
|
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
|
let custom = registry
|
||||||
.register(MemoryOperationsDefinition, interface_validator)
|
.register(
|
||||||
|
&scope,
|
||||||
|
"memory.operations",
|
||||||
|
MemoryOperationsDefinition,
|
||||||
|
interface_validator,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let read_ref = defaults.read.reference().clone();
|
let read_ref = defaults.read.reference().clone();
|
||||||
let custom_ref = custom.reference().clone();
|
let custom_ref = custom.reference().clone();
|
||||||
@@ -687,18 +729,38 @@ impl Fixture {
|
|||||||
custom,
|
custom,
|
||||||
shared: shared.clone(),
|
shared: 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 {
|
Self {
|
||||||
engine: Projection::new(
|
engine,
|
||||||
MemoryBackend(shared.clone()),
|
|
||||||
registry,
|
|
||||||
composition,
|
|
||||||
policy,
|
|
||||||
limits,
|
|
||||||
),
|
|
||||||
shared,
|
shared,
|
||||||
custom: custom_ref.protocol_reference(),
|
custom: custom_ref.protocol_reference(),
|
||||||
read: read_ref.protocol_reference(),
|
read: read_ref.protocol_reference(),
|
||||||
limits,
|
limits,
|
||||||
|
descriptors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
|
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
|
||||||
@@ -712,11 +774,7 @@ impl Fixture {
|
|||||||
principal: &Principal,
|
principal: &Principal,
|
||||||
reference: &wip_protocol::InterfaceReference,
|
reference: &wip_protocol::InterfaceReference,
|
||||||
) -> InterfaceDescriptor {
|
) -> InterfaceDescriptor {
|
||||||
self.engine
|
self.descriptors[&(matches!(principal, Principal::Admin), reference.clone())].clone()
|
||||||
.view(principal)
|
|
||||||
.fetch_interface(&fetch_request(reference))
|
|
||||||
.unwrap()
|
|
||||||
.descriptor
|
|
||||||
}
|
}
|
||||||
fn request(
|
fn request(
|
||||||
&self,
|
&self,
|
||||||
@@ -930,7 +988,10 @@ fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_cond
|
|||||||
Some("public memory entry")
|
Some("public memory entry")
|
||||||
);
|
);
|
||||||
assert!(observation.children.is_none());
|
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 {
|
for reference in &observation.object.interfaces {
|
||||||
let fetched = fixture
|
let fetched = fixture
|
||||||
.engine
|
.engine
|
||||||
@@ -1383,10 +1444,12 @@ fn policy_cannot_add_an_unimplemented_interface() {
|
|||||||
ProtocolErrorCode::Internal
|
ProtocolErrorCode::Internal
|
||||||
);
|
);
|
||||||
let request = fixture.request("/plain", &fixture.custom, "denied", None);
|
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(
|
let (response, decoded) = fixture.http_call(
|
||||||
&Principal::Admin,
|
&Principal::Admin,
|
||||||
&request,
|
&request,
|
||||||
&fixture.descriptor(&Principal::Admin, &fixture.custom),
|
&MemoryOperationsDefinition::descriptor(),
|
||||||
&ExecutionContext::new(),
|
&ExecutionContext::new(),
|
||||||
);
|
);
|
||||||
wire_error(decoded, ProtocolErrorCode::Internal);
|
wire_error(decoded, ProtocolErrorCode::Internal);
|
||||||
@@ -2036,3 +2099,150 @@ fn observation_depth_node_and_context_budgets_fail_without_truncating_the_tree()
|
|||||||
);
|
);
|
||||||
no_secrets(&response);
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+312
-115
@@ -3,7 +3,7 @@
|
|||||||
//! # Responsibility boundaries
|
//! # Responsibility boundaries
|
||||||
//!
|
//!
|
||||||
//! [`InterfaceRegistry::register`] validates and freezes a generated interface
|
//! [`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
|
//! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
|
||||||
//! request-scoped Rust object with a registered interface; its
|
//! request-scoped Rust object with a registered interface; its
|
||||||
//! [`InterfaceImplementation`] bound proves at compile time that the object
|
//! [`InterfaceImplementation`] bound proves at compile time that the object
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
//! and a publication with no interfaces is valid.
|
//! and a publication with no interfaces is valid.
|
||||||
//!
|
//!
|
||||||
//! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
|
//! [`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`]
|
//! and backend target fixed within the provider's [`ObjectProvider::with_object`]
|
||||||
//! callback. Dispatch is qualified by `(interface reference, operation name)`.
|
//! callback. Dispatch is qualified by `(interface reference, operation name)`.
|
||||||
//! The same frozen operation declaration validates both arguments and results.
|
//! The same frozen operation declaration validates both arguments and results.
|
||||||
@@ -29,14 +30,13 @@
|
|||||||
|
|
||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
use std::any::TypeId;
|
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt::{self, Display, Formatter};
|
use std::fmt::{self, Display, Formatter};
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use wip_interface::DispatchError;
|
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.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
///
|
||||||
pub struct InterfaceRef(String);
|
/// 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 InterfaceRef {
|
impl Scope {
|
||||||
/// Returns the legacy opaque registration reference.
|
/// Creates a fresh lifetime without implicitly publishing an Object.
|
||||||
#[must_use]
|
pub fn new(path: impl Into<String>) -> Result<Self, ValidationError> {
|
||||||
pub fn as_str(&self) -> &str {
|
let path = path.into();
|
||||||
&self.0
|
wip_protocol::validate_path(&path)?;
|
||||||
|
Ok(Self {
|
||||||
|
path,
|
||||||
|
lifetime: Arc::new(AtomicBool::new(true)),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps the legacy registration reference to a root-scoped protocol reference.
|
/// Returns the client-facing canonical scope path.
|
||||||
///
|
#[must_use]
|
||||||
/// The opaque registration string is copied as the name without parsing it.
|
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(InterfaceReference);
|
||||||
|
|
||||||
|
impl InterfaceRef {
|
||||||
|
/// Returns the local name without parsing or adding display delimiters.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the structured Protocol reference.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn protocol_reference(&self) -> InterfaceReference {
|
pub fn protocol_reference(&self) -> InterfaceReference {
|
||||||
InterfaceReference {
|
self.0.clone()
|
||||||
scope: "/".into(),
|
|
||||||
name: self.0.clone(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One atomic observation of an interface reference, descriptor, and validator.
|
/// One atomic observation of an interface reference, descriptor, and validator.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct InterfaceObservation {
|
pub struct InterfaceObservation {
|
||||||
/// Opaque reference selected by published objects and operation calls.
|
/// Structured reference selected by published objects and operation calls.
|
||||||
pub reference: InterfaceRef,
|
pub reference: InterfaceRef,
|
||||||
/// Canonical descriptor frozen when the interface was registered.
|
/// Canonical descriptor frozen when the interface was registered.
|
||||||
pub descriptor: InterfaceDescriptor,
|
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.
|
/// Optional validator. Its presence makes it a required call precondition.
|
||||||
pub validator: Option<Validator>,
|
pub validator: Option<Validator>,
|
||||||
}
|
}
|
||||||
@@ -242,7 +310,7 @@ impl InterfaceObservation {
|
|||||||
let response = FetchInterfaceResponse {
|
let response = FetchInterfaceResponse {
|
||||||
interface: self.reference.protocol_reference(),
|
interface: self.reference.protocol_reference(),
|
||||||
descriptor: self.descriptor.clone(),
|
descriptor: self.descriptor.clone(),
|
||||||
scope_ref: None,
|
scope_ref: self.scope_ref.clone(),
|
||||||
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
||||||
};
|
};
|
||||||
response
|
response
|
||||||
@@ -259,6 +327,7 @@ impl InterfaceObservation {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct InterfaceArtifact {
|
struct InterfaceArtifact {
|
||||||
|
scope: Scope,
|
||||||
reference: InterfaceRef,
|
reference: InterfaceRef,
|
||||||
descriptor: InterfaceDescriptor,
|
descriptor: InterfaceDescriptor,
|
||||||
validator: Option<Validator>,
|
validator: Option<Validator>,
|
||||||
@@ -267,7 +336,7 @@ struct InterfaceArtifact {
|
|||||||
/// A typed registration token for a generated interface definition.
|
/// A typed registration token for a generated interface definition.
|
||||||
///
|
///
|
||||||
/// Tokens can be reused by any number of request-scoped objects. They expose an
|
/// 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> {
|
pub struct RegisteredInterface<D> {
|
||||||
artifact: Arc<InterfaceArtifact>,
|
artifact: Arc<InterfaceArtifact>,
|
||||||
definition: PhantomData<fn() -> D>,
|
definition: PhantomData<fn() -> D>,
|
||||||
@@ -283,170 +352,161 @@ impl<D> Clone for RegisteredInterface<D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<D> RegisteredInterface<D> {
|
impl<D> RegisteredInterface<D> {
|
||||||
/// Returns the opaque protocol reference assigned by the registry.
|
/// Returns the explicit scope/name reference.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn reference(&self) -> &InterfaceRef {
|
pub fn reference(&self) -> &InterfaceRef {
|
||||||
&self.artifact.reference
|
&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
|
/// Artifact retention is not proof of current publication. Fetch requires a live
|
||||||
/// references. A validator-free artifact is immutable for the lifetime of its
|
/// provider-attested ScopeObservation; execution requires the same lifetime in
|
||||||
/// reference. A validator-bearing artifact requires that exact validator on a
|
/// the target's atomic boundary. Collision keys are exact (scope, name) pairs,
|
||||||
/// call. Re-registering a definition is rejected rather than silently changing
|
/// not Rust TypeId: the same Definition can be registered at multiple coordinates.
|
||||||
/// the observation associated with an existing reference.
|
#[derive(Debug, Default)]
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct InterfaceRegistry {
|
pub struct InterfaceRegistry {
|
||||||
namespace: u64,
|
|
||||||
next_reference: u64,
|
|
||||||
definitions: HashSet<TypeId>,
|
|
||||||
artifacts: HashMap<InterfaceRef, Arc<InterfaceArtifact>>,
|
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 {
|
impl InterfaceRegistry {
|
||||||
/// Creates an empty interface registry.
|
/// Creates an empty artifact registry without publishing any Object.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers and freezes one generated interface definition.
|
/// Freezes a Definition at an explicit scope and local name.
|
||||||
pub fn register<D>(
|
pub fn register<D>(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
scope: &Scope,
|
||||||
|
name: impl Into<String>,
|
||||||
_definition: D,
|
_definition: D,
|
||||||
validator: Option<Validator>,
|
validator: Option<Validator>,
|
||||||
) -> Result<RegisteredInterface<D>, RegistrationError>
|
) -> Result<RegisteredInterface<D>, RegistrationError>
|
||||||
where
|
where
|
||||||
D: InterfaceDefinition + 'static,
|
D: InterfaceDefinition + 'static,
|
||||||
{
|
{
|
||||||
let definition = TypeId::of::<D>();
|
let reference = InterfaceRef(InterfaceReference {
|
||||||
if self.definitions.contains(&definition) {
|
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);
|
return Err(RegistrationError::AlreadyRegistered);
|
||||||
}
|
}
|
||||||
|
|
||||||
let descriptor = D::descriptor();
|
let descriptor = D::descriptor();
|
||||||
descriptor
|
descriptor
|
||||||
.validate()
|
.validate()
|
||||||
.map_err(RegistrationError::InvalidDescriptor)?;
|
.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 {
|
let artifact = Arc::new(InterfaceArtifact {
|
||||||
|
scope: scope.clone(),
|
||||||
reference: reference.clone(),
|
reference: reference.clone(),
|
||||||
descriptor,
|
descriptor,
|
||||||
validator,
|
validator,
|
||||||
});
|
});
|
||||||
self.definitions.insert(definition);
|
|
||||||
self.artifacts.insert(reference, Arc::clone(&artifact));
|
self.artifacts.insert(reference, Arc::clone(&artifact));
|
||||||
|
|
||||||
Ok(RegisteredInterface {
|
Ok(RegisteredInterface {
|
||||||
artifact,
|
artifact,
|
||||||
definition: PhantomData,
|
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
|
/// Retained tokens cannot start new calls/fetches. Started calls retain their
|
||||||
/// verbatim; no scope or name parsing is performed.
|
/// 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]
|
#[must_use]
|
||||||
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
|
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
|
||||||
if reference.scope != "/" {
|
let reference = InterfaceRef(reference.clone());
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let reference = InterfaceRef(reference.name.clone());
|
|
||||||
self.artifacts.contains_key(&reference).then_some(reference)
|
self.artifacts.contains_key(&reference).then_some(reference)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Observes a registered interface by its host-side reference.
|
/// Iterates registered scope handles, possibly repeated for multiple names.
|
||||||
#[must_use]
|
pub fn scopes(&self) -> impl Iterator<Item = &Scope> {
|
||||||
pub fn observe_object(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
|
self.artifacts.values().map(|artifact| &artifact.scope)
|
||||||
self.artifacts.get(reference).map(Self::observation)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implements canonical `fetch_interface` semantics for a structured reference.
|
/// Fetches only with a coherent current scope Object attestation.
|
||||||
///
|
|
||||||
/// Malformed references map to `InvalidRequest`. Unknown and currently
|
|
||||||
/// unpublished references (including non-root scopes) map to `InterfaceNotFound`.
|
|
||||||
pub fn fetch_interface(
|
pub fn fetch_interface(
|
||||||
&self,
|
&self,
|
||||||
reference: &InterfaceReference,
|
reference: &InterfaceReference,
|
||||||
|
current: &ScopeObservation,
|
||||||
) -> Result<InterfaceObservation, ProtocolError> {
|
) -> Result<InterfaceObservation, ProtocolError> {
|
||||||
reference.validate().map_err(|error| ProtocolError {
|
reference.validate().map_err(|error| ProtocolError {
|
||||||
code: ProtocolErrorCode::InvalidRequest,
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
})?;
|
})?;
|
||||||
self.resolve(reference)
|
self.artifacts
|
||||||
.and_then(|reference| self.artifacts.get(&reference))
|
.get(&InterfaceRef(reference.clone()))
|
||||||
.map(Self::observation)
|
.filter(|artifact| {
|
||||||
.ok_or_else(|| ProtocolError {
|
artifact.scope.active() && artifact.scope.same_lifetime(¤t.scope)
|
||||||
code: ProtocolErrorCode::InterfaceNotFound,
|
|
||||||
message: "interface reference is not currently published".into(),
|
|
||||||
})
|
})
|
||||||
}
|
.map(|artifact| InterfaceObservation {
|
||||||
|
reference: artifact.reference.clone(),
|
||||||
fn observation(artifact: &Arc<InterfaceArtifact>) -> InterfaceObservation {
|
descriptor: artifact.descriptor.clone(),
|
||||||
InterfaceObservation {
|
scope_ref: current.reference.clone(),
|
||||||
reference: artifact.reference.clone(),
|
validator: artifact.validator.clone(),
|
||||||
descriptor: artifact.descriptor.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)]
|
#[derive(Debug)]
|
||||||
pub enum RegistrationError {
|
pub enum RegistrationError {
|
||||||
/// The same generated definition was already assigned a reference.
|
/// The exact scope/name coordinate is already occupied.
|
||||||
AlreadyRegistered,
|
AlreadyRegistered,
|
||||||
/// The generated descriptor is structurally invalid.
|
/// The generated Descriptor is invalid.
|
||||||
InvalidDescriptor(ValidationError),
|
InvalidDescriptor(ValidationError),
|
||||||
/// The registry cannot assign another opaque reference.
|
/// The scope or local name is invalid.
|
||||||
ReferenceSpaceExhausted,
|
InvalidReference(ValidationError),
|
||||||
|
/// The lifetime ended; re-publication needs a fresh Scope.
|
||||||
|
RetiredScope,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for RegistrationError {
|
impl Display for RegistrationError {
|
||||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::AlreadyRegistered => {
|
Self::AlreadyRegistered => {
|
||||||
formatter.write_str("interface definition already registered")
|
formatter.write_str("interface scope/name already registered")
|
||||||
}
|
}
|
||||||
Self::InvalidDescriptor(error) => {
|
Self::InvalidDescriptor(error) => {
|
||||||
write!(formatter, "invalid interface descriptor: {error}")
|
write!(formatter, "invalid interface descriptor: {error}")
|
||||||
}
|
}
|
||||||
Self::ReferenceSpaceExhausted => {
|
Self::InvalidReference(error) => {
|
||||||
formatter.write_str("interface reference space exhausted")
|
write!(formatter, "invalid interface reference: {error}")
|
||||||
}
|
}
|
||||||
|
Self::RetiredScope => formatter.write_str("scope publication lifetime has ended"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Error for RegistrationError {
|
impl Error for RegistrationError {
|
||||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidDescriptor(error) => Some(error),
|
Self::InvalidDescriptor(error) | Self::InvalidReference(error) => Some(error),
|
||||||
Self::AlreadyRegistered | Self::ReferenceSpaceExhausted => None,
|
Self::AlreadyRegistered | Self::RetiredScope => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -553,6 +613,7 @@ struct BoundInterface<T> {
|
|||||||
pub struct PublishedObject<T: WipObject> {
|
pub struct PublishedObject<T: WipObject> {
|
||||||
implementation: T,
|
implementation: T,
|
||||||
interfaces: Vec<BoundInterface<T>>,
|
interfaces: Vec<BoundInterface<T>>,
|
||||||
|
scopes: Vec<ScopeObservation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: WipObject> PublishedObject<T> {
|
impl<T: WipObject> PublishedObject<T> {
|
||||||
@@ -562,13 +623,45 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
Self {
|
Self {
|
||||||
implementation,
|
implementation,
|
||||||
interfaces: Vec::new(),
|
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.
|
/// Explicitly exposes a registered generated interface in insertion order.
|
||||||
///
|
///
|
||||||
/// The `InterfaceImplementation<T>` bound is generated only when `T`
|
/// 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.
|
/// references are rejected.
|
||||||
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
|
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
|
||||||
where
|
where
|
||||||
@@ -621,6 +714,7 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
let mut descriptor = bound.artifact.descriptor.clone();
|
let mut descriptor = bound.artifact.descriptor.clone();
|
||||||
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
|
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
|
||||||
bound.artifact = Arc::new(InterfaceArtifact {
|
bound.artifact = Arc::new(InterfaceArtifact {
|
||||||
|
scope: bound.artifact.scope.clone(),
|
||||||
reference: reference.clone(),
|
reference: reference.clone(),
|
||||||
descriptor,
|
descriptor,
|
||||||
validator: bound.artifact.validator.clone(),
|
validator: bound.artifact.validator.clone(),
|
||||||
@@ -637,9 +731,13 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
&self.implementation
|
&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.
|
/// credential, or provider resolution state.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn protocol_object(&self) -> Object {
|
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(
|
fn dispatch_resolved(
|
||||||
&self,
|
&self,
|
||||||
context: &ExecutionContext,
|
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 (
|
match (
|
||||||
bound.artifact.validator.as_ref(),
|
bound.artifact.validator.as_ref(),
|
||||||
interface.validator.as_deref(),
|
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
|
/// `with_object` must resolve the current object at `path` exactly once and pass
|
||||||
/// a publication that pins its current interface set, implementations, and
|
/// 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.
|
/// runtime-neutral carrier for cancellation, deadline, and resource controls.
|
||||||
/// The borrowed publication cannot escape the callback.
|
/// The borrowed publication cannot escape the callback.
|
||||||
///
|
///
|
||||||
@@ -1022,7 +1174,9 @@ pub trait ObjectProvider {
|
|||||||
wip_protocol::validate_path(path)
|
wip_protocol::validate_path(path)
|
||||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
let resolved = catch_unwind(AssertUnwindSafe(|| {
|
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(|_| {
|
.map_err(|_| {
|
||||||
CallError::new(
|
CallError::new(
|
||||||
@@ -1031,7 +1185,7 @@ pub trait ObjectProvider {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let object = match resolved {
|
let object = match resolved {
|
||||||
ProviderResult::Found(object) => object,
|
ProviderResult::Found(object) => object?,
|
||||||
ProviderResult::NotFound => {
|
ProviderResult::NotFound => {
|
||||||
return Err(CallError::new(
|
return Err(CallError::new(
|
||||||
CallErrorKind::NotFound,
|
CallErrorKind::NotFound,
|
||||||
@@ -1040,15 +1194,58 @@ pub trait ObjectProvider {
|
|||||||
}
|
}
|
||||||
ProviderResult::Failure(error) => return Err(CallError::from_read_failure(error)),
|
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)
|
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.
|
/// Resolves and executes a canonical path-targeted operation request.
|
||||||
///
|
///
|
||||||
/// Structural path validation precedes provider resolution. For a valid
|
/// Structural path validation precedes provider resolution. For a valid
|
||||||
@@ -1168,7 +1365,7 @@ pub trait ObjectProvider {
|
|||||||
/// Failure to construct an explicit object publication.
|
/// Failure to construct an explicit object publication.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum PublicationError {
|
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),
|
DuplicateInterface(InterfaceRef),
|
||||||
/// Projection attempted to add an interface without an implementation.
|
/// Projection attempted to add an interface without an implementation.
|
||||||
UnexposedInterface(InterfaceRef),
|
UnexposedInterface(InterfaceRef),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
|
|||||||
use wip_host::{
|
use wip_host::{
|
||||||
CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef,
|
CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef,
|
||||||
InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult,
|
InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult,
|
||||||
PublishedObject, RegisteredInterface, Target, Validator, WipObject,
|
PublishedObject, RegisteredInterface, Scope, ScopeObservation, Target, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_interface::{
|
use wip_interface::{
|
||||||
CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value,
|
CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value,
|
||||||
@@ -17,9 +17,16 @@ use wip_protocol::ProtocolErrorCode;
|
|||||||
#[test]
|
#[test]
|
||||||
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
|
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let read = registry.register(ReaderDefinition, None).unwrap();
|
let root = Scope::new("/").unwrap();
|
||||||
let write = registry.register(WriterDefinition, None).unwrap();
|
let read = registry
|
||||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
.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 = || {
|
let publication = || {
|
||||||
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
|
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
|
||||||
.expose(&read)
|
.expose(&read)
|
||||||
@@ -61,8 +68,9 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
|||||||
.interfaces
|
.interfaces
|
||||||
.is_empty()
|
.is_empty()
|
||||||
);
|
);
|
||||||
|
let current = ScopeObservation::new(&root, &Document::open("", None, Rc::new(Cell::new(0))));
|
||||||
let full = registry
|
let full = registry
|
||||||
.fetch_interface(&read.reference().protocol_reference())
|
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let filtered = full
|
let filtered = full
|
||||||
.clone()
|
.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!(full.descriptor.operations.len(), 3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry
|
registry
|
||||||
.fetch_interface(&read.reference().protocol_reference())
|
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
full
|
full
|
||||||
);
|
);
|
||||||
@@ -309,6 +317,7 @@ impl Broken for Document {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TestProvider {
|
struct TestProvider {
|
||||||
|
root: Scope,
|
||||||
reader: RegisteredInterface<ReaderDefinition>,
|
reader: RegisteredInterface<ReaderDefinition>,
|
||||||
writer: RegisteredInterface<WriterDefinition>,
|
writer: RegisteredInterface<WriterDefinition>,
|
||||||
broken: RegisteredInterface<BrokenDefinition>,
|
broken: RegisteredInterface<BrokenDefinition>,
|
||||||
@@ -326,11 +335,13 @@ struct TestProvider {
|
|||||||
|
|
||||||
impl TestProvider {
|
impl TestProvider {
|
||||||
fn new(
|
fn new(
|
||||||
|
root: Scope,
|
||||||
reader: RegisteredInterface<ReaderDefinition>,
|
reader: RegisteredInterface<ReaderDefinition>,
|
||||||
writer: RegisteredInterface<WriterDefinition>,
|
writer: RegisteredInterface<WriterDefinition>,
|
||||||
broken: RegisteredInterface<BrokenDefinition>,
|
broken: RegisteredInterface<BrokenDefinition>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
root,
|
||||||
reader,
|
reader,
|
||||||
writer,
|
writer,
|
||||||
broken,
|
broken,
|
||||||
@@ -362,7 +373,10 @@ impl ObjectProvider for TestProvider {
|
|||||||
self.observed_cancelled
|
self.observed_cancelled
|
||||||
.set(context.cancellation().is_cancelled());
|
.set(context.cancellation().is_cancelled());
|
||||||
self.resolutions.set(self.resolutions.get() + 1);
|
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 {
|
let (document, interfaces) = match path {
|
||||||
|
"/" => (scope_object, 0),
|
||||||
"/document" => (
|
"/document" => (
|
||||||
Document::strict(
|
Document::strict(
|
||||||
"document",
|
"document",
|
||||||
@@ -420,6 +434,7 @@ impl ObjectProvider for TestProvider {
|
|||||||
3 => publication.expose(&self.broken).unwrap(),
|
3 => publication.expose(&self.broken).unwrap(),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
|
let publication = publication.bind_scope(current_scope);
|
||||||
ProviderResult::Found(operation(&publication))
|
ProviderResult::Found(operation(&publication))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,13 +476,25 @@ fn registry_and_provider() -> (
|
|||||||
RegisteredInterface<HiddenDefinition>,
|
RegisteredInterface<HiddenDefinition>,
|
||||||
) {
|
) {
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
|
let root = Scope::new("/").unwrap();
|
||||||
let reader = registry
|
let reader = registry
|
||||||
.register(ReaderDefinition, Some(Validator::new(b"schema-v1")))
|
.register(
|
||||||
|
&root,
|
||||||
|
"reader",
|
||||||
|
ReaderDefinition,
|
||||||
|
Some(Validator::new(b"schema-v1")),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let writer = registry.register(WriterDefinition, None).unwrap();
|
let writer = registry
|
||||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
.register(&root, "writer::#日本語", WriterDefinition, None)
|
||||||
let broken = registry.register(BrokenDefinition, None).unwrap();
|
.unwrap();
|
||||||
let provider = TestProvider::new(reader, writer, broken);
|
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)
|
(registry, provider, hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,8 +532,8 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
|||||||
let reference = provider.reader.reference();
|
let reference = provider.reader.reference();
|
||||||
|
|
||||||
assert_ne!(reference.as_str(), "Reader");
|
assert_ne!(reference.as_str(), "Reader");
|
||||||
let observation = registry
|
let observation = provider
|
||||||
.fetch_interface(&reference.protocol_reference())
|
.fetch_interface(®istry, &reference.protocol_reference())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(observation.reference, *reference);
|
assert_eq!(observation.reference, *reference);
|
||||||
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
|
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()));
|
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
||||||
response.descriptor.validate().unwrap();
|
response.descriptor.validate().unwrap();
|
||||||
|
|
||||||
let mut invalid_observation = registry
|
let mut invalid_observation = provider
|
||||||
.fetch_interface(&reference.protocol_reference())
|
.fetch_interface(®istry, &reference.protocol_reference())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
||||||
invalid_observation
|
invalid_observation
|
||||||
@@ -529,11 +556,14 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
|||||||
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
||||||
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
||||||
|
|
||||||
let error = registry
|
let error = provider
|
||||||
.fetch_interface(&wip_protocol::InterfaceReference {
|
.fetch_interface(
|
||||||
scope: "/".into(),
|
®istry,
|
||||||
name: "unknown".into(),
|
&wip_protocol::InterfaceReference {
|
||||||
})
|
scope: "/".into(),
|
||||||
|
name: "unknown".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
||||||
}
|
}
|
||||||
@@ -1146,14 +1176,13 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 (registry, provider, _) = registry_and_provider();
|
||||||
let legacy = provider.writer.reference();
|
let legacy = provider.writer.reference();
|
||||||
let reference = legacy.protocol_reference();
|
let reference = legacy.protocol_reference();
|
||||||
assert_eq!(reference.scope, "/");
|
assert_eq!(reference.scope, "/");
|
||||||
assert_eq!(reference.name, legacy.as_str());
|
assert_eq!(reference.name, legacy.as_str());
|
||||||
// The registry's colon-delimited name remains opaque, not parsed.
|
assert_eq!(reference.name, "writer::#日本語");
|
||||||
assert!(reference.name.starts_with("wip-interface:"));
|
|
||||||
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
|
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
|
||||||
let nested = wip_protocol::InterfaceReference {
|
let nested = wip_protocol::InterfaceReference {
|
||||||
scope: "/open".into(),
|
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!(registry.resolve(&nested).is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.fetch_interface(&nested).unwrap_err().code,
|
provider
|
||||||
|
.fetch_interface(®istry, &nested)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
ProtocolErrorCode::InterfaceNotFound
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
);
|
);
|
||||||
let malformed = wip_protocol::InterfaceReference {
|
let malformed = wip_protocol::InterfaceReference {
|
||||||
@@ -1169,7 +1201,10 @@ fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
|||||||
name: reference.name,
|
name: reference.name,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.fetch_interface(&malformed).unwrap_err().code,
|
provider
|
||||||
|
.fetch_interface(®istry, &malformed)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
ProtocolErrorCode::InvalidRequest
|
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() {
|
fn main() {
|
||||||
let mut registry = InterfaceRegistry::new();
|
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();
|
let _published = PublishedObject::new(Object).expose(&search).unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,11 @@
|
|||||||
//!
|
//!
|
||||||
//! The canonical API targets operations by `target.path` rather than a mandatory
|
//! The canonical API targets operations by `target.path` rather than a mandatory
|
||||||
//! object identifier. Object `ref` and object/interface validators are separate,
|
//! 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
|
//! separately from descriptors, and descriptors contain neither a Rust trait
|
||||||
//! name nor an opaque-reference `name` field. Application records, enums,
|
//! name nor an opaque-reference `name` field. Application records, enums,
|
||||||
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
||||||
@@ -41,8 +45,8 @@ use std::io;
|
|||||||
|
|
||||||
use wip_host::{
|
use wip_host::{
|
||||||
CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget,
|
CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget,
|
||||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Target, Validator,
|
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Scope, ScopeObservation,
|
||||||
WipObject,
|
Target, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_interface as wip;
|
use wip_interface as wip;
|
||||||
use wip_interface::WipType;
|
use wip_interface::WipType;
|
||||||
@@ -136,14 +140,20 @@ pub struct DemoReport {
|
|||||||
pub object_validator: Option<Vec<u8>>,
|
pub object_validator: Option<Vec<u8>>,
|
||||||
/// Structured interface references published by the object, in exposure order.
|
/// Structured interface references published by the object, in exposure order.
|
||||||
pub published_interfaces: Vec<InterfaceReference>,
|
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,
|
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,
|
pub audit_interface_reference: InterfaceReference,
|
||||||
/// Descriptor format observed separately for the catalog reference.
|
/// Descriptor format observed separately for the catalog reference.
|
||||||
pub catalog_descriptor_format: String,
|
pub catalog_descriptor_format: String,
|
||||||
/// Descriptor format observed separately for the audit reference.
|
/// Descriptor format observed separately for the audit reference.
|
||||||
pub audit_descriptor_format: String,
|
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`].
|
/// Typed successful application result decoded from a validated [`Value`].
|
||||||
pub published: PublishOutcome,
|
pub published: PublishOutcome,
|
||||||
/// Typed application-domain rejection decoded from a validated [`Value`].
|
/// Typed application-domain rejection decoded from a validated [`Value`].
|
||||||
@@ -182,25 +192,34 @@ pub struct DemoReport {
|
|||||||
/// application-value decoding fails unexpectedly.
|
/// application-value decoding fails unexpectedly.
|
||||||
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
|
let scope = Scope::new(OBJECT_PATH)?;
|
||||||
let audit = registry.register(AuditDefinition, None)?;
|
let catalog = registry.register(
|
||||||
let diagnostics = registry.register(DiagnosticsDefinition, None)?;
|
&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 {
|
let provider = ApplicationProvider {
|
||||||
|
scope,
|
||||||
catalog: catalog.clone(),
|
catalog: catalog.clone(),
|
||||||
audit: audit.clone(),
|
audit: audit.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let object = provider.observe_object(OBJECT_PATH)?;
|
let object = provider.observe_object(OBJECT_PATH)?;
|
||||||
let catalog_response = registry
|
let catalog_response = provider
|
||||||
.fetch_interface(
|
.fetch_interface(
|
||||||
|
®istry,
|
||||||
object
|
object
|
||||||
.interfaces
|
.interfaces
|
||||||
.first()
|
.first()
|
||||||
.ok_or_else(|| io::Error::other("catalog interface was not published"))?,
|
.ok_or_else(|| io::Error::other("catalog interface was not published"))?,
|
||||||
)?
|
)?
|
||||||
.protocol_response()?;
|
.protocol_response()?;
|
||||||
let audit_response = registry
|
let audit_response = provider
|
||||||
.fetch_interface(
|
.fetch_interface(
|
||||||
|
®istry,
|
||||||
object
|
object
|
||||||
.interfaces
|
.interfaces
|
||||||
.get(1)
|
.get(1)
|
||||||
@@ -319,14 +338,31 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
.protocol_error()
|
.protocol_error()
|
||||||
.code;
|
.code;
|
||||||
|
|
||||||
let interface_not_found = registry
|
let interface_not_found = provider
|
||||||
.fetch_interface(&InterfaceReference {
|
.fetch_interface(
|
||||||
scope: "/".into(),
|
®istry,
|
||||||
name: "missing-interface".into(),
|
&InterfaceReference {
|
||||||
})
|
scope: OBJECT_PATH.into(),
|
||||||
|
name: "missing-interface".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.expect_err("unknown interface references must not resolve")
|
.expect_err("unknown interface references must not resolve")
|
||||||
.code;
|
.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
|
let precedence_failure = provider
|
||||||
.call_operation(CallRequest {
|
.call_operation(CallRequest {
|
||||||
target: Target {
|
target: Target {
|
||||||
@@ -354,6 +390,9 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
audit_interface_reference: audit.reference().protocol_reference(),
|
audit_interface_reference: audit.reference().protocol_reference(),
|
||||||
catalog_descriptor_format: catalog_response.descriptor.format,
|
catalog_descriptor_format: catalog_response.descriptor.format,
|
||||||
audit_descriptor_format: audit_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,
|
published,
|
||||||
rejected,
|
rejected,
|
||||||
catalog_status,
|
catalog_status,
|
||||||
@@ -417,7 +456,7 @@ fn request(
|
|||||||
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
scope_ref: None,
|
scope_ref: Some("article-service-instance".into()),
|
||||||
reference: interface.protocol_reference(),
|
reference: interface.protocol_reference(),
|
||||||
validator: interface_validator.map(<[u8]>::to_vec),
|
validator: interface_validator.map(<[u8]>::to_vec),
|
||||||
},
|
},
|
||||||
@@ -427,6 +466,7 @@ fn request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ApplicationProvider {
|
struct ApplicationProvider {
|
||||||
|
scope: Scope,
|
||||||
catalog: RegisteredInterface<CatalogDefinition>,
|
catalog: RegisteredInterface<CatalogDefinition>,
|
||||||
audit: RegisteredInterface<AuditDefinition>,
|
audit: RegisteredInterface<AuditDefinition>,
|
||||||
}
|
}
|
||||||
@@ -443,7 +483,12 @@ impl ObjectProvider for ApplicationProvider {
|
|||||||
if path != OBJECT_PATH {
|
if path != OBJECT_PATH {
|
||||||
return ProviderResult::NotFound;
|
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)
|
.expose(&self.catalog)
|
||||||
.expect("ApplicationService implements Catalog")
|
.expect("ApplicationService implements Catalog")
|
||||||
.expose(&self.audit)
|
.expose(&self.audit)
|
||||||
|
|||||||
@@ -28,10 +28,20 @@ fn public_crates_support_the_complete_qualified_end_to_end_scenario() {
|
|||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
report.catalog_interface_reference, report.audit_interface_reference,
|
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.catalog_descriptor_format, INTERFACE_FORMAT_V1);
|
||||||
assert_eq!(report.audit_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!(
|
assert_eq!(
|
||||||
report.published,
|
report.published,
|
||||||
PublishOutcome::Published("article-1".into())
|
PublishOutcome::Published("article-1".into())
|
||||||
|
|||||||
@@ -64,6 +64,38 @@ enter `contents` as RFC 4648 base64—for example, `dXBkYXRlZA==` for
|
|||||||
`updated`—then review the target Entry, interface reference, operation, and value
|
`updated`—then review the target Entry, interface reference, operation, and value
|
||||||
on the confirmation screen before pressing Enter again.
|
on the confirmation screen before pressing Enter again.
|
||||||
|
|
||||||
|
## Scoped reference formatting
|
||||||
|
|
||||||
|
`wip_tui::reference::{format_interface_reference, format_qualified_operation}`
|
||||||
|
provides labels for the existing UI from `InterfaceReference` and a separate
|
||||||
|
operation name, following `4.2-text-view.md` reference notation (`6086f3c`).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wip_protocol::InterfaceReference;
|
||||||
|
use wip_tui::reference::{format_interface_reference, format_qualified_operation};
|
||||||
|
|
||||||
|
let reference = InterfaceReference { scope: "/github".into(), name: "issue#read".into() };
|
||||||
|
assert_eq!(format_interface_reference(&reference)?, "/github::\"issue#read\"");
|
||||||
|
assert_eq!(format_qualified_operation(&reference, "read#raw")?,
|
||||||
|
"/github::\"issue#read\"#\"read#raw\"");
|
||||||
|
# Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
Both return `Result<String, ValidationError>` and reject invalid reference shape,
|
||||||
|
including relative/omitted scope. Root is explicit `/`. Non-simple components use
|
||||||
|
JSON string quoting with additional DEL/C1, Unicode line/paragraph separator and
|
||||||
|
bidi-control escapes. Input values are borrowed and never normalized, case-folded,
|
||||||
|
URL-encoded or split on delimiters. Ref, validators and `scope_ref` do not enter
|
||||||
|
these labels. The output has no extra outer signature string quoting.
|
||||||
|
|
||||||
|
Keep selection, cache and calls keyed by structured references and operation
|
||||||
|
names, never display text. No parser, scope inference, identity reconstruction,
|
||||||
|
Object/Interface signature renderer or new crate is introduced. Membership,
|
||||||
|
ancestry, authorization and freshness remain runtime concerns. Screen integration
|
||||||
|
is follow-up T-23; the current bounded screen/value/diagnostic rendering is
|
||||||
|
unchanged. At integration, apply existing UI layout bounds to display labels
|
||||||
|
without feeding shortened labels back into identity or calls.
|
||||||
|
|
||||||
## Keyboard bindings
|
## Keyboard bindings
|
||||||
|
|
||||||
All required actions are keyboard-accessible.
|
All required actions are keyboard-accessible.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
|
pub mod reference;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! Scoped Interface/Operation labels for the existing TUI.
|
||||||
|
//!
|
||||||
|
//! Follows the reference notation and safety escapes in WIP `4.2-text-view.md`.
|
||||||
|
//! These are display labels, not wire values, cache keys or call targets. Keep
|
||||||
|
//! [`InterfaceReference`] and operation names structured for selection and calls;
|
||||||
|
//! do not split or reparse labels. No Object/Interface signature renderer or
|
||||||
|
//! shorthand input parser is provided. Screen integration and bounded layout
|
||||||
|
//! remain separate from formatting.
|
||||||
|
//!
|
||||||
|
//! Scope is always explicit and canonical absolute. Adapters accepting omitted
|
||||||
|
//! scope must resolve it with an explicit context at their input boundary, never
|
||||||
|
//! default to root, infer from a target path or search ancestors. Membership,
|
||||||
|
//! ancestry and authorization are separate checks, not claims made by a label.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use wip_protocol::{InterfaceReference, ValidationError};
|
||||||
|
|
||||||
|
/// Formats a complete `scope::interface` label from structured values.
|
||||||
|
///
|
||||||
|
/// Validates reference shape (including canonical absolute scope), then quotes
|
||||||
|
/// components as needed. There is no outer signature JSON string layer. The
|
||||||
|
/// exact input is preserved without normalization, case conversion or URL
|
||||||
|
/// encoding. Ref, validators and `scope_ref` are not inputs and cannot leak into
|
||||||
|
/// the label. This does not validate membership, freshness or authorization.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wip_protocol::InterfaceReference;
|
||||||
|
/// use wip_tui::reference::format_interface_reference;
|
||||||
|
/// let reference = InterfaceReference {
|
||||||
|
/// scope: "/github::archive".into(), name: "issue#read".into(),
|
||||||
|
/// };
|
||||||
|
/// assert_eq!(format_interface_reference(&reference)?,
|
||||||
|
/// "\"/github::archive\"::\"issue#read\"");
|
||||||
|
/// # Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
/// ```
|
||||||
|
pub fn format_interface_reference(
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
) -> Result<String, ValidationError> {
|
||||||
|
reference.validate()?;
|
||||||
|
Ok(format!(
|
||||||
|
"{}::{}",
|
||||||
|
component(&reference.scope, true),
|
||||||
|
component(&reference.name, false)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a complete `scope::interface#operation` display designation.
|
||||||
|
///
|
||||||
|
/// Operation remains a separate exact Unicode name, not an input expression.
|
||||||
|
/// Delimiters inside it are quoted rather than split. This checks reference
|
||||||
|
/// shape, not operation existence, and adds no operation-name restrictions to
|
||||||
|
/// the Protocol. The output is plain text, without ANSI or Markdown; the UI
|
||||||
|
/// remains responsible for bounded screen layout, not identity reconstruction.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wip_protocol::InterfaceReference;
|
||||||
|
/// use wip_tui::reference::format_qualified_operation;
|
||||||
|
/// let reference = InterfaceReference { scope: "/".into(), name: "issue.read".into() };
|
||||||
|
/// assert_eq!(format_qualified_operation(&reference, "read#raw")?,
|
||||||
|
/// "/::issue.read#\"read#raw\"");
|
||||||
|
/// # Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
/// ```
|
||||||
|
pub fn format_qualified_operation(
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
operation: &str,
|
||||||
|
) -> Result<String, ValidationError> {
|
||||||
|
let mut output = format_interface_reference(reference)?;
|
||||||
|
output.push('#');
|
||||||
|
output.push_str(&component(operation, false));
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component(value: &str, scope: bool) -> String {
|
||||||
|
if !value.is_empty()
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b) || (scope && b == b'/'))
|
||||||
|
{
|
||||||
|
return value.to_owned();
|
||||||
|
}
|
||||||
|
let mut output = String::from("\"");
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'\\' => output.push_str("\\\\"),
|
||||||
|
'"' => output.push_str("\\\""),
|
||||||
|
'\u{08}' => output.push_str("\\b"),
|
||||||
|
'\u{0c}' => output.push_str("\\f"),
|
||||||
|
'\n' => output.push_str("\\n"),
|
||||||
|
'\r' => output.push_str("\\r"),
|
||||||
|
'\t' => output.push_str("\\t"),
|
||||||
|
'\u{00}'..='\u{1f}'
|
||||||
|
| '\u{7f}'..='\u{9f}'
|
||||||
|
| '\u{2028}'
|
||||||
|
| '\u{2029}'
|
||||||
|
| '\u{061c}'
|
||||||
|
| '\u{200e}'..='\u{200f}'
|
||||||
|
| '\u{202a}'..='\u{202e}'
|
||||||
|
| '\u{2066}'..='\u{2069}' => {
|
||||||
|
// String's fmt::Write implementation is infallible.
|
||||||
|
write!(output, "\\u{:04x}", ch as u32).expect("String write");
|
||||||
|
}
|
||||||
|
_ => output.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.push('"');
|
||||||
|
output
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
use wip_protocol::{InterfaceReference, InterfaceTarget};
|
||||||
|
use wip_tui::reference::{format_interface_reference, format_qualified_operation};
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn specification_reference_examples() {
|
||||||
|
for (scope, name, operation, expected) in [
|
||||||
|
("/github", "issue.read", "read", "/github::issue.read#read"),
|
||||||
|
("/", "issue.read", "read", "/::issue.read#read"),
|
||||||
|
(
|
||||||
|
"/github::archive",
|
||||||
|
"issue.read",
|
||||||
|
"read",
|
||||||
|
"\"/github::archive\"::issue.read#read",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/github",
|
||||||
|
"issue#read",
|
||||||
|
"read",
|
||||||
|
"/github::\"issue#read\"#read",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/github",
|
||||||
|
"issue.read",
|
||||||
|
"read#raw",
|
||||||
|
"/github::issue.read#\"read#raw\"",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/開発",
|
||||||
|
"課題.read",
|
||||||
|
"read",
|
||||||
|
"\"/開発\"::\"課題.read\"#read",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let input = reference(scope, name);
|
||||||
|
input.validate().unwrap();
|
||||||
|
let before = input.clone();
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, operation).unwrap(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(input, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_self_and_same_name_different_scope_stay_distinct() {
|
||||||
|
let root = reference("/", "read");
|
||||||
|
let parent = reference("/a", "read");
|
||||||
|
let self_scope = reference("/a/b", "read");
|
||||||
|
let labels: Vec<_> = [&root, &parent, &self_scope]
|
||||||
|
.into_iter()
|
||||||
|
.map(|input| {
|
||||||
|
input.validate_for_path("/a/b").unwrap();
|
||||||
|
format_interface_reference(input).unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(labels, ["/::read", "/a::read", "/a/b::read"]);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&self_scope, "read").unwrap(),
|
||||||
|
"/a/b::read#read"
|
||||||
|
);
|
||||||
|
assert_ne!(root, self_scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn components_not_declaration_identifiers_or_signatures() {
|
||||||
|
// Reference components follow their own ASCII rules, not identifier/keyword
|
||||||
|
// backtick quoting or the outer JSON string quoting used by full signatures.
|
||||||
|
let input = reference("/a-b/c_d.1", "1-name.entry");
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&input).unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "operation").unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry#operation"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "a/b").unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry#\"a/b\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "").unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry#\"\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&reference("/", "name`tick")).unwrap(),
|
||||||
|
"/::\"name`tick\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delimiters_quote_backslash_whitespace_and_newline_are_component_data() {
|
||||||
|
let input = reference("/a::b#c\"\\\n", "x::y#z\"\\\n");
|
||||||
|
let before = input.clone();
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&input).unwrap(),
|
||||||
|
"\"/a::b#c\\\"\\\\\\n\"::\"x::y#z\\\"\\\\\\n\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "read::raw# \"\\\n\t").unwrap(),
|
||||||
|
"\"/a::b#c\\\"\\\\\\n\"::\"x::y#z\\\"\\\\\\n\"#\"read::raw# \\\"\\\\\\n\\t\""
|
||||||
|
);
|
||||||
|
assert_eq!(input, before);
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&reference("/space here", " name ")).unwrap(),
|
||||||
|
"\"/space here\"::\" name \""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_required_safety_escape_in_each_component() {
|
||||||
|
let controls = (0..=0x1f)
|
||||||
|
.chain(0x7f..=0x9f)
|
||||||
|
.chain([0x061c, 0x200e, 0x200f, 0x2028, 0x2029])
|
||||||
|
.chain(0x202a..=0x202e)
|
||||||
|
.chain(0x2066..=0x2069);
|
||||||
|
for code in controls {
|
||||||
|
let ch = char::from_u32(code).unwrap();
|
||||||
|
let escape = match code {
|
||||||
|
8 => "\\b".into(),
|
||||||
|
9 => "\\t".into(),
|
||||||
|
10 => "\\n".into(),
|
||||||
|
12 => "\\f".into(),
|
||||||
|
13 => "\\r".into(),
|
||||||
|
_ => format!("\\u{code:04x}"),
|
||||||
|
};
|
||||||
|
let input = reference(&format!("/a{ch}z"), &format!("n{ch}z"));
|
||||||
|
let operation = format!("o{ch}z");
|
||||||
|
let before = (input.clone(), operation.clone());
|
||||||
|
let label = format_qualified_operation(&input, &operation).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
label,
|
||||||
|
format!("\"/a{escape}z\"::\"n{escape}z\"#\"o{escape}z\""),
|
||||||
|
"U+{code:04X}"
|
||||||
|
);
|
||||||
|
assert!(!label.contains(ch), "raw U+{code:04X} leaked");
|
||||||
|
assert_eq!((input, operation), before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_remains_exact_no_normalization_case_folding_or_url_encoding() {
|
||||||
|
for name in ["é", "e\u{301}", "課題", "λ😀", "%23"] {
|
||||||
|
let input = reference(&format!("/{name}"), name);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, name).unwrap(),
|
||||||
|
format!("\"/{name}\"::\"{name}\"#\"{name}\"")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
format_interface_reference(&reference("/", "é")).unwrap(),
|
||||||
|
format_interface_reference(&reference("/", "e\u{301}")).unwrap()
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
format_interface_reference(&reference("/", "Read")).unwrap(),
|
||||||
|
format_interface_reference(&reference("/", "read")).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_shape_instead_of_inferring_scope() {
|
||||||
|
for scope in [
|
||||||
|
"",
|
||||||
|
"github",
|
||||||
|
"../github",
|
||||||
|
"/a/../b",
|
||||||
|
"/a/./b",
|
||||||
|
"/a//b",
|
||||||
|
"/a/",
|
||||||
|
] {
|
||||||
|
let input = reference(scope, "read");
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&input).unwrap_err(),
|
||||||
|
input.validate().unwrap_err()
|
||||||
|
);
|
||||||
|
assert!(format_qualified_operation(&input, "read").is_err());
|
||||||
|
}
|
||||||
|
assert!(format_interface_reference(&reference("/", "")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_metadata_and_call_identity_stay_separate_from_display() {
|
||||||
|
let mut target = InterfaceTarget {
|
||||||
|
reference: reference("/a", "read#raw"),
|
||||||
|
scope_ref: Some("secret-scope-ref".into()),
|
||||||
|
validator: Some(vec![27, 255]),
|
||||||
|
};
|
||||||
|
let before = target.clone();
|
||||||
|
let label = format_qualified_operation(&target.reference, "run").unwrap();
|
||||||
|
assert_eq!(label, "/a::\"read#raw\"#run");
|
||||||
|
assert_eq!(target, before);
|
||||||
|
target.scope_ref = None;
|
||||||
|
target.validator = None;
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&target.reference, "run").unwrap(),
|
||||||
|
label
|
||||||
|
);
|
||||||
|
// Selection/call keys are still exact protocol pairs; labels are disposable.
|
||||||
|
let mut selected = std::collections::BTreeMap::new();
|
||||||
|
selected.insert(target.reference.clone(), "run");
|
||||||
|
assert_eq!(selected.get(&before.reference), Some(&"run"));
|
||||||
|
assert!(!selected.contains_key(&reference("/", "read#raw")));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user