Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
755e6aeb88 | ||
|
|
8ecc307fc9 |
@@ -2,6 +2,21 @@
|
||||
|
||||
All notable changes to the published WIP Rust crates are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Breaking Protocol/HTTP migration from opaque Interface reference strings to
|
||||
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
||||
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
||||
without changing routes, validators or the wire error-code set.
|
||||
- Breaking Host registration migration to explicit Scope publication lifetimes and
|
||||
local names, with coherent provider-attested scope Object binding for fetch and
|
||||
dispatch. Supplied scope_ref is checked at membership precedence; scope deletion
|
||||
retires retained tokens, while ordinary Object state updates preserve lifetime.
|
||||
- Migrates FS root publication and public-example self-scopes to the Host API,
|
||||
including coherent backend observations and explicit built-in names. Non-root
|
||||
FS semantics and Client cache recovery remain follow-up work. See
|
||||
[MIGRATION.md](MIGRATION.md) for API/wire examples and provider responsibilities.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
Initial public release of:
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
# Structured Interface reference and scope publication migration
|
||||
|
||||
This unreleased API/wire change follows `wip-reference` canonical Core Model,
|
||||
Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`.
|
||||
It replaces the earlier opaque Interface reference contract; HTTP routes and
|
||||
error codes have **not** changed. Peers must upgrade together: the codec does
|
||||
not accept legacy strings or display shorthand as Protocol references.
|
||||
|
||||
## Rust API
|
||||
|
||||
`Object.interfaces` is now `Vec<InterfaceReference>`.
|
||||
`FetchInterfaceRequest.interface`, `FetchInterfaceResponse.interface`, and
|
||||
`InterfaceTarget.reference` all use the same record:
|
||||
|
||||
```rust
|
||||
use wip_protocol::{FetchInterfaceRequest, InterfaceReference, InterfaceTarget};
|
||||
|
||||
let reference = InterfaceReference {
|
||||
scope: "/github".into(),
|
||||
name: "issue.read".into(),
|
||||
};
|
||||
reference.validate()?; // reference shape, no target Object path needed
|
||||
let fetch = FetchInterfaceRequest { interface: reference.clone() };
|
||||
let target = InterfaceTarget {
|
||||
reference,
|
||||
scope_ref: Some("opaque-scope-object-ref".into()),
|
||||
validator: None,
|
||||
};
|
||||
# Ok::<(), wip_protocol::ValidationError>(())
|
||||
```
|
||||
|
||||
Construct scope and name explicitly from your Worldspace publication context.
|
||||
There is no implicit conversion from a string, delimiter parser, normalization,
|
||||
case folding, or URL decoding. A name may contain `/`, `.`, `::`, `#`, or Unicode;
|
||||
only an empty name is invalid. Scope must be a canonical absolute Worldspace
|
||||
path: `/`, or nonempty slash-separated segments other than `.` and `..`, with
|
||||
no empty segments or trailing slash. `%2F` is ordinary field text, not an escape
|
||||
for a Worldspace path separator.
|
||||
|
||||
Equality, hashing and ordering operate on both fields, independent of member
|
||||
order/JSON escape spelling. Use the record itself as a membership/cache key;
|
||||
identical names in different scopes are different references. The descriptor
|
||||
still contains only format, documentation, types and operations, not identity.
|
||||
|
||||
`FetchInterfaceResponse` and `InterfaceTarget` additionally require the Rust
|
||||
field `scope_ref: Option<String>` in struct literals. Use `None` when omitted,
|
||||
not an invented identity. This is an opaque scope Object ref observation/call
|
||||
precondition, **not** a third Interface identity component or a validator.
|
||||
Host now enforces scope publication lifetimes and optional scope-ref identity
|
||||
inside the provider boundary (T-21). Client cache-recovery semantics remain
|
||||
follow-up work (T-22); do not mistake registry artifact retention for publication.
|
||||
|
||||
## Validation boundaries
|
||||
|
||||
- `InterfaceReference::validate()` and `FetchInterfaceRequest::validate()`
|
||||
check reference shape independently of a target Object path.
|
||||
- `Object::validate()` checks shape and duplicate `(scope, name)` pairs;
|
||||
`Object::validate_at_path()` additionally checks placement, including scope.
|
||||
`ObserveRequest::validate_response()` checks every derived node path,
|
||||
including the depth boundary. Invalid success responses are Client-local
|
||||
`InvalidResponse`, not Host-returned Protocol errors.
|
||||
- `InterfaceReference::validate_for_path(path)` checks ancestor-or-self scope
|
||||
compatibility by path segments. Root and self scope are valid; `/a` is not
|
||||
an ancestor of `/ab`. This has no indexability dependency.
|
||||
- `CallOperationRequest::validate()` checks descriptor-independent shape.
|
||||
`validate_with()` adds operation/argument checks but does not replace Host
|
||||
publication lookup. Host interface selection checks scope compatibility and
|
||||
exact membership **after** target lookup: invalid reference shape is
|
||||
`InvalidRequest`, but a valid reference outside the target's scope is
|
||||
`InterfaceMismatch`, with `NotFound` retaining precedence.
|
||||
|
||||
## HTTP wire
|
||||
|
||||
All four reference positions use an exact object with required string fields:
|
||||
|
||||
```json
|
||||
{"scope":"/github","name":"issue.read"}
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"interface": {"scope":"/github","name":"issue.read"},
|
||||
"scope_ref": "opaque-scope-object-ref",
|
||||
"descriptor": {"format":"wip-interface/1","types":[],"operations":[]}
|
||||
}
|
||||
```
|
||||
|
||||
A call uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"target": {"path":"/github/issues/123"},
|
||||
"interface": {
|
||||
"reference": {"scope":"/github","name":"issue.read"},
|
||||
"scope_ref": "opaque-scope-object-ref"
|
||||
},
|
||||
"operation":"refresh",
|
||||
"arguments":{}
|
||||
}
|
||||
```
|
||||
|
||||
Unknown/duplicate members, missing fields, null fields, relative scopes, empty
|
||||
names, legacy `"interface-v1"` and shorthand `"/github::issue.read"` strings
|
||||
are rejected. Field order and JSON escaping do not affect decoded identity.
|
||||
`scope_ref` is a normal opaque string, **not Base64**; omission is allowed but
|
||||
explicit null and nonstrings are invalid. An empty string is a valid opaque
|
||||
ref. Validators remain canonical padded standard Base64.
|
||||
|
||||
Descriptor-independent HTTP call metadata decoding enforces reference shape
|
||||
and the same strict JSON rules without turning scope compatibility into early
|
||||
`InvalidRequest`. Normal descriptor-bound encode/decode uses the same reference
|
||||
codec. Request violations retain the existing `400 invalid_request` boundary;
|
||||
malformed success responses retain Client-local `InvalidResponse`. Standard
|
||||
`POST v1/observe`, `v1/fetch_interface`, `v1/call_operation`, error-code/status
|
||||
mapping and Transport failure classification are unchanged.
|
||||
|
||||
## Host registration and provider migration
|
||||
|
||||
Registration now requires an explicit canonical Scope and local name:
|
||||
|
||||
```rust,ignore
|
||||
let mut registry = wip_host::InterfaceRegistry::new();
|
||||
let scope = wip_host::Scope::new("/service")?;
|
||||
let api = registry.register(&scope, "catalog", CatalogDefinition, None)?;
|
||||
|
||||
// INSIDE the backend transaction/lock/stable-handle boundary:
|
||||
let current = wip_host::ScopeObservation::new(&scope, ¤t_scope_object);
|
||||
let publication = wip_host::PublishedObject::new(current_target_object)
|
||||
.expose(&api)?
|
||||
.bind_scope(current);
|
||||
```
|
||||
|
||||
Collision keys are exact `(scope, name)` coordinates, not Rust Definition TypeId.
|
||||
The same Definition can be registered in different scopes or under different
|
||||
names. Root, self and ancestor scopes are valid, but ancestor compatibility and
|
||||
explicit target membership remain independent checks. Descriptors still have
|
||||
no publication identity fields.
|
||||
|
||||
A Scope carries a private publication lifetime, not a generated Protocol ref.
|
||||
Clone it only while the same scope Object remains published: ordinary Object
|
||||
state/validator updates do not retire it. Deletion, replacement or movement
|
||||
requires retiring the old lifetime with `registry.remove_scope(&scope)` and
|
||||
creating a fresh Scope before republication, even at the same path or ref.
|
||||
Retained registration tokens cannot fetch or start calls after retirement.
|
||||
Ref-less replacement cannot be tracked by a Client identity precondition; refs
|
||||
and scope_ref remain optional, not forcibly generated.
|
||||
|
||||
`ScopeObservation::new(&scope, &object)` is a provider attestation that this
|
||||
lifetime currently belongs to the Object at the scope path. Construct and attach
|
||||
it with `PublishedObject::bind_scope` inside the same coherent boundary as target
|
||||
resolution, membership, Descriptor/validator selection, implementation and
|
||||
backend handles, through dispatch and commit/rollback. A matching ref or an
|
||||
independent scope prelookup is not sufficient. The scope Object need not expose
|
||||
any interfaces. Providers own atomicity and external-writer limitations; the
|
||||
runtime checks the attached lifetime and optional scope_ref, and validates both
|
||||
arguments and results against the same frozen Descriptor. A supplied scope_ref
|
||||
mismatch, including a missing current ref, is `InterfaceMismatch` at membership
|
||||
precedence, after target existence and a supplied Object validator mismatch.
|
||||
Omission applies no scope identity precondition.
|
||||
|
||||
Artifact-only `registry.fetch_interface(reference)` is removed. Prefer
|
||||
`ObjectProvider::fetch_interface(&provider, ®istry, &reference)`, which resolves
|
||||
the visible scope Object with `with_object` and uses its attached current binding.
|
||||
Low-level `registry.fetch_interface(&reference, ¤t_observation)` requires
|
||||
the same coherent current attestation, not a cached snapshot. Fetch returns
|
||||
Descriptor, Interface validator and optional scope Object ref together; Clients
|
||||
need not additionally fetch an ancestor Object. `registry.scopes()` may repeat
|
||||
a handle for several names; do not treat it as a unique-scope iterator.
|
||||
|
||||
`PublishedObject::protocol_object()` is a path-free composition projection, not
|
||||
publication authority. For current observations use `ObjectProvider::observe_object`
|
||||
or `publication.protocol_object_at(path)` inside the coherent provider boundary;
|
||||
the latter also validates compatible live scope associations and response shape.
|
||||
|
||||
## Downstream status
|
||||
|
||||
The filesystem consumer registers `file.read`, `symlink.inspect` and `file.write`
|
||||
at its deployment-pinned root Scope. It binds that lifetime from the refreshed
|
||||
retained root lineage inside the backend execution boundary for calls, generic
|
||||
Object observations and fetch. A textual Linux root rename/replacement does not
|
||||
substitute the pinned root Object. The root has no Object ref, so fetch omits
|
||||
scope_ref; the private lifetime check still applies. Non-root FS registrations
|
||||
fail closed pending broader FS/TUI semantics integration (T-23).
|
||||
|
||||
The public API example self-scopes `catalog`, `audit` and `diagnostics` at
|
||||
`/article-service`, fetches through its provider, and carries the observed scope
|
||||
ref in calls. Client keys use structured values; scope deletion/replacement
|
||||
cache recovery remains T-22. This Host change does not modify TUI formatting;
|
||||
canonical Text View work is tracked separately in T-24. Interface authoring/macros remain
|
||||
descriptor-only and need no identity fields or declaration changes.
|
||||
@@ -79,6 +79,13 @@ maps standard routes/envelopes to `CallOperationRequest` and
|
||||
`CallOperationResponse` without placing transport-specific types in descriptors
|
||||
or application traits.
|
||||
|
||||
## Structured Interface references
|
||||
|
||||
The unreleased Protocol/HTTP API uses explicit `InterfaceReference { scope,
|
||||
name }` records and optional `scope_ref` observation/precondition metadata.
|
||||
Legacy opaque-string wire references are rejected. See [MIGRATION.md](MIGRATION.md)
|
||||
for construction examples, validation boundaries and downstream migration status.
|
||||
|
||||
## End-to-end example
|
||||
|
||||
[`crates/wip-public-api-example/src/lib.rs`](crates/wip-public-api-example/src/lib.rs)
|
||||
@@ -91,7 +98,7 @@ runs it. It demonstrates all of the following:
|
||||
2. two public traits declared with `#[wip::interface]`;
|
||||
3. a private host struct implementing both traits;
|
||||
4. explicit publication of both interfaces as an ordered set on one object;
|
||||
5. separate observation of opaque interface references, descriptors, and
|
||||
5. separate observation of structured interface references, descriptors, and
|
||||
descriptor validators;
|
||||
6. calls qualified by `target.path`, object validator, interface reference and
|
||||
validator, operation, and schema-neutral `Value` arguments;
|
||||
@@ -219,25 +226,44 @@ choose the ordered interface set for each object publication:
|
||||
|
||||
```rust,ignore
|
||||
let mut interfaces = wip_host::InterfaceRegistry::new();
|
||||
let messenger = interfaces.register(MessengerDefinition, None)?;
|
||||
let scope = wip_host::Scope::new("/service")?;
|
||||
let messenger = interfaces.register(&scope, "messenger", MessengerDefinition, None)?;
|
||||
|
||||
// Inside the provider coherent backend boundary, not a stale scope prelookup:
|
||||
let current = wip_host::ScopeObservation::new(&scope, ¤t_scope_object);
|
||||
|
||||
let published = wip_host::PublishedObject::new(request_scoped_object)
|
||||
.expose(&messenger)?;
|
||||
let protocol_object = published.protocol_object();
|
||||
.expose(&messenger)?
|
||||
.bind_scope(current);
|
||||
let protocol_object = published.protocol_object_at("/service/item")?;
|
||||
```
|
||||
|
||||
`expose` compiles only when the object implements the generated definition's
|
||||
interface trait. Implementing a trait does not expose it automatically, empty
|
||||
interface sets are valid, insertion order is preserved, and duplicate
|
||||
references are rejected. Registry references are opaque and remain outside the
|
||||
descriptor; generated descriptors contain neither those references nor Rust
|
||||
trait identity.
|
||||
references are rejected. Registry collisions use exact `(scope, name)` pairs,
|
||||
not Rust Definition identity; the same Definition may be registered at multiple
|
||||
coordinates. Structured references remain outside the Descriptor.
|
||||
|
||||
`Scope` carries a private Object publication lifetime. Keep it for ordinary state
|
||||
updates; retire it with `remove_scope` and create a fresh Scope on deletion,
|
||||
replacement or movement, even at the same path. Providers attach a current
|
||||
`ScopeObservation` from the scope Object inside the coherent target/backend
|
||||
boundary, never from a separate prelookup. The scope Object need not expose any
|
||||
interfaces. Fetch uses `ObjectProvider::fetch_interface(&provider, ®istry,
|
||||
&reference)` to observe the visible scope Object and return its optional ref,
|
||||
Descriptor and Interface validator together; retained artifacts alone are not
|
||||
publication. Calls require the current attached lifetime and check supplied
|
||||
`scope_ref` at membership precedence; omission adds no identity precondition.
|
||||
Refs, Object validators and Interface validators retain distinct responsibilities.
|
||||
See [MIGRATION.md](MIGRATION.md) for the provider atomicity and lifecycle contract.
|
||||
|
||||
Dispatch is qualified by interface reference and operation name and targets a
|
||||
canonical Worldspace path. An `ObjectProvider` validates that path before
|
||||
resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
|
||||
exactly once inside `with_object`; the host checks the supplied object validator,
|
||||
current interface membership, interface validator, descriptor operation,
|
||||
current scope lifetime/optional scope_ref and interface membership, Interface
|
||||
validator, Descriptor operation,
|
||||
operation-specific object-validator policy, arguments, and authorization in
|
||||
canonical precedence. Path and children remain provider resolution context and
|
||||
are never projected by `WipObject`; host-private fields and backend handles are
|
||||
|
||||
@@ -11,6 +11,8 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs.
|
||||
|
||||
Licensed under either of Apache-2.0 or MIT at your option.
|
||||
|
||||
@@ -21,8 +21,8 @@ use wip_http::{
|
||||
};
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
|
||||
InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation, ObserveRequest,
|
||||
OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
|
||||
InterfaceReference, InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation,
|
||||
ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
|
||||
};
|
||||
|
||||
/// Monotonic identity assigned to one logical request.
|
||||
@@ -226,12 +226,14 @@ pub struct TreeObservation {
|
||||
/// Cached interface descriptor observation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceObservation {
|
||||
/// Opaque interface reference.
|
||||
pub reference: String,
|
||||
/// Structured interface reference.
|
||||
pub reference: InterfaceReference,
|
||||
/// Last accepted descriptor, if one has been received.
|
||||
pub descriptor: Option<InterfaceDescriptor>,
|
||||
/// Optional interface validator. Absence makes the reference immutable.
|
||||
pub validator: Option<Vec<u8>>,
|
||||
/// Optional opaque identity of the interface scope Object.
|
||||
pub scope_ref: Option<String>,
|
||||
/// Current observation lifecycle.
|
||||
pub state: ObservationState,
|
||||
}
|
||||
@@ -404,7 +406,7 @@ struct Session {
|
||||
objects: BTreeMap<u64, ObjectRecord>,
|
||||
refs: BTreeMap<String, u64>,
|
||||
trees: BTreeMap<String, TreeObservation>,
|
||||
interfaces: BTreeMap<String, InterfaceRecord>,
|
||||
interfaces: BTreeMap<InterfaceReference, InterfaceRecord>,
|
||||
calls: VecDeque<CallRecord>,
|
||||
diagnostics: VecDeque<Diagnostic>,
|
||||
next_object_key: u64,
|
||||
@@ -596,7 +598,11 @@ impl Client {
|
||||
|
||||
/// Returns the current interface observation.
|
||||
#[must_use]
|
||||
pub fn interface(&self, session: &SessionId, reference: &str) -> Option<&InterfaceObservation> {
|
||||
pub fn interface(
|
||||
&self,
|
||||
session: &SessionId,
|
||||
reference: &InterfaceReference,
|
||||
) -> Option<&InterfaceObservation> {
|
||||
self.sessions
|
||||
.get(session)?
|
||||
.interfaces
|
||||
@@ -762,9 +768,8 @@ impl Client {
|
||||
pub fn ensure_interface(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
reference: impl Into<String>,
|
||||
reference: InterfaceReference,
|
||||
) -> Result<Option<PreparedRequest>, ClientError> {
|
||||
let reference = reference.into();
|
||||
let state = self.session(session)?;
|
||||
if state.interfaces.contains_key(&reference)
|
||||
|| state.interfaces.len() >= self.limits.interfaces_per_session
|
||||
@@ -782,11 +787,11 @@ impl Client {
|
||||
pub fn prepare_interface(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
reference: impl Into<String>,
|
||||
reference: InterfaceReference,
|
||||
) -> Result<PreparedRequest, ClientError> {
|
||||
self.ensure_capacity()?;
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference.into(),
|
||||
interface: reference,
|
||||
};
|
||||
let endpoint = session_endpoint(session)?;
|
||||
let encoded = encode_fetch_interface_request(&endpoint, &request, self.wire_limits)
|
||||
@@ -804,6 +809,7 @@ impl Client {
|
||||
.or_insert(InterfaceRecord {
|
||||
observation: InterfaceObservation {
|
||||
reference: request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: None,
|
||||
validator: None,
|
||||
state: ObservationState::Loading(id),
|
||||
@@ -833,7 +839,7 @@ impl Client {
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
path: &str,
|
||||
interface: &str,
|
||||
interface: &InterfaceReference,
|
||||
operation: impl Into<String>,
|
||||
arguments: BTreeMap<String, Value>,
|
||||
) -> Result<PreparedRequest, ClientError> {
|
||||
@@ -900,7 +906,8 @@ impl Client {
|
||||
validator: object.validator.clone(),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: interface.to_owned(),
|
||||
reference: interface.clone(),
|
||||
scope_ref: interface_observation.scope_ref.clone(),
|
||||
validator: interface_observation.validator.clone(),
|
||||
},
|
||||
operation,
|
||||
@@ -1182,6 +1189,7 @@ impl Client {
|
||||
let touched = state.tick();
|
||||
let observation = InterfaceObservation {
|
||||
reference: value.interface,
|
||||
scope_ref: value.scope_ref,
|
||||
descriptor: Some(value.descriptor),
|
||||
validator: value.validator,
|
||||
state: ObservationState::Fresh,
|
||||
@@ -1466,7 +1474,7 @@ impl Client {
|
||||
fn interface_request_is_current(
|
||||
&self,
|
||||
session: &SessionId,
|
||||
reference: &str,
|
||||
reference: &InterfaceReference,
|
||||
id: RequestId,
|
||||
) -> bool {
|
||||
matches!(
|
||||
@@ -1511,7 +1519,7 @@ impl Client {
|
||||
#[derive(Debug)]
|
||||
enum RetrievalSubject {
|
||||
Observe(ObserveRequest),
|
||||
Interface(String),
|
||||
Interface(InterfaceReference),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -1617,7 +1625,7 @@ fn set_observe_subject_state(
|
||||
|
||||
fn ensure_interface_slot(
|
||||
state: &mut Session,
|
||||
reference: &str,
|
||||
reference: &InterfaceReference,
|
||||
maximum: usize,
|
||||
) -> Result<(), ClientError> {
|
||||
if state.interfaces.contains_key(reference) || state.interfaces.len() < maximum {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use wip_client::{
|
||||
@@ -39,7 +46,7 @@ fn object() -> Object {
|
||||
Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: vec!["math".into()],
|
||||
interfaces: vec![root_reference("math")],
|
||||
r#ref: None,
|
||||
validator: Some(b"object-v1".to_vec()),
|
||||
}
|
||||
@@ -72,7 +79,7 @@ fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_resp
|
||||
let cases: &[&[u8]] = &[
|
||||
b"{not json",
|
||||
br#"{"name":"item","interfaces":[],"unknown":true}"#,
|
||||
br#"{"name":"item","interfaces":["math","math"]}"#,
|
||||
br#"{"name":"item","interfaces":[{"scope":"/","name":"math"},{"scope":"/","name":"math"}]}"#,
|
||||
br#"{"name":"wrong","interfaces":[]}"#,
|
||||
];
|
||||
for body in cases {
|
||||
@@ -181,12 +188,15 @@ fn non_success_without_valid_envelope_remains_transport_failure() {
|
||||
#[test]
|
||||
fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
|
||||
let (mut client, session) = setup();
|
||||
let request = client.prepare_interface(&session, "math").unwrap();
|
||||
let request = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
let logical = FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
};
|
||||
let value = FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor("future-format/2"),
|
||||
validator: Some(b"v2".to_vec()),
|
||||
};
|
||||
@@ -216,16 +226,19 @@ fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
||||
let interface = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
client
|
||||
.complete(
|
||||
interface.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||
validator: None,
|
||||
},
|
||||
@@ -238,7 +251,7 @@ fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object()
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
"math",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
||||
)
|
||||
@@ -293,16 +306,19 @@ fn plain_413_after_call_dispatch_is_unknown_transport_outcome() {
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
||||
let interface = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
client
|
||||
.complete(
|
||||
interface.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||
validator: None,
|
||||
},
|
||||
@@ -315,7 +331,7 @@ fn plain_413_after_call_dispatch_is_unknown_transport_outcome() {
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
"math",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use wip_client::{
|
||||
@@ -65,7 +72,7 @@ fn object(name: &str, reference: Option<&str>, validator: &[u8]) -> Object {
|
||||
Object {
|
||||
name: name.into(),
|
||||
description: Some(format!("object {name}")),
|
||||
interfaces: vec!["math".into()],
|
||||
interfaces: vec![root_reference("math")],
|
||||
r#ref: reference.map(str::to_owned),
|
||||
validator: Some(validator.to_vec()),
|
||||
}
|
||||
@@ -296,12 +303,18 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
||||
.unwrap();
|
||||
|
||||
let request = client
|
||||
.ensure_interface(&session, "math")
|
||||
.ensure_interface(&session, root_reference("math"))
|
||||
.unwrap()
|
||||
.expect("missing descriptor starts a fetch");
|
||||
assert!(client.ensure_interface(&session, "math").unwrap().is_none());
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, root_reference("math"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let value = FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"math-v1".to_vec()),
|
||||
};
|
||||
@@ -310,7 +323,7 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
||||
request.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&value,
|
||||
wire_limits(),
|
||||
@@ -318,10 +331,15 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(client.ensure_interface(&session, "math").unwrap().is_none());
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, root_reference("math"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let failed = client
|
||||
.ensure_interface(&session, "broken")
|
||||
.ensure_interface(&session, root_reference("broken"))
|
||||
.unwrap()
|
||||
.expect("new descriptor starts a fetch");
|
||||
assert_eq!(
|
||||
@@ -333,7 +351,7 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, "broken")
|
||||
.ensure_interface(&session, root_reference("broken"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"retained interface failures require an explicit refresh"
|
||||
@@ -348,7 +366,10 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
||||
.unwrap();
|
||||
let pending_object = client.refresh_object(&session, "/busy").unwrap();
|
||||
assert!(
|
||||
client.ensure_interface(&session, "math").unwrap().is_none(),
|
||||
client
|
||||
.ensure_interface(&session, root_reference("math"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"lookahead waits while the in-flight bound is full"
|
||||
);
|
||||
client
|
||||
@@ -359,11 +380,12 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
||||
.unwrap();
|
||||
|
||||
let request = client
|
||||
.ensure_interface(&session, "math")
|
||||
.ensure_interface(&session, root_reference("math"))
|
||||
.unwrap()
|
||||
.expect("lookahead resumes after capacity becomes available");
|
||||
let value = FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: None,
|
||||
};
|
||||
@@ -372,7 +394,7 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
||||
request.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&value,
|
||||
wire_limits(),
|
||||
@@ -383,18 +405,32 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
||||
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, "other")
|
||||
.ensure_interface(&session, root_reference("other"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"lookahead does not churn a full descriptor cache"
|
||||
);
|
||||
assert!(client.interface(&session, "math").is_some());
|
||||
assert!(client.interface(&session, "other").is_none());
|
||||
assert!(
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.interface(&session, &root_reference("other"))
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let explicit = client.prepare_interface(&session, "other").unwrap();
|
||||
assert!(client.interface(&session, "math").is_none());
|
||||
let explicit = client
|
||||
.prepare_interface(&session, root_reference("other"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.is_none()
|
||||
);
|
||||
assert!(matches!(
|
||||
client.interface(&session, "other").unwrap().state,
|
||||
client.interface(&session, &root_reference("other")).unwrap().state,
|
||||
ObservationState::Loading(id) if id == explicit.id
|
||||
));
|
||||
}
|
||||
@@ -408,7 +444,9 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
||||
|
||||
let first = client.refresh_observed(&session, "/one", 0).unwrap();
|
||||
let second = client.refresh_observed(&session, "/two", 0).unwrap();
|
||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
||||
let interface = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.request.uri().path(), "/wip/v1/observe");
|
||||
assert_eq!(second.request.uri().path(), "/wip/v1/observe");
|
||||
@@ -426,10 +464,11 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
||||
interface.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"math-v1".to_vec()),
|
||||
},
|
||||
@@ -454,7 +493,10 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
||||
ObservationState::Fresh
|
||||
);
|
||||
assert_eq!(
|
||||
client.interface(&session, "math").unwrap().state,
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
@@ -941,14 +983,21 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"interface-v1".to_vec()),
|
||||
},
|
||||
);
|
||||
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(7),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
@@ -965,7 +1014,8 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::String),
|
||||
validator: Some(b"interface-v2".to_vec()),
|
||||
},
|
||||
@@ -981,7 +1031,8 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
||||
validator: Some(b"object-v1".to_vec()),
|
||||
},
|
||||
interface: wip_protocol::InterfaceTarget {
|
||||
reference: "math".into(),
|
||||
scope_ref: None,
|
||||
reference: root_reference("math"),
|
||||
validator: Some(b"interface-v1".to_vec()),
|
||||
},
|
||||
operation: "calculate".into(),
|
||||
@@ -1015,7 +1066,10 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
||||
);
|
||||
assert!(matches!(history_context.outcome, CallOutcome::Success(_)));
|
||||
assert_eq!(
|
||||
client.interface(&session, "math").unwrap().descriptor,
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.unwrap()
|
||||
.descriptor,
|
||||
Some(descriptor(TypeExpr::String))
|
||||
);
|
||||
let observation = client.object(&session, "/item").unwrap();
|
||||
@@ -1039,14 +1093,21 @@ fn successful_call_without_validator_preserves_observed_validator() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
let request = client
|
||||
.pending_call_context(call.id)
|
||||
@@ -1080,7 +1141,13 @@ fn successful_call_without_validator_preserves_observed_validator() {
|
||||
assert_eq!(observation.validator, Some(b"object-v1".to_vec()));
|
||||
|
||||
let next = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(2))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(2),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
@@ -1109,14 +1176,21 @@ fn newer_observation_prevents_older_success_validator_overwrite() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
let request = client
|
||||
.pending_call_context(call.id)
|
||||
@@ -1173,7 +1247,8 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"interface-v1".to_vec()),
|
||||
},
|
||||
@@ -1181,7 +1256,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
||||
|
||||
let older_alias = client.refresh_observed(&session, "/alias/item", 0).unwrap();
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(7),
|
||||
)
|
||||
.unwrap();
|
||||
let request = client
|
||||
.pending_call_context(call.id)
|
||||
@@ -1227,7 +1308,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
||||
assert_eq!(alias.validator, Some(b"object-v2".to_vec()));
|
||||
assert_eq!(
|
||||
client
|
||||
.prepare_call(&session, "/alias/item", "math", "calculate", args(1))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/alias/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(1)
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ClientErrorKind::MissingObservation
|
||||
@@ -1243,7 +1330,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
||||
.refresh_observed(&session, "/second/item", 0)
|
||||
.unwrap();
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(8))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(8),
|
||||
)
|
||||
.unwrap();
|
||||
client.mark_dispatched(call.id).unwrap();
|
||||
let unknown = ProtocolError {
|
||||
@@ -1292,13 +1385,20 @@ fn in_flight_calls_pin_target_identity_until_staleness_is_applied() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"interface-v1".to_vec()),
|
||||
},
|
||||
);
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(7),
|
||||
)
|
||||
.unwrap();
|
||||
client.mark_dispatched(call.id).unwrap();
|
||||
|
||||
@@ -1350,13 +1450,20 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"i1".to_vec()),
|
||||
},
|
||||
);
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
client.mark_dispatched(call.id).unwrap();
|
||||
let error = ProtocolError {
|
||||
@@ -1369,7 +1476,10 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
||||
client.complete(call.id, response).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
client.interface(&session, "math").unwrap().state,
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1384,7 +1494,8 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"i2".to_vec()),
|
||||
},
|
||||
@@ -1404,14 +1515,21 @@ fn protocol_and_transport_unknown_outcomes_stale_without_retry() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
client.mark_dispatched(call.id).unwrap();
|
||||
let unknown = ProtocolError {
|
||||
@@ -1447,7 +1565,13 @@ fn protocol_and_transport_unknown_outcomes_stale_without_retry() {
|
||||
|
||||
observe_object(&mut client, &session, "/item", object("item", None, b"v2"));
|
||||
let second = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(2))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(2),
|
||||
)
|
||||
.unwrap();
|
||||
client.mark_dispatched(second.id).unwrap();
|
||||
let completion = client
|
||||
@@ -1695,8 +1819,12 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let old = client.prepare_interface(&session, "math").unwrap();
|
||||
let newer = client.prepare_interface(&session, "math").unwrap();
|
||||
let old = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
let newer = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.fail_transport(old.id, "old connection failed")
|
||||
@@ -1704,11 +1832,12 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
||||
Completion::StaleResponseRejected(old.id)
|
||||
);
|
||||
assert!(matches!(
|
||||
client.interface(&session, "math").unwrap().state,
|
||||
client.interface(&session, &root_reference("math")).unwrap().state,
|
||||
ObservationState::Loading(id) if id == newer.id
|
||||
));
|
||||
let value = FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"current".to_vec()),
|
||||
};
|
||||
@@ -1717,7 +1846,7 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
||||
newer.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&value,
|
||||
wire_limits(),
|
||||
@@ -1737,15 +1866,19 @@ fn validator_free_interface_observation_remains_immutable() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
|
||||
let refresh = client.prepare_interface(&session, "math").unwrap();
|
||||
let refresh = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
let contradictory = FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"became-mutable".to_vec()),
|
||||
};
|
||||
@@ -1754,7 +1887,7 @@ fn validator_free_interface_observation_remains_immutable() {
|
||||
refresh.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&contradictory,
|
||||
wire_limits(),
|
||||
@@ -1763,7 +1896,13 @@ fn validator_free_interface_observation_remains_immutable() {
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
|
||||
assert_eq!(client.interface(&session, "math").unwrap().validator, None);
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.unwrap()
|
||||
.validator,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1876,3 +2015,64 @@ fn in_flight_and_history_are_bounded() {
|
||||
.unwrap_err();
|
||||
assert_eq!(client.diagnostics(&session).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_interface_keys_membership_and_scope_ref_are_preserved() {
|
||||
let mut client = client(8, 8, 8, 8);
|
||||
let session = client
|
||||
.open_session("https://example.test/wip", SecurityContext::new("subject"))
|
||||
.unwrap();
|
||||
let root = root_reference("same#::操作");
|
||||
let local = wip_protocol::InterfaceReference {
|
||||
scope: "/item".into(),
|
||||
name: root.name.clone(),
|
||||
};
|
||||
let mut value = object("item", None, b"object-v1");
|
||||
value.interfaces = vec![local.clone()];
|
||||
observe_object(&mut client, &session, "/item", value);
|
||||
for (reference, scope_ref) in [
|
||||
(root.clone(), None),
|
||||
(local.clone(), Some("scope-identity".into())),
|
||||
] {
|
||||
observe_interface(
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: reference,
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
scope_ref,
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
assert!(client.interface(&session, &root).is_some());
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &local)
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("scope-identity")
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.prepare_call(&session, "/item", &root, "calculate", args(1))
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ClientErrorKind::InvalidRequest
|
||||
);
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", &local, "calculate", args(1))
|
||||
.unwrap();
|
||||
let context = client.pending_call_context(call.id).unwrap();
|
||||
assert_eq!(context.request().interface.reference, local);
|
||||
assert_eq!(
|
||||
context.request().interface.scope_ref.as_deref(),
|
||||
Some("scope-identity")
|
||||
);
|
||||
assert_eq!(
|
||||
context.interface().scope_ref.as_deref(),
|
||||
Some("scope-identity")
|
||||
);
|
||||
assert_eq!(client.interface(&session, &root).unwrap().scope_ref, None);
|
||||
}
|
||||
|
||||
@@ -26,9 +26,13 @@ For an external backend/composition:
|
||||
protected state. Implement `FilesystemBackend` to resolve one connected
|
||||
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
|
||||
bounded children, and execute against that already-resolved lineage.
|
||||
2. Register generated `#[wip_interface::interface]` definitions using the existing
|
||||
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies
|
||||
reusable built-in tokens; using those tokens does not force their publication.
|
||||
2. Create `wip_host::Scope::new("/")` for the deployment-pinned root, then
|
||||
register generated definitions with `registry.register(&scope, name,
|
||||
Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)`
|
||||
optionally supplies reusable built-in tokens named `file.read`,
|
||||
`symlink.inspect`, and `file.write`; tokens do not force publication. The
|
||||
current engine binds only the root scope and fails closed for non-root
|
||||
registrations; broader FS scope semantics remain follow-up work.
|
||||
3. Implement `Composition<Entry>` on a local type. Its local Object wraps the entry
|
||||
and implements both `WipObject` and the chosen generated traits, avoiding orphan
|
||||
rules. `PublishedObject::expose` proves each implementation at compile time.
|
||||
@@ -61,7 +65,26 @@ from one connected backend traversal/snapshot, not independent path-prefix
|
||||
resolutions. A root target has no ancestors; other targets retain the root first,
|
||||
then intermediate parent containers. This is backend-private context, never wire
|
||||
metadata. Backend `resolve` and `execute` signatures both carry this type;
|
||||
compositions still receive only the exact scoped target `Entry`.
|
||||
compositions receive exact retained entries, including publication of the root
|
||||
scope Object while another target is executing, never independent raw-path
|
||||
lookups. Root publication must be deterministic and side-effect-free.
|
||||
|
||||
Every lineage must retain the same deployment-pinned root Object. Replacing that
|
||||
Object requires a new projection and fresh `Scope`, not reuse of registrations
|
||||
at the same path. The engine constructs `ScopeObservation` from the published
|
||||
root Object's metadata using the refreshed retained root capability **inside**
|
||||
`FilesystemBackend::execute`, then attaches it with `PublishedObject::bind_scope`.
|
||||
It never independently looks up the raw configured root path. Root Objects need
|
||||
not expose any interfaces. Calls check both the private lifetime and optional
|
||||
`scope_ref`; normal root state updates do not retire the lifetime.
|
||||
|
||||
`with_object` (including fetch) and individual tree Object publications also
|
||||
enter this backend boundary. Generic read callbacks use a `NotStarted` attempt
|
||||
as an adapter to the existing execution API; no typed operation runs and no
|
||||
commit is implied. Tree observations are bounded traversals, not one global tree
|
||||
transaction. Fetch resolves the visible scope Object through the provider and
|
||||
returns its ref, Descriptor and Interface validator together; retained registry
|
||||
artifacts alone never establish publication.
|
||||
|
||||
`FilesystemBackend::execute` must validate ancestor membership/identity and
|
||||
refresh **all** lineage state/validators inside its transaction or lock before
|
||||
@@ -195,13 +218,17 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
|
||||
--data '{"path":"/","depth":2}'
|
||||
```
|
||||
|
||||
A regular-file observation returns the opaque read interface reference and an object
|
||||
validator. Retrieve that descriptor with:
|
||||
A regular-file observation returns a structured read interface reference and an
|
||||
object validator. Built-ins explicitly use scope `/` and the local names
|
||||
`file.read`, `symlink.inspect`, and `file.write`. The Linux root does not issue an
|
||||
Object ref, so fetch omits `scope_ref`; its private publication lifetime still
|
||||
binds fetch and call to the pinned deployment root. Copy the complete reference
|
||||
from the observation to retrieve that descriptor:
|
||||
|
||||
```console
|
||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{"interface":"<read-interface-from-observation>"}'
|
||||
--data '{"interface":{"scope":"/","name":"file.read"}}'
|
||||
```
|
||||
|
||||
Then call a bounded range read:
|
||||
@@ -211,7 +238,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":"<read-interface>"},
|
||||
"interface":{"reference":{"scope":"/","name":"file.read"}},
|
||||
"operation":"read",
|
||||
"arguments":{"offset":0,"length":4096}
|
||||
}'
|
||||
@@ -225,7 +252,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":"<write-interface>"},
|
||||
"interface":{"reference":{"scope":"/","name":"file.write"}},
|
||||
"operation":"replace",
|
||||
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||
}'
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::projection::{LinuxBackend, LinuxEntry};
|
||||
use crate::{AccessMode, ResourceLimits};
|
||||
use wip_host::{
|
||||
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
|
||||
RegisteredInterface, RegistrationError, Validator, WipObject,
|
||||
RegisteredInterface, RegistrationError, Scope, Validator, WipObject,
|
||||
};
|
||||
use wip_interface as wip;
|
||||
|
||||
@@ -22,12 +22,17 @@ pub struct DefaultInterfaces {
|
||||
pub write: RegisteredInterface<FileWriteDefinition>,
|
||||
}
|
||||
impl DefaultInterfaces {
|
||||
/// Register reusable default descriptors without publishing any objects.
|
||||
pub fn register(registry: &mut InterfaceRegistry) -> Result<Self, RegistrationError> {
|
||||
/// Register reusable descriptors at an explicit publication scope.
|
||||
///
|
||||
/// The current FS engine binds only its deployment root scope (`/`).
|
||||
pub fn register(
|
||||
registry: &mut InterfaceRegistry,
|
||||
scope: &Scope,
|
||||
) -> Result<Self, RegistrationError> {
|
||||
Ok(Self {
|
||||
read: registry.register(FileReadDefinition, None)?,
|
||||
symlink: registry.register(SymlinkInspectDefinition, None)?,
|
||||
write: registry.register(FileWriteDefinition, None)?,
|
||||
read: registry.register(scope, "file.read", FileReadDefinition, None)?,
|
||||
symlink: registry.register(scope, "symlink.inspect", SymlinkInspectDefinition, None)?,
|
||||
write: registry.register(scope, "file.write", FileWriteDefinition, None)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -135,7 +140,9 @@ pub fn default_projection(
|
||||
) -> Result<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>, std::io::Error> {
|
||||
let backend = LinuxBackend::open(root, access, limits)?;
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let interfaces = DefaultInterfaces::register(&mut registry).map_err(std::io::Error::other)?;
|
||||
let scope = Scope::new("/").map_err(std::io::Error::other)?;
|
||||
let interfaces =
|
||||
DefaultInterfaces::register(&mut registry, &scope).map_err(std::io::Error::other)?;
|
||||
Ok(Projection::new(
|
||||
backend,
|
||||
registry,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::ResourceLimits;
|
||||
use wip_host::{
|
||||
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
|
||||
OperationAttempt, ProviderResult, PublishedObject, WipObject,
|
||||
OperationAttempt, ProviderResult, PublishedObject, ScopeObservation, WipObject,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
|
||||
@@ -72,6 +72,12 @@ impl<E> ResolvedEntry<E> {
|
||||
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
||||
/// backend paths, credentials or hidden metadata.
|
||||
///
|
||||
/// The root is a deployment-pinned Object: every resolution/execution lineage
|
||||
/// must retain that same root, even if its textual backend path is renamed or
|
||||
/// replaced. Replacing the deployment root requires a new projection and Scope
|
||||
/// lifetime. The engine currently binds only `/`; non-root registrations are
|
||||
/// not published until a backend can attest their Object lifetimes.
|
||||
///
|
||||
/// `execute` must retain the same target and complete lineage and serialize
|
||||
/// ancestor identity/membership checks, policy metadata, validator observation,
|
||||
/// publication, validation, typed dispatch and commit/rollback. Changed/detached
|
||||
@@ -123,7 +129,10 @@ pub trait Composition<E: BackendEntry> {
|
||||
/// Build a request-scoped publication using this exact capability/context.
|
||||
///
|
||||
/// Publication and policy hooks must be side-effect-free with respect to the
|
||||
/// backend. A publication failure is a pre-dispatch rejection, never a
|
||||
/// backend. This hook may also publish the retained root to observe its scope
|
||||
/// metadata while another target is executing. Root publication must be
|
||||
/// deterministic within the view and use this same retained capability.
|
||||
/// A publication failure is a pre-dispatch rejection, never a
|
||||
/// started Operation. Delegate metadata/validators to the boundary snapshot.
|
||||
fn publish(
|
||||
&self,
|
||||
@@ -286,12 +295,21 @@ where
|
||||
fn publication(
|
||||
&self,
|
||||
entry: B::Entry,
|
||||
root: &B::Entry,
|
||||
context: &ExecutionContext,
|
||||
) -> Result<PublishedObject<C::Object>, HostError> {
|
||||
let is_root = entry.name().is_empty();
|
||||
let publication = self
|
||||
.projection
|
||||
.composition
|
||||
.publish(entry.clone(), context)?;
|
||||
// Scope refs describe the published root Object, not an independently
|
||||
// resolved raw path or the backend entry's possibly different metadata.
|
||||
let root_publication = if is_root {
|
||||
None
|
||||
} else {
|
||||
Some(self.projection.composition.publish(root.clone(), context)?)
|
||||
};
|
||||
let available = publication
|
||||
.protocol_object()
|
||||
.interfaces
|
||||
@@ -308,17 +326,81 @@ where
|
||||
.policy
|
||||
.interfaces(self.principal, &entry, &available);
|
||||
selected.retain(|reference| {
|
||||
self.projection
|
||||
.policy
|
||||
.interface_visible(self.principal, reference)
|
||||
// Non-root lifetimes are not attested by this engine yet. Never
|
||||
// publish an artifact merely because the registry retains it.
|
||||
reference.protocol_reference().scope == "/"
|
||||
&& self
|
||||
.projection
|
||||
.policy
|
||||
.interface_visible(self.principal, reference)
|
||||
});
|
||||
publication
|
||||
let mut publication = publication
|
||||
.project(&selected, |reference, operation| {
|
||||
self.projection
|
||||
.policy
|
||||
.operation_visible(self.principal, reference, operation)
|
||||
})
|
||||
.map_err(|_| internal())
|
||||
.map_err(|_| internal())?;
|
||||
// Called only inside backend.execute, with its refreshed pinned lineage.
|
||||
// scopes() may repeat the handle for different names; bind_scope replaces
|
||||
// the same-path snapshot rather than treating repetition as a collision.
|
||||
for scope in self
|
||||
.projection
|
||||
.registry
|
||||
.scopes()
|
||||
.filter(|scope| scope.path() == "/")
|
||||
{
|
||||
let object = root_publication
|
||||
.as_ref()
|
||||
.map_or(publication.implementation(), |root| root.implementation());
|
||||
let current = ScopeObservation::new(scope, object);
|
||||
publication = publication.bind_scope(current);
|
||||
}
|
||||
Ok(publication)
|
||||
}
|
||||
|
||||
// Adapt read-only callbacks to the existing backend execution boundary. The
|
||||
// sentinel is NotStarted: no typed operation ran and no commit/rollback may
|
||||
// be inferred. The actual callback result never escapes until execute exits.
|
||||
fn with_publication<R>(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
resolved: ResolvedEntry<B::Entry>,
|
||||
operation: impl FnOnce(&PublishedObject<C::Object>) -> R,
|
||||
) -> ProviderResult<R> {
|
||||
let mut observed = None;
|
||||
let boundary = self
|
||||
.projection
|
||||
.backend
|
||||
.execute(context, resolved, |resolved| {
|
||||
if !lineage_matches_path(&resolved, path) {
|
||||
observed = Some(ProviderResult::Failure(internal()));
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::Internal);
|
||||
}
|
||||
if !self.lineage_visible(&resolved) {
|
||||
observed = Some(ProviderResult::NotFound);
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||
}
|
||||
let root = resolved.ancestors().first().unwrap_or(resolved.target());
|
||||
observed = Some(
|
||||
match preflight(context).and_then(|()| {
|
||||
self.publication(resolved.target().clone(), root, context)
|
||||
.map(|publication| operation(&publication))
|
||||
}) {
|
||||
Ok(result) => ProviderResult::Found(result),
|
||||
Err(error) => ProviderResult::Failure(error),
|
||||
},
|
||||
);
|
||||
OperationAttempt::reject(wip_host::CallErrorKind::Internal)
|
||||
});
|
||||
match boundary {
|
||||
ProviderResult::Found(_) => {
|
||||
observed.unwrap_or_else(|| ProviderResult::Failure(internal()))
|
||||
}
|
||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve the descriptor with precisely this principal's visible operations.
|
||||
@@ -333,10 +415,8 @@ where
|
||||
&self,
|
||||
request: &FetchInterfaceRequest,
|
||||
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||
let observation = self
|
||||
.projection
|
||||
.registry
|
||||
.fetch_interface(&request.interface)?;
|
||||
let observation =
|
||||
ObjectProvider::fetch_interface(self, &self.projection.registry, &request.interface)?;
|
||||
if !self
|
||||
.projection
|
||||
.policy
|
||||
@@ -387,10 +467,10 @@ where
|
||||
}
|
||||
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||
};
|
||||
let (_ancestors, entry) = entry.into_parts();
|
||||
let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
|
||||
.min(context.resource_budget().unwrap_or(u64::MAX));
|
||||
let response = self.observe_entry(context, entry, request.depth, &mut remaining)?;
|
||||
let response =
|
||||
self.observe_entry(context, &request.path, entry, request.depth, &mut remaining)?;
|
||||
request.validate_response(&response).map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::Internal,
|
||||
@@ -403,7 +483,8 @@ where
|
||||
fn observe_entry(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
entry: B::Entry,
|
||||
path: &str,
|
||||
resolved: ResolvedEntry<B::Entry>,
|
||||
depth: u32,
|
||||
remaining: &mut u64,
|
||||
) -> Result<ObjectObservation, ProtocolError> {
|
||||
@@ -412,10 +493,19 @@ where
|
||||
return Err(resource_protocol());
|
||||
}
|
||||
*remaining -= 1;
|
||||
let object = self
|
||||
.publication(entry.clone(), context)
|
||||
.map_err(read_error)?
|
||||
.protocol_object();
|
||||
let object = match self.with_publication(context, path, resolved.clone(), |publication| {
|
||||
publication.protocol_object_at(path)
|
||||
}) {
|
||||
ProviderResult::Found(object) => object.map_err(|error| error.protocol_error())?,
|
||||
ProviderResult::NotFound => {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::NotFound,
|
||||
"target path is not published",
|
||||
));
|
||||
}
|
||||
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||
};
|
||||
let (mut ancestors, entry) = resolved.into_parts();
|
||||
if depth == 0 {
|
||||
return Ok(ObjectObservation {
|
||||
object,
|
||||
@@ -436,13 +526,25 @@ where
|
||||
if entries.len() as u64 > *remaining {
|
||||
return Err(resource_protocol());
|
||||
}
|
||||
ancestors.push(entry.clone());
|
||||
for child in entries {
|
||||
if self
|
||||
.projection
|
||||
.policy
|
||||
.object_visible(self.principal, &child)
|
||||
{
|
||||
children.push(self.observe_entry(context, child, depth - 1, remaining)?);
|
||||
let child_path = if path == "/" {
|
||||
format!("/{}", child.name())
|
||||
} else {
|
||||
format!("{path}/{}", child.name())
|
||||
};
|
||||
children.push(self.observe_entry(
|
||||
context,
|
||||
&child_path,
|
||||
ResolvedEntry::new(ancestors.clone(), child),
|
||||
depth - 1,
|
||||
remaining,
|
||||
)?);
|
||||
} else {
|
||||
if *remaining == 0 {
|
||||
return Err(resource_protocol());
|
||||
@@ -473,11 +575,7 @@ where
|
||||
) -> ProviderResult<R> {
|
||||
match self.resolve(context, path) {
|
||||
ProviderResult::Found(resolved) => {
|
||||
let (_ancestors, entry) = resolved.into_parts();
|
||||
match self.publication(entry, context) {
|
||||
Ok(publication) => ProviderResult::Found(operation(&publication)),
|
||||
Err(error) => ProviderResult::Failure(error),
|
||||
}
|
||||
self.with_publication(context, path, resolved, operation)
|
||||
}
|
||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||
@@ -503,7 +601,8 @@ where
|
||||
if !self.lineage_visible(&resolved) {
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||
}
|
||||
let (_ancestors, entry) = resolved.into_parts();
|
||||
let root = resolved.ancestors().first().unwrap_or(resolved.target());
|
||||
let entry = resolved.target().clone();
|
||||
if let Err(error) = preflight(context) {
|
||||
return OperationAttempt::reject(match error.kind() {
|
||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||
@@ -512,7 +611,7 @@ where
|
||||
}
|
||||
});
|
||||
}
|
||||
match self.publication(entry, context) {
|
||||
match self.publication(entry, root, context) {
|
||||
Ok(publication) => operation(&publication),
|
||||
Err(error) => OperationAttempt::reject(match error.kind() {
|
||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||
|
||||
@@ -17,6 +17,18 @@
|
||||
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
|
||||
//! existing standard HTTP binding to a deployment-established principal view.
|
||||
//!
|
||||
//! # Scope publication migration
|
||||
//!
|
||||
//! Registration uses `registry.register(&scope, name, Definition, validator)`.
|
||||
//! [`DefaultInterfaces::register`] takes an explicit [`wip_host::Scope`]; built-ins
|
||||
//! use root-local names `file.read`, `symlink.inspect` and `file.write`. The engine
|
||||
//! binds only its deployment-pinned root lifetime, inside the backend execution
|
||||
//! boundary, using the current published root Object from the retained lineage.
|
||||
//! Observation/fetch callbacks enter that same coherent boundary. Non-root
|
||||
//! registrations fail closed until their Object lifetimes can be attested.
|
||||
//! The Linux root survives textual root renames and does not issue an Object ref;
|
||||
//! fetch therefore omits `scope_ref` without weakening the private lifetime check.
|
||||
//!
|
||||
//! Backends and Operations are trusted code, responsible for truthful effects,
|
||||
//! public-only metadata and ongoing execution-context bounds. Policies must be
|
||||
//! stable within a view; changing descriptor visibility requires new reference
|
||||
|
||||
@@ -827,7 +827,7 @@ mod tests {
|
||||
projection: &DefaultProjection,
|
||||
path: &str,
|
||||
validator: Option<Vec<u8>>,
|
||||
interface: String,
|
||||
interface: wip_protocol::InterfaceReference,
|
||||
operation: &str,
|
||||
arguments: BTreeMap<String, Value>,
|
||||
) -> Result<wip_protocol::CallOperationResponse, wip_host::CallError> {
|
||||
@@ -837,6 +837,7 @@ mod tests {
|
||||
validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: interface,
|
||||
validator: None,
|
||||
},
|
||||
@@ -1611,6 +1612,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scoped_interfaces_follow_the_pinned_deployment_not_textual_replacement() {
|
||||
let temporary = tempdir().expect("temporary parent");
|
||||
let configured = temporary.path().join("root");
|
||||
let moved = temporary.path().join("moved");
|
||||
fs::create_dir(&configured).unwrap();
|
||||
fs::write(configured.join("file"), b"original").unwrap();
|
||||
let engine = projection(&configured, AccessMode::ReadOnly);
|
||||
let object = engine.observe_object("/file").unwrap();
|
||||
let reference = object.interfaces[0].clone();
|
||||
assert_eq!(reference.scope, "/");
|
||||
assert_eq!(reference.name, "file.read");
|
||||
fs::rename(&configured, &moved).unwrap();
|
||||
fs::create_dir(&configured).unwrap();
|
||||
fs::write(configured.join("file"), b"replacement").unwrap();
|
||||
let fetched = engine
|
||||
.fetch_interface(&wip_protocol::FetchInterfaceRequest {
|
||||
interface: reference.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(fetched.scope_ref.is_none());
|
||||
let result = call(
|
||||
&engine,
|
||||
"/file",
|
||||
object.validator,
|
||||
reference.clone(),
|
||||
"read",
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.result, Value::Bytes(b"original".to_vec()));
|
||||
let error = engine
|
||||
.call_operation(wip_host::CallRequest {
|
||||
target: wip_host::Target {
|
||||
path: "/file".into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: wip_host::InterfaceTarget {
|
||||
reference,
|
||||
scope_ref: Some("invented-root-ref".into()),
|
||||
validator: None,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_lock_wait_observes_the_operation_deadline() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
@@ -1621,8 +1671,6 @@ mod tests {
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.expect("open lock owner");
|
||||
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
||||
|
||||
let limits = ResourceLimits {
|
||||
request_timeout: std::time::Duration::from_millis(20),
|
||||
..ResourceLimits::default()
|
||||
@@ -1630,6 +1678,9 @@ mod tests {
|
||||
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
|
||||
.expect("open projection");
|
||||
let observed = projection.observe_object("/file").expect("observe file");
|
||||
// Observation also enters the coherent backend boundary now. Acquire
|
||||
// the interfering lock only after obtaining Client-known metadata.
|
||||
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
||||
let started = std::time::Instant::now();
|
||||
let error = call(
|
||||
&projection,
|
||||
|
||||
@@ -98,6 +98,7 @@ fn real_listener_serves_three_core_interactions_and_write() {
|
||||
validator: file.validator.clone(),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: file.interfaces[0].clone(),
|
||||
validator: None,
|
||||
},
|
||||
@@ -141,6 +142,7 @@ fn real_listener_serves_three_core_interactions_and_write() {
|
||||
validator: file.validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: file.interfaces[1].clone(),
|
||||
validator: None,
|
||||
},
|
||||
@@ -412,7 +414,7 @@ fn call_argument_decoding_does_not_mask_missing_target() {
|
||||
let config = ServerConfig::for_root(temporary.path());
|
||||
let server = WipFsServer::new(config).expect("build server");
|
||||
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
|
||||
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":"unknown"},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
|
||||
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":{"scope":"/","name":"unknown"}},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
.uri(endpoint.route(Route::CallOperation).as_str())
|
||||
@@ -490,6 +492,7 @@ fn read_response_size_failure_is_transport_local() {
|
||||
validator: file.validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: file.interfaces[0].clone(),
|
||||
validator: None,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ use wip_fs_server::{
|
||||
use wip_host::{
|
||||
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
||||
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
|
||||
RegisteredInterface, Validator, WipObject,
|
||||
RegisteredInterface, Scope, Validator, WipObject,
|
||||
};
|
||||
use wip_http::{
|
||||
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
|
||||
@@ -101,6 +101,9 @@ impl WipObject for Entry {
|
||||
fn description(&self) -> Option<&str> {
|
||||
Some("public memory entry")
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
(self.path == "/").then_some("entry-root")
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
Some(&self.validator)
|
||||
}
|
||||
@@ -157,7 +160,13 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct MemoryBackend(Arc<Shared>);
|
||||
struct MemoryBackend(Arc<Shared>, Arc<Node>);
|
||||
impl MemoryBackend {
|
||||
fn new(shared: Arc<Shared>) -> Self {
|
||||
let root = shared.node("/");
|
||||
Self(shared, root)
|
||||
}
|
||||
}
|
||||
fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
||||
if context.cancellation().is_cancelled()
|
||||
|| context
|
||||
@@ -193,6 +202,12 @@ impl FilesystemBackend for MemoryBackend {
|
||||
// One connected map snapshot, serialized with participating mutations.
|
||||
let _transaction = self.0.transaction.lock().unwrap();
|
||||
let nodes = self.0.nodes.lock().unwrap();
|
||||
if !nodes
|
||||
.get("/")
|
||||
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
|
||||
{
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
let Some(node) = nodes.get(path) else {
|
||||
return ProviderResult::NotFound;
|
||||
};
|
||||
@@ -320,6 +335,12 @@ impl FilesystemBackend for MemoryBackend {
|
||||
// Verify lineage identity/membership within the same transaction. The
|
||||
// callback cannot see a substituted ancestor, nor a redirected target.
|
||||
let nodes = self.0.nodes.lock().unwrap();
|
||||
if !nodes
|
||||
.get("/")
|
||||
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
|
||||
{
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
for ancestor in &ancestors {
|
||||
if !nodes
|
||||
.get(&ancestor.path)
|
||||
@@ -371,6 +392,11 @@ impl WipObject for Object {
|
||||
fn description(&self) -> Option<&str> {
|
||||
self.entry.description()
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
// Public representation identity need not be the backend entry ref.
|
||||
// ScopeObservation must capture this published Object's ref.
|
||||
(self.entry.path == "/").then_some("memory-root")
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
self.entry.validator()
|
||||
}
|
||||
@@ -574,11 +600,19 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
|
||||
for panic_object in [true, false] {
|
||||
let fixture = Fixture::new();
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
||||
let custom = registry.register(MemoryOperationsDefinition, None).unwrap();
|
||||
let reference = custom.reference().as_str().to_owned();
|
||||
let scope = Scope::new("/").unwrap();
|
||||
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
|
||||
let custom = registry
|
||||
.register(
|
||||
&scope,
|
||||
"memory.operations",
|
||||
MemoryOperationsDefinition,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reference = custom.reference().protocol_reference();
|
||||
let engine = Projection::new(
|
||||
MemoryBackend(fixture.shared.clone()),
|
||||
MemoryBackend::new(fixture.shared.clone()),
|
||||
registry,
|
||||
MemoryComposition {
|
||||
defaults,
|
||||
@@ -616,9 +650,11 @@ type Engine = Projection<MemoryBackend, MemoryComposition, Policy>;
|
||||
struct Fixture {
|
||||
engine: Engine,
|
||||
shared: Arc<Shared>,
|
||||
custom: String,
|
||||
read: String,
|
||||
custom: wip_protocol::InterfaceReference,
|
||||
read: wip_protocol::InterfaceReference,
|
||||
limits: ResourceLimits,
|
||||
// Client-known descriptors acquired coherently before execution fault injection.
|
||||
descriptors: BTreeMap<(bool, wip_protocol::InterfaceReference), InterfaceDescriptor>,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
@@ -669,9 +705,15 @@ impl Fixture {
|
||||
seen: Mutex::new(vec![]),
|
||||
});
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
||||
let scope = Scope::new("/").unwrap();
|
||||
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
|
||||
let custom = registry
|
||||
.register(MemoryOperationsDefinition, interface_validator)
|
||||
.register(
|
||||
&scope,
|
||||
"memory.operations",
|
||||
MemoryOperationsDefinition,
|
||||
interface_validator,
|
||||
)
|
||||
.unwrap();
|
||||
let read_ref = defaults.read.reference().clone();
|
||||
let custom_ref = custom.reference().clone();
|
||||
@@ -687,18 +729,38 @@ impl Fixture {
|
||||
custom,
|
||||
shared: shared.clone(),
|
||||
};
|
||||
Self {
|
||||
engine: Projection::new(
|
||||
MemoryBackend(shared.clone()),
|
||||
registry,
|
||||
composition,
|
||||
policy,
|
||||
limits,
|
||||
),
|
||||
shared,
|
||||
custom: custom_ref.as_str().into(),
|
||||
read: read_ref.as_str().into(),
|
||||
let engine = Projection::new(
|
||||
MemoryBackend::new(shared.clone()),
|
||||
registry,
|
||||
composition,
|
||||
policy,
|
||||
limits,
|
||||
);
|
||||
let mut descriptors = BTreeMap::new();
|
||||
if !invalid_selection {
|
||||
for principal in [Principal::Admin, Principal::Limited] {
|
||||
for reference in [&read_ref, &custom_ref] {
|
||||
let reference = reference.protocol_reference();
|
||||
if let Ok(observation) = engine
|
||||
.view(&principal)
|
||||
.fetch_interface(&fetch_request(&reference))
|
||||
{
|
||||
descriptors.insert(
|
||||
(matches!(principal, Principal::Admin), reference),
|
||||
observation.descriptor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shared.seen.lock().unwrap().clear();
|
||||
Self {
|
||||
engine,
|
||||
shared,
|
||||
custom: custom_ref.protocol_reference(),
|
||||
read: read_ref.protocol_reference(),
|
||||
limits,
|
||||
descriptors,
|
||||
}
|
||||
}
|
||||
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
|
||||
@@ -707,17 +769,17 @@ impl Fixture {
|
||||
.observe(&ExecutionContext::new(), &observe_request(path, depth))
|
||||
.unwrap()
|
||||
}
|
||||
fn descriptor(&self, principal: &Principal, reference: &str) -> InterfaceDescriptor {
|
||||
self.engine
|
||||
.view(principal)
|
||||
.fetch_interface(&fetch_request(reference))
|
||||
.unwrap()
|
||||
.descriptor
|
||||
fn descriptor(
|
||||
&self,
|
||||
principal: &Principal,
|
||||
reference: &wip_protocol::InterfaceReference,
|
||||
) -> InterfaceDescriptor {
|
||||
self.descriptors[&(matches!(principal, Principal::Admin), reference.clone())].clone()
|
||||
}
|
||||
fn request(
|
||||
&self,
|
||||
path: &str,
|
||||
reference: &str,
|
||||
reference: &wip_protocol::InterfaceReference,
|
||||
operation: &str,
|
||||
validator: Option<Vec<u8>>,
|
||||
) -> CallOperationRequest {
|
||||
@@ -727,7 +789,8 @@ impl Fixture {
|
||||
validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: reference.into(),
|
||||
scope_ref: None,
|
||||
reference: reference.clone(),
|
||||
validator: None,
|
||||
},
|
||||
operation: operation.into(),
|
||||
@@ -866,9 +929,9 @@ fn observe_request(path: &str, depth: u32) -> ObserveRequest {
|
||||
depth,
|
||||
}
|
||||
}
|
||||
fn fetch_request(reference: &str) -> FetchInterfaceRequest {
|
||||
fn fetch_request(reference: &wip_protocol::InterfaceReference) -> FetchInterfaceRequest {
|
||||
FetchInterfaceRequest {
|
||||
interface: reference.into(),
|
||||
interface: reference.clone(),
|
||||
}
|
||||
}
|
||||
fn success<T: std::fmt::Debug>(response: DecodedResponse<T>) -> T {
|
||||
@@ -925,7 +988,10 @@ fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_cond
|
||||
Some("public memory entry")
|
||||
);
|
||||
assert!(observation.children.is_none());
|
||||
assert!(observation.object.r#ref.is_none());
|
||||
assert_eq!(
|
||||
observation.object.r#ref.as_deref(),
|
||||
(path == "/").then_some("memory-root")
|
||||
);
|
||||
for reference in &observation.object.interfaces {
|
||||
let fetched = fixture
|
||||
.engine
|
||||
@@ -1378,10 +1444,12 @@ fn policy_cannot_add_an_unimplemented_interface() {
|
||||
ProtocolErrorCode::Internal
|
||||
);
|
||||
let request = fixture.request("/plain", &fixture.custom, "denied", None);
|
||||
// This deliberately invalid publication cannot supply a fetch response.
|
||||
// Use the application's authored schema solely to encode the test request.
|
||||
let (response, decoded) = fixture.http_call(
|
||||
&Principal::Admin,
|
||||
&request,
|
||||
&fixture.descriptor(&Principal::Admin, &fixture.custom),
|
||||
&MemoryOperationsDefinition::descriptor(),
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
wire_error(decoded, ProtocolErrorCode::Internal);
|
||||
@@ -1804,8 +1872,8 @@ fn policy_panics_and_guessed_operation_diagnostics_do_not_leak_to_http() {
|
||||
// This valid metadata names a nonexistent operation; bypass client-side
|
||||
// descriptor validation to exercise server diagnostics for untrusted names.
|
||||
let body = format!(
|
||||
r#"{{"target":{{"path":"/added"}},"interface":{{"reference":"{}"}},"operation":"{}","arguments":{{}}}}"#,
|
||||
fixture.custom, POLICY_SECRET
|
||||
r#"{{"target":{{"path":"/added"}},"interface":{{"reference":{{"scope":"{}","name":"{}"}}}},"operation":"{}","arguments":{{}}}}"#,
|
||||
fixture.custom.scope, fixture.custom.name, POLICY_SECRET
|
||||
);
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
@@ -2031,3 +2099,150 @@ fn observation_depth_node_and_context_budgets_fail_without_truncating_the_tree()
|
||||
);
|
||||
no_secrets(&response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scope_fetch_and_call_use_the_current_boundary_snapshot() {
|
||||
let fixture = Fixture::new();
|
||||
let view = fixture.engine.view(&Principal::Admin);
|
||||
let fetched = view
|
||||
.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap();
|
||||
assert_eq!(fetched.scope_ref.as_deref(), Some("memory-root"));
|
||||
assert!(
|
||||
fixture
|
||||
.observe(&Principal::Admin, "/", 0)
|
||||
.object
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let mut request = fixture.request("/added", &fixture.custom, "denied", None);
|
||||
request.interface.scope_ref = Some("wrong-root".into());
|
||||
assert_eq!(
|
||||
view.call_operation(request.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node("/added")
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// Ordinary state updates do not replace the publication lifetime or ref.
|
||||
fixture.shared.node("/").data.lock().unwrap().revision += 1;
|
||||
request.interface.scope_ref = fetched.scope_ref;
|
||||
view.call_operation(request).unwrap();
|
||||
|
||||
// Fetch cannot attest a stale prelookup: visibility is refreshed under the
|
||||
// same boundary even though resolve initially sees a visible root.
|
||||
fixture.shared.controls.lock().unwrap().hide_before_execute = true;
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scope_replacement_during_call_rejects_the_prelookup() {
|
||||
let fixture = Fixture::new();
|
||||
let view = fixture.engine.view(&Principal::Admin);
|
||||
// Simulate a retained non-root lineage whose root is replaced before call.
|
||||
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
|
||||
let request = fixture.request("/added", &fixture.custom, "denied", None);
|
||||
assert_eq!(
|
||||
view.call_operation(request).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::NotFound
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node("/added")
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
|
||||
let fixture = Fixture::new();
|
||||
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
|
||||
assert_eq!(
|
||||
fixture
|
||||
.engine
|
||||
.view(&Principal::Admin)
|
||||
.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonroot_registrations_fail_closed_until_the_engine_can_attest_their_lifetimes() {
|
||||
let fixture = Fixture::new();
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let root = Scope::new("/").unwrap();
|
||||
let local = Scope::new("/added").unwrap();
|
||||
let defaults = DefaultInterfaces::register(&mut registry, &root).unwrap();
|
||||
let custom = registry
|
||||
.register(
|
||||
&local,
|
||||
"memory.operations",
|
||||
MemoryOperationsDefinition,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reference = custom.reference().protocol_reference();
|
||||
let policy = Policy {
|
||||
custom: custom.reference().clone(),
|
||||
read: defaults.read.reference().clone(),
|
||||
hide_read: false,
|
||||
invalid_selection: false,
|
||||
panic_authorize: false,
|
||||
};
|
||||
let engine = Projection::new(
|
||||
MemoryBackend::new(fixture.shared.clone()),
|
||||
registry,
|
||||
MemoryComposition {
|
||||
defaults,
|
||||
custom,
|
||||
shared: fixture.shared.clone(),
|
||||
},
|
||||
policy,
|
||||
fixture.limits,
|
||||
);
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let object = view.observe_object("/added").unwrap();
|
||||
assert!(!object.interfaces.contains(&reference));
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&reference))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let request = fixture.request("/added", &reference, "denied", None);
|
||||
assert_eq!(
|
||||
view.call_operation(request).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node("/added")
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ wip-host = "0.1.0"
|
||||
wip-interface = "0.1.0"
|
||||
```
|
||||
|
||||
Protocol publication and dispatch use `wip_protocol::InterfaceReference { scope, name }` and compare both fields exactly. The legacy `InterfaceRef` registration token remains Host-private: `protocol_reference()` maps its opaque string verbatim to the name at root scope `/`, without parsing it. Registry lookup and fetch accept structured references; non-root references do not resolve legacy registrations. Legacy responses omit `scope_ref`.
|
||||
|
||||
Malformed interface-reference shapes fail calls as `InvalidRequest` before object resolution. A well-shaped reference outside the target path's ancestor-or-self scope fails at the membership check as `InterfaceMismatch`.
|
||||
|
||||
See the [API documentation](https://docs.rs/wip-host) for publication, provider, execution-context, and effect-guarantee boundaries.
|
||||
|
||||
Licensed under either of Apache-2.0 or MIT at your option.
|
||||
|
||||
+343
-106
@@ -3,7 +3,7 @@
|
||||
//! # Responsibility boundaries
|
||||
//!
|
||||
//! [`InterfaceRegistry::register`] validates and freezes a generated interface
|
||||
//! descriptor, assigns an opaque [`InterfaceRef`], and leaves that reference
|
||||
//! descriptor at an explicit scope and local name, and leaves that reference
|
||||
//! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
|
||||
//! request-scoped Rust object with a registered interface; its
|
||||
//! [`InterfaceImplementation`] bound proves at compile time that the object
|
||||
@@ -11,7 +11,8 @@
|
||||
//! and a publication with no interfaces is valid.
|
||||
//!
|
||||
//! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
|
||||
//! keeps the resulting object, interface membership, descriptor, implementation,
|
||||
//! keeps the resulting object, current scope Object association/ref, interface
|
||||
//! membership, descriptor, implementation,
|
||||
//! and backend target fixed within the provider's [`ObjectProvider::with_object`]
|
||||
//! callback. Dispatch is qualified by `(interface reference, operation name)`.
|
||||
//! The same frozen operation declaration validates both arguments and results.
|
||||
@@ -29,14 +30,13 @@
|
||||
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use std::any::TypeId;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::error::Error;
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
use std::marker::PhantomData;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use wip_interface::DispatchError;
|
||||
@@ -49,8 +49,8 @@ pub use wip_protocol::{
|
||||
Target,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor, Object, ProtocolError,
|
||||
ProtocolErrorCode, ValidationError, Value,
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor, InterfaceReference, Object,
|
||||
ProtocolError, ProtocolErrorCode, ValidationError, Value,
|
||||
};
|
||||
|
||||
/// Opaque bytes used as a conditional execution precondition.
|
||||
@@ -187,25 +187,104 @@ impl ExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque host-assigned reference to one registered interface artifact.
|
||||
/// One publication lifetime of the Object at a canonical scope path.
|
||||
///
|
||||
/// Clone while the same Object remains published, including state updates. Create
|
||||
/// a new handle after deletion, replacement or movement, even with the same ref.
|
||||
/// This private lifetime is not a Protocol identity component or generated ref.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Scope {
|
||||
path: String,
|
||||
lifetime: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
/// Creates a fresh lifetime without implicitly publishing an Object.
|
||||
pub fn new(path: impl Into<String>) -> Result<Self, ValidationError> {
|
||||
let path = path.into();
|
||||
wip_protocol::validate_path(&path)?;
|
||||
Ok(Self {
|
||||
path,
|
||||
lifetime: Arc::new(AtomicBool::new(true)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the client-facing canonical scope path.
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn same_lifetime(&self, other: &Self) -> bool {
|
||||
self.path == other.path && Arc::ptr_eq(&self.lifetime, &other.lifetime)
|
||||
}
|
||||
|
||||
fn active(&self) -> bool {
|
||||
self.lifetime.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-attested current scope Object association.
|
||||
///
|
||||
/// Construct only inside a coherent boundary that proves this lifetime belongs
|
||||
/// to the Object at `scope.path()`. A prelookup or matching ref alone is not proof.
|
||||
/// The Object need not expose interfaces. Its validator is not scope identity.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScopeObservation {
|
||||
scope: Scope,
|
||||
reference: Option<String>,
|
||||
}
|
||||
|
||||
impl ScopeObservation {
|
||||
/// Attests the association and captures the current Object's optional ref.
|
||||
#[must_use]
|
||||
pub fn new(scope: &Scope, object: &impl WipObject) -> Self {
|
||||
Self {
|
||||
scope: scope.clone(),
|
||||
reference: object.reference().map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the observed optional ref.
|
||||
#[must_use]
|
||||
pub fn reference(&self) -> Option<&str> {
|
||||
self.reference.as_deref()
|
||||
}
|
||||
|
||||
/// Returns the associated scope publication lifetime.
|
||||
#[must_use]
|
||||
pub fn scope(&self) -> &Scope {
|
||||
&self.scope
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured client-facing reference to a scoped Interface publication.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct InterfaceRef(String);
|
||||
pub struct InterfaceRef(InterfaceReference);
|
||||
|
||||
impl InterfaceRef {
|
||||
/// Returns the opaque reference for a protocol transport.
|
||||
/// Returns the local name without parsing or adding display delimiters.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
&self.0.name
|
||||
}
|
||||
|
||||
/// Returns the structured Protocol reference.
|
||||
#[must_use]
|
||||
pub fn protocol_reference(&self) -> InterfaceReference {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// One atomic observation of an interface reference, descriptor, and validator.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct InterfaceObservation {
|
||||
/// Opaque reference selected by published objects and operation calls.
|
||||
/// Structured reference selected by published objects and operation calls.
|
||||
pub reference: InterfaceRef,
|
||||
/// Canonical descriptor frozen when the interface was registered.
|
||||
pub descriptor: InterfaceDescriptor,
|
||||
/// Optional ref of the scope Object from this same observation.
|
||||
pub scope_ref: Option<String>,
|
||||
/// Optional validator. Its presence makes it a required call precondition.
|
||||
pub validator: Option<Validator>,
|
||||
}
|
||||
@@ -229,13 +308,14 @@ impl InterfaceObservation {
|
||||
/// local validation error.
|
||||
pub fn protocol_response(&self) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||
let response = FetchInterfaceResponse {
|
||||
interface: self.reference.0.clone(),
|
||||
interface: self.reference.protocol_reference(),
|
||||
descriptor: self.descriptor.clone(),
|
||||
scope_ref: self.scope_ref.clone(),
|
||||
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
||||
};
|
||||
response
|
||||
.validate_for(&FetchInterfaceRequest {
|
||||
interface: self.reference.0.clone(),
|
||||
interface: self.reference.protocol_reference(),
|
||||
})
|
||||
.map_err(|_| ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
@@ -247,6 +327,7 @@ impl InterfaceObservation {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InterfaceArtifact {
|
||||
scope: Scope,
|
||||
reference: InterfaceRef,
|
||||
descriptor: InterfaceDescriptor,
|
||||
validator: Option<Validator>,
|
||||
@@ -255,7 +336,7 @@ struct InterfaceArtifact {
|
||||
/// A typed registration token for a generated interface definition.
|
||||
///
|
||||
/// Tokens can be reused by any number of request-scoped objects. They expose an
|
||||
/// opaque reference, not the Rust type name or an address.
|
||||
/// scoped reference and a private publication lifetime, not a Rust address.
|
||||
pub struct RegisteredInterface<D> {
|
||||
artifact: Arc<InterfaceArtifact>,
|
||||
definition: PhantomData<fn() -> D>,
|
||||
@@ -271,158 +352,161 @@ impl<D> Clone for RegisteredInterface<D> {
|
||||
}
|
||||
|
||||
impl<D> RegisteredInterface<D> {
|
||||
/// Returns the opaque protocol reference assigned by the registry.
|
||||
/// Returns the explicit scope/name reference.
|
||||
#[must_use]
|
||||
pub fn reference(&self) -> &InterfaceRef {
|
||||
&self.artifact.reference
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry of validated, immutable interface artifacts.
|
||||
/// Registry of frozen definition artifacts and explicitly scoped registrations.
|
||||
///
|
||||
/// Generated descriptors are never rewritten to contain their assigned
|
||||
/// references. A validator-free artifact is immutable for the lifetime of its
|
||||
/// reference. A validator-bearing artifact requires that exact validator on a
|
||||
/// call. Re-registering a definition is rejected rather than silently changing
|
||||
/// the observation associated with an existing reference.
|
||||
#[derive(Debug)]
|
||||
/// Artifact retention is not proof of current publication. Fetch requires a live
|
||||
/// provider-attested ScopeObservation; execution requires the same lifetime in
|
||||
/// the target's atomic boundary. Collision keys are exact (scope, name) pairs,
|
||||
/// not Rust TypeId: the same Definition can be registered at multiple coordinates.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InterfaceRegistry {
|
||||
namespace: u64,
|
||||
next_reference: u64,
|
||||
definitions: HashSet<TypeId>,
|
||||
artifacts: HashMap<InterfaceRef, Arc<InterfaceArtifact>>,
|
||||
}
|
||||
|
||||
static NEXT_REGISTRY_NAMESPACE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
impl Default for InterfaceRegistry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
namespace: NEXT_REGISTRY_NAMESPACE.fetch_add(1, Ordering::Relaxed),
|
||||
next_reference: 0,
|
||||
definitions: HashSet::new(),
|
||||
artifacts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceRegistry {
|
||||
/// Creates an empty interface registry.
|
||||
/// Creates an empty artifact registry without publishing any Object.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers and freezes one generated interface definition.
|
||||
/// Freezes a Definition at an explicit scope and local name.
|
||||
pub fn register<D>(
|
||||
&mut self,
|
||||
scope: &Scope,
|
||||
name: impl Into<String>,
|
||||
_definition: D,
|
||||
validator: Option<Validator>,
|
||||
) -> Result<RegisteredInterface<D>, RegistrationError>
|
||||
where
|
||||
D: InterfaceDefinition + 'static,
|
||||
{
|
||||
let definition = TypeId::of::<D>();
|
||||
if self.definitions.contains(&definition) {
|
||||
let reference = InterfaceRef(InterfaceReference {
|
||||
scope: scope.path.clone(),
|
||||
name: name.into(),
|
||||
});
|
||||
reference
|
||||
.0
|
||||
.validate()
|
||||
.map_err(RegistrationError::InvalidReference)?;
|
||||
if !scope.active() {
|
||||
return Err(RegistrationError::RetiredScope);
|
||||
}
|
||||
if self.artifacts.contains_key(&reference) {
|
||||
return Err(RegistrationError::AlreadyRegistered);
|
||||
}
|
||||
|
||||
let descriptor = D::descriptor();
|
||||
descriptor
|
||||
.validate()
|
||||
.map_err(RegistrationError::InvalidDescriptor)?;
|
||||
|
||||
let reference = InterfaceRef(format!(
|
||||
"wip-interface:{}:{}",
|
||||
self.namespace, self.next_reference
|
||||
));
|
||||
self.next_reference = self
|
||||
.next_reference
|
||||
.checked_add(1)
|
||||
.ok_or(RegistrationError::ReferenceSpaceExhausted)?;
|
||||
let artifact = Arc::new(InterfaceArtifact {
|
||||
scope: scope.clone(),
|
||||
reference: reference.clone(),
|
||||
descriptor,
|
||||
validator,
|
||||
});
|
||||
self.definitions.insert(definition);
|
||||
self.artifacts.insert(reference, Arc::clone(&artifact));
|
||||
|
||||
Ok(RegisteredInterface {
|
||||
artifact,
|
||||
definition: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves a transport-provided opaque reference string in this registry.
|
||||
/// Ends a scope lifetime and releases its registry entries.
|
||||
///
|
||||
/// Retained tokens cannot start new calls/fetches. Started calls retain their
|
||||
/// frozen Descriptor. Re-publication requires a fresh Scope.
|
||||
pub fn remove_scope(&mut self, scope: &Scope) {
|
||||
scope.lifetime.store(false, Ordering::Release);
|
||||
self.artifacts
|
||||
.retain(|_, artifact| !artifact.scope.same_lifetime(scope));
|
||||
}
|
||||
|
||||
/// Resolves a stored artifact, not evidence of current publication.
|
||||
#[must_use]
|
||||
pub fn resolve(&self, reference: &str) -> Option<InterfaceRef> {
|
||||
let reference = InterfaceRef(reference.to_owned());
|
||||
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
|
||||
let reference = InterfaceRef(reference.clone());
|
||||
self.artifacts.contains_key(&reference).then_some(reference)
|
||||
}
|
||||
|
||||
/// Observes a registered interface by its host-side reference.
|
||||
#[must_use]
|
||||
pub fn observe_object(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
|
||||
self.artifacts.get(reference).map(Self::observation)
|
||||
/// Iterates registered scope handles, possibly repeated for multiple names.
|
||||
pub fn scopes(&self) -> impl Iterator<Item = &Scope> {
|
||||
self.artifacts.values().map(|artifact| &artifact.scope)
|
||||
}
|
||||
|
||||
/// Implements canonical `fetch_interface` semantics for an opaque reference.
|
||||
///
|
||||
/// Unknown and currently unpublished references map to `InterfaceNotFound`,
|
||||
/// not object membership errors or authorization failures.
|
||||
pub fn fetch_interface(&self, reference: &str) -> Result<InterfaceObservation, ProtocolError> {
|
||||
let reference = InterfaceRef(reference.to_owned());
|
||||
/// Fetches only with a coherent current scope Object attestation.
|
||||
pub fn fetch_interface(
|
||||
&self,
|
||||
reference: &InterfaceReference,
|
||||
current: &ScopeObservation,
|
||||
) -> Result<InterfaceObservation, ProtocolError> {
|
||||
reference.validate().map_err(|error| ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
self.artifacts
|
||||
.get(&reference)
|
||||
.map(Self::observation)
|
||||
.ok_or_else(|| ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceNotFound,
|
||||
message: "interface reference is not currently published".into(),
|
||||
.get(&InterfaceRef(reference.clone()))
|
||||
.filter(|artifact| {
|
||||
artifact.scope.active() && artifact.scope.same_lifetime(¤t.scope)
|
||||
})
|
||||
}
|
||||
|
||||
fn observation(artifact: &Arc<InterfaceArtifact>) -> InterfaceObservation {
|
||||
InterfaceObservation {
|
||||
reference: artifact.reference.clone(),
|
||||
descriptor: artifact.descriptor.clone(),
|
||||
validator: artifact.validator.clone(),
|
||||
}
|
||||
.map(|artifact| InterfaceObservation {
|
||||
reference: artifact.reference.clone(),
|
||||
descriptor: artifact.descriptor.clone(),
|
||||
scope_ref: current.reference.clone(),
|
||||
validator: artifact.validator.clone(),
|
||||
})
|
||||
.ok_or_else(interface_not_found)
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure while registering a generated interface definition.
|
||||
fn interface_not_found() -> ProtocolError {
|
||||
ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceNotFound,
|
||||
message: "interface is not currently published".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure while registering a generated interface Definition.
|
||||
#[derive(Debug)]
|
||||
pub enum RegistrationError {
|
||||
/// The same generated definition was already assigned a reference.
|
||||
/// The exact scope/name coordinate is already occupied.
|
||||
AlreadyRegistered,
|
||||
/// The generated descriptor is structurally invalid.
|
||||
/// The generated Descriptor is invalid.
|
||||
InvalidDescriptor(ValidationError),
|
||||
/// The registry cannot assign another opaque reference.
|
||||
ReferenceSpaceExhausted,
|
||||
/// The scope or local name is invalid.
|
||||
InvalidReference(ValidationError),
|
||||
/// The lifetime ended; re-publication needs a fresh Scope.
|
||||
RetiredScope,
|
||||
}
|
||||
|
||||
impl Display for RegistrationError {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::AlreadyRegistered => {
|
||||
formatter.write_str("interface definition already registered")
|
||||
formatter.write_str("interface scope/name already registered")
|
||||
}
|
||||
Self::InvalidDescriptor(error) => {
|
||||
write!(formatter, "invalid interface descriptor: {error}")
|
||||
}
|
||||
Self::ReferenceSpaceExhausted => {
|
||||
formatter.write_str("interface reference space exhausted")
|
||||
Self::InvalidReference(error) => {
|
||||
write!(formatter, "invalid interface reference: {error}")
|
||||
}
|
||||
Self::RetiredScope => formatter.write_str("scope publication lifetime has ended"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RegistrationError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::InvalidDescriptor(error) => Some(error),
|
||||
Self::AlreadyRegistered | Self::ReferenceSpaceExhausted => None,
|
||||
Self::InvalidDescriptor(error) | Self::InvalidReference(error) => Some(error),
|
||||
Self::AlreadyRegistered | Self::RetiredScope => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -529,6 +613,7 @@ struct BoundInterface<T> {
|
||||
pub struct PublishedObject<T: WipObject> {
|
||||
implementation: T,
|
||||
interfaces: Vec<BoundInterface<T>>,
|
||||
scopes: Vec<ScopeObservation>,
|
||||
}
|
||||
|
||||
impl<T: WipObject> PublishedObject<T> {
|
||||
@@ -538,13 +623,45 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
Self {
|
||||
implementation,
|
||||
interfaces: Vec::new(),
|
||||
scopes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches a current scope snapshot inside the provider's atomic boundary.
|
||||
///
|
||||
/// Never populate this from a stale prelookup. Target, scope association,
|
||||
/// Descriptor and backend handles must remain coherent through dispatch and
|
||||
/// commit. A later same-path binding replaces the earlier snapshot.
|
||||
#[must_use]
|
||||
pub fn bind_scope(mut self, current: ScopeObservation) -> Self {
|
||||
self.scopes
|
||||
.retain(|old| old.scope.path != current.scope.path);
|
||||
self.scopes.push(current);
|
||||
self
|
||||
}
|
||||
|
||||
fn current_scope(&self, scope: &Scope) -> Option<&ScopeObservation> {
|
||||
self.scopes
|
||||
.iter()
|
||||
.find(|current| scope.active() && scope.same_lifetime(¤t.scope))
|
||||
}
|
||||
|
||||
fn scopes_valid_at(&self, path: &str) -> bool {
|
||||
self.interfaces.iter().all(|bound| {
|
||||
bound.artifact.reference.0.validate_for_path(path).is_ok()
|
||||
&& self
|
||||
.current_scope(&bound.artifact.scope)
|
||||
.is_some_and(|current| {
|
||||
current.scope.path != path
|
||||
|| current.reference() == self.implementation.reference()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Explicitly exposes a registered generated interface in insertion order.
|
||||
///
|
||||
/// The `InterfaceImplementation<T>` bound is generated only when `T`
|
||||
/// implements the corresponding Rust interface trait. Duplicate opaque
|
||||
/// implements the corresponding Rust interface trait. Duplicate scoped
|
||||
/// references are rejected.
|
||||
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
|
||||
where
|
||||
@@ -597,6 +714,7 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
let mut descriptor = bound.artifact.descriptor.clone();
|
||||
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
|
||||
bound.artifact = Arc::new(InterfaceArtifact {
|
||||
scope: bound.artifact.scope.clone(),
|
||||
reference: reference.clone(),
|
||||
descriptor,
|
||||
validator: bound.artifact.validator.clone(),
|
||||
@@ -613,9 +731,13 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
&self.implementation
|
||||
}
|
||||
|
||||
/// Builds canonical protocol object metadata in interface insertion order.
|
||||
/// Builds metadata from the explicit exposure set in insertion order.
|
||||
///
|
||||
/// The observation contains no path, children, descriptor, backend handle,
|
||||
/// This path-free projection is useful while composing a publication, but is
|
||||
/// not proof that its scopes are currently published. Use
|
||||
/// [`ObjectProvider::observe_object`] for a validated current observation, or
|
||||
/// [`Self::protocol_object_at`] inside a provider's coherent boundary.
|
||||
/// The metadata contains no path, children, descriptor, backend handle,
|
||||
/// credential, or provider resolution state.
|
||||
#[must_use]
|
||||
pub fn protocol_object(&self) -> Object {
|
||||
@@ -625,7 +747,7 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
interfaces: self
|
||||
.interfaces
|
||||
.iter()
|
||||
.map(|bound| bound.artifact.reference.0.clone())
|
||||
.map(|bound| bound.artifact.reference.protocol_reference())
|
||||
.collect(),
|
||||
r#ref: self.implementation.reference().map(str::to_owned),
|
||||
validator: self
|
||||
@@ -635,6 +757,30 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a current Object observation inside the provider's boundary.
|
||||
///
|
||||
/// All exposed interfaces must have compatible, live scope associations. The
|
||||
/// provider must still establish those associations atomically; this method
|
||||
/// cannot verify backend lifetime assertions or lock external writers.
|
||||
pub fn protocol_object_at(&self, path: &str) -> Result<Object, CallError> {
|
||||
wip_protocol::validate_path(path)
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
if !self.scopes_valid_at(path) {
|
||||
return Err(CallError::new(
|
||||
CallErrorKind::Internal,
|
||||
"Host published interfaces without current compatible scope Objects",
|
||||
));
|
||||
}
|
||||
let object = self.protocol_object();
|
||||
object.validate_at_path(path).map_err(|_| {
|
||||
CallError::new(
|
||||
CallErrorKind::Internal,
|
||||
"Host produced an invalid object response",
|
||||
)
|
||||
})?;
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
fn dispatch_resolved(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
@@ -678,10 +824,17 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
);
|
||||
}
|
||||
|
||||
if interface.reference.validate_for_path(&target.path).is_err() {
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::InterfaceMismatch,
|
||||
"interface scope is not an ancestor of the target path",
|
||||
);
|
||||
}
|
||||
|
||||
let Some(bound) = self
|
||||
.interfaces
|
||||
.iter()
|
||||
.find(|bound| bound.artifact.reference.as_str() == interface.reference.as_str())
|
||||
.find(|bound| bound.artifact.reference.protocol_reference() == interface.reference)
|
||||
else {
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::InterfaceMismatch,
|
||||
@@ -689,6 +842,31 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
);
|
||||
};
|
||||
|
||||
let Some(current_scope) = self.current_scope(&bound.artifact.scope) else {
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::InterfaceMismatch,
|
||||
"interface scope Object is not currently associated with this publication",
|
||||
);
|
||||
};
|
||||
if current_scope.scope.path == target.path
|
||||
&& current_scope.reference() != self.implementation.reference()
|
||||
{
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::Internal,
|
||||
"provider produced inconsistent self-scope metadata",
|
||||
);
|
||||
}
|
||||
if interface
|
||||
.scope_ref
|
||||
.as_deref()
|
||||
.is_some_and(|supplied| current_scope.reference() != Some(supplied))
|
||||
{
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::InterfaceMismatch,
|
||||
"scope Object ref does not match the current association",
|
||||
);
|
||||
}
|
||||
|
||||
match (
|
||||
bound.artifact.validator.as_ref(),
|
||||
interface.validator.as_deref(),
|
||||
@@ -927,7 +1105,12 @@ pub enum ProviderResult<T> {
|
||||
///
|
||||
/// `with_object` must resolve the current object at `path` exactly once and pass
|
||||
/// a publication that pins its current interface set, implementations, and
|
||||
/// backend target for the duration of `operation`. The execution context is the
|
||||
/// backend target and all current scope Object associations for the duration of
|
||||
/// `operation`. Attach them with PublishedObject::bind_scope inside this same
|
||||
/// boundary, never via an independent scope prelookup. A new Object at the same
|
||||
/// path needs a fresh Scope; ordinary state changes retain the handle. Providers
|
||||
/// own lifetime/atomicity enforcement, including external writers and backend
|
||||
/// commit/rollback. The execution context is the
|
||||
/// runtime-neutral carrier for cancellation, deadline, and resource controls.
|
||||
/// The borrowed publication cannot escape the callback.
|
||||
///
|
||||
@@ -991,7 +1174,9 @@ pub trait ObjectProvider {
|
||||
wip_protocol::validate_path(path)
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
let resolved = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.with_object(context, path, PublishedObject::protocol_object)
|
||||
self.with_object(context, path, |publication| {
|
||||
publication.protocol_object_at(path)
|
||||
})
|
||||
}))
|
||||
.map_err(|_| {
|
||||
CallError::new(
|
||||
@@ -1000,7 +1185,7 @@ pub trait ObjectProvider {
|
||||
)
|
||||
})?;
|
||||
let object = match resolved {
|
||||
ProviderResult::Found(object) => object,
|
||||
ProviderResult::Found(object) => object?,
|
||||
ProviderResult::NotFound => {
|
||||
return Err(CallError::new(
|
||||
CallErrorKind::NotFound,
|
||||
@@ -1009,15 +1194,58 @@ pub trait ObjectProvider {
|
||||
}
|
||||
ProviderResult::Failure(error) => return Err(CallError::from_read_failure(error)),
|
||||
};
|
||||
object.validate_at_path(path).map_err(|_| {
|
||||
CallError::new(
|
||||
CallErrorKind::Internal,
|
||||
"Host produced an invalid object response",
|
||||
)
|
||||
})?;
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
/// Fetches Descriptor, scope Object ref and Interface validator atomically.
|
||||
///
|
||||
/// The scope Object may expose no interfaces. Its publication must still
|
||||
/// bind the live ScopeObservation. Registry retention is not publication.
|
||||
fn fetch_interface(
|
||||
&self,
|
||||
registry: &InterfaceRegistry,
|
||||
reference: &InterfaceReference,
|
||||
) -> Result<InterfaceObservation, ProtocolError> {
|
||||
reference.validate().map_err(|error| ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let observed = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.with_object(
|
||||
&ExecutionContext::default(),
|
||||
&reference.scope,
|
||||
|publication| {
|
||||
publication
|
||||
.scopes
|
||||
.iter()
|
||||
.find(|current| current.scope.path == reference.scope)
|
||||
.ok_or_else(interface_not_found)
|
||||
.and_then(|current| {
|
||||
if current.reference() != publication.implementation.reference() {
|
||||
return Err(ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
message: "provider produced inconsistent scope Object metadata"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
registry.fetch_interface(reference, current)
|
||||
})
|
||||
},
|
||||
)
|
||||
}))
|
||||
.map_err(|_| ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
message: "provider panicked while observing an interface".into(),
|
||||
})?;
|
||||
match observed {
|
||||
ProviderResult::Found(observation) => observation,
|
||||
ProviderResult::NotFound => Err(interface_not_found()),
|
||||
ProviderResult::Failure(error) => {
|
||||
Err(CallError::from_read_failure(error).protocol_error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves and executes a canonical path-targeted operation request.
|
||||
///
|
||||
/// Structural path validation precedes provider resolution. For a valid
|
||||
@@ -1039,6 +1267,11 @@ pub trait ObjectProvider {
|
||||
) -> Result<CallResult, CallError> {
|
||||
wip_protocol::validate_path(&request.target.path)
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
request
|
||||
.interface
|
||||
.reference
|
||||
.validate()
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
let path = request.target.path.clone();
|
||||
let execution = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.execute_operation(context, &path, |publication| {
|
||||
@@ -1088,6 +1321,10 @@ pub trait ObjectProvider {
|
||||
) -> Result<CallResult, CallError> {
|
||||
wip_protocol::validate_path(&target.path)
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
interface
|
||||
.reference
|
||||
.validate()
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
let path = target.path.clone();
|
||||
let execution = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.execute_operation(context, &path, |publication| {
|
||||
@@ -1128,7 +1365,7 @@ pub trait ObjectProvider {
|
||||
/// Failure to construct an explicit object publication.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PublicationError {
|
||||
/// The same opaque interface reference was exposed more than once.
|
||||
/// The same scope/name Interface reference was exposed more than once.
|
||||
DuplicateInterface(InterfaceRef),
|
||||
/// Projection attempted to add an interface without an implementation.
|
||||
UnexposedInterface(InterfaceRef),
|
||||
|
||||
+177
-23
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
|
||||
use wip_host::{
|
||||
CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef,
|
||||
InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult,
|
||||
PublishedObject, RegisteredInterface, Target, Validator, WipObject,
|
||||
PublishedObject, RegisteredInterface, Scope, ScopeObservation, Target, Validator, WipObject,
|
||||
};
|
||||
use wip_interface::{
|
||||
CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value,
|
||||
@@ -17,9 +17,16 @@ use wip_protocol::ProtocolErrorCode;
|
||||
#[test]
|
||||
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let read = registry.register(ReaderDefinition, None).unwrap();
|
||||
let write = registry.register(WriterDefinition, None).unwrap();
|
||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
||||
let root = Scope::new("/").unwrap();
|
||||
let read = registry
|
||||
.register(&root, "reader", ReaderDefinition, None)
|
||||
.unwrap();
|
||||
let write = registry
|
||||
.register(&root, "writer::#日本語", WriterDefinition, None)
|
||||
.unwrap();
|
||||
let hidden = registry
|
||||
.register(&root, "hidden", HiddenDefinition, None)
|
||||
.unwrap();
|
||||
let publication = || {
|
||||
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
|
||||
.expose(&read)
|
||||
@@ -35,7 +42,10 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
projected.protocol_object().interfaces,
|
||||
vec![write.reference().as_str(), read.reference().as_str()]
|
||||
vec![
|
||||
write.reference().protocol_reference(),
|
||||
read.reference().protocol_reference()
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
publication()
|
||||
@@ -58,14 +68,19 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
let full = registry.fetch_interface(read.reference().as_str()).unwrap();
|
||||
let current = ScopeObservation::new(&root, &Document::open("", None, Rc::new(Cell::new(0))));
|
||||
let full = registry
|
||||
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||
.unwrap();
|
||||
let filtered = full
|
||||
.clone()
|
||||
.project_operations(|operation| operation == "label");
|
||||
assert_eq!(filtered.descriptor.operations.len(), 1);
|
||||
assert_eq!(full.descriptor.operations.len(), 3);
|
||||
assert_eq!(
|
||||
registry.fetch_interface(read.reference().as_str()).unwrap(),
|
||||
registry
|
||||
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||
.unwrap(),
|
||||
full
|
||||
);
|
||||
}
|
||||
@@ -302,6 +317,7 @@ impl Broken for Document {
|
||||
}
|
||||
|
||||
struct TestProvider {
|
||||
root: Scope,
|
||||
reader: RegisteredInterface<ReaderDefinition>,
|
||||
writer: RegisteredInterface<WriterDefinition>,
|
||||
broken: RegisteredInterface<BrokenDefinition>,
|
||||
@@ -319,11 +335,13 @@ struct TestProvider {
|
||||
|
||||
impl TestProvider {
|
||||
fn new(
|
||||
root: Scope,
|
||||
reader: RegisteredInterface<ReaderDefinition>,
|
||||
writer: RegisteredInterface<WriterDefinition>,
|
||||
broken: RegisteredInterface<BrokenDefinition>,
|
||||
) -> Self {
|
||||
Self {
|
||||
root,
|
||||
reader,
|
||||
writer,
|
||||
broken,
|
||||
@@ -355,7 +373,10 @@ impl ObjectProvider for TestProvider {
|
||||
self.observed_cancelled
|
||||
.set(context.cancellation().is_cancelled());
|
||||
self.resolutions.set(self.resolutions.get() + 1);
|
||||
let scope_object = Document::open("", None, Rc::clone(&self.calls));
|
||||
let current_scope = ScopeObservation::new(&self.root, &scope_object);
|
||||
let (document, interfaces) = match path {
|
||||
"/" => (scope_object, 0),
|
||||
"/document" => (
|
||||
Document::strict(
|
||||
"document",
|
||||
@@ -413,6 +434,7 @@ impl ObjectProvider for TestProvider {
|
||||
3 => publication.expose(&self.broken).unwrap(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let publication = publication.bind_scope(current_scope);
|
||||
ProviderResult::Found(operation(&publication))
|
||||
}
|
||||
|
||||
@@ -454,13 +476,25 @@ fn registry_and_provider() -> (
|
||||
RegisteredInterface<HiddenDefinition>,
|
||||
) {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let root = Scope::new("/").unwrap();
|
||||
let reader = registry
|
||||
.register(ReaderDefinition, Some(Validator::new(b"schema-v1")))
|
||||
.register(
|
||||
&root,
|
||||
"reader",
|
||||
ReaderDefinition,
|
||||
Some(Validator::new(b"schema-v1")),
|
||||
)
|
||||
.unwrap();
|
||||
let writer = registry.register(WriterDefinition, None).unwrap();
|
||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
||||
let broken = registry.register(BrokenDefinition, None).unwrap();
|
||||
let provider = TestProvider::new(reader, writer, broken);
|
||||
let writer = registry
|
||||
.register(&root, "writer::#日本語", WriterDefinition, None)
|
||||
.unwrap();
|
||||
let hidden = registry
|
||||
.register(&root, "hidden", HiddenDefinition, None)
|
||||
.unwrap();
|
||||
let broken = registry
|
||||
.register(&root, "broken", BrokenDefinition, None)
|
||||
.unwrap();
|
||||
let provider = TestProvider::new(root, reader, writer, broken);
|
||||
(registry, provider, hidden)
|
||||
}
|
||||
|
||||
@@ -478,7 +512,8 @@ fn request(
|
||||
validator: object_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: reference.as_str().to_owned(),
|
||||
reference: reference.protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: interface_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
operation: operation.into(),
|
||||
@@ -497,17 +532,22 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
let reference = provider.reader.reference();
|
||||
|
||||
assert_ne!(reference.as_str(), "Reader");
|
||||
let observation = registry.fetch_interface(reference.as_str()).unwrap();
|
||||
let observation = provider
|
||||
.fetch_interface(®istry, &reference.protocol_reference())
|
||||
.unwrap();
|
||||
assert_eq!(observation.reference, *reference);
|
||||
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
|
||||
assert_eq!(observation.validator, Some(Validator::new(b"schema-v1")));
|
||||
let response = observation.protocol_response().unwrap();
|
||||
assert_eq!(response.interface, reference.as_str());
|
||||
assert_eq!(response.interface, reference.protocol_reference());
|
||||
assert_eq!(response.scope_ref, None);
|
||||
assert_eq!(response.descriptor, ReaderDefinition::descriptor());
|
||||
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
||||
response.descriptor.validate().unwrap();
|
||||
|
||||
let mut invalid_observation = registry.fetch_interface(reference.as_str()).unwrap();
|
||||
let mut invalid_observation = provider
|
||||
.fetch_interface(®istry, &reference.protocol_reference())
|
||||
.unwrap();
|
||||
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
||||
invalid_observation
|
||||
.descriptor
|
||||
@@ -516,7 +556,15 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
||||
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
||||
|
||||
let error = registry.fetch_interface("unknown").unwrap_err();
|
||||
let error = provider
|
||||
.fetch_interface(
|
||||
®istry,
|
||||
&wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: "unknown".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
||||
}
|
||||
|
||||
@@ -532,8 +580,8 @@ fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
|
||||
assert_eq!(
|
||||
object.interfaces,
|
||||
vec![
|
||||
provider.writer.reference().as_str().to_owned(),
|
||||
provider.reader.reference().as_str().to_owned(),
|
||||
provider.writer.reference().protocol_reference(),
|
||||
provider.reader.reference().protocol_reference(),
|
||||
]
|
||||
);
|
||||
assert!(!format!("{object:?}").contains("secret-handle"));
|
||||
@@ -814,7 +862,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"stale".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: hidden.reference().as_str().into(),
|
||||
reference: hidden.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
"missing".into(),
|
||||
@@ -832,7 +881,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"stale".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: hidden.reference().as_str().into(),
|
||||
reference: hidden.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
"hidden".into(),
|
||||
@@ -850,7 +900,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"object-v1".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: hidden.reference().as_str().into(),
|
||||
reference: hidden.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
"hidden".into(),
|
||||
@@ -868,7 +919,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"object-v1".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: provider.reader.reference().as_str().into(),
|
||||
reference: provider.reader.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: Some(b"schema-v1".to_vec()),
|
||||
},
|
||||
"add".into(),
|
||||
@@ -1122,3 +1174,105 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
|
||||
);
|
||||
assert_eq!(provider.calls.get(), calls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_boundaries_preserve_names_verbatim_and_match_exact_scope() {
|
||||
let (registry, provider, _) = registry_and_provider();
|
||||
let legacy = provider.writer.reference();
|
||||
let reference = legacy.protocol_reference();
|
||||
assert_eq!(reference.scope, "/");
|
||||
assert_eq!(reference.name, legacy.as_str());
|
||||
assert_eq!(reference.name, "writer::#日本語");
|
||||
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
|
||||
let nested = wip_protocol::InterfaceReference {
|
||||
scope: "/open".into(),
|
||||
name: reference.name.clone(),
|
||||
};
|
||||
assert!(registry.resolve(&nested).is_none());
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &nested)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let malformed = wip_protocol::InterfaceReference {
|
||||
scope: "relative".into(),
|
||||
name: reference.name,
|
||||
};
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &malformed)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_reference_shape_precedes_resolution_and_scope_membership_is_exact() {
|
||||
let (_, provider, _) = registry_and_provider();
|
||||
for (scope, name) in [("relative", "valid"), ("/", ""), ("/bad/", "valid")] {
|
||||
let mut call = request(
|
||||
"/open",
|
||||
None,
|
||||
provider.writer.reference(),
|
||||
None,
|
||||
"label",
|
||||
BTreeMap::new(),
|
||||
);
|
||||
call.interface.reference = wip_protocol::InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
};
|
||||
assert_eq!(
|
||||
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||
CallErrorKind::InvalidRequest
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::new(),
|
||||
call.target,
|
||||
call.interface,
|
||||
call.operation,
|
||||
|_| Ok::<_, ()>(BTreeMap::new()),
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InvalidRequest
|
||||
);
|
||||
}
|
||||
assert_eq!(provider.resolutions.get(), 0);
|
||||
// /other is outside the target scope; /open is self but has no legacy artifact.
|
||||
for scope in ["/other", "/open"] {
|
||||
let mut call = request(
|
||||
"/open",
|
||||
None,
|
||||
provider.writer.reference(),
|
||||
None,
|
||||
"label",
|
||||
BTreeMap::new(),
|
||||
);
|
||||
call.interface.reference.scope = scope.into();
|
||||
assert_eq!(
|
||||
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::new(),
|
||||
call.target,
|
||||
call.interface,
|
||||
call.operation,
|
||||
|_| -> Result<BTreeMap<String, Value>, ()> { panic!("must not decode") },
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
assert_eq!(provider.calls.get(), 0);
|
||||
assert_eq!(provider.authorization_checks.get(), 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wip_host::{
|
||||
CallErrorKind, CallRequest, ExecutionContext, InterfaceRegistry, InterfaceTarget,
|
||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, RegistrationError, Scope,
|
||||
ScopeObservation, Target, Validator, WipObject,
|
||||
};
|
||||
use wip_interface::{OperationResult, interface};
|
||||
use wip_protocol::{InterfaceReference, ProtocolErrorCode, Value};
|
||||
|
||||
#[interface]
|
||||
pub trait Read {
|
||||
fn read(&self, value: String) -> OperationResult<String>;
|
||||
}
|
||||
|
||||
#[interface]
|
||||
pub trait Count {
|
||||
fn read(&self, value: i64) -> OperationResult<i64>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Metadata {
|
||||
name: String,
|
||||
reference: Option<String>,
|
||||
validator: Option<Validator>,
|
||||
}
|
||||
impl WipObject for Metadata {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
self.reference.as_deref()
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
self.validator.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
type OnStart = Rc<RefCell<Option<Box<dyn Fn()>>>>;
|
||||
|
||||
struct Implementation {
|
||||
metadata: Metadata,
|
||||
calls: Rc<Cell<usize>>,
|
||||
on_start: OnStart,
|
||||
}
|
||||
impl WipObject for Implementation {
|
||||
fn name(&self) -> &str {
|
||||
self.metadata.name()
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
self.metadata.reference()
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
self.metadata.validator()
|
||||
}
|
||||
}
|
||||
impl Read for Implementation {
|
||||
fn read(&self, value: String) -> OperationResult<String> {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
if let Some(action) = self.on_start.borrow_mut().take() {
|
||||
action();
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
struct Provider {
|
||||
interface: RegisteredInterface<ReadDefinition>,
|
||||
current: Rc<RefCell<Option<(Scope, Metadata)>>>,
|
||||
target_path: String,
|
||||
target_exists: Cell<bool>,
|
||||
member: Cell<bool>,
|
||||
calls: Rc<Cell<usize>>,
|
||||
resolutions: Cell<usize>,
|
||||
on_start: OnStart,
|
||||
}
|
||||
impl Provider {
|
||||
fn new(
|
||||
scope: &Scope,
|
||||
interface: RegisteredInterface<ReadDefinition>,
|
||||
target: &str,
|
||||
reference: Option<&str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
interface,
|
||||
current: Rc::new(RefCell::new(Some((
|
||||
scope.clone(),
|
||||
Metadata {
|
||||
name: scope.path().rsplit('/').next().unwrap().into(),
|
||||
reference: reference.map(str::to_owned),
|
||||
validator: Some(Validator::new(b"scope-state-v1")),
|
||||
},
|
||||
)))),
|
||||
target_path: target.into(),
|
||||
target_exists: Cell::new(true),
|
||||
member: Cell::new(true),
|
||||
calls: Rc::new(Cell::new(0)),
|
||||
resolutions: Cell::new(0),
|
||||
on_start: Rc::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
fn request(&self, scope_ref: Option<&str>) -> CallRequest {
|
||||
CallRequest {
|
||||
target: Target {
|
||||
path: self.target_path.clone(),
|
||||
validator: None,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: self.interface.reference().protocol_reference(),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
validator: Some(b"descriptor-v1".to_vec()),
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::from([("value".into(), Value::String("original backend".into()))]),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ObjectProvider for Provider {
|
||||
type Object = Implementation;
|
||||
fn with_object<R>(
|
||||
&self,
|
||||
_: &ExecutionContext,
|
||||
path: &str,
|
||||
callback: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
||||
) -> ProviderResult<R> {
|
||||
self.resolutions.set(self.resolutions.get() + 1);
|
||||
// A single boundary captures both the target and the live scope association.
|
||||
let current = self.current.borrow().clone();
|
||||
let target = path == self.target_path;
|
||||
let metadata = if target {
|
||||
if !self.target_exists.get() {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
if let Some((scope, object)) = ¤t
|
||||
&& scope.path() == path
|
||||
{
|
||||
object.clone()
|
||||
} else {
|
||||
Metadata {
|
||||
name: path.rsplit('/').next().unwrap().into(),
|
||||
reference: None,
|
||||
validator: Some(Validator::new(b"target-v1")),
|
||||
}
|
||||
}
|
||||
} else if let Some((scope, object)) = ¤t {
|
||||
if path != scope.path() {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
object.clone()
|
||||
} else {
|
||||
return ProviderResult::NotFound;
|
||||
};
|
||||
let mut publication = PublishedObject::new(Implementation {
|
||||
metadata,
|
||||
calls: self.calls.clone(),
|
||||
on_start: self.on_start.clone(),
|
||||
});
|
||||
if target && self.member.get() {
|
||||
publication = publication.expose(&self.interface).unwrap();
|
||||
}
|
||||
if let Some((scope, object)) = current {
|
||||
publication = publication.bind_scope(ScopeObservation::new(&scope, &object));
|
||||
}
|
||||
ProviderResult::Found(callback(&publication))
|
||||
}
|
||||
}
|
||||
fn setup(
|
||||
scope: &str,
|
||||
target: &str,
|
||||
reference: Option<&str>,
|
||||
) -> (InterfaceRegistry, Scope, Provider) {
|
||||
let scope = Scope::new(scope).unwrap();
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let interface = registry
|
||||
.register(
|
||||
&scope,
|
||||
"read::#日本語",
|
||||
ReadDefinition,
|
||||
Some(Validator::new(b"descriptor-v1")),
|
||||
)
|
||||
.unwrap();
|
||||
let provider = Provider::new(&scope, interface, target, reference);
|
||||
(registry, scope, provider)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registrations_use_scope_name_not_definition_type_or_delimiters() {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let left = Scope::new("/left").unwrap();
|
||||
let right = Scope::new("/right").unwrap();
|
||||
let a = registry
|
||||
.register(&left, "read::#日本語", ReadDefinition, None)
|
||||
.unwrap();
|
||||
let b = registry
|
||||
.register(&right, "read::#日本語", ReadDefinition, None)
|
||||
.unwrap();
|
||||
assert_ne!(a.reference(), b.reference());
|
||||
assert_eq!(
|
||||
a.reference().protocol_reference(),
|
||||
InterfaceReference {
|
||||
scope: "/left".into(),
|
||||
name: "read::#日本語".into(),
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
registry.register(&left, "read::#日本語", CountDefinition, None),
|
||||
Err(RegistrationError::AlreadyRegistered)
|
||||
));
|
||||
assert!(matches!(
|
||||
registry.register(&left, "", ReadDefinition, None),
|
||||
Err(RegistrationError::InvalidReference(_))
|
||||
));
|
||||
assert!(Scope::new("/invalid/").is_err());
|
||||
registry
|
||||
.register(&left, "another", ReadDefinition, None)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_root_ancestor_and_membership_are_independent() {
|
||||
for (scope, target) in [
|
||||
("/", "/"),
|
||||
("/", "/target"),
|
||||
("/scope", "/scope"),
|
||||
("/scope", "/scope/target"),
|
||||
] {
|
||||
let (registry, _, provider) = setup(scope, target, Some("scope-id"));
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(Some("scope-id")))
|
||||
.unwrap()
|
||||
.result,
|
||||
Value::String("original backend".into())
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("scope-id")
|
||||
);
|
||||
provider.member.set(false);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
// Non-self scope Objects need not implement or expose any Interface.
|
||||
let (registry, _, provider) = setup("/scope", "/scope/target", None);
|
||||
assert!(
|
||||
provider
|
||||
.observe_object("/scope")
|
||||
.unwrap()
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap();
|
||||
for target in ["/sibling/target", "/scopelong", "/"] {
|
||||
let (_, _, provider) = setup("/scope", target, None);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 0);
|
||||
assert_eq!(
|
||||
provider.observe_object(target).unwrap_err().kind(),
|
||||
CallErrorKind::Internal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_scope_ref_and_state_validator_have_distinct_responsibilities() {
|
||||
for current in [Some("scope-id"), None, Some("")] {
|
||||
let (registry, _, provider) = setup("/scope", "/scope/target", current);
|
||||
let observation = provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
observation
|
||||
.protocol_response()
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
current
|
||||
);
|
||||
assert_eq!(
|
||||
observation.validator,
|
||||
Some(Validator::new(b"descriptor-v1"))
|
||||
);
|
||||
provider.call_operation(provider.request(None)).unwrap();
|
||||
if let Some(reference) = current {
|
||||
provider
|
||||
.call_operation(provider.request(Some(reference)))
|
||||
.unwrap();
|
||||
}
|
||||
let before = provider.calls.get();
|
||||
for supplied in ["other", "missing"] {
|
||||
let mut call = provider.request(Some(supplied));
|
||||
call.interface.validator = None;
|
||||
call.operation = "missing".into();
|
||||
assert_eq!(
|
||||
provider.call_operation(call).unwrap_err().kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
assert_eq!(provider.calls.get(), before);
|
||||
provider.current.borrow_mut().as_mut().unwrap().1.validator =
|
||||
Some(Validator::new(b"scope-state-v2"));
|
||||
provider.call_operation(provider.request(current)).unwrap();
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap(),
|
||||
observation
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_precondition_is_membership_stage_in_both_dispatch_paths() {
|
||||
let (_, _, provider) = setup("/scope", "/scope/target", Some("current"));
|
||||
let mut call = provider.request(Some("stale"));
|
||||
call.target.validator = Some(b"wrong".to_vec());
|
||||
assert_eq!(
|
||||
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||
CallErrorKind::ValidatorMismatch
|
||||
);
|
||||
provider.target_exists.set(false);
|
||||
assert_eq!(
|
||||
provider.call_operation(call).unwrap_err().kind(),
|
||||
CallErrorKind::NotFound
|
||||
);
|
||||
provider.target_exists.set(true);
|
||||
let call = provider.request(Some("stale"));
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::new(),
|
||||
call.target,
|
||||
call.interface,
|
||||
call.operation,
|
||||
|_| -> Result<BTreeMap<String, Value>, ()> { panic!("decoder must not run") }
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_republication_and_movement_never_inherit_retained_artifacts() {
|
||||
let (mut registry, scope, provider) = setup("/scope", "/scope/target", Some("old"));
|
||||
let reference = provider.request(None).interface.reference;
|
||||
provider.fetch_interface(®istry, &reference).unwrap();
|
||||
*provider.current.borrow_mut() = None;
|
||||
provider.target_exists.set(false);
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &reference)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::NotFound
|
||||
);
|
||||
provider.target_exists.set(true);
|
||||
// Even the same ref is not proof that a replacement inherits registration.
|
||||
let replacement = Scope::new("/scope").unwrap();
|
||||
*provider.current.borrow_mut() = Some((
|
||||
replacement.clone(),
|
||||
Metadata {
|
||||
name: "scope".into(),
|
||||
reference: Some("old".into()),
|
||||
validator: None,
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &reference)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(Some("old")))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
registry.remove_scope(&scope);
|
||||
assert!(matches!(
|
||||
registry.register(&scope, "new", ReadDefinition, None),
|
||||
Err(RegistrationError::RetiredScope)
|
||||
));
|
||||
let fresh = registry
|
||||
.register(&replacement, reference.name.clone(), ReadDefinition, None)
|
||||
.unwrap();
|
||||
provider.fetch_interface(®istry, &reference).unwrap();
|
||||
// Old target membership still binds the old lifetime, even after new publication.
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
let new_provider = Provider::new(&replacement, fresh, "/scope/target", Some("new"));
|
||||
let mut new_call = new_provider.request(Some("new"));
|
||||
new_call.interface.validator = None;
|
||||
new_provider.call_operation(new_call).unwrap();
|
||||
let moved = Scope::new("/moved").unwrap();
|
||||
new_provider.target_exists.set(false);
|
||||
*new_provider.current.borrow_mut() = Some((
|
||||
moved.clone(),
|
||||
Metadata {
|
||||
name: "moved".into(),
|
||||
reference: Some("new".into()),
|
||||
validator: None,
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
new_provider
|
||||
.fetch_interface(®istry, &reference)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
assert_eq!(
|
||||
new_provider
|
||||
.call_operation(new_provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::NotFound
|
||||
);
|
||||
let moved_interface = registry
|
||||
.register(&moved, reference.name, ReadDefinition, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
moved_interface.reference().protocol_reference().scope,
|
||||
"/moved"
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_after_fetch_rejects_old_ref_but_omission_and_refless_are_allowed() {
|
||||
for previous_ref in [Some("old"), None] {
|
||||
let (mut registry, old_scope, old_provider) =
|
||||
setup("/scope", "/scope/target", previous_ref);
|
||||
let observed = old_provider
|
||||
.fetch_interface(®istry, &old_provider.request(None).interface.reference)
|
||||
.unwrap();
|
||||
registry.remove_scope(&old_scope);
|
||||
let new_scope = Scope::new("/scope").unwrap();
|
||||
let token = registry
|
||||
.register(
|
||||
&new_scope,
|
||||
"read::#日本語",
|
||||
ReadDefinition,
|
||||
Some(Validator::new(b"descriptor-v1")),
|
||||
)
|
||||
.unwrap();
|
||||
let new_provider = Provider::new(&new_scope, token, "/scope/target", None);
|
||||
if let Some(reference) = observed.scope_ref {
|
||||
assert_eq!(
|
||||
new_provider
|
||||
.call_operation(new_provider.request(Some(&reference)))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(new_provider.calls.get(), 0);
|
||||
}
|
||||
new_provider
|
||||
.call_operation(new_provider.request(None))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn started_execution_retains_target_and_same_descriptor_through_replacement() {
|
||||
let (registry, old_scope, provider) = setup("/scope", "/scope/target", Some("old"));
|
||||
let registry = Rc::new(RefCell::new(registry));
|
||||
let current = provider.current.clone();
|
||||
let replacement = Scope::new("/scope").unwrap();
|
||||
let registry_for_action = registry.clone();
|
||||
let replacement_for_action = replacement.clone();
|
||||
*provider.on_start.borrow_mut() = Some(Box::new(move || {
|
||||
let mut registry = registry_for_action.borrow_mut();
|
||||
registry.remove_scope(&old_scope);
|
||||
registry
|
||||
.register(
|
||||
&replacement_for_action,
|
||||
"read::#日本語",
|
||||
CountDefinition,
|
||||
Some(Validator::new(b"descriptor-v2")),
|
||||
)
|
||||
.unwrap();
|
||||
*current.borrow_mut() = Some((
|
||||
replacement_for_action.clone(),
|
||||
Metadata {
|
||||
name: "scope".into(),
|
||||
reference: Some("new".into()),
|
||||
validator: None,
|
||||
},
|
||||
));
|
||||
}));
|
||||
let call = provider.request(Some("old"));
|
||||
let result = provider
|
||||
.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::new(),
|
||||
call.target,
|
||||
call.interface,
|
||||
call.operation,
|
||||
|descriptor| {
|
||||
assert_eq!(descriptor, &ReadDefinition::descriptor());
|
||||
Ok::<_, ()>(call.arguments)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.result, Value::String("original backend".into()));
|
||||
assert_eq!(provider.calls.get(), 1);
|
||||
assert_eq!(provider.resolutions.get(), 1);
|
||||
let new = provider
|
||||
.fetch_interface(
|
||||
®istry.borrow(),
|
||||
&provider.request(None).interface.reference,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(new.descriptor, CountDefinition::descriptor());
|
||||
assert_eq!(new.scope_ref.as_deref(), Some("new"));
|
||||
// The old bound target can no longer start a call against the new descriptor.
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 1);
|
||||
}
|
||||
@@ -16,6 +16,6 @@ impl WipObject for Object {
|
||||
|
||||
fn main() {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let search = registry.register(SearchDefinition, None).unwrap();
|
||||
let search = registry.register(&wip_host::Scope::new("/").unwrap(), "search", SearchDefinition, None).unwrap();
|
||||
let _published = PublishedObject::new(Object).expose(&search).unwrap();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
```
|
||||
|
||||
## Interface references and scope identity
|
||||
|
||||
Every interface reference is encoded as an exact JSON object with required
|
||||
`scope` and `name` strings, for example `{"scope":"/github","name":"issue.read"}`.
|
||||
This applies to `Object.interfaces` elements, fetch request/response `interface`,
|
||||
and call `interface.reference`. Scope must be a canonical absolute Worldspace
|
||||
path and name must be nonempty. Legacy opaque strings, short-form strings,
|
||||
relative scopes, missing fields, and extra members are rejected. Unicode, `::`,
|
||||
and `#` in names remain literal data; comparison uses decoded field values,
|
||||
independent of JSON member order and escapes. HTTP endpoint prefixes do not
|
||||
change Worldspace scope.
|
||||
|
||||
Fetch responses and call interface targets additionally carry optional
|
||||
`scope_ref`, an opaque string copied from the scope Object's `ref` observation.
|
||||
It is not base64 and is not part of interface identity. Absent values are omitted;
|
||||
null and nonstring values are rejected. Descriptor-independent call metadata
|
||||
validates reference shape, but leaves scope ancestry and identity preconditions
|
||||
to the Host's `InterfaceMismatch` check, preserving protocol error precedence.
|
||||
Invalid success responses remain client-local `InvalidResponse` errors; protocol
|
||||
error envelopes and transport failures retain their existing classification.
|
||||
|
||||
See the [API documentation](https://docs.rs/wip-http) for codec and failure-boundary details.
|
||||
|
||||
Licensed under either of Apache-2.0 or MIT at your option.
|
||||
|
||||
@@ -296,11 +296,14 @@ pub fn decode_observe_request(body: &[u8], limits: Limits) -> Result<ObserveRequ
|
||||
}
|
||||
|
||||
/// Encodes a `fetch_interface` HTTP request.
|
||||
///
|
||||
/// Validates reference shape without requiring a target Object path.
|
||||
pub fn encode_fetch_interface_request(
|
||||
endpoint: &Endpoint,
|
||||
value: &FetchInterfaceRequest,
|
||||
limits: Limits,
|
||||
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
|
||||
value.interface.validate().map_err(CodecError::from)?;
|
||||
let body = json::serialize(
|
||||
&json::fetch_interface_request_to_json(value),
|
||||
BodyKind::Request,
|
||||
@@ -358,6 +361,10 @@ impl CallOperationMetadata {
|
||||
}
|
||||
|
||||
/// Decodes descriptor-independent `call_operation` routing metadata.
|
||||
///
|
||||
/// Reference shape is validated, but scope ancestry and `scope_ref` identity
|
||||
/// are left to Host resolution so `NotFound`, validator failures, and
|
||||
/// `InterfaceMismatch` retain their protocol precedence.
|
||||
pub fn decode_call_operation_metadata(
|
||||
body: &[u8],
|
||||
limits: Limits,
|
||||
|
||||
+49
-14
@@ -8,9 +8,9 @@ use serde_json::{Map, Number, Value as Json};
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||
InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
|
||||
ObserveResponse, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
|
||||
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object,
|
||||
ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration,
|
||||
ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
};
|
||||
|
||||
use crate::{BodyKind, CodecError, Limits};
|
||||
@@ -285,8 +285,25 @@ pub(crate) fn observe_request_from_json(value: &Json) -> Result<ObserveRequest,
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn interface_reference_to_json(reference: &InterfaceReference) -> Json {
|
||||
serde_json::json!({"scope": reference.scope, "name": reference.name})
|
||||
}
|
||||
|
||||
fn interface_reference_from_json(
|
||||
value: &Json,
|
||||
field: &str,
|
||||
) -> Result<InterfaceReference, CodecError> {
|
||||
let fields = exact_object(value, field, &["scope", "name"])?;
|
||||
let reference = InterfaceReference {
|
||||
scope: string(required(fields, "scope")?, &qualified(field, "scope"))?,
|
||||
name: string(required(fields, "name")?, &qualified(field, "name"))?,
|
||||
};
|
||||
reference.validate()?;
|
||||
Ok(reference)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_interface_request_to_json(request: &FetchInterfaceRequest) -> Json {
|
||||
serde_json::json!({"interface": request.interface})
|
||||
serde_json::json!({"interface": interface_reference_to_json(&request.interface)})
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_interface_request_from_json(
|
||||
@@ -294,7 +311,7 @@ pub(crate) fn fetch_interface_request_from_json(
|
||||
) -> Result<FetchInterfaceRequest, CodecError> {
|
||||
let fields = exact_object(value, "request", &["interface"])?;
|
||||
Ok(FetchInterfaceRequest {
|
||||
interface: string(required(fields, "interface")?, "interface")?,
|
||||
interface: interface_reference_from_json(required(fields, "interface")?, "interface")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -308,8 +325,7 @@ pub(crate) fn object_to_json(object: &Object) -> Json {
|
||||
object
|
||||
.interfaces
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Json::String)
|
||||
.map(interface_reference_to_json)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
@@ -327,7 +343,7 @@ pub(crate) fn object_from_json(value: &Json, field: &str) -> Result<Object, Code
|
||||
let interfaces = array(required(fields, "interfaces")?, "interfaces")?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| string(value, &format!("interfaces[{index}]")))
|
||||
.map(|(index, value)| interface_reference_from_json(value, &format!("interfaces[{index}]")))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let object = Object {
|
||||
name: string(required(fields, "name")?, "name")?,
|
||||
@@ -745,7 +761,11 @@ pub(crate) fn descriptor_from_json(
|
||||
|
||||
pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse) -> Json {
|
||||
let mut fields = Map::new();
|
||||
fields.insert("interface".into(), Json::String(response.interface.clone()));
|
||||
fields.insert(
|
||||
"interface".into(),
|
||||
interface_reference_to_json(&response.interface),
|
||||
);
|
||||
insert_optional_string(&mut fields, "scope_ref", &response.scope_ref);
|
||||
fields.insert(
|
||||
"descriptor".into(),
|
||||
descriptor_to_json(&response.descriptor),
|
||||
@@ -757,8 +777,15 @@ pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse
|
||||
pub(crate) fn fetch_interface_response_from_json(
|
||||
value: &Json,
|
||||
) -> Result<FetchInterfaceResponse, CodecError> {
|
||||
let fields = exact_object(value, "response", &["interface", "descriptor", "validator"])?;
|
||||
let interface = string(required(fields, "interface")?, "interface")?;
|
||||
let fields = exact_object(
|
||||
value,
|
||||
"response",
|
||||
&["interface", "scope_ref", "descriptor", "validator"],
|
||||
)?;
|
||||
let interface = interface_reference_from_json(required(fields, "interface")?, "interface")?;
|
||||
let scope_ref = optional(fields, "scope_ref")?
|
||||
.map(|value| string(value, "scope_ref"))
|
||||
.transpose()?;
|
||||
let validator = optional(fields, "validator")?
|
||||
.map(|value| base64_decode(value, "validator"))
|
||||
.transpose()?;
|
||||
@@ -780,6 +807,7 @@ pub(crate) fn fetch_interface_response_from_json(
|
||||
};
|
||||
Ok(FetchInterfaceResponse {
|
||||
interface,
|
||||
scope_ref,
|
||||
descriptor,
|
||||
validator,
|
||||
})
|
||||
@@ -1121,18 +1149,25 @@ fn target_from_json(value: &Json, field: &str) -> Result<Target, CodecError> {
|
||||
|
||||
fn interface_target_to_json(target: &InterfaceTarget) -> Json {
|
||||
let mut fields = Map::new();
|
||||
fields.insert("reference".into(), Json::String(target.reference.clone()));
|
||||
fields.insert(
|
||||
"reference".into(),
|
||||
interface_reference_to_json(&target.reference),
|
||||
);
|
||||
insert_optional_string(&mut fields, "scope_ref", &target.scope_ref);
|
||||
insert_optional_bytes(&mut fields, "validator", &target.validator);
|
||||
Json::Object(fields)
|
||||
}
|
||||
|
||||
fn interface_target_from_json(value: &Json, field: &str) -> Result<InterfaceTarget, CodecError> {
|
||||
let fields = exact_object(value, field, &["reference", "validator"])?;
|
||||
let fields = exact_object(value, field, &["reference", "scope_ref", "validator"])?;
|
||||
Ok(InterfaceTarget {
|
||||
reference: string(
|
||||
reference: interface_reference_from_json(
|
||||
required(fields, "reference")?,
|
||||
&qualified(field, "reference"),
|
||||
)?,
|
||||
scope_ref: optional(fields, "scope_ref")?
|
||||
.map(|value| string(value, &qualified(field, "scope_ref")))
|
||||
.transpose()?,
|
||||
validator: optional(fields, "validator")?
|
||||
.map(|value| base64_decode(value, &qualified(field, "validator")))
|
||||
.transpose()?,
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
|
||||
//! does not infer WIP validator semantics from `ETag` or `If-Match`.
|
||||
//!
|
||||
//! Interface references use exact JSON objects with required `scope` and `name`
|
||||
//! strings in every position. Legacy strings and display shorthand are rejected.
|
||||
//! Scope is a canonical absolute Worldspace path, not an HTTP endpoint prefix.
|
||||
//! Optional `scope_ref` metadata on fetch responses and call interface targets is
|
||||
//! an opaque string, never base64; absent fields are omitted and null is rejected.
|
||||
//! Call metadata validates reference shape without resolving scope ancestry or
|
||||
//! identity, which remain Host checks with `InterfaceMismatch` precedence.
|
||||
//!
|
||||
//! ```
|
||||
//! use wip_http::{Endpoint, Limits, encode_observe_request};
|
||||
//! use wip_protocol::ObserveRequest;
|
||||
|
||||
@@ -4,9 +4,9 @@ use wip_http::http::StatusCode;
|
||||
use wip_http::wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||
InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
|
||||
OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration,
|
||||
TypeExpr, UnionCase, Value,
|
||||
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation,
|
||||
ObserveRequest, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
|
||||
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
};
|
||||
use wip_http::{
|
||||
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
||||
@@ -25,11 +25,18 @@ fn endpoint() -> Endpoint {
|
||||
Endpoint::parse("https://example.test/wip").unwrap()
|
||||
}
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn object(name: &str, validator: &[u8]) -> Object {
|
||||
Object {
|
||||
name: name.into(),
|
||||
description: Some("description".into()),
|
||||
interfaces: vec!["interface-v1".into()],
|
||||
interfaces: vec![reference("/", "interface-v1")],
|
||||
r#ref: Some(format!("object:{name}")),
|
||||
validator: Some(validator.to_vec()),
|
||||
}
|
||||
@@ -152,7 +159,8 @@ fn call_request() -> CallOperationRequest {
|
||||
validator: Some(vec![0, 1, 2, 255]),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: "interface-v1".into(),
|
||||
reference: reference("/", "interface-v1"),
|
||||
scope_ref: None,
|
||||
validator: Some(vec![9, 8, 7]),
|
||||
},
|
||||
operation: "everything".into(),
|
||||
@@ -206,7 +214,7 @@ fn every_single_interaction_request_accepts_json() {
|
||||
let fetch_interface = encode_fetch_interface_request(
|
||||
&endpoint(),
|
||||
&FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
interface: reference("/", "interface-v1"),
|
||||
},
|
||||
limits(),
|
||||
)
|
||||
@@ -286,7 +294,7 @@ fn observe_and_interface_round_trip_nested_logical_models() {
|
||||
);
|
||||
|
||||
let interface_request = FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
interface: reference("/", "interface-v1"),
|
||||
};
|
||||
let encoded_request =
|
||||
encode_fetch_interface_request(&endpoint(), &interface_request, limits()).unwrap();
|
||||
@@ -296,6 +304,7 @@ fn observe_and_interface_round_trip_nested_logical_models() {
|
||||
);
|
||||
let interface = FetchInterfaceResponse {
|
||||
interface: interface_request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: descriptor(),
|
||||
validator: Some(vec![4, 5, 6, 255]),
|
||||
};
|
||||
@@ -383,7 +392,7 @@ fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
|
||||
let descriptor = descriptor();
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"target": {"path": "/items/123", "validator": null},
|
||||
"interface": {"reference": "interface-v1"},
|
||||
"interface": {"reference": {"scope": "/", "name": "interface-v1"}},
|
||||
"operation": "everything",
|
||||
"arguments": {}
|
||||
}))
|
||||
@@ -443,10 +452,11 @@ fn caller_limits_remain_local_codec_failures() {
|
||||
#[test]
|
||||
fn unsupported_descriptor_format_is_client_local() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
interface: reference("/", "interface-v1"),
|
||||
};
|
||||
let mut interface = FetchInterfaceResponse {
|
||||
interface: request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
};
|
||||
@@ -462,7 +472,7 @@ fn unsupported_descriptor_format_is_client_local() {
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(
|
||||
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2","future_shape":true}}"#
|
||||
br#"{"interface":{"scope":"/","name":"interface-v1"},"descriptor":{"format":"wip-interface/2","future_shape":true}}"#
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -473,8 +483,8 @@ fn unsupported_descriptor_format_is_client_local() {
|
||||
));
|
||||
|
||||
for malformed in [
|
||||
br#"{"interface":"wrong","descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
|
||||
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"#
|
||||
br#"{"interface":{"scope":"/","name":"wrong"},"descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
|
||||
br#"{"interface":{"scope":"/","name":"interface-v1"},"descriptor":{"format":"wip-interface/2"},"validator":null}"#
|
||||
.as_slice(),
|
||||
] {
|
||||
let response = wip_http::http::Response::builder()
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value as Json, json};
|
||||
use wip_http::http::{Response, StatusCode};
|
||||
use wip_http::wip_protocol::{
|
||||
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
|
||||
InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object, ObjectObservation,
|
||||
ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
|
||||
ReturnDeclaration, Target, TypeExpr,
|
||||
};
|
||||
use wip_http::{
|
||||
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
||||
TransportFailureKind, decode_call_operation_metadata, decode_call_operation_request,
|
||||
decode_call_operation_response, decode_fetch_interface_request,
|
||||
decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
|
||||
encode_fetch_interface_request, encode_fetch_interface_response, encode_observe_response,
|
||||
encode_protocol_error_response,
|
||||
};
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::new(64 * 1024, 64 * 1024, 64).unwrap()
|
||||
}
|
||||
|
||||
fn endpoint() -> Endpoint {
|
||||
Endpoint::parse("https://example.test/deployment/prefix").unwrap()
|
||||
}
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor() -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "run#::操作".into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Unit,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn call(reference: InterfaceReference, scope_ref: Option<String>) -> CallOperationRequest {
|
||||
CallOperationRequest {
|
||||
target: Target {
|
||||
path: "/世界::#/item".into(),
|
||||
validator: Some(vec![0, 255]),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference,
|
||||
scope_ref,
|
||||
validator: Some(vec![1, 255]),
|
||||
},
|
||||
operation: "run#::操作".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn observation(references: Vec<InterfaceReference>) -> ObjectObservation {
|
||||
ObjectObservation {
|
||||
object: Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: references,
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
},
|
||||
children: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_request() -> ObserveRequest {
|
||||
ObserveRequest {
|
||||
path: "/世界::#/item".into(),
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn response(status: StatusCode, value: Json) -> Response<Vec<u8>> {
|
||||
raw_response(status, &serde_json::to_vec(&value).unwrap())
|
||||
}
|
||||
|
||||
fn raw_response(status: StatusCode, body: &[u8]) -> Response<Vec<u8>> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(body.to_vec())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn fetch_json(interface: Json) -> Json {
|
||||
json!({
|
||||
"interface": interface,
|
||||
"descriptor": {"format": INTERFACE_FORMAT_V1, "types": [], "operations": []}
|
||||
})
|
||||
}
|
||||
|
||||
fn call_json(reference: Json) -> Json {
|
||||
json!({
|
||||
"target": {"path": "/世界::#/item"},
|
||||
"interface": {"reference": reference},
|
||||
"operation": "run#::操作",
|
||||
"arguments": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn invalid_success<T: std::fmt::Debug>(result: Result<T, ClientResponseError>) {
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
ClientResponseError::InvalidResponse {
|
||||
kind: InvalidResponseKind::InvalidSuccessBody,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_structured_reference_is_encoded_and_round_tripped_in_every_position() {
|
||||
let interface_ref = reference("/世界::#", "é::接口#read/with\"quote\\slash");
|
||||
let expected = json!({"scope": interface_ref.scope, "name": interface_ref.name});
|
||||
let fetch_request = FetchInterfaceRequest {
|
||||
interface: interface_ref.clone(),
|
||||
};
|
||||
let encoded = encode_fetch_interface_request(&endpoint(), &fetch_request, limits()).unwrap();
|
||||
assert_eq!(
|
||||
encoded.uri().path(),
|
||||
"/deployment/prefix/v1/fetch_interface"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Json>(encoded.body()).unwrap(),
|
||||
json!({"interface": expected})
|
||||
);
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||
fetch_request
|
||||
);
|
||||
|
||||
let fetch_response = FetchInterfaceResponse {
|
||||
interface: interface_ref.clone(),
|
||||
scope_ref: Some("opaque:世界::# not base64!".into()),
|
||||
descriptor: descriptor(),
|
||||
validator: Some(vec![1, 255]),
|
||||
};
|
||||
let encoded =
|
||||
encode_fetch_interface_response(&fetch_request, &fetch_response, limits()).unwrap();
|
||||
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
assert_eq!(body["interface"], expected);
|
||||
assert_eq!(body["scope_ref"], "opaque:世界::# not base64!");
|
||||
assert_eq!(body["validator"], "Af8=");
|
||||
assert_eq!(
|
||||
decode_fetch_interface_response(&fetch_request, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(fetch_response)
|
||||
);
|
||||
|
||||
let observation = observation(vec![interface_ref.clone(), reference("/", "root")]);
|
||||
let encoded = encode_observe_response(&observe_request(), &observation, limits()).unwrap();
|
||||
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
assert_eq!(
|
||||
body["object"]["interfaces"],
|
||||
json!([expected, {"scope": "/", "name": "root"}])
|
||||
);
|
||||
assert_eq!(
|
||||
decode_observe_response(&observe_request(), &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(observation)
|
||||
);
|
||||
|
||||
let request = call(interface_ref, Some("opaque:世界::# not base64!".into()));
|
||||
let encoded =
|
||||
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
assert_eq!(body["interface"]["reference"], expected);
|
||||
assert_eq!(body["interface"]["scope_ref"], "opaque:世界::# not base64!");
|
||||
assert_eq!(body["interface"]["validator"], "Af8=");
|
||||
assert_eq!(body["target"]["validator"], "AP8=");
|
||||
assert_eq!(body["operation"], "run#::操作");
|
||||
assert_eq!(
|
||||
decode_call_operation_request(encoded.body(), &descriptor(), limits()).unwrap(),
|
||||
request
|
||||
);
|
||||
assert_eq!(
|
||||
decode_call_operation_metadata(encoded.body(), limits())
|
||||
.unwrap()
|
||||
.interface,
|
||||
request.interface
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_reference_corpus_is_rejected_in_all_positions() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "interface-v1"),
|
||||
};
|
||||
for bad in [
|
||||
json!("interface-v1"),
|
||||
json!("/::interface-v1"),
|
||||
json!("interface-v1#run"),
|
||||
Json::Null,
|
||||
json!(false),
|
||||
json!(1),
|
||||
json!([]),
|
||||
json!({}),
|
||||
json!({"name": "interface-v1"}),
|
||||
json!({"scope": "/"}),
|
||||
json!({"scope": null, "name": "interface-v1"}),
|
||||
json!({"scope": 1, "name": "interface-v1"}),
|
||||
json!({"scope": "/", "name": null}),
|
||||
json!({"scope": "/", "name": []}),
|
||||
json!({"scope": "/", "name": ""}),
|
||||
json!({"scope": "/", "name": "interface-v1", "extra": true}),
|
||||
json!({"scope": "/", "name": "interface-v1", "scope_ref": "identity"}),
|
||||
json!({"Scope": "/", "name": "interface-v1"}),
|
||||
json!({"scope": "", "name": "interface-v1"}),
|
||||
json!({"scope": "relative", "name": "interface-v1"}),
|
||||
json!({"scope": ".", "name": "interface-v1"}),
|
||||
json!({"scope": "..", "name": "interface-v1"}),
|
||||
json!({"scope": "/a/", "name": "interface-v1"}),
|
||||
json!({"scope": "//a", "name": "interface-v1"}),
|
||||
json!({"scope": "/a//b", "name": "interface-v1"}),
|
||||
json!({"scope": "/a/./b", "name": "interface-v1"}),
|
||||
json!({"scope": "/a/../b", "name": "interface-v1"}),
|
||||
] {
|
||||
let body = serde_json::to_vec(&json!({"interface": bad})).unwrap();
|
||||
assert!(
|
||||
decode_fetch_interface_request(&body, limits()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
let body = serde_json::to_vec(&call_json(bad.clone())).unwrap();
|
||||
assert!(
|
||||
decode_call_operation_metadata(&body, limits()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
assert!(
|
||||
decode_call_operation_request(&body, &descriptor(), limits()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, fetch_json(bad.clone())),
|
||||
limits(),
|
||||
));
|
||||
let mut future = fetch_json(bad.clone());
|
||||
future["descriptor"] = json!({"format": "future-format", "future": true});
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, future),
|
||||
limits(),
|
||||
));
|
||||
invalid_success(decode_observe_response(
|
||||
&observe_request(),
|
||||
&response(
|
||||
StatusCode::OK,
|
||||
json!({"object": {"name": "item", "interfaces": [bad]}}),
|
||||
),
|
||||
limits(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_reference_members_are_rejected_even_when_escaped_or_equal() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "x"),
|
||||
};
|
||||
for bad in [
|
||||
r#"{"scope":"/","scope":"/","name":"x"}"#,
|
||||
r#"{"scope":"/","name":"x","name":"x"}"#,
|
||||
r#"{"scope":"/","name":"x","na\u006de":"x"}"#,
|
||||
] {
|
||||
let fetch = format!(r#"{{"interface":{bad}}}"#);
|
||||
assert!(matches!(
|
||||
decode_fetch_interface_request(fetch.as_bytes(), limits()).unwrap_err(),
|
||||
CodecError::InvalidJson(_)
|
||||
));
|
||||
let call = format!(
|
||||
r#"{{"target":{{"path":"/"}},"interface":{{"reference":{bad}}},"operation":"run","arguments":{{}}}}"#
|
||||
);
|
||||
assert!(matches!(
|
||||
decode_call_operation_metadata(call.as_bytes(), limits()).unwrap_err(),
|
||||
CodecError::InvalidJson(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_call_operation_request(call.as_bytes(), &descriptor(), limits()).unwrap_err(),
|
||||
CodecError::InvalidJson(_)
|
||||
));
|
||||
let fetch_response = format!(
|
||||
r#"{{"interface":{bad},"descriptor":{{"format":"wip-interface/1","types":[],"operations":[]}}}}"#
|
||||
);
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&raw_response(StatusCode::OK, fetch_response.as_bytes()),
|
||||
limits(),
|
||||
));
|
||||
let observe = format!(r#"{{"object":{{"name":"item","interfaces":[{bad}]}}}}"#);
|
||||
invalid_success(decode_observe_response(
|
||||
&observe_request(),
|
||||
&raw_response(StatusCode::OK, observe.as_bytes()),
|
||||
limits(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_order_and_json_escapes_do_not_change_reference_identity() {
|
||||
let expected = reference("/世界::#", "é::接口#");
|
||||
let raw_reference = r#"{"name":"\u00e9::\u63a5\u53e3#","scope":"\/\u4e16\u754c::#"}"#;
|
||||
let fetch = format!(r#"{{"interface":{raw_reference}}}"#);
|
||||
let request = decode_fetch_interface_request(fetch.as_bytes(), limits()).unwrap();
|
||||
assert_eq!(request.interface, expected);
|
||||
let fetch_response = format!(
|
||||
r#"{{"descriptor":{{"operations":[],"types":[],"format":"wip-interface/1"}},"interface":{raw_reference}}}"#
|
||||
);
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&request, &raw_response(StatusCode::OK, fetch_response.as_bytes()), limits()).unwrap(), DecodedResponse::Success(value) if value.interface == expected)
|
||||
);
|
||||
let call = format!(
|
||||
r#"{{"arguments":{{}},"operation":"run#::操作","interface":{{"reference":{raw_reference}}},"target":{{"path":"/世界::#/item"}}}}"#
|
||||
);
|
||||
assert_eq!(
|
||||
decode_call_operation_metadata(call.as_bytes(), limits())
|
||||
.unwrap()
|
||||
.interface
|
||||
.reference,
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
decode_call_operation_request(call.as_bytes(), &descriptor(), limits())
|
||||
.unwrap()
|
||||
.interface
|
||||
.reference,
|
||||
expected
|
||||
);
|
||||
let observe = format!(r#"{{"object":{{"interfaces":[{raw_reference}],"name":"item"}}}}"#);
|
||||
assert!(
|
||||
matches!(decode_observe_response(&observe_request(), &raw_response(StatusCode::OK, observe.as_bytes()), limits()).unwrap(), DecodedResponse::Success(value) if value.object.interfaces == vec![expected])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_comparison_is_exact_without_normalization_case_or_url_decoding() {
|
||||
for different in [
|
||||
reference("/", "e\u{301}"),
|
||||
reference("/", "É"),
|
||||
reference("/", "%C3%A9"),
|
||||
reference("/other", "é"),
|
||||
] {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "é"),
|
||||
};
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&response(
|
||||
StatusCode::OK,
|
||||
fetch_json(json!({"scope": different.scope, "name": different.name})),
|
||||
),
|
||||
limits(),
|
||||
));
|
||||
}
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/%2E", "é"),
|
||||
};
|
||||
let encoded = encode_fetch_interface_request(&endpoint(), &request, limits()).unwrap();
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||
request
|
||||
);
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "."),
|
||||
};
|
||||
assert!(encode_fetch_interface_request(&endpoint(), &request, limits()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_encoding_rejects_invalid_reference_shape_without_needing_a_fetch_target() {
|
||||
for bad in [
|
||||
reference("relative", "x"),
|
||||
reference("", "x"),
|
||||
reference("/a/../b", "x"),
|
||||
reference("/", ""),
|
||||
] {
|
||||
assert!(
|
||||
encode_fetch_interface_request(
|
||||
&endpoint(),
|
||||
&FetchInterfaceRequest {
|
||||
interface: bad.clone()
|
||||
},
|
||||
limits()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
encode_call_operation_request(&endpoint(), &call(bad, None), &descriptor(), limits())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/unrelated/scope", "x"),
|
||||
};
|
||||
let encoded = encode_fetch_interface_request(&endpoint(), &request, limits()).unwrap();
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_metadata_leaves_valid_out_of_scope_references_and_identity_to_host_resolution() {
|
||||
let request = call(
|
||||
reference("/elsewhere", "x"),
|
||||
Some("not-current-identity".into()),
|
||||
);
|
||||
let encoded =
|
||||
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||
let metadata = decode_call_operation_metadata(encoded.body(), limits()).unwrap();
|
||||
assert_eq!(metadata.interface, request.interface);
|
||||
assert_eq!(
|
||||
metadata
|
||||
.decode_request(encoded.body(), &descriptor(), limits())
|
||||
.unwrap(),
|
||||
request
|
||||
);
|
||||
|
||||
// Neither an unknown operation nor descriptor-bound argument values are
|
||||
// resolved by the metadata boundary before Host precondition checks.
|
||||
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
body["operation"] = json!("missing");
|
||||
body["arguments"] = json!({"not_declared": [null, {"anything": true}]});
|
||||
let metadata =
|
||||
decode_call_operation_metadata(&serde_json::to_vec(&body).unwrap(), limits()).unwrap();
|
||||
assert_eq!(metadata.operation, "missing");
|
||||
assert_eq!(metadata.interface, request.interface);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observation_scope_validation_uses_each_nodes_path_and_structural_duplicates() {
|
||||
let request = ObserveRequest {
|
||||
path: "/a".into(),
|
||||
depth: 1,
|
||||
};
|
||||
let valid = json!({
|
||||
"object": {"name": "a", "interfaces": [{"scope": "/", "name": "x"}, {"scope": "/a", "name": "x"}]},
|
||||
"children": [{"object": {"name": "b", "interfaces": [{"scope": "/a/b", "name": "x"}]}}]
|
||||
});
|
||||
assert!(
|
||||
decode_observe_response(&request, &response(StatusCode::OK, valid.clone()), limits())
|
||||
.is_ok()
|
||||
);
|
||||
for scope in ["/ab", "/elsewhere", "/a/b/c"] {
|
||||
let mut bad = valid.clone();
|
||||
bad["children"][0]["object"]["interfaces"][0]["scope"] = json!(scope);
|
||||
invalid_success(decode_observe_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, bad),
|
||||
limits(),
|
||||
));
|
||||
}
|
||||
let mut duplicate = valid;
|
||||
duplicate["object"]["interfaces"] =
|
||||
json!([{"scope": "/a", "name": "x"}, {"name": "x", "scope": "/a"}]);
|
||||
invalid_success(decode_observe_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, duplicate),
|
||||
limits(),
|
||||
));
|
||||
let root_request = ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 0,
|
||||
};
|
||||
for (scope, valid) in [("/", true), ("/a", false)] {
|
||||
let result = decode_observe_response(
|
||||
&root_request,
|
||||
&response(
|
||||
StatusCode::OK,
|
||||
json!({"object": {"name": "", "interfaces": [{"scope": scope, "name": "x"}]}}),
|
||||
),
|
||||
limits(),
|
||||
);
|
||||
if valid {
|
||||
assert!(result.is_ok());
|
||||
} else {
|
||||
invalid_success(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_ref_omission_and_opaque_strings_round_trip_in_both_positions() {
|
||||
for scope_ref in [
|
||||
None,
|
||||
Some(String::new()),
|
||||
Some("opaque::# 世界 ! not base64 /+=".into()),
|
||||
Some("\"\\\n\u{0}".into()),
|
||||
] {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "x"),
|
||||
};
|
||||
let expected = FetchInterfaceResponse {
|
||||
interface: request.interface.clone(),
|
||||
scope_ref: scope_ref.clone(),
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
};
|
||||
let encoded = encode_fetch_interface_response(&request, &expected, limits()).unwrap();
|
||||
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
assert_eq!(
|
||||
body.get("scope_ref"),
|
||||
scope_ref
|
||||
.as_ref()
|
||||
.map(|value| Json::String(value.clone()))
|
||||
.as_ref()
|
||||
);
|
||||
assert!(body.get("validator").is_none());
|
||||
assert_eq!(
|
||||
decode_fetch_interface_response(&request, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(expected)
|
||||
);
|
||||
let call = call(request.interface, scope_ref);
|
||||
let encoded =
|
||||
encode_call_operation_request(&endpoint(), &call, &descriptor(), limits()).unwrap();
|
||||
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
assert_eq!(
|
||||
body["interface"].get("scope_ref"),
|
||||
call.interface
|
||||
.scope_ref
|
||||
.as_ref()
|
||||
.map(|value| Json::String(value.clone()))
|
||||
.as_ref()
|
||||
);
|
||||
assert_eq!(
|
||||
decode_call_operation_metadata(encoded.body(), limits())
|
||||
.unwrap()
|
||||
.interface,
|
||||
call.interface
|
||||
);
|
||||
assert_eq!(
|
||||
decode_call_operation_request(encoded.body(), &descriptor(), limits()).unwrap(),
|
||||
call
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_ref_null_and_every_nonstring_type_are_rejected_before_descriptor_resolution() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "x"),
|
||||
};
|
||||
for bad in [Json::Null, json!(true), json!(0), json!([]), json!({})] {
|
||||
let mut fetch = fetch_json(json!({"scope": "/", "name": "x"}));
|
||||
fetch["scope_ref"] = bad.clone();
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, fetch.clone()),
|
||||
limits(),
|
||||
));
|
||||
// Unknown format does not mask malformed common response fields.
|
||||
fetch["descriptor"] = json!({"format": "future-format", "future": true});
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, fetch),
|
||||
limits(),
|
||||
));
|
||||
let mut call = call_json(json!({"scope": "/", "name": "x"}));
|
||||
call["interface"]["scope_ref"] = bad.clone();
|
||||
let body = serde_json::to_vec(&call).unwrap();
|
||||
let error = decode_call_operation_metadata(&body, limits()).unwrap_err();
|
||||
if bad.is_null() {
|
||||
assert!(matches!(error, CodecError::NullOptionalField { .. }));
|
||||
} else {
|
||||
assert!(matches!(error, CodecError::InvalidField { .. }));
|
||||
}
|
||||
assert!(decode_call_operation_request(&body, &descriptor(), limits()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_ref_is_only_allowed_on_fetch_responses_and_call_interface_targets() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "x"),
|
||||
};
|
||||
let reference = json!({"scope": "/", "name": "x"});
|
||||
let fetch_request = json!({"interface": reference, "scope_ref": "identity"});
|
||||
assert!(
|
||||
decode_fetch_interface_request(&serde_json::to_vec(&fetch_request).unwrap(), limits())
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let mut call = call_json(reference.clone());
|
||||
call["scope_ref"] = json!("identity");
|
||||
assert!(decode_call_operation_metadata(&serde_json::to_vec(&call).unwrap(), limits()).is_err());
|
||||
let mut call = call_json(reference);
|
||||
call["target"]["scope_ref"] = json!("identity");
|
||||
assert!(decode_call_operation_metadata(&serde_json::to_vec(&call).unwrap(), limits()).is_err());
|
||||
|
||||
let duplicate = br#"{"interface":{"scope":"/","name":"x"},"scope_ref":"identity","scope_ref":"identity","descriptor":{"format":"wip-interface/1","types":[],"operations":[]}}"#;
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&raw_response(StatusCode::OK, duplicate),
|
||||
limits(),
|
||||
));
|
||||
let duplicate = br#"{"target":{"path":"/"},"interface":{"reference":{"scope":"/","name":"x"},"scope_ref":"identity","scope_ref":"identity"},"operation":"run#::\u64cd\u4f5c","arguments":{}}"#;
|
||||
assert!(matches!(
|
||||
decode_call_operation_metadata(duplicate, limits()).unwrap_err(),
|
||||
CodecError::InvalidJson(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_call_operation_request(duplicate, &descriptor(), limits()).unwrap_err(),
|
||||
CodecError::InvalidJson(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_correspondence_includes_reference_and_scope_ref_but_not_wire_order() {
|
||||
let request = call(reference("/", "x"), Some("identity".into()));
|
||||
let encoded =
|
||||
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||
let metadata = decode_call_operation_metadata(encoded.body(), limits()).unwrap();
|
||||
for (field, changed) in [
|
||||
("scope_ref", json!("other")),
|
||||
("reference", json!({"scope": "/", "name": "other"})),
|
||||
] {
|
||||
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
body["interface"][field] = changed;
|
||||
assert!(
|
||||
matches!(metadata.decode_request(&serde_json::to_vec(&body).unwrap(), &descriptor(), limits()).unwrap_err(), CodecError::InvalidField { field, .. } if field == "request")
|
||||
);
|
||||
}
|
||||
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
body["interface"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("scope_ref");
|
||||
assert!(
|
||||
metadata
|
||||
.decode_request(&serde_json::to_vec(&body).unwrap(), &descriptor(), limits())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_classification_preserves_transport_status_and_error_envelope_semantics() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "x"),
|
||||
};
|
||||
let valid = fetch_json(json!({"scope": "/", "name": "x"}));
|
||||
for (status, kind) in [
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
InvalidResponseKind::NonCanonicalSuccessStatus,
|
||||
),
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
InvalidResponseKind::SuccessBodyOnErrorStatus,
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&request, &response(status, valid.clone()), limits()).unwrap_err(), ClientResponseError::InvalidResponse { kind: actual, .. } if actual == kind)
|
||||
);
|
||||
}
|
||||
for status in [
|
||||
StatusCode::UNAUTHORIZED,
|
||||
StatusCode::FORBIDDEN,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
] {
|
||||
let malformed = response(status, fetch_json(json!("legacy")));
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&request, &malformed, limits()).unwrap_err(), ClientResponseError::TransportBinding(failure) if failure.status == status)
|
||||
);
|
||||
}
|
||||
let error = ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceNotFound,
|
||||
message: "unpublished".into(),
|
||||
};
|
||||
let mut encoded =
|
||||
encode_protocol_error_response(ProtocolInteraction::FetchInterface, &error, limits())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decode_fetch_interface_response(&request, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::ProtocolFailure {
|
||||
error: error.clone(),
|
||||
status_mismatch: None
|
||||
}
|
||||
);
|
||||
*encoded.status_mut() = StatusCode::BAD_GATEWAY;
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&request, &encoded, limits()).unwrap(), DecodedResponse::ProtocolFailure { error: actual, status_mismatch: Some(_) } if actual == error)
|
||||
);
|
||||
*encoded.status_mut() = StatusCode::OK;
|
||||
assert!(matches!(
|
||||
decode_fetch_interface_response(&request, &encoded, limits()).unwrap_err(),
|
||||
ClientResponseError::InvalidResponse {
|
||||
kind: InvalidResponseKind::ErrorEnvelopeOnSuccessStatus,
|
||||
..
|
||||
}
|
||||
));
|
||||
let disallowed = response(
|
||||
StatusCode::CONFLICT,
|
||||
json!({"error": {"code": "interface_mismatch", "message": "wrong interaction"}}),
|
||||
);
|
||||
assert!(matches!(
|
||||
decode_fetch_interface_response(&request, &disallowed, limits()).unwrap_err(),
|
||||
ClientResponseError::InvalidResponse {
|
||||
kind: InvalidResponseKind::DisallowedProtocolCode,
|
||||
..
|
||||
}
|
||||
));
|
||||
let mut coded = response(StatusCode::OK, valid);
|
||||
coded
|
||||
.headers_mut()
|
||||
.insert("content-encoding", "gzip".parse().unwrap());
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&request, &coded, limits()).unwrap_err(), ClientResponseError::TransportBinding(failure) if failure.kind == TransportFailureKind::UnsupportedContentCoding)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_interface_mismatch_envelope_remains_protocol_failure_with_structured_metadata() {
|
||||
let request = call(reference("/unrelated", "x"), Some("stale identity".into()));
|
||||
let error = ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceMismatch,
|
||||
message: "interface precondition".into(),
|
||||
};
|
||||
let encoded =
|
||||
encode_protocol_error_response(ProtocolInteraction::CallOperation, &error, limits())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decode_call_operation_response(&request, &descriptor(), &encoded, limits()).unwrap(),
|
||||
DecodedResponse::ProtocolFailure {
|
||||
error,
|
||||
status_mismatch: None
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,15 @@ This crate defines the canonical Object and Interface Descriptor models, the thr
|
||||
wip-protocol = "0.1.0"
|
||||
```
|
||||
|
||||
Interface identity is an exact `InterfaceReference { scope, name }` pair.
|
||||
`scope` is a canonical absolute Worldspace path and `name` is nonempty Unicode.
|
||||
Use `validate()` for shape, `validate_for_path()` for ancestor-or-self placement,
|
||||
and Object/interaction validation at response boundaries. Fetching needs no
|
||||
target Object path. Fetch responses and interface call targets carry optional
|
||||
opaque string `scope_ref` metadata, not a third identity field. Descriptors do
|
||||
not embed identity. See the repository's [migration guide](../../MIGRATION.md)
|
||||
for the breaking Rust and HTTP wire changes and deferred consumer semantics.
|
||||
|
||||
See the [API documentation](https://docs.rs/wip-protocol) for the complete model and validation rules.
|
||||
|
||||
Licensed under either of Apache-2.0 or MIT at your option.
|
||||
|
||||
@@ -143,8 +143,17 @@ pub enum ValidationErrorKind {
|
||||
},
|
||||
/// An object's ordered interface set contains a duplicate.
|
||||
DuplicateInterface {
|
||||
/// Duplicate opaque interface reference.
|
||||
reference: String,
|
||||
/// Duplicate structured interface reference.
|
||||
reference: crate::InterfaceReference,
|
||||
},
|
||||
/// The local interface name is empty.
|
||||
EmptyInterfaceName,
|
||||
/// A valid scope is not an ancestor-or-self of the target path.
|
||||
InterfaceScopeMismatch {
|
||||
/// Selected scope path.
|
||||
scope: String,
|
||||
/// Target Object path.
|
||||
path: String,
|
||||
},
|
||||
/// A string is not a canonical absolute Worldspace path.
|
||||
InvalidPath {
|
||||
@@ -175,10 +184,10 @@ pub enum ValidationErrorKind {
|
||||
},
|
||||
/// A response refers to a different interface than its request.
|
||||
InterfaceResponseMismatch {
|
||||
/// Requested opaque reference.
|
||||
expected: String,
|
||||
/// Returned opaque reference.
|
||||
actual: String,
|
||||
/// Requested structured reference (boxed to keep validation errors small).
|
||||
expected: Box<crate::InterfaceReference>,
|
||||
/// Returned structured reference (boxed to keep validation errors small).
|
||||
actual: Box<crate::InterfaceReference>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -226,7 +235,7 @@ impl Error for ValidationError {}
|
||||
pub enum ProtocolInteraction {
|
||||
/// Observe one object and its indexable descendants to a requested depth.
|
||||
Observe,
|
||||
/// Fetch one interface descriptor by its opaque reference.
|
||||
/// Fetch one interface descriptor by its structured reference.
|
||||
FetchInterface,
|
||||
/// Invoke one operation on a path-targeted object.
|
||||
CallOperation,
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
//! guarantee; Clients must not automatically retry it.
|
||||
//!
|
||||
//! Paths, object refs, interface refs, and validators remain separate concepts.
|
||||
//! They are represented by ordinary protocol strings/bytes in the corresponding
|
||||
//! Interface refs use [`InterfaceReference`]; other metadata use strings/bytes in their
|
||||
//! fields. In particular, object refs are optional, operation targeting uses a
|
||||
//! path, and this crate defines no interaction that fetches an object by ref.
|
||||
//!
|
||||
@@ -52,9 +52,10 @@ pub use error::{
|
||||
};
|
||||
pub use model::{
|
||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||
FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object,
|
||||
ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration,
|
||||
ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceReference,
|
||||
InterfaceTarget, Object, ObjectObservation, ObserveRequest, ObserveResponse,
|
||||
OperationDeclaration, ParameterDeclaration, ProtocolResult, ReturnDeclaration, Target,
|
||||
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
};
|
||||
|
||||
/// Validates a canonical absolute Worldspace path.
|
||||
|
||||
@@ -11,9 +11,49 @@ pub struct Documentation {
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
/// An interface identified by an exact `(scope, name)` pair in one Worldspace.
|
||||
///
|
||||
/// Neither field is normalized, case-folded, URL-decoded, or split on display
|
||||
/// delimiters. Descriptor identity is external to the descriptor itself.
|
||||
///
|
||||
/// ```
|
||||
/// use wip_protocol::{FetchInterfaceRequest, InterfaceReference};
|
||||
/// let reference = InterfaceReference {
|
||||
/// scope: "/世::界/#".into(),
|
||||
/// name: "read::書く#λ".into(),
|
||||
/// };
|
||||
/// reference.validate_for_path("/世::界/#/item")?;
|
||||
/// // Fetching is independent of an Object target path.
|
||||
/// FetchInterfaceRequest { interface: reference }.validate()?;
|
||||
/// # Ok::<(), wip_protocol::ValidationError>(())
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct InterfaceReference {
|
||||
/// Canonical absolute Worldspace path of the interface's scope Object.
|
||||
pub scope: String,
|
||||
/// Nonempty Unicode local name, with no delimiter restrictions.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl InterfaceReference {
|
||||
/// Validates reference shape without requiring a target Object path.
|
||||
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||
crate::validation::validate_interface_reference(self)
|
||||
}
|
||||
|
||||
/// Validates shape and that scope is an ancestor-or-self of `path`.
|
||||
///
|
||||
/// This is independent of indexability. A Host maps a valid reference's
|
||||
/// scope mismatch to `InterfaceMismatch` at the interface-selection stage,
|
||||
/// not to the earlier shape-validation `InvalidRequest` stage.
|
||||
pub fn validate_for_path(&self, path: &str) -> Result<(), ValidationError> {
|
||||
crate::validation::validate_interface_scope(self, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The protocol projection currently published at one Worldspace path.
|
||||
///
|
||||
/// `name` is a path segment, `interfaces` is an ordered set of opaque interface
|
||||
/// `name` is a path segment, `interfaces` is an ordered set of structured interface
|
||||
/// references, `r#ref` is optional object identity, and `validator` is an
|
||||
/// independent optional observation precondition. In particular, neither the
|
||||
/// path nor `r#ref` is a mandatory object identifier.
|
||||
@@ -23,8 +63,8 @@ pub struct Object {
|
||||
pub name: String,
|
||||
/// Short public description of the object.
|
||||
pub description: Option<String>,
|
||||
/// Opaque interface references in host publication order.
|
||||
pub interfaces: Vec<String>,
|
||||
/// Structured interface references in host publication order.
|
||||
pub interfaces: Vec<InterfaceReference>,
|
||||
/// Optional opaque identity within one client-facing Worldspace.
|
||||
pub r#ref: Option<String>,
|
||||
/// Optional opaque validator for the object's observed state.
|
||||
@@ -239,18 +279,20 @@ pub struct ObjectObservation {
|
||||
/// Successful `observe` response.
|
||||
pub type ObserveResponse = ObjectObservation;
|
||||
|
||||
/// Request for the current descriptor of one opaque interface reference.
|
||||
/// Request for the current descriptor of one structured interface reference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FetchInterfaceRequest {
|
||||
/// Opaque interface reference obtained from an object.
|
||||
pub interface: String,
|
||||
/// Structured interface reference obtained from an object.
|
||||
pub interface: InterfaceReference,
|
||||
}
|
||||
|
||||
/// Successful `fetch_interface` response.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FetchInterfaceResponse {
|
||||
/// Opaque interface reference corresponding to the request.
|
||||
pub interface: String,
|
||||
/// Structured interface reference corresponding to the request.
|
||||
pub interface: InterfaceReference,
|
||||
/// Optional opaque ref of the scope Object, not part of interface identity.
|
||||
pub scope_ref: Option<String>,
|
||||
/// Current validated descriptor.
|
||||
pub descriptor: InterfaceDescriptor,
|
||||
/// Optional opaque validator for this descriptor observation.
|
||||
@@ -269,8 +311,10 @@ pub struct Target {
|
||||
/// Interface portion of an operation target.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceTarget {
|
||||
/// Opaque interface reference selected from the object's interface set.
|
||||
pub reference: String,
|
||||
/// Structured reference selected from the object's interface set.
|
||||
pub reference: InterfaceReference,
|
||||
/// Optional scope Object ref copied from the same interface observation.
|
||||
pub scope_ref: Option<String>,
|
||||
/// Optional validator copied from the interface observation.
|
||||
pub validator: Option<Vec<u8>>,
|
||||
}
|
||||
@@ -369,6 +413,13 @@ impl ObserveRequest {
|
||||
}
|
||||
}
|
||||
|
||||
impl FetchInterfaceRequest {
|
||||
/// Validates reference shape; fetching an interface needs no target path.
|
||||
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||
self.interface.validate()
|
||||
}
|
||||
}
|
||||
|
||||
impl FetchInterfaceResponse {
|
||||
/// Validates request correspondence and descriptor semantics.
|
||||
///
|
||||
@@ -382,7 +433,16 @@ impl FetchInterfaceResponse {
|
||||
}
|
||||
|
||||
impl CallOperationRequest {
|
||||
/// Validates this call against the descriptor selected by `interface`.
|
||||
/// Validates descriptor-independent request shape only.
|
||||
///
|
||||
/// Scope compatibility is deliberately deferred to interface selection so
|
||||
/// that `NotFound` precedes `InterfaceMismatch` at the Host boundary.
|
||||
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||
crate::validation::validate_call_shape(self)
|
||||
}
|
||||
|
||||
/// Validates this call's shape and descriptor-bound operation/arguments.
|
||||
/// Scope compatibility and membership require Host publication context.
|
||||
pub fn validate_with(&self, descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
||||
descriptor.validate_call(self)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use crate::{
|
||||
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
|
||||
MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest,
|
||||
PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD, UNION_VALUE_FIELD, ValidationError,
|
||||
ValidationErrorKind, Value, ValueKind,
|
||||
InterfaceReference, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, Object,
|
||||
ObjectObservation, ObserveRequest, PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD,
|
||||
UNION_VALUE_FIELD, ValidationError, ValidationErrorKind, Value, ValueKind,
|
||||
};
|
||||
|
||||
pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> {
|
||||
validate_object_name(&object.name, true)?;
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
for interface in &object.interfaces {
|
||||
if !seen.insert(interface.as_str()) {
|
||||
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||
interface.validate().map_err(|error| {
|
||||
error
|
||||
.prepend(PathSegment::Index(index))
|
||||
.prepend(PathSegment::Field("interfaces".into()))
|
||||
})?;
|
||||
if !seen.insert(interface) {
|
||||
return Err(
|
||||
ValidationError::new(ValidationErrorKind::DuplicateInterface {
|
||||
reference: interface.clone(),
|
||||
@@ -51,6 +56,14 @@ pub(crate) fn validate_object_at_path(object: &Object, path: &str) -> Result<(),
|
||||
.next()
|
||||
.expect("non-root path has a segment")
|
||||
};
|
||||
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||
interface.validate_for_path(path).map_err(|error| {
|
||||
error
|
||||
.prepend(PathSegment::Index(index))
|
||||
.prepend(PathSegment::Field("interfaces".into()))
|
||||
.prepend(PathSegment::Object(path.to_owned()))
|
||||
})?;
|
||||
}
|
||||
if object.name == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -106,6 +119,54 @@ pub(crate) fn validate_path(path: &str) -> Result<(), ValidationError> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_interface_reference(
|
||||
reference: &InterfaceReference,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_path(&reference.scope)
|
||||
.map_err(|error| error.prepend(PathSegment::Field("scope".into())))?;
|
||||
if reference.name.is_empty() {
|
||||
return Err(
|
||||
ValidationError::new(ValidationErrorKind::EmptyInterfaceName)
|
||||
.prepend(PathSegment::Field("name".into())),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_interface_scope(
|
||||
reference: &InterfaceReference,
|
||||
path: &str,
|
||||
) -> Result<(), ValidationError> {
|
||||
reference.validate()?;
|
||||
validate_path(path)?;
|
||||
let scope = reference.scope.as_str();
|
||||
if scope == "/"
|
||||
|| path == scope
|
||||
|| path
|
||||
.strip_prefix(scope)
|
||||
.is_some_and(|rest| rest.starts_with('/'))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::new(
|
||||
ValidationErrorKind::InterfaceScopeMismatch {
|
||||
scope: reference.scope.clone(),
|
||||
path: path.to_owned(),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_call_shape(request: &CallOperationRequest) -> Result<(), ValidationError> {
|
||||
validate_path(&request.target.path)
|
||||
.map_err(|error| error.prepend(PathSegment::Field("target".into())))?;
|
||||
request.interface.reference.validate().map_err(|error| {
|
||||
error
|
||||
.prepend(PathSegment::Field("reference".into()))
|
||||
.prepend(PathSegment::Field("interface".into()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn validate_descriptor(descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
||||
let declarations = declaration_map(descriptor)?;
|
||||
|
||||
@@ -582,8 +643,7 @@ pub(crate) fn validate_call(
|
||||
descriptor: &InterfaceDescriptor,
|
||||
request: &CallOperationRequest,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_path(&request.target.path)
|
||||
.map_err(|error| error.prepend(PathSegment::Field("target".into())))?;
|
||||
request.validate()?;
|
||||
validate_arguments(descriptor, &request.operation, &request.arguments)
|
||||
}
|
||||
|
||||
@@ -660,11 +720,13 @@ pub(crate) fn validate_fetch_interface_response(
|
||||
response: &FetchInterfaceResponse,
|
||||
request: &FetchInterfaceRequest,
|
||||
) -> Result<(), ValidationError> {
|
||||
request.validate()?;
|
||||
response.interface.validate()?;
|
||||
if response.interface != request.interface {
|
||||
return Err(
|
||||
ValidationError::new(ValidationErrorKind::InterfaceResponseMismatch {
|
||||
expected: request.interface.clone(),
|
||||
actual: response.interface.clone(),
|
||||
expected: Box::new(request.interface.clone()),
|
||||
actual: Box::new(response.interface.clone()),
|
||||
})
|
||||
.prepend(PathSegment::Field("interface".into())),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::collections::BTreeMap;
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
|
||||
InterfaceReference, InterfaceTarget, Object, ObjectObservation, ObserveRequest,
|
||||
OperationDeclaration, ReturnDeclaration, Target, TypeExpr, ValidationErrorKind,
|
||||
};
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn object(path: &str, interfaces: Vec<InterfaceReference>) -> Object {
|
||||
Object {
|
||||
name: path.rsplit('/').next().unwrap().into(),
|
||||
description: None,
|
||||
interfaces,
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor() -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {
|
||||
format: wip_protocol::INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "run".into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Unit,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_segment_based_ancestor_or_self_not_indexability() {
|
||||
for (scope, path) in [
|
||||
("/", "/"),
|
||||
("/", "/a/b"),
|
||||
("/a", "/a"),
|
||||
("/a", "/a/b"),
|
||||
("/a/b", "/a/b/c"),
|
||||
] {
|
||||
reference(scope, "read").validate_for_path(path).unwrap();
|
||||
object(path, vec![reference(scope, "read")])
|
||||
.validate_at_path(path)
|
||||
.unwrap();
|
||||
}
|
||||
for (scope, path) in [
|
||||
("/a", "/ab"),
|
||||
("/a", "/ab/c"),
|
||||
("/a/b", "/a/c"),
|
||||
("/a/b", "/a"),
|
||||
("/a", "/"),
|
||||
] {
|
||||
assert!(matches!(
|
||||
reference(scope, "read")
|
||||
.validate_for_path(path)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||
));
|
||||
// Without the placement context, the Object/reference shape is valid.
|
||||
let object = object(path, vec![reference(scope, "read")]);
|
||||
object.validate().unwrap();
|
||||
assert!(matches!(
|
||||
object.validate_at_path(path).unwrap_err().kind,
|
||||
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_validation_does_not_normalize_or_parse_fields() {
|
||||
for scope in ["", "relative", "//a", "/a/", "/a//b", "/a/./b", "/a/../b"] {
|
||||
let reference = reference(scope, "read");
|
||||
assert!(matches!(
|
||||
reference.validate().unwrap_err().kind,
|
||||
ValidationErrorKind::InvalidPath { .. }
|
||||
));
|
||||
assert!(
|
||||
FetchInterfaceRequest {
|
||||
interface: reference.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(object("/a", vec![reference]).validate().is_err());
|
||||
}
|
||||
assert_eq!(
|
||||
reference("/", "").validate().unwrap_err().kind,
|
||||
ValidationErrorKind::EmptyInterfaceName
|
||||
);
|
||||
for name in [".", "..", "a/b", "::", "#", "読み::書き#λ", "e\u{301}"] {
|
||||
let reference = reference("/世::界/#/%2F", name);
|
||||
reference.validate().unwrap();
|
||||
assert_eq!(reference.name, name);
|
||||
assert_eq!(reference.scope, "/世::界/#/%2F");
|
||||
}
|
||||
assert_ne!(reference("/", "é"), reference("/", "e\u{301}"));
|
||||
assert_ne!(reference("/A", "read"), reference("/a", "read"));
|
||||
assert_ne!(reference("/%61", "read"), reference("/a", "read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_membership_and_correlation_use_complete_pair() {
|
||||
let root = reference("/", "read");
|
||||
let scoped = reference("/a", "read");
|
||||
let mut object = object("/a", vec![root.clone(), scoped.clone()]);
|
||||
object.validate_at_path("/a").unwrap();
|
||||
assert!(object.interfaces.contains(&scoped));
|
||||
assert!(!object.interfaces.contains(&reference("/b", "read")));
|
||||
object.interfaces.push(root.clone());
|
||||
assert_eq!(
|
||||
object.validate().unwrap_err().kind,
|
||||
ValidationErrorKind::DuplicateInterface {
|
||||
reference: root.clone()
|
||||
}
|
||||
);
|
||||
let request = FetchInterfaceRequest { interface: root };
|
||||
let mut response = FetchInterfaceResponse {
|
||||
interface: scoped,
|
||||
scope_ref: None,
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
response.validate_for(&request).unwrap_err().kind,
|
||||
ValidationErrorKind::InterfaceResponseMismatch { .. }
|
||||
));
|
||||
response.interface = request.interface.clone();
|
||||
for scope_ref in [None, Some(String::new()), Some("opaque::世界#/%".into())] {
|
||||
response.scope_ref = scope_ref;
|
||||
response.validate_for(&request).unwrap();
|
||||
}
|
||||
response.interface.name.clear();
|
||||
assert_eq!(
|
||||
response.validate_for(&request).unwrap_err().kind,
|
||||
ValidationErrorKind::EmptyInterfaceName
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_checks_scope_at_every_node_including_depth_boundary() {
|
||||
let request = ObserveRequest {
|
||||
path: "/a".into(),
|
||||
depth: 1,
|
||||
};
|
||||
let mut response = ObjectObservation {
|
||||
object: object("/a", vec![reference("/a", "read")]),
|
||||
children: Some(vec![ObjectObservation {
|
||||
object: object("/a/b", vec![reference("/a/b", "read")]),
|
||||
children: None,
|
||||
}]),
|
||||
};
|
||||
request.validate_response(&response).unwrap();
|
||||
response.children.as_mut().unwrap()[0].object.interfaces[0].scope = "/a/c".into();
|
||||
assert!(matches!(
|
||||
request.validate_response(&response).unwrap_err().kind,
|
||||
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_needs_no_target_path_and_call_separates_shape_from_scope_selection() {
|
||||
let reference = reference("/some/unrelated/scope", "read");
|
||||
let fetch = FetchInterfaceRequest {
|
||||
interface: reference.clone(),
|
||||
};
|
||||
fetch.validate().unwrap();
|
||||
FetchInterfaceResponse {
|
||||
interface: reference.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
}
|
||||
.validate_for(&fetch)
|
||||
.unwrap();
|
||||
let mut call = CallOperationRequest {
|
||||
target: Target {
|
||||
path: "/a".into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference,
|
||||
scope_ref: Some("opaque".into()),
|
||||
validator: None,
|
||||
},
|
||||
operation: "run".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
};
|
||||
call.validate().unwrap();
|
||||
call.validate_with(&descriptor()).unwrap();
|
||||
// This stage is Host InterfaceMismatch, after NotFound, not shape InvalidRequest.
|
||||
assert!(matches!(
|
||||
call.interface
|
||||
.reference
|
||||
.validate_for_path(&call.target.path)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||
));
|
||||
call.interface.reference.name.clear();
|
||||
assert_eq!(
|
||||
call.validate().unwrap_err().kind,
|
||||
ValidationErrorKind::EmptyInterfaceName
|
||||
);
|
||||
assert_eq!(
|
||||
call.validate_with(&descriptor()).unwrap_err().kind,
|
||||
ValidationErrorKind::EmptyInterfaceName
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,19 @@ use std::collections::BTreeMap;
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest,
|
||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||
InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest,
|
||||
OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, ProtocolErrorCode,
|
||||
ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr,
|
||||
UnionCase, ValidationErrorKind, Value, ValueKind,
|
||||
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object,
|
||||
ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration, PathSegment,
|
||||
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ProtocolResult, ReturnDeclaration,
|
||||
Target, TypeDeclaration, TypeExpr, UnionCase, ValidationErrorKind, Value, ValueKind,
|
||||
};
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value {
|
||||
Value::Record(
|
||||
entries
|
||||
@@ -135,7 +142,7 @@ fn object(name: &str) -> Object {
|
||||
Object {
|
||||
name: name.into(),
|
||||
description: Some(format!("Object {name}")),
|
||||
interfaces: vec!["catalog-v1".into(), "search-v2".into()],
|
||||
interfaces: vec![reference("/", "catalog-v1"), reference("/", "search-v2")],
|
||||
r#ref: Some("opaque-object-ref".into()),
|
||||
validator: Some(vec![1, 2, 3]),
|
||||
}
|
||||
@@ -146,16 +153,19 @@ fn object_fields_have_canonical_identity_and_ordered_set_semantics() {
|
||||
let object = object("article");
|
||||
object.validate().unwrap();
|
||||
object.validate_at_path("/articles/article").unwrap();
|
||||
assert_eq!(object.interfaces, ["catalog-v1", "search-v2"]);
|
||||
assert_eq!(
|
||||
object.interfaces,
|
||||
[reference("/", "catalog-v1"), reference("/", "search-v2")]
|
||||
);
|
||||
|
||||
let duplicate = Object {
|
||||
interfaces: vec!["catalog-v1".into(), "catalog-v1".into()],
|
||||
interfaces: vec![reference("/", "catalog-v1"), reference("/", "catalog-v1")],
|
||||
..object.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
duplicate.validate().unwrap_err().kind,
|
||||
ValidationErrorKind::DuplicateInterface {
|
||||
reference: "catalog-v1".into()
|
||||
reference: reference("/", "catalog-v1")
|
||||
}
|
||||
);
|
||||
|
||||
@@ -574,7 +584,8 @@ fn calls_validate_canonical_paths_named_arguments_and_unit_results() {
|
||||
validator: Some(vec![1]),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: "catalog-v1".into(),
|
||||
scope_ref: None,
|
||||
reference: reference("/", "catalog-v1"),
|
||||
validator: Some(vec![2]),
|
||||
},
|
||||
operation: "publish".into(),
|
||||
@@ -659,17 +670,18 @@ fn observe_and_fetch_interface_dtos_validate_request_response_correspondence() {
|
||||
}
|
||||
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: "catalog-v1".into(),
|
||||
interface: reference("/", "catalog-v1"),
|
||||
};
|
||||
let response = FetchInterfaceResponse {
|
||||
interface: "catalog-v1".into(),
|
||||
scope_ref: None,
|
||||
interface: reference("/", "catalog-v1"),
|
||||
descriptor: descriptor(),
|
||||
validator: Some(vec![9]),
|
||||
};
|
||||
response.validate_for(&request).unwrap();
|
||||
assert!(matches!(
|
||||
FetchInterfaceResponse {
|
||||
interface: "other".into(),
|
||||
interface: reference("/", "other"),
|
||||
..response
|
||||
}
|
||||
.validate_for(&request)
|
||||
@@ -847,7 +859,7 @@ fn invalid_success_payloads_remain_client_local_validation_failures() {
|
||||
));
|
||||
|
||||
let interface_request = FetchInterfaceRequest {
|
||||
interface: "catalog-v1".into(),
|
||||
interface: reference("/", "catalog-v1"),
|
||||
};
|
||||
let mut invalid_descriptor = descriptor();
|
||||
invalid_descriptor
|
||||
@@ -856,6 +868,7 @@ fn invalid_success_payloads_remain_client_local_validation_failures() {
|
||||
assert!(matches!(
|
||||
FetchInterfaceResponse {
|
||||
interface: interface_request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: invalid_descriptor,
|
||||
validator: None,
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
//!
|
||||
//! The canonical API targets operations by `target.path` rather than a mandatory
|
||||
//! object identifier. Object `ref` and object/interface validators are separate,
|
||||
//! optional observation fields. Interface references are opaque values observed
|
||||
//! optional observation fields. This demo registers explicit `catalog`, `audit`
|
||||
//! and `diagnostics` names at the service Object self-scope. A private [`Scope`]
|
||||
//! lifetime and coherent [`ScopeObservation`] bind fetch and call to that Object;
|
||||
//! successful calls carry its optional `scope_ref` identity precondition.
|
||||
//! Interface references are structured scope/name values observed
|
||||
//! separately from descriptors, and descriptors contain neither a Rust trait
|
||||
//! name nor an opaque-reference `name` field. Application records, enums,
|
||||
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
||||
@@ -41,12 +45,12 @@ use std::io;
|
||||
|
||||
use wip_host::{
|
||||
CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget,
|
||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Target, Validator,
|
||||
WipObject,
|
||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Scope, ScopeObservation,
|
||||
Target, Validator, WipObject,
|
||||
};
|
||||
use wip_interface as wip;
|
||||
use wip_interface::WipType;
|
||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, Value};
|
||||
use wip_protocol::{InterfaceReference, ProtocolError, ProtocolErrorCode, Value};
|
||||
|
||||
const OBJECT_PATH: &str = "/article-service";
|
||||
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
|
||||
@@ -134,16 +138,22 @@ pub struct DemoReport {
|
||||
pub object_reference: Option<String>,
|
||||
/// Optional object-state validator.
|
||||
pub object_validator: Option<Vec<u8>>,
|
||||
/// Opaque interface references published by the object, in exposure order.
|
||||
pub published_interfaces: Vec<String>,
|
||||
/// Opaque reference assigned to the catalog descriptor.
|
||||
pub catalog_interface_reference: String,
|
||||
/// Opaque reference assigned to the audit descriptor.
|
||||
pub audit_interface_reference: String,
|
||||
/// Structured interface references published by the object, in exposure order.
|
||||
pub published_interfaces: Vec<InterfaceReference>,
|
||||
/// Self-scoped reference assigned to the catalog descriptor.
|
||||
pub catalog_interface_reference: InterfaceReference,
|
||||
/// Self-scoped reference assigned to the audit descriptor.
|
||||
pub audit_interface_reference: InterfaceReference,
|
||||
/// Descriptor format observed separately for the catalog reference.
|
||||
pub catalog_descriptor_format: String,
|
||||
/// Descriptor format observed separately for the audit reference.
|
||||
pub audit_descriptor_format: String,
|
||||
/// Scope Object ref observed together with the catalog Descriptor.
|
||||
pub catalog_scope_ref: Option<String>,
|
||||
/// Scope Object ref observed together with the audit Descriptor.
|
||||
pub audit_scope_ref: Option<String>,
|
||||
/// Membership rejection when the supplied scope Object ref is stale.
|
||||
pub scope_mismatch: ProtocolErrorCode,
|
||||
/// Typed successful application result decoded from a validated [`Value`].
|
||||
pub published: PublishOutcome,
|
||||
/// Typed application-domain rejection decoded from a validated [`Value`].
|
||||
@@ -182,25 +192,34 @@ pub struct DemoReport {
|
||||
/// application-value decoding fails unexpectedly.
|
||||
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
|
||||
let audit = registry.register(AuditDefinition, None)?;
|
||||
let diagnostics = registry.register(DiagnosticsDefinition, None)?;
|
||||
let scope = Scope::new(OBJECT_PATH)?;
|
||||
let catalog = registry.register(
|
||||
&scope,
|
||||
"catalog",
|
||||
CatalogDefinition,
|
||||
Some(Validator::new(CATALOG_VALIDATOR)),
|
||||
)?;
|
||||
let audit = registry.register(&scope, "audit", AuditDefinition, None)?;
|
||||
let diagnostics = registry.register(&scope, "diagnostics", DiagnosticsDefinition, None)?;
|
||||
let provider = ApplicationProvider {
|
||||
scope,
|
||||
catalog: catalog.clone(),
|
||||
audit: audit.clone(),
|
||||
};
|
||||
|
||||
let object = provider.observe_object(OBJECT_PATH)?;
|
||||
let catalog_response = registry
|
||||
let catalog_response = provider
|
||||
.fetch_interface(
|
||||
®istry,
|
||||
object
|
||||
.interfaces
|
||||
.first()
|
||||
.ok_or_else(|| io::Error::other("catalog interface was not published"))?,
|
||||
)?
|
||||
.protocol_response()?;
|
||||
let audit_response = registry
|
||||
let audit_response = provider
|
||||
.fetch_interface(
|
||||
®istry,
|
||||
object
|
||||
.interfaces
|
||||
.get(1)
|
||||
@@ -296,7 +315,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
validator: Some(b"stale-object".to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: diagnostics.reference().as_str().to_owned(),
|
||||
scope_ref: None,
|
||||
reference: diagnostics.reference().protocol_reference(),
|
||||
validator: Some(b"stale-interface".to_vec()),
|
||||
},
|
||||
operation: "missing".into(),
|
||||
@@ -318,11 +338,31 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
.protocol_error()
|
||||
.code;
|
||||
|
||||
let interface_not_found = registry
|
||||
.fetch_interface("missing-interface")
|
||||
let interface_not_found = provider
|
||||
.fetch_interface(
|
||||
®istry,
|
||||
&InterfaceReference {
|
||||
scope: OBJECT_PATH.into(),
|
||||
name: "missing-interface".into(),
|
||||
},
|
||||
)
|
||||
.expect_err("unknown interface references must not resolve")
|
||||
.code;
|
||||
|
||||
let mut stale_scope = request(
|
||||
OBJECT_PATH,
|
||||
catalog.reference(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"status",
|
||||
BTreeMap::new(),
|
||||
);
|
||||
stale_scope.interface.scope_ref = Some("replaced-service-instance".into());
|
||||
let scope_mismatch = provider
|
||||
.call_operation(stale_scope)
|
||||
.expect_err("stale scope identity must reject before operation dispatch")
|
||||
.protocol_error()
|
||||
.code;
|
||||
|
||||
let precedence_failure = provider
|
||||
.call_operation(CallRequest {
|
||||
target: Target {
|
||||
@@ -330,7 +370,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
validator: Some(b"stale-object".to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: diagnostics.reference().as_str().to_owned(),
|
||||
scope_ref: None,
|
||||
reference: diagnostics.reference().protocol_reference(),
|
||||
validator: Some(b"stale-interface".to_vec()),
|
||||
},
|
||||
operation: "missing".into(),
|
||||
@@ -345,10 +386,13 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
object_reference: object.r#ref,
|
||||
object_validator: object.validator,
|
||||
published_interfaces: object.interfaces,
|
||||
catalog_interface_reference: catalog.reference().as_str().to_owned(),
|
||||
audit_interface_reference: audit.reference().as_str().to_owned(),
|
||||
catalog_interface_reference: catalog.reference().protocol_reference(),
|
||||
audit_interface_reference: audit.reference().protocol_reference(),
|
||||
catalog_descriptor_format: catalog_response.descriptor.format,
|
||||
audit_descriptor_format: audit_response.descriptor.format,
|
||||
catalog_scope_ref: catalog_response.scope_ref,
|
||||
audit_scope_ref: audit_response.scope_ref,
|
||||
scope_mismatch,
|
||||
published,
|
||||
rejected,
|
||||
catalog_status,
|
||||
@@ -412,7 +456,8 @@ fn request(
|
||||
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: interface.as_str().to_owned(),
|
||||
scope_ref: Some("article-service-instance".into()),
|
||||
reference: interface.protocol_reference(),
|
||||
validator: interface_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
operation: operation.into(),
|
||||
@@ -421,6 +466,7 @@ fn request(
|
||||
}
|
||||
|
||||
struct ApplicationProvider {
|
||||
scope: Scope,
|
||||
catalog: RegisteredInterface<CatalogDefinition>,
|
||||
audit: RegisteredInterface<AuditDefinition>,
|
||||
}
|
||||
@@ -437,7 +483,12 @@ impl ObjectProvider for ApplicationProvider {
|
||||
if path != OBJECT_PATH {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
let publication = PublishedObject::new(ApplicationService::new())
|
||||
// The immutable demo has no mutable backend actors. A real backend
|
||||
// must retain its transaction/lock across association and operation.
|
||||
let service = ApplicationService::new();
|
||||
let current = ScopeObservation::new(&self.scope, &service);
|
||||
let publication = PublishedObject::new(service)
|
||||
.bind_scope(current)
|
||||
.expose(&self.catalog)
|
||||
.expect("ApplicationService implements Catalog")
|
||||
.expose(&self.audit)
|
||||
|
||||
@@ -28,10 +28,20 @@ fn public_crates_support_the_complete_qualified_end_to_end_scenario() {
|
||||
);
|
||||
assert_ne!(
|
||||
report.catalog_interface_reference, report.audit_interface_reference,
|
||||
"interfaces form an ordered set of distinct opaque references"
|
||||
"interfaces form an ordered set of distinct scope/name references"
|
||||
);
|
||||
assert_eq!(report.catalog_interface_reference.scope, "/article-service");
|
||||
assert_eq!(report.catalog_interface_reference.name, "catalog");
|
||||
assert_eq!(report.audit_interface_reference.scope, "/article-service");
|
||||
assert_eq!(report.audit_interface_reference.name, "audit");
|
||||
assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1);
|
||||
assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1);
|
||||
assert_eq!(
|
||||
report.catalog_scope_ref.as_deref(),
|
||||
Some("article-service-instance")
|
||||
);
|
||||
assert_eq!(report.audit_scope_ref, report.catalog_scope_ref);
|
||||
assert_eq!(report.scope_mismatch, ProtocolErrorCode::InterfaceMismatch);
|
||||
assert_eq!(
|
||||
report.published,
|
||||
PublishOutcome::Published("article-1".into())
|
||||
@@ -97,7 +107,7 @@ fn object_fields_keep_path_context_and_optional_ref_separate() {
|
||||
let object = Object {
|
||||
name: "article".into(),
|
||||
description: None,
|
||||
interfaces: vec!["catalog-ref".into(), "audit-ref".into()],
|
||||
interfaces: vec![root_reference("catalog-ref"), root_reference("audit-ref")],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
};
|
||||
@@ -105,7 +115,10 @@ fn object_fields_keep_path_context_and_optional_ref_separate() {
|
||||
object.validate_at_path("/library/article").unwrap();
|
||||
assert_eq!(object.name, "article");
|
||||
assert_eq!(object.r#ref, None);
|
||||
assert_eq!(object.interfaces, ["catalog-ref", "audit-ref"]);
|
||||
assert_eq!(
|
||||
object.interfaces,
|
||||
[root_reference("catalog-ref"), root_reference("audit-ref")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -244,3 +257,10 @@ fn assert_schema_neutral(value: &Value) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,10 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
|
||||
interfaces, and freshness. Validators, credentials, Host internals, and backend
|
||||
paths are deliberately hidden. If current metadata disappears, old text is not
|
||||
presented as current.
|
||||
- **Interfaces / Operations** identifies every operation by `(interface reference,
|
||||
operation name)`. Descriptor lookahead follows fresh visible Objects, shares the
|
||||
- **Interfaces / Operations** identifies every operation by `(interface scope,
|
||||
interface name, operation name)` and explicitly displays `scope=…` and `name=…`.
|
||||
Structured references remain intact in selection, acquisition, and call state;
|
||||
display labels are never parsed back into references. Descriptor lookahead follows fresh visible Objects, shares the
|
||||
client's in-flight/cache bounds, and never retries retained failures implicitly.
|
||||
The pane shows descriptor documentation, ordered parameters, required/optional
|
||||
status, and result types.
|
||||
|
||||
+71
-16
@@ -3,7 +3,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use wip_client::{InterfaceObservation, ObjectObservation, ObservationState, TreeObservation};
|
||||
use wip_protocol::{InterfaceDescriptor, Object, Value};
|
||||
use wip_protocol::{InterfaceDescriptor, InterfaceReference, Object, Value};
|
||||
|
||||
use crate::editor::ArgumentEditor;
|
||||
|
||||
@@ -139,8 +139,8 @@ impl EntryView {
|
||||
/// Cached interface descriptor state used by presentation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceView {
|
||||
/// Opaque interface reference.
|
||||
pub reference: String,
|
||||
/// Structured interface reference.
|
||||
pub reference: InterfaceReference,
|
||||
/// Descriptor when accepted by the client runtime.
|
||||
pub descriptor: Option<InterfaceDescriptor>,
|
||||
/// Descriptor observation state.
|
||||
@@ -187,13 +187,13 @@ pub enum InFlightKind {
|
||||
/// Tree expansion or refresh.
|
||||
Tree(String),
|
||||
/// Descriptor fetch.
|
||||
Interface(String),
|
||||
Interface(InterfaceReference),
|
||||
/// Explicit operation call.
|
||||
Operation {
|
||||
/// Target path.
|
||||
path: String,
|
||||
/// Interface reference.
|
||||
interface: String,
|
||||
interface: InterfaceReference,
|
||||
/// Operation name.
|
||||
operation: String,
|
||||
/// Whether the transport dispatch boundary was crossed.
|
||||
@@ -241,13 +241,13 @@ pub enum Action {
|
||||
path: String,
|
||||
},
|
||||
/// Retrieve one interface descriptor.
|
||||
FetchInterface(String),
|
||||
FetchInterface(InterfaceReference),
|
||||
/// Execute only after explicit confirmation.
|
||||
Execute {
|
||||
/// Entry target.
|
||||
path: String,
|
||||
/// Interface reference.
|
||||
interface: String,
|
||||
interface: InterfaceReference,
|
||||
/// Operation name.
|
||||
operation: String,
|
||||
/// Descriptor-validated arguments.
|
||||
@@ -276,8 +276,8 @@ pub struct App {
|
||||
pub selected_path: String,
|
||||
/// Focused pane.
|
||||
pub focus: Focus,
|
||||
/// Interface descriptor views by opaque reference.
|
||||
pub interfaces: BTreeMap<String, InterfaceView>,
|
||||
/// Interface descriptor views by structured reference.
|
||||
pub interfaces: BTreeMap<InterfaceReference, InterfaceView>,
|
||||
/// Selected row in the flattened interface/operation view.
|
||||
pub operation_cursor: usize,
|
||||
/// Selected recent-history row.
|
||||
@@ -615,7 +615,7 @@ impl App {
|
||||
/// Returns interface references advertised by fresh visible Objects in display
|
||||
/// and per-Object declaration order, with duplicates removed.
|
||||
#[must_use]
|
||||
pub fn visible_interface_references(&self) -> Vec<String> {
|
||||
pub fn visible_interface_references(&self) -> Vec<InterfaceReference> {
|
||||
let mut references = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for path in self.visible_paths() {
|
||||
@@ -716,7 +716,7 @@ impl App {
|
||||
.or_insert_with(|| EntryView::placeholder(path))
|
||||
}
|
||||
|
||||
fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView {
|
||||
fn ensure_interface(&mut self, reference: &InterfaceReference) -> &mut InterfaceView {
|
||||
if !self.interfaces.contains_key(reference)
|
||||
&& self.interfaces.len() >= MAX_PRESENTATION_INTERFACES
|
||||
{
|
||||
@@ -757,7 +757,7 @@ impl App {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OperationRow {
|
||||
/// Interface reference, which participates in operation identity.
|
||||
pub interface: String,
|
||||
pub interface: InterfaceReference,
|
||||
/// Operation name, or `None` for the interface heading.
|
||||
pub operation: Option<String>,
|
||||
}
|
||||
@@ -773,6 +773,12 @@ pub fn parent_path(path: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
use wip_protocol::{INTERFACE_FORMAT_V1, OperationDeclaration, ReturnDeclaration, TypeExpr};
|
||||
|
||||
@@ -780,7 +786,10 @@ mod tests {
|
||||
Object {
|
||||
name: name.into(),
|
||||
description: Some(format!("{name} description")),
|
||||
interfaces: interfaces.iter().map(|value| (*value).into()).collect(),
|
||||
interfaces: interfaces
|
||||
.iter()
|
||||
.map(|value| root_reference(value))
|
||||
.collect(),
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
}
|
||||
@@ -846,7 +855,51 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
app.visible_interface_references(),
|
||||
["root", "shared", "child"]
|
||||
[
|
||||
root_reference("root"),
|
||||
root_reference("shared"),
|
||||
root_reference("child")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_retains_equal_names_in_distinct_scopes() {
|
||||
let mut app = App::new("https://example.test/wip");
|
||||
let root = root_reference("shared#::操作");
|
||||
let local = InterfaceReference {
|
||||
scope: "/a".into(),
|
||||
name: root.name.clone(),
|
||||
};
|
||||
let mut value = object("a", &[]);
|
||||
value.interfaces = vec![root.clone(), local.clone()];
|
||||
app.navigate_entry("/a");
|
||||
app.apply_object(ObjectObservation {
|
||||
path: "/a".into(),
|
||||
object: Some(value),
|
||||
validator: None,
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
for reference in [&root, &local] {
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: reference.clone(),
|
||||
descriptor: None,
|
||||
validator: None,
|
||||
scope_ref: None,
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
}
|
||||
assert_eq!(app.interfaces.len(), 2);
|
||||
assert_eq!(
|
||||
app.visible_interface_references(),
|
||||
vec![root.clone(), local.clone()]
|
||||
);
|
||||
assert_eq!(
|
||||
app.operation_rows()
|
||||
.iter()
|
||||
.map(|row| row.interface.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![root, local]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -874,7 +927,8 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: "iface".into(),
|
||||
scope_ref: None,
|
||||
reference: root_reference("iface"),
|
||||
descriptor: Some(descriptor),
|
||||
validator: None,
|
||||
state: ObservationState::Fresh,
|
||||
@@ -903,7 +957,8 @@ mod tests {
|
||||
|
||||
for index in 0..(MAX_PRESENTATION_INTERFACES + 50) {
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: format!("interface-{index}"),
|
||||
scope_ref: None,
|
||||
reference: root_reference(&format!("interface-{index}")),
|
||||
descriptor: None,
|
||||
validator: None,
|
||||
state: ObservationState::Fresh,
|
||||
|
||||
@@ -4,7 +4,9 @@ use std::collections::BTreeMap;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use wip_protocol::{InterfaceDescriptor, OperationDeclaration, TypeExpr, Value};
|
||||
use wip_protocol::{
|
||||
InterfaceDescriptor, InterfaceReference, OperationDeclaration, TypeExpr, Value,
|
||||
};
|
||||
|
||||
const MAX_FIELD_INPUT_BYTES: usize = 256 * 1024;
|
||||
|
||||
@@ -30,8 +32,8 @@ pub struct InvalidArguments;
|
||||
/// Pure, terminal-independent editor built from an operation descriptor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArgumentEditor {
|
||||
/// Opaque interface reference selected for the call.
|
||||
pub interface: String,
|
||||
/// Structured interface reference selected for the call.
|
||||
pub interface: InterfaceReference,
|
||||
/// Selected operation declaration.
|
||||
pub operation: OperationDeclaration,
|
||||
/// Complete descriptor used to resolve named types.
|
||||
@@ -46,7 +48,7 @@ impl ArgumentEditor {
|
||||
/// Builds an editor whose fields preserve descriptor order.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
interface: impl Into<String>,
|
||||
interface: InterfaceReference,
|
||||
descriptor: InterfaceDescriptor,
|
||||
operation: OperationDeclaration,
|
||||
) -> Self {
|
||||
@@ -66,7 +68,7 @@ impl ArgumentEditor {
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
interface: interface.into(),
|
||||
interface,
|
||||
operation,
|
||||
descriptor,
|
||||
fields,
|
||||
@@ -466,6 +468,12 @@ fn json_to_untyped(json: &serde_json::Value, field: &str) -> Result<Value, Strin
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
use wip_protocol::{
|
||||
EnumCase, FieldDeclaration, INTERFACE_FORMAT_V1, ParameterDeclaration, ReturnDeclaration,
|
||||
@@ -560,7 +568,7 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
let operation = descriptor.operations[0].clone();
|
||||
let mut editor = ArgumentEditor::new("test", descriptor, operation);
|
||||
let mut editor = ArgumentEditor::new(root_reference("test"), descriptor, operation);
|
||||
let inputs = [
|
||||
"null",
|
||||
"true",
|
||||
@@ -595,7 +603,7 @@ mod tests {
|
||||
vec![],
|
||||
);
|
||||
let operation = descriptor.operations[0].clone();
|
||||
let mut editor = ArgumentEditor::new("test", descriptor, operation);
|
||||
let mut editor = ArgumentEditor::new(root_reference("test"), descriptor, operation);
|
||||
editor.fields[1].input = "not-a-bool".into();
|
||||
assert_eq!(editor.validate(), Err(InvalidArguments));
|
||||
assert!(
|
||||
|
||||
@@ -11,6 +11,14 @@ use crate::editor::type_name;
|
||||
|
||||
const MAX_RENDER_TEXT: usize = 512;
|
||||
|
||||
fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
||||
format!(
|
||||
"scope={}, name={}",
|
||||
sanitize(&reference.scope),
|
||||
sanitize(&reference.name)
|
||||
)
|
||||
}
|
||||
|
||||
/// Draws the complete application without mutating it.
|
||||
pub fn draw(frame: &mut Frame<'_>, app: &App) {
|
||||
let area = frame.area();
|
||||
@@ -162,7 +170,7 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
||||
object
|
||||
.interfaces
|
||||
.iter()
|
||||
.map(|value| sanitize(value))
|
||||
.map(interface_label)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
@@ -224,7 +232,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.find(|operation| operation.name == *name)
|
||||
});
|
||||
let signature = declaration.map_or_else(
|
||||
|| format!(" ({}, {})", row.interface, name),
|
||||
|| format!(" ({}, {})", interface_label(&row.interface), name),
|
||||
|operation| {
|
||||
let parameters = operation
|
||||
.parameters
|
||||
@@ -246,7 +254,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
" ({}, {}) ({parameters}) -> {}{}",
|
||||
row.interface,
|
||||
interface_label(&row.interface),
|
||||
name,
|
||||
type_name(&operation.returns.r#type),
|
||||
documentation
|
||||
@@ -266,7 +274,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
(
|
||||
Line::from(format!(
|
||||
"Interface {} [{}]{}",
|
||||
sanitize(&row.interface),
|
||||
interface_label(&row.interface),
|
||||
state,
|
||||
documentation
|
||||
)),
|
||||
@@ -355,7 +363,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
let mut lines = vec![Line::from(format!(
|
||||
"Target: {} Operation: ({}, {})",
|
||||
sanitize(&app.selected_path),
|
||||
sanitize(&editor.interface),
|
||||
interface_label(&editor.interface),
|
||||
sanitize(&editor.operation.name)
|
||||
))];
|
||||
if editor.fields.is_empty() {
|
||||
@@ -405,7 +413,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Line::from(format!("Entry: {}", sanitize(&app.selected_path))),
|
||||
Line::from(format!("Interface: {}", sanitize(&editor.interface))),
|
||||
Line::from(format!("Interface: {}", interface_label(&editor.interface))),
|
||||
Line::from(format!("Operation: {}", sanitize(&editor.operation.name))),
|
||||
Line::from("Input values:"),
|
||||
];
|
||||
@@ -579,6 +587,12 @@ fn bounded_value_at(value: &wip_protocol::Value, depth: usize) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
@@ -664,7 +678,7 @@ mod tests {
|
||||
object: Some(Object {
|
||||
name: String::new(),
|
||||
description: Some("public root description".into()),
|
||||
interfaces: vec!["demo-interface".into()],
|
||||
interfaces: vec![root_reference("demo-interface")],
|
||||
r#ref: None,
|
||||
validator: Some(b"must-not-render".to_vec()),
|
||||
}),
|
||||
@@ -691,7 +705,8 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: "demo-interface".into(),
|
||||
scope_ref: None,
|
||||
reference: root_reference("demo-interface"),
|
||||
descriptor: Some(descriptor.clone()),
|
||||
validator: Some(b"must-not-render".to_vec()),
|
||||
state: ObservationState::Fresh,
|
||||
@@ -711,7 +726,7 @@ mod tests {
|
||||
}
|
||||
let snapshot = rendered(&app, 140, 40);
|
||||
assert!(snapshot.contains("public root description"));
|
||||
assert!(snapshot.contains("(demo-interface, run)"));
|
||||
assert!(snapshot.contains("(scope=/, name=demo-interface, run)"));
|
||||
assert!(snapshot.contains("SUCCESS"));
|
||||
assert!(snapshot.contains("PROTOCOL ERROR"));
|
||||
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
||||
@@ -719,7 +734,7 @@ mod tests {
|
||||
assert!(!snapshot.contains("must-not-render"));
|
||||
|
||||
let mut editor = ArgumentEditor::new(
|
||||
"demo-interface",
|
||||
root_reference("demo-interface"),
|
||||
descriptor.clone(),
|
||||
descriptor.operations[0].clone(),
|
||||
);
|
||||
@@ -774,7 +789,7 @@ mod tests {
|
||||
object: Some(Object {
|
||||
name: String::new(),
|
||||
description: None,
|
||||
interfaces: vec!["many".into()],
|
||||
interfaces: vec![root_reference("many")],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
}),
|
||||
@@ -793,7 +808,8 @@ mod tests {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: "many".into(),
|
||||
scope_ref: None,
|
||||
reference: root_reference("many"),
|
||||
descriptor: Some(InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
@@ -806,7 +822,7 @@ mod tests {
|
||||
app.focus = Focus::Operations;
|
||||
app.operation_cursor = app.operation_rows().len() - 1;
|
||||
let snapshot = rendered(&app, 100, 30);
|
||||
assert!(snapshot.contains("(many, operation-19)"));
|
||||
assert!(snapshot.contains("(scope=/, name=many, operation-19)"));
|
||||
|
||||
for index in 0..20 {
|
||||
app.push_history(HistoryItem {
|
||||
@@ -828,6 +844,19 @@ mod tests {
|
||||
assert!(snapshot.contains("too small"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_labels_show_scope_and_name_without_parsing() {
|
||||
let reference = wip_protocol::InterfaceReference {
|
||||
scope: "/nested".into(),
|
||||
name: "#::操作".into(),
|
||||
};
|
||||
assert_eq!(interface_label(&reference), "scope=/nested, name=#::操作");
|
||||
assert_ne!(
|
||||
interface_label(&reference),
|
||||
interface_label(&root_reference(&reference.name))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_characters_and_large_bytes_are_bounded() {
|
||||
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
|
||||
|
||||
@@ -303,7 +303,10 @@ impl<T: Transport> Runtime<T> {
|
||||
Err(error) => self.local_error("subtree refresh", error),
|
||||
},
|
||||
Action::FetchInterface(reference) => {
|
||||
match self.client.prepare_interface(&self.session, &reference) {
|
||||
match self
|
||||
.client
|
||||
.prepare_interface(&self.session, reference.clone())
|
||||
{
|
||||
Ok(request) => {
|
||||
self.dispatch(request, InFlightKind::Interface(reference), false)
|
||||
}
|
||||
@@ -367,7 +370,10 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
}
|
||||
for reference in references {
|
||||
match self.client.ensure_interface(&self.session, &reference) {
|
||||
match self
|
||||
.client
|
||||
.ensure_interface(&self.session, reference.clone())
|
||||
{
|
||||
Ok(Some(request)) => {
|
||||
self.dispatch(request, InFlightKind::Interface(reference), false);
|
||||
}
|
||||
@@ -599,8 +605,9 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
||||
let key = format!(
|
||||
"({}, {}) on {}",
|
||||
record.context.interface().reference,
|
||||
"(scope={}, name={}, {}) on {}",
|
||||
sanitize(&record.context.interface().reference.scope),
|
||||
sanitize(&record.context.interface().reference.name),
|
||||
record.context.operation().name,
|
||||
record.context.object().path
|
||||
);
|
||||
@@ -683,7 +690,11 @@ impl<T: Transport> Runtime<T> {
|
||||
let title = match kind {
|
||||
Some(InFlightKind::Object(path)) => format!("observe {path}"),
|
||||
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
|
||||
Some(InFlightKind::Interface(reference)) => format!("interface {reference}"),
|
||||
Some(InFlightKind::Interface(reference)) => format!(
|
||||
"interface scope={}, name={}",
|
||||
sanitize(&reference.scope),
|
||||
sanitize(&reference.name)
|
||||
),
|
||||
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
|
||||
None => "request".into(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user