Compare commits
13
Commits
4544bc0d8b
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c3edf4c6f | ||
|
|
1cbe03b49e | ||
|
|
26cba3398f | ||
|
|
d58809bdbc | ||
|
|
84cb5904df | ||
|
|
fc52d72a01 | ||
|
|
a7186173b4 | ||
|
|
827bef8478 | ||
|
|
b2be41d540 | ||
|
|
cc0ec0ccc8 | ||
|
|
755e6aeb88 | ||
|
|
fe6dccda83 | ||
|
|
8b8041cba1 |
+24
-2
@@ -4,12 +4,34 @@ All notable changes to the published WIP Rust crates are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- Adds public Text View `Token`/`TokenKind`, `tokenize_object`,
|
||||
`tokenize_interface`, and delimiter-free `to_plain_text`. Direct rendering uses
|
||||
the same token generation path; HTML and terminal-color renderers are not included.
|
||||
- Makes Client Interface caches scope-lifetime-aware, invalidates observations on
|
||||
confirmed binding loss, and retains immutable contexts for in-flight calls.
|
||||
|
||||
- Adds `wip-text-view`, an independent single-entity Object/Interface signature
|
||||
library with Protocol validation, typed errors, full ordered declarations,
|
||||
documentation summaries and safe Text View quoting/escapes. No path, lookup,
|
||||
cache, UI, network or Protocol wire-schema changes are required.
|
||||
|
||||
- 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.
|
||||
- Mechanically migrates workspace consumers and fixtures. See [MIGRATION.md](MIGRATION.md)
|
||||
for API/wire examples and the deferred Host/Client lifecycle semantics.
|
||||
- Breaking Host registration migration to explicit Scope publication lifetimes and
|
||||
local names, with coherent provider-attested scope Object binding for fetch and
|
||||
dispatch. Supplied scope_ref is checked at membership precedence; scope deletion
|
||||
retires retained tokens, while ordinary Object state updates preserve lifetime.
|
||||
- Integrates scoped references across filesystem and public consumers and the TUI.
|
||||
External FS scopes bind to visible current connected lineage, not retained
|
||||
registry artifacts. TUI labels use absolute safely quoted `::`/`#` notation;
|
||||
structured selection and Client scope invalidation prevent stale UI dispatch.
|
||||
The public example carries fetched scope refs and includes real HTTP lifetime,
|
||||
visibility and validator-precedence conformance tests with optional refs. See
|
||||
[MIGRATION.md](MIGRATION.md) for API/wire examples and provider responsibilities.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
|
||||
Generated
+18
-9
@@ -1235,7 +1235,7 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
|
||||
[[package]]
|
||||
name = "wip-client"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"wip-http",
|
||||
"wip-protocol",
|
||||
@@ -1243,7 +1243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wip-fs-server"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"http",
|
||||
"rustix 1.1.5",
|
||||
@@ -1256,7 +1256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wip-host"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"trybuild",
|
||||
"wip-interface",
|
||||
@@ -1265,7 +1265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wip-http"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"http",
|
||||
@@ -1277,7 +1277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wip-interface"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"trybuild",
|
||||
"wip-interface-macros",
|
||||
@@ -1286,7 +1286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wip-interface-macros"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1295,20 +1295,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wip-protocol"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "wip-public-api-example"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"wip-client",
|
||||
"wip-host",
|
||||
"wip-http",
|
||||
"wip-interface",
|
||||
"wip-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wip-text-view"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"wip-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wip-tui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"crossterm",
|
||||
|
||||
+9
-8
@@ -3,17 +3,18 @@ members = ["crates/*"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://gitea.hareworks.net/Hare/wip-rs"
|
||||
|
||||
[workspace.dependencies]
|
||||
wip-client = { version = "0.1.0", path = "crates/wip-client" }
|
||||
wip-fs-server = { version = "0.1.0", path = "crates/wip-fs-server" }
|
||||
wip-host = { version = "0.1.0", path = "crates/wip-host" }
|
||||
wip-http = { version = "0.1.0", path = "crates/wip-http" }
|
||||
wip-interface = { version = "0.1.0", path = "crates/wip-interface" }
|
||||
wip-interface-macros = { version = "0.1.0", path = "crates/wip-interface-macros" }
|
||||
wip-protocol = { version = "0.1.0", path = "crates/wip-protocol" }
|
||||
wip-client = { version = "0.2.0", path = "crates/wip-client" }
|
||||
wip-fs-server = { version = "0.2.0", path = "crates/wip-fs-server" }
|
||||
wip-host = { version = "0.2.0", path = "crates/wip-host" }
|
||||
wip-http = { version = "0.2.0", path = "crates/wip-http" }
|
||||
wip-interface = { version = "0.2.0", path = "crates/wip-interface" }
|
||||
wip-interface-macros = { version = "0.2.0", path = "crates/wip-interface-macros" }
|
||||
wip-protocol = { version = "0.2.0", path = "crates/wip-protocol" }
|
||||
wip-text-view = { version = "0.2.0", path = "crates/wip-text-view" }
|
||||
|
||||
+93
-15
@@ -1,6 +1,6 @@
|
||||
# Structured Interface reference migration
|
||||
# Structured Interface reference and scope publication migration
|
||||
|
||||
This unreleased API/wire change follows `wip-reference` canonical Core Model,
|
||||
The 0.2.0 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
|
||||
@@ -46,9 +46,11 @@ still contains only format, documentation, types and operations, not identity.
|
||||
field `scope_ref: Option<String>` in struct literals. Use `None` when omitted,
|
||||
not an invented identity. This is an opaque scope Object ref observation/call
|
||||
precondition, **not** a third Interface identity component or a validator.
|
||||
The scope-ref lifecycle/atomic Host enforcement and Client cache-recovery
|
||||
semantics are follow-up work (T-21/T-22); this migration supplies the data model
|
||||
and codec, not a completed lifecycle implementation.
|
||||
Host enforces scope publication lifetimes and optional scope-ref identity
|
||||
inside the provider boundary. Client caches keep the complete structured reference
|
||||
and optional fetched scope binding; confirmed scope deletion or ref replacement
|
||||
invalidates bound observations, while local eviction is not deletion evidence.
|
||||
Do not mistake registry artifact retention for publication.
|
||||
|
||||
## Validation boundaries
|
||||
|
||||
@@ -116,15 +118,91 @@ malformed success responses retain Client-local `InvalidResponse`. Standard
|
||||
`POST v1/observe`, `v1/fetch_interface`, `v1/call_operation`, error-code/status
|
||||
mapping and Transport failure classification are unchanged.
|
||||
|
||||
## Host registration and provider migration
|
||||
|
||||
Registration now requires an explicit canonical Scope and local name:
|
||||
|
||||
```rust,ignore
|
||||
let mut registry = wip_host::InterfaceRegistry::new();
|
||||
let scope = wip_host::Scope::new("/service")?;
|
||||
let api = registry.register(&scope, "catalog", CatalogDefinition, None)?;
|
||||
|
||||
// INSIDE the backend transaction/lock/stable-handle boundary:
|
||||
let current = wip_host::ScopeObservation::new(&scope, ¤t_scope_object);
|
||||
let publication = wip_host::PublishedObject::new(current_target_object)
|
||||
.expose(&api)?
|
||||
.bind_scope(current);
|
||||
```
|
||||
|
||||
Collision keys are exact `(scope, name)` coordinates, not Rust Definition TypeId.
|
||||
The same Definition can be registered in different scopes or under different
|
||||
names. Root, self and ancestor scopes are valid, but ancestor compatibility and
|
||||
explicit target membership remain independent checks. Descriptors still have
|
||||
no publication identity fields.
|
||||
|
||||
A Scope carries a private publication lifetime, not a generated Protocol ref.
|
||||
Clone it only while the same scope Object remains published: ordinary Object
|
||||
state/validator updates do not retire it. Deletion, replacement or movement
|
||||
requires retiring the old lifetime with `registry.remove_scope(&scope)` and
|
||||
creating a fresh Scope before republication, even at the same path or ref.
|
||||
Retained registration tokens cannot fetch or start calls after retirement.
|
||||
Ref-less replacement cannot be tracked by a Client identity precondition; refs
|
||||
and scope_ref remain optional, not forcibly generated.
|
||||
|
||||
`ScopeObservation::new(&scope, &object)` is a provider attestation that this
|
||||
lifetime currently belongs to the Object at the scope path. Construct and attach
|
||||
it with `PublishedObject::bind_scope` inside the same coherent boundary as target
|
||||
resolution, membership, Descriptor/validator selection, implementation and
|
||||
backend handles, through dispatch and commit/rollback. A matching ref or an
|
||||
independent scope prelookup is not sufficient. The scope Object need not expose
|
||||
any interfaces. Providers own atomicity and external-writer limitations; the
|
||||
runtime checks the attached lifetime and optional scope_ref, and validates both
|
||||
arguments and results against the same frozen Descriptor. A supplied scope_ref
|
||||
mismatch, including a missing current ref, is `InterfaceMismatch` at membership
|
||||
precedence, after target existence and a supplied Object validator mismatch.
|
||||
Omission applies no scope identity precondition.
|
||||
|
||||
Artifact-only `registry.fetch_interface(reference)` is removed. Prefer
|
||||
`ObjectProvider::fetch_interface(&provider, ®istry, &reference)`, which resolves
|
||||
the visible scope Object with `with_object` and uses its attached current binding.
|
||||
Low-level `registry.fetch_interface(&reference, ¤t_observation)` requires
|
||||
the same coherent current attestation, not a cached snapshot. Fetch returns
|
||||
Descriptor, Interface validator and optional scope Object ref together; Clients
|
||||
need not additionally fetch an ancestor Object. `registry.scopes()` may repeat
|
||||
a handle for several names; do not treat it as a unique-scope iterator.
|
||||
|
||||
`PublishedObject::protocol_object()` is a path-free composition projection, not
|
||||
publication authority. For current observations use `ObjectProvider::observe_object`
|
||||
or `publication.protocol_object_at(path)` inside the coherent provider boundary;
|
||||
the latter also validates compatible live scope associations and response shape.
|
||||
|
||||
## Downstream status
|
||||
|
||||
The workspace Client, Host, filesystem server, TUI and public-API example
|
||||
are mechanically migrated to structured protocol values and updated fixtures.
|
||||
The existing Host's generated opaque registration handle is bridged explicitly
|
||||
to scope `/` with its full handle as the name; no delimiter parsing or scope
|
||||
registration/lifetime model is introduced here. The existing Client keys use
|
||||
structured values; this change does not implement scope deletion/replacement
|
||||
cache recovery. TUI formatting is only the type-compatibility adaptation, not
|
||||
the full canonical Text View work (T-24). Filesystem/TUI semantics integration
|
||||
remains T-23. Interface authoring/macros are descriptor-only and require no
|
||||
identity fields or declaration changes.
|
||||
The filesystem default registers local names `file.read`, `symlink.inspect` and
|
||||
`file.write` at its deployment-pinned root Scope, displayed as `/::file.read` etc.
|
||||
External compositions choose explicit Scope/local names and implement
|
||||
`Composition::scope` to attest a non-root lifetime from the exact refreshed backend
|
||||
entry in its connected lineage. The engine never independently resolves a scope
|
||||
path during dispatch. Default composition remains root-scoped; exposing non-root
|
||||
registrations requires the application's current lifetime attestation, not merely
|
||||
an artifact or matching ref/validator. All bindings are formed inside backend
|
||||
execution, with current principal lineage visibility and publication metadata.
|
||||
Retire/recreate Scope handles on deletion, replacement or movement; a pinned leaf
|
||||
alone is not evidence that a self-scope Object remains published at that path.
|
||||
The Linux deployment root remains pinned across textual root rename/replacement,
|
||||
and its ref-less default publication continues to omit scope_ref.
|
||||
|
||||
The public API example self-scopes `catalog`, `audit` and `diagnostics` at
|
||||
`/article-service`, fetches through its provider, and constructs call metadata
|
||||
from the fetched structured reference and scope_ref. External-style conformance
|
||||
tests also cross real TCP HTTP for root/self-scopes, same names in different
|
||||
scopes, optional refs, stale refs, deletion/republication and validator precedence.
|
||||
|
||||
The TUI stores structured references in cache keys, selections, actions, editors
|
||||
and in-flight subjects. Normal screens reuse the canonical absolute scoped
|
||||
formatter (`/scope::name#operation`, with JSON component quoting when needed).
|
||||
Labels are never parsed to reconstruct identity, and scope_ref/validators are not
|
||||
normal-screen reference labels. Client invalidation is synchronized back into UI
|
||||
observations before dispatch; re-observation is not automatic execution. Typed
|
||||
result entries are still fetched on selection, not all at once, and do not create
|
||||
inferred indexable edges. Interface authoring/macros remain descriptor-only.
|
||||
|
||||
@@ -7,7 +7,8 @@ under `crates/*`. It provides a transport-independent protocol model, the
|
||||
standard framework-independent WIP over HTTP v1 binding, a stateful
|
||||
presentation-independent client runtime, a Rust interface-authoring facade, a
|
||||
request-scoped host runtime, a bounded Linux filesystem projection server, an
|
||||
interactive terminal client, and a private public-API conformance consumer. The
|
||||
interactive terminal client, a standalone Text View signature library, and a
|
||||
private public-API conformance consumer. The
|
||||
repository root is not itself a package.
|
||||
|
||||
## Crate graph and responsibilities
|
||||
@@ -15,6 +16,9 @@ repository root is not itself a package.
|
||||
```text
|
||||
Published libraries
|
||||
|
||||
wip-text-view
|
||||
└── wip-protocol
|
||||
|
||||
wip-client
|
||||
├── wip-http
|
||||
│ └── wip-protocol
|
||||
@@ -47,19 +51,20 @@ wip-public-api-example
|
||||
|
||||
| Crate | Responsibility | Does not own |
|
||||
| --- | --- | --- |
|
||||
| `wip-text-view` | Standalone, validated single-Object and single-Interface Text View signatures with full declarations, summaries and safe escapes. | Retrieval, cache, placement context, wire schema, UI layout, parsing or call-target reconstruction. |
|
||||
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
|
||||
| `wip-http` | Standard WIP over HTTP v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, and error/status classification. | HTTP I/O, URL scheme or TLS policy, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
|
||||
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
|
||||
| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTP I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. |
|
||||
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
|
||||
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
|
||||
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
|
||||
| `wip-host` | Scoped interface registration and publication lifetimes, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
|
||||
| `wip-fs-server` | Linux directory-handle-confined request-time projection, complete depth-bounded observations and bounded file operations, standard `wip-http` dispatch, and a loopback HTTP binary. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, TLS termination, or filesystem-wide preregistration. |
|
||||
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
|
||||
|
||||
## Published crates
|
||||
|
||||
The `0.1.0` release consists of these six crates:
|
||||
The `0.2.0` release consists of these seven crates:
|
||||
|
||||
- `wip-protocol`
|
||||
- `wip-interface-macros` — implementation dependency re-exported by `wip-interface`
|
||||
@@ -67,6 +72,13 @@ The `0.1.0` release consists of these six crates:
|
||||
- `wip-host`
|
||||
- `wip-http`
|
||||
- `wip-client`
|
||||
- `wip-text-view`
|
||||
|
||||
The [`wip-text-view`](crates/wip-text-view/README.md) library's
|
||||
`render_object(&Object)` API needs no path;
|
||||
`render_interface(&Interface)` takes one borrowed entity pairing the complete
|
||||
structured reference with its descriptor, without changing Protocol wire fields.
|
||||
Both return one complete plain-text signature or a typed error.
|
||||
|
||||
The filesystem server, terminal client, and public-API example are workspace-only packages and set `publish = false`. All workspace packages require Rust 1.88 or newer and are licensed under either Apache-2.0 or MIT at your option.
|
||||
|
||||
@@ -81,10 +93,17 @@ or application traits.
|
||||
|
||||
## Structured Interface references
|
||||
|
||||
The unreleased Protocol/HTTP API uses explicit `InterfaceReference { scope,
|
||||
The 0.2.0 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.
|
||||
The filesystem default publishes `/::file.read`, `/::symlink.inspect` and
|
||||
`/::file.write`; external compositions explicitly choose Scope/local names.
|
||||
The TUI retains structured references for selection and dispatch, while normal
|
||||
screens use absolute `scope::name#operation` labels with safely quoted components.
|
||||
These labels are display-only: they are never parsed as cache keys or call targets,
|
||||
and scope refs/validators remain runtime metadata rather than normal-screen labels.
|
||||
Client scope invalidation is reflected in the UI before another explicit call.
|
||||
|
||||
## End-to-end example
|
||||
|
||||
@@ -226,25 +245,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
|
||||
|
||||
+8
-5
@@ -1,12 +1,12 @@
|
||||
# Releasing
|
||||
|
||||
The six crates published from this workspace use one version and must be released in dependency order. The filesystem server, terminal client, and public-API example set `publish = false` and are never uploaded.
|
||||
The seven public crates in the current workspace (including `wip-text-view`) use one version and must be released in dependency order. The filesystem server, terminal client, and public-API example set `publish = false` and are never uploaded.
|
||||
|
||||
## Preconditions
|
||||
|
||||
1. Work from a clean `master` whose `HEAD` matches `origin/master`.
|
||||
2. Confirm the intended version in `[workspace.package]` and every internal requirement in `[workspace.dependencies]`.
|
||||
3. Confirm ownership of all six names on crates.io.
|
||||
3. Confirm ownership of all seven names on crates.io.
|
||||
4. Run the complete repository validation suite:
|
||||
|
||||
```console
|
||||
@@ -44,6 +44,9 @@ cargo publish --package wip-interface-macros
|
||||
Wait until both versions are available from the crates.io index, then continue:
|
||||
|
||||
```console
|
||||
cargo publish --dry-run --package wip-text-view
|
||||
cargo publish --package wip-text-view
|
||||
|
||||
cargo publish --dry-run --package wip-interface
|
||||
cargo publish --package wip-interface
|
||||
|
||||
@@ -65,11 +68,11 @@ Registry propagation can take time. A dependent dry-run reporting that a WIP cra
|
||||
|
||||
## Tag
|
||||
|
||||
After all six uploads resolve successfully, tag the exact published commit and push the tag:
|
||||
After all seven uploads resolve successfully, tag the exact published commit and push the tag:
|
||||
|
||||
```console
|
||||
git tag -a v0.1.0 -m "release: 0.1.0"
|
||||
git push origin v0.1.0
|
||||
git tag -a v0.2.0 -m "chore: release 0.2.0"
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
Do not move or recreate a published version or its tag. Prepare a new version for any subsequent correction.
|
||||
|
||||
@@ -6,12 +6,30 @@ Presentation-independent stateful runtime for Web Interface Protocol (WIP) clien
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
wip-client = "0.1.0"
|
||||
wip-client = "0.2.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.
|
||||
## Interface identity and scope lifetime
|
||||
|
||||
Interface acquisition and call APIs use `wip_protocol::InterfaceReference { scope, name }` directly. Within an endpoint/security-context session, the complete scoped pair is the cache key and the Object membership key: equal names in different scopes remain distinct. A scope is a canonical absolute Worldspace path and must be an ancestor of, or equal to, the call target path. Root scope (`/`) and self scope are valid; clients must not rewrite either into a different reference.
|
||||
|
||||
`InterfaceObservation::scope_ref` is optional opaque metadata identifying the scope Object, not a third reference component, a path, or an Interface Descriptor validator. Its absence does not prevent acquisition or calls. Calls copy the exact observed reference, optional `scope_ref`, and optional Interface Descriptor validator into `InterfaceTarget`; the target Object validator is copied separately. The `CallContext` fixes those observations, the descriptor, and the operation declaration before dispatch.
|
||||
|
||||
A descriptor without an Interface Descriptor validator is immutable only within the lifetime of its scope Object, not forever at that path. Descriptors with validators are also bound to that scope lifetime. When accepted observations establish that a known scope binding has changed or been lost, dependent cached interface state is invalidated and cannot authorize a new call until explicitly reacquired. A superseded interface response must not restore an observation from the old lifetime. A previously prepared call retains its original context rather than silently adopting a new scope identity or descriptor.
|
||||
|
||||
Scope binding evidence is shared across Interface names at the same scope path: a new binding learned by fetching one name retires older descriptors for the other names too. Separately observed Object refs also provide lifetime evidence even when a descriptor's `scope_ref` was omitted. This corroboration is private bounded cache state, not an invented identity, and never fills in missing call metadata. Same-lifetime descriptor validation still applies; a detected ref change or loss retires the old lifetime instead.
|
||||
|
||||
Object deletion, entry-edge removal, and local cache eviction are different events. A direct observe `NotFound` confirms unavailability at the requested scope path and removes cached interfaces scoped to that path; it does not guarantee identity-wide Object deletion or unavailability through every alias. Removing one entry edge likewise does not prove that the Object was deleted or invalidate every remaining alias. Cache eviction is local loss of retained knowledge, not evidence of Host deletion. Losing a known binding at an interface's scope path nevertheless ends safe reuse of descriptors dependent on that binding, even if the same Object remains reachable elsewhere. Optional `scope_ref` can provide identity evidence, but clients do not fabricate it when it is absent.
|
||||
|
||||
Without Object `ref` or `scope_ref` identity evidence, a replacement at the same scope path can be indistinguishable from the previous Object. The runtime cannot detect every ref-less replacement from path and descriptor contents alone; absence of detectable change is not proof that the scope lifetime continued.
|
||||
|
||||
## Explicit recovery and operation outcomes
|
||||
|
||||
`ensure_observed` and `ensure_interface` avoid duplicate fresh or loading work; they do not automatically refresh retained stale or failed observations. Use the explicit object/children refresh APIs and `prepare_interface` to recover the observations required for a new call. An operation `InterfaceMismatch` invalidates both sides of the call's assumptions: it marks the target Object observation stale and invalidates the selected cached interface observation, so recovery requires reobserving membership and reacquiring the descriptor. Reobserving an Object and reacquiring its interface are retrievals, not a retry of an operation.
|
||||
|
||||
The runtime never automatically retries an operation. Mark a call dispatched when handing it to the transport and report completion or transport failure with its original `RequestId`. A timeout, disconnect, or invalid response after dispatch can leave the outcome unknown: execution or commit cannot be ruled out. Refreshing stale state does not resolve that uncertainty and must not automatically resubmit the call; any new operation is an explicit application decision.
|
||||
|
||||
See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs.
|
||||
|
||||
|
||||
+197
-11
@@ -223,14 +223,20 @@ pub struct TreeObservation {
|
||||
pub state: ObservationState,
|
||||
}
|
||||
|
||||
/// Cached interface descriptor observation.
|
||||
/// Cached interface descriptor observation for one structured `(scope, name)` pair.
|
||||
///
|
||||
/// A known lost scope binding retires the descriptor rather than replacing only
|
||||
/// its [`Self::scope_ref`]. A direct scope `observe` returning `NotFound` may remove
|
||||
/// this cache entry; edge omission and local Object eviction do not prove deletion.
|
||||
/// Prepared calls retain their own snapshot until result validation completes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceObservation {
|
||||
/// 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.
|
||||
/// Optional interface validator. Absence makes the descriptor immutable only
|
||||
/// while the scope binding remains valid.
|
||||
pub validator: Option<Vec<u8>>,
|
||||
/// Optional opaque identity of the interface scope Object.
|
||||
pub scope_ref: Option<String>,
|
||||
@@ -245,6 +251,7 @@ pub struct CallContext {
|
||||
object_key: u64,
|
||||
object: ObjectObservation,
|
||||
interface: InterfaceObservation,
|
||||
interface_revision: RequestId,
|
||||
descriptor: InterfaceDescriptor,
|
||||
operation: OperationDeclaration,
|
||||
request: CallOperationRequest,
|
||||
@@ -390,6 +397,9 @@ struct EntryRecord {
|
||||
object_key: Option<u64>,
|
||||
revision: RequestId,
|
||||
state: ObservationState,
|
||||
// Retrieval state can change without losing the last accepted binding.
|
||||
// Only confirmed unavailability ends that path's lifetime evidence.
|
||||
confirmed_missing: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -416,9 +426,28 @@ struct Session {
|
||||
#[derive(Debug, Clone)]
|
||||
struct InterfaceRecord {
|
||||
observation: InterfaceObservation,
|
||||
// Ownership of this exact fetch/observation, not a Protocol identity.
|
||||
revision: RequestId,
|
||||
// Starting a refresh does not rebind its retained Descriptor. Scope evidence
|
||||
// must still be able to retire that old lifetime while the refresh loads.
|
||||
accepted_revision: RequestId,
|
||||
// Scope evidence is shared across names and can also come from Observe.
|
||||
// This private corroboration is never substituted for wire scope_ref and is
|
||||
// retained only with the bounded Interface record, not as a new identity/key.
|
||||
scope_binding: Option<String>,
|
||||
binding_revision: RequestId,
|
||||
touched: u64,
|
||||
}
|
||||
|
||||
impl InterfaceRecord {
|
||||
fn retire(&mut self, revision: RequestId) {
|
||||
self.revision = self.revision.max(revision);
|
||||
self.observation.descriptor = None;
|
||||
self.observation.validator = None;
|
||||
self.observation.state = ObservationState::Stale;
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
@@ -596,7 +625,8 @@ impl Client {
|
||||
self.sessions.get(session)?.trees.get(path)
|
||||
}
|
||||
|
||||
/// Returns the current interface observation.
|
||||
/// Returns the current interface observation, isolated by session and the
|
||||
/// complete structured reference (never by display text or serialized JSON).
|
||||
#[must_use]
|
||||
pub fn interface(
|
||||
&self,
|
||||
@@ -724,6 +754,7 @@ impl Client {
|
||||
object_key: None,
|
||||
revision: id,
|
||||
state: ObservationState::Loading(id),
|
||||
confirmed_missing: false,
|
||||
});
|
||||
entry.revision = id;
|
||||
entry.state = ObservationState::Loading(id);
|
||||
@@ -802,6 +833,8 @@ impl Client {
|
||||
let maximum = self.limits.interfaces_per_session;
|
||||
let state = self.session_mut(session)?;
|
||||
ensure_interface_slot(state, &request.interface, maximum)?;
|
||||
let (scope_binding, binding_revision) =
|
||||
latest_scope_binding(state, &request.interface.scope);
|
||||
let touched = state.tick();
|
||||
let record = state
|
||||
.interfaces
|
||||
@@ -814,8 +847,13 @@ impl Client {
|
||||
validator: None,
|
||||
state: ObservationState::Loading(id),
|
||||
},
|
||||
revision: id,
|
||||
accepted_revision: RequestId(0),
|
||||
scope_binding,
|
||||
binding_revision,
|
||||
touched,
|
||||
});
|
||||
record.revision = id;
|
||||
record.observation.state = ObservationState::Loading(id);
|
||||
record.touched = touched;
|
||||
self.pending.insert(
|
||||
@@ -833,8 +871,11 @@ impl Client {
|
||||
|
||||
/// Constructs an operation from fresh object and interface observations.
|
||||
///
|
||||
/// Membership, both validators, the descriptor, and the operation declaration
|
||||
/// are fixed in the returned request's immutable [`CallContext`].
|
||||
/// Membership, scope binding, both validators, the descriptor, and the operation
|
||||
/// declaration are fixed in the returned request's immutable [`CallContext`].
|
||||
/// The descriptor's optional `scope_ref` is copied unchanged, never substituted
|
||||
/// from a separate Object observation. No ancestor fetch is required. Without
|
||||
/// a scope ref, same-path replacement cannot always be detected.
|
||||
pub fn prepare_call(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
@@ -885,6 +926,7 @@ impl Client {
|
||||
"interface observation is not fresh",
|
||||
));
|
||||
}
|
||||
let interface_revision = state.interfaces[interface].revision;
|
||||
let descriptor = interface_observation
|
||||
.descriptor
|
||||
.clone()
|
||||
@@ -921,6 +963,7 @@ impl Client {
|
||||
object_key,
|
||||
object: object_observation,
|
||||
interface: interface_observation,
|
||||
interface_revision,
|
||||
descriptor,
|
||||
operation: operation_declaration,
|
||||
request,
|
||||
@@ -1138,6 +1181,11 @@ impl Client {
|
||||
error.to_string(),
|
||||
))
|
||||
};
|
||||
if error.code == ProtocolErrorCode::NotFound {
|
||||
let state = self.session_mut(session)?;
|
||||
retire_scope_subtree(state, &request.path, id);
|
||||
mark_subtree_stale(state, &request.path, id);
|
||||
}
|
||||
set_observe_subject_state(
|
||||
self.session_mut(session)?,
|
||||
&request,
|
||||
@@ -1165,8 +1213,37 @@ impl Client {
|
||||
match decode_fetch_interface_response(&request, &response, self.wire_limits) {
|
||||
Ok(DecodedResponse::Success(value)) => {
|
||||
let state = self.session_mut(session)?;
|
||||
let existing = &state.interfaces[&request.interface].observation;
|
||||
// Newer scope evidence (Observe or another Interface name) can
|
||||
// supersede even the first fetch for this pair.
|
||||
let (scope_binding, binding_revision) =
|
||||
latest_scope_binding(state, &request.interface.scope);
|
||||
if binding_revision > id
|
||||
&& value.scope_ref.is_some()
|
||||
&& value.scope_ref != scope_binding
|
||||
{
|
||||
state
|
||||
.interfaces
|
||||
.get_mut(&request.interface)
|
||||
.expect("interface exists")
|
||||
.retire(binding_revision);
|
||||
return self.reject_stale(session, id);
|
||||
}
|
||||
let existing_record = &state.interfaces[&request.interface];
|
||||
let existing = &existing_record.observation;
|
||||
let lost_wire_binding = existing.scope_ref.is_some() && value.scope_ref.is_none();
|
||||
// Omission stays omission in the public observation and call. A
|
||||
// known Object/sibling binding can still supply private lifetime
|
||||
// evidence unless this response explicitly lost its prior ref.
|
||||
let current_binding = if lost_wire_binding || value.scope_ref.is_some() {
|
||||
value.scope_ref.clone()
|
||||
} else {
|
||||
scope_binding
|
||||
};
|
||||
let same_lifetime = existing.scope_ref == value.scope_ref
|
||||
|| (current_binding.is_some()
|
||||
&& existing_record.scope_binding == current_binding);
|
||||
if let Some(descriptor) = &existing.descriptor
|
||||
&& same_lifetime
|
||||
&& ((existing.validator.is_none()
|
||||
&& (value.validator.is_some() || descriptor != &value.descriptor))
|
||||
|| (existing.validator.is_some()
|
||||
@@ -1186,6 +1263,14 @@ impl Client {
|
||||
self.push_failure_diagnostic(session, id, &error)?;
|
||||
return Err(error);
|
||||
}
|
||||
update_scope_binding(
|
||||
state,
|
||||
&request.interface.scope,
|
||||
current_binding.clone(),
|
||||
id,
|
||||
false,
|
||||
Some(&request.interface),
|
||||
);
|
||||
let touched = state.tick();
|
||||
let observation = InterfaceObservation {
|
||||
reference: value.interface,
|
||||
@@ -1199,6 +1284,9 @@ impl Client {
|
||||
.get_mut(&request.interface)
|
||||
.expect("interface exists");
|
||||
record.observation = observation.clone();
|
||||
record.accepted_revision = id;
|
||||
record.scope_binding = current_binding;
|
||||
record.binding_revision = id;
|
||||
record.touched = touched;
|
||||
Ok(Completion::Interface(observation))
|
||||
}
|
||||
@@ -1285,14 +1373,28 @@ impl Client {
|
||||
match error.code {
|
||||
ProtocolErrorCode::ValidatorRequired
|
||||
| ProtocolErrorCode::ValidatorMismatch
|
||||
| ProtocolErrorCode::InterfaceMismatch => {
|
||||
| ProtocolErrorCode::NotFound => {
|
||||
state.mark_object_stale(context.object_key, id);
|
||||
}
|
||||
ProtocolErrorCode::InterfaceMismatch => {
|
||||
state.mark_object_stale(context.object_key, id);
|
||||
if let Some(interface) =
|
||||
state.interfaces.get_mut(&context.interface.reference)
|
||||
&& interface.revision == context.interface_revision
|
||||
{
|
||||
// The call cannot reuse this observation, but mismatch
|
||||
// alone does not prove the scope lifetime ended. Retain
|
||||
// its Descriptor for same-binding consistency checks.
|
||||
interface.revision = interface.revision.max(id);
|
||||
interface.observation.state = ObservationState::Stale;
|
||||
}
|
||||
}
|
||||
ProtocolErrorCode::InterfaceValidatorRequired
|
||||
| ProtocolErrorCode::InterfaceValidatorMismatch => {
|
||||
if let Some(interface) = state
|
||||
.interfaces
|
||||
.get_mut(&context.request.interface.reference)
|
||||
&& interface.revision == context.interface_revision
|
||||
{
|
||||
interface.observation.state = ObservationState::Stale;
|
||||
}
|
||||
@@ -1762,6 +1864,12 @@ fn insert_object(
|
||||
let existing_path_key = existing_path_entry
|
||||
.as_ref()
|
||||
.and_then(|entry| entry.object_key);
|
||||
let previous_scope_ref = existing_path_entry
|
||||
.as_ref()
|
||||
.filter(|entry| !entry.confirmed_missing)
|
||||
.and_then(|entry| entry.object_key)
|
||||
.and_then(|key| state.objects.get(&key))
|
||||
.and_then(|record| record.object.r#ref.clone());
|
||||
let replaceable_key = existing_path_key.filter(|key| {
|
||||
state
|
||||
.objects
|
||||
@@ -1839,6 +1947,7 @@ fn insert_object(
|
||||
object_key: Some(key),
|
||||
revision: ownership_revision,
|
||||
state: ObservationState::Stale,
|
||||
confirmed_missing: false,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
@@ -1846,9 +1955,15 @@ fn insert_object(
|
||||
object_key: Some(key),
|
||||
revision,
|
||||
state: ObservationState::Fresh,
|
||||
confirmed_missing: false,
|
||||
}
|
||||
};
|
||||
state.entries.insert(path.to_owned(), entry);
|
||||
if !suppressed {
|
||||
let scope_ref = state.objects[&key].object.r#ref.clone();
|
||||
let changed = previous_scope_ref.is_some() && previous_scope_ref != scope_ref;
|
||||
update_scope_binding(state, path, scope_ref, revision, changed, None);
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
@@ -1997,15 +2112,86 @@ fn reserve_observation_metadata(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) {
|
||||
let prefix = format!("{root}/");
|
||||
// Only retained observations contribute scope evidence. No unbounded scope
|
||||
// tombstones or synthetic identity are needed: evidence is kept with bounded
|
||||
// Interface records, survives Object eviction, and disappears with cache cleanup.
|
||||
fn latest_scope_binding(state: &Session, scope: &str) -> (Option<String>, RequestId) {
|
||||
let object_binding = state
|
||||
.entries
|
||||
.get(scope)
|
||||
.filter(|entry| !entry.confirmed_missing)
|
||||
.and_then(|entry| entry.object_key)
|
||||
.and_then(|key| state.objects.get(&key))
|
||||
.map(|record| (record.object.r#ref.clone(), record.revision));
|
||||
state
|
||||
.interfaces
|
||||
.iter()
|
||||
.filter(|(reference, _)| reference.scope == scope)
|
||||
.map(|(_, record)| (record.scope_binding.clone(), record.binding_revision))
|
||||
.chain(object_binding)
|
||||
.max_by_key(|(_, revision)| *revision)
|
||||
.unwrap_or((None, RequestId(0)))
|
||||
}
|
||||
|
||||
fn update_scope_binding(
|
||||
state: &mut Session,
|
||||
scope: &str,
|
||||
scope_ref: Option<String>,
|
||||
revision: RequestId,
|
||||
confirmed_change: bool,
|
||||
except: Option<&InterfaceReference>,
|
||||
) {
|
||||
for (reference, interface) in &mut state.interfaces {
|
||||
if reference.scope != scope
|
||||
|| except == Some(reference)
|
||||
|| interface.accepted_revision > revision
|
||||
|| interface.binding_revision > revision
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if confirmed_change
|
||||
|| (interface.scope_binding.is_some() && interface.scope_binding != scope_ref)
|
||||
{
|
||||
interface.retire(revision);
|
||||
}
|
||||
// Corroboration is not wire metadata: never update observation.scope_ref
|
||||
// on a retained Descriptor, even when the first known ref is learned.
|
||||
interface.scope_binding = scope_ref.clone();
|
||||
interface.binding_revision = revision;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_subtree_path(root: &str, path: &str) -> bool {
|
||||
root == "/"
|
||||
|| path == root
|
||||
|| path
|
||||
.strip_prefix(root)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
}
|
||||
|
||||
// Direct NotFound confirms that this scope path is no longer available. Edge
|
||||
// omission and local eviction deliberately do not call this function.
|
||||
fn retire_scope_subtree(state: &mut Session, root: &str, revision: RequestId) {
|
||||
for (path, entry) in &mut state.entries {
|
||||
if (path == root || path.starts_with(&prefix)) && entry.revision <= request_id {
|
||||
if is_subtree_path(root, path) && entry.revision <= revision {
|
||||
entry.confirmed_missing = true;
|
||||
}
|
||||
}
|
||||
state.interfaces.retain(|reference, record| {
|
||||
!is_subtree_path(root, &reference.scope) || record.accepted_revision > revision
|
||||
});
|
||||
}
|
||||
|
||||
fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) {
|
||||
for (path, entry) in &mut state.entries {
|
||||
if is_subtree_path(root, path) && entry.revision <= request_id {
|
||||
entry.revision = request_id;
|
||||
entry.state = ObservationState::Stale;
|
||||
}
|
||||
}
|
||||
for (path, tree) in &mut state.trees {
|
||||
if (path == root || path.starts_with(&prefix)) && tree.revision <= request_id {
|
||||
if is_subtree_path(root, path) && tree.revision <= request_id {
|
||||
tree.revision = request_id;
|
||||
tree.state = ObservationState::Stale;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ use wip_client::{
|
||||
};
|
||||
use wip_http::http::{Response, StatusCode};
|
||||
use wip_http::{
|
||||
Limits, encode_fetch_interface_response, encode_observe_response,
|
||||
encode_protocol_error_response,
|
||||
Limits, decode_call_operation_request, decode_fetch_interface_request,
|
||||
encode_fetch_interface_response, encode_observe_response, encode_protocol_error_response,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||
Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration,
|
||||
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
|
||||
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
|
||||
InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object, ObjectObservation,
|
||||
ObserveRequest, OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ProtocolInteraction, ReturnDeclaration, Target, TypeExpr, Value,
|
||||
};
|
||||
|
||||
fn limits() -> Limits {
|
||||
@@ -74,6 +75,352 @@ fn descriptor(format: &str) -> InterfaceDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn observe(client: &mut Client, session: &wip_client::SessionId, path: &str, value: Object) {
|
||||
let request = client.refresh_object(session, path).unwrap();
|
||||
client
|
||||
.complete(
|
||||
request.id,
|
||||
encode_observe_response(
|
||||
&ObserveRequest {
|
||||
path: path.into(),
|
||||
depth: 0,
|
||||
},
|
||||
&ObjectObservation {
|
||||
object: value,
|
||||
children: None,
|
||||
},
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn fetch(
|
||||
client: &mut Client,
|
||||
session: &wip_client::SessionId,
|
||||
value: FetchInterfaceResponse,
|
||||
) -> Completion {
|
||||
let request = client
|
||||
.prepare_interface(session, value.interface.clone())
|
||||
.unwrap();
|
||||
let logical = FetchInterfaceRequest {
|
||||
interface: value.interface.clone(),
|
||||
};
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(request.request.body(), limits()).unwrap(),
|
||||
logical
|
||||
);
|
||||
client
|
||||
.complete(
|
||||
request.id,
|
||||
encode_fetch_interface_response(&logical, &value, limits()).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_accepts_same_name_interfaces_in_different_scopes() {
|
||||
let (mut client, session) = setup();
|
||||
let request = client.refresh_object(&session, "/item").unwrap();
|
||||
let completion = client
|
||||
.complete(
|
||||
request.id,
|
||||
response(
|
||||
StatusCode::OK,
|
||||
br#"{"object":{"name":"item","interfaces":[{"scope":"/","name":"math"},{"scope":"/item","name":"math"}]}}"#,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let expected = vec![root_reference("math"), reference("/item", "math")];
|
||||
assert!(matches!(
|
||||
completion,
|
||||
Completion::Object(observation)
|
||||
if observation.state == ObservationState::Fresh
|
||||
&& observation.object.as_ref().unwrap().interfaces == expected
|
||||
));
|
||||
let cached = client.object(&session, "/item").unwrap();
|
||||
assert_eq!(cached.state, ObservationState::Fresh);
|
||||
assert_eq!(cached.object.unwrap().interfaces, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_name_different_scopes_have_independent_requests_and_cached_descriptors() {
|
||||
let (mut client, session) = setup();
|
||||
let root = root_reference("math");
|
||||
let scoped = reference("/area", "math");
|
||||
let root_request = client
|
||||
.ensure_interface(&session, root.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let scoped_request = client
|
||||
.ensure_interface(&session, scoped.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(root_request.id, scoped_request.id);
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(root_request.request.body(), limits()).unwrap(),
|
||||
FetchInterfaceRequest {
|
||||
interface: root.clone(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(scoped_request.request.body(), limits()).unwrap(),
|
||||
FetchInterfaceRequest {
|
||||
interface: scoped.clone(),
|
||||
}
|
||||
);
|
||||
for selected in [&root, &scoped] {
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, selected.clone())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert_eq!(client.in_flight_len(), 2);
|
||||
|
||||
// Reverse completion order must not supersede the other scope's request.
|
||||
for (request, selected, documentation, scope_ref, validator) in [
|
||||
(
|
||||
scoped_request,
|
||||
&scoped,
|
||||
"area math",
|
||||
Some("area-object"),
|
||||
Some(b"area-v1".to_vec()),
|
||||
),
|
||||
(root_request, &root, "root math", None, None),
|
||||
] {
|
||||
let mut expected_descriptor = descriptor(INTERFACE_FORMAT_V1);
|
||||
expected_descriptor.documentation = Some(wip_protocol::Documentation {
|
||||
summary: documentation.into(),
|
||||
details: None,
|
||||
});
|
||||
let value = FetchInterfaceResponse {
|
||||
interface: selected.clone(),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
descriptor: expected_descriptor.clone(),
|
||||
validator: validator.clone(),
|
||||
};
|
||||
let completion = client
|
||||
.complete(
|
||||
request.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: selected.clone(),
|
||||
},
|
||||
&value,
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(completion, Completion::Interface(_)));
|
||||
let cached = client.interface(&session, selected).unwrap();
|
||||
assert_eq!(cached.reference, *selected);
|
||||
assert_eq!(cached.descriptor, Some(expected_descriptor));
|
||||
assert_eq!(cached.scope_ref, value.scope_ref);
|
||||
assert_eq!(cached.validator, validator);
|
||||
assert_eq!(cached.state, ObservationState::Fresh);
|
||||
}
|
||||
for selected in [&root, &scoped] {
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, selected.clone())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &root)
|
||||
.unwrap()
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.documentation
|
||||
.as_ref()
|
||||
.map(|documentation| documentation.summary.as_str()),
|
||||
Some("root math")
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &scoped)
|
||||
.unwrap()
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.documentation
|
||||
.as_ref()
|
||||
.map(|documentation| documentation.summary.as_str()),
|
||||
Some("area math")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_completion_retains_optional_scope_ref_independently_of_validator() {
|
||||
for scope_ref in [None, Some("opaque scope identity / not a path")] {
|
||||
for validator in [None, Some(b"interface-v1".to_vec())] {
|
||||
let (mut client, session) = setup();
|
||||
let selected = reference("/area", "math");
|
||||
let expected = wip_client::InterfaceObservation {
|
||||
reference: selected.clone(),
|
||||
descriptor: Some(descriptor(INTERFACE_FORMAT_V1)),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
validator: validator.clone(),
|
||||
state: ObservationState::Fresh,
|
||||
};
|
||||
let completion = fetch(
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: selected.clone(),
|
||||
descriptor: expected.descriptor.clone().unwrap(),
|
||||
scope_ref: expected.scope_ref.clone(),
|
||||
validator,
|
||||
},
|
||||
);
|
||||
assert_eq!(completion, Completion::Interface(expected.clone()));
|
||||
assert_eq!(client.interface(&session, &selected), Some(&expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_membership_requires_the_complete_scope_and_name_pair() {
|
||||
let (mut client, session) = setup();
|
||||
let member = reference("/item", "math");
|
||||
let same_name = root_reference("math");
|
||||
let mut value = object();
|
||||
value.interfaces = vec![member.clone()];
|
||||
observe(&mut client, &session, "/item", value);
|
||||
for selected in [&member, &same_name] {
|
||||
fetch(
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: selected.clone(),
|
||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let arguments = BTreeMap::from([("value".into(), Value::Integer(2))]);
|
||||
assert_eq!(
|
||||
client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&same_name,
|
||||
"calculate",
|
||||
arguments.clone(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ClientErrorKind::InvalidRequest
|
||||
);
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", &member, "calculate", arguments)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decode_call_operation_request(
|
||||
call.request.body(),
|
||||
&descriptor(INTERFACE_FORMAT_V1),
|
||||
limits(),
|
||||
)
|
||||
.unwrap()
|
||||
.interface
|
||||
.reference,
|
||||
member
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calls_copy_exact_observed_references_metadata_and_validators_for_root_ancestor_and_self_scopes()
|
||||
{
|
||||
for (path, scope) in [
|
||||
("/", "/"),
|
||||
("/item", "/"),
|
||||
("/item", "/item"),
|
||||
("/area/item", "/area"),
|
||||
("/area/item", "/area/item"),
|
||||
] {
|
||||
for scope_ref in [None, Some("opaque scope identity / not a path")] {
|
||||
for interface_validator in [None, Some(b"interface-v1".to_vec())] {
|
||||
for object_validator in [None, Some(b"object-v1".to_vec())] {
|
||||
let (mut client, session) = setup();
|
||||
let selected = reference(scope, "math");
|
||||
let expected_descriptor = descriptor(INTERFACE_FORMAT_V1);
|
||||
let mut value = object();
|
||||
value.name = path.rsplit('/').next().unwrap().into();
|
||||
value.interfaces = vec![selected.clone()];
|
||||
value.validator = object_validator.clone();
|
||||
if path == scope {
|
||||
value.r#ref = scope_ref.map(str::to_owned);
|
||||
}
|
||||
observe(&mut client, &session, path, value.clone());
|
||||
fetch(
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: selected.clone(),
|
||||
descriptor: expected_descriptor.clone(),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
validator: interface_validator.clone(),
|
||||
},
|
||||
);
|
||||
let expected_interface = client.interface(&session, &selected).unwrap().clone();
|
||||
let expected_object = client.object(&session, path).unwrap();
|
||||
let arguments = BTreeMap::from([("value".into(), Value::Integer(2))]);
|
||||
let expected = CallOperationRequest {
|
||||
target: Target {
|
||||
path: path.into(),
|
||||
validator: object_validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: selected.clone(),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
validator: interface_validator.clone(),
|
||||
},
|
||||
operation: "calculate".into(),
|
||||
arguments: arguments.clone(),
|
||||
};
|
||||
let call = client
|
||||
.prepare_call(&session, path, &selected, "calculate", arguments)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decode_call_operation_request(
|
||||
call.request.body(),
|
||||
&expected_descriptor,
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
let context = client.pending_call_context(call.id).unwrap();
|
||||
assert_eq!(context.request(), &expected);
|
||||
assert_eq!(context.interface(), &expected_interface);
|
||||
assert_eq!(context.object(), &expected_object);
|
||||
assert_eq!(context.object().object.as_ref(), Some(&value));
|
||||
assert_eq!(context.descriptor(), &expected_descriptor);
|
||||
assert_eq!(context.operation(), &expected_descriptor.operations[0]);
|
||||
assert_eq!(context.session(), &session);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() {
|
||||
let cases: &[&[u8]] = &[
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,12 @@ envelopes, and HTTP status mapping.
|
||||
|
||||
## Library assembly and migration
|
||||
|
||||
The existing `ServerConfig`, `WipFsServer::new`, `handle_http`, CLI flags and wire
|
||||
representation remain compatible. The formerly private projection has become a
|
||||
public, generic library engine. No package version or publication policy changes
|
||||
are required.
|
||||
`ServerConfig`, `WipFsServer::new`, `handle_http` and CLI flags retain their
|
||||
assembly shape. The scoped Host/Protocol API is a breaking Rust/wire migration:
|
||||
Interface references are `{scope, name}` values, registration takes a `Scope`
|
||||
and local name, and calls use `interface.reference` plus optional `scope_ref`.
|
||||
Fetch returns the same structured reference and current scope Object's optional
|
||||
ref. This is the standard WIP wire format, not an FS-specific profile.
|
||||
|
||||
Use `default_projection(root, access, limits)` to obtain the default Linux engine
|
||||
without starting a listener. `projection.view(&principal)` is the effective view;
|
||||
@@ -26,9 +28,15 @@ 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. For a
|
||||
non-root scope, create a handle at its canonical WIP path and implement
|
||||
`Composition::scope(path, &entry, context)` to attest that handle only for
|
||||
its current Object lifetime. Local names are exact names, not parsed `::`/`#`
|
||||
display strings; the same local name can be registered at different scopes.
|
||||
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 +69,49 @@ 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.
|
||||
|
||||
`Composition::scope` is the trusted non-root lifetime attestation hook. It runs
|
||||
only for registered scope paths in the refreshed, principal-visible execution
|
||||
lineage, never through independent scope-path resolution. Return `Some(scope)`
|
||||
only when the retained entry is still the current Object published at that path;
|
||||
use retained backend identity and application publication-lifecycle state under
|
||||
the backend boundary, not ref or validator equality. Deletion, replacement or
|
||||
movement needs a fresh handle and registration, even if a ref is absent or reused.
|
||||
Ordinary state changes retain the handle. A different returned path is an internal
|
||||
publication failure; `None` or a fresh unregistered lifetime hides old artifacts.
|
||||
The default hook returns `None`, so existing compositions fail closed outside `/`.
|
||||
|
||||
Self-scope needs special care: a backend may allow ordinary execution against an
|
||||
unlinked/replaced pinned leaf, but that does not establish that leaf as the current
|
||||
scope Object. The hook must attest current membership as well as retained identity
|
||||
within the same boundary. The default Linux composition does not attest non-root
|
||||
lifetimes or invent inode-based Object refs. External compositions must provide
|
||||
that guarantee themselves, including their documented external-writer limits.
|
||||
Scope metadata is obtained by publishing that retained entry (reusing the target
|
||||
publication for self-scope); scope Objects need not expose interfaces. Optional
|
||||
refs are taken from the published Object, not from a raw backend entry. Calls
|
||||
without `scope_ref` remain valid when the lifetime is current; supplied stale refs
|
||||
reject with `InterfaceMismatch` after Object validator checks.
|
||||
|
||||
`with_object` (including fetch) and individual tree Object publications also
|
||||
enter this backend boundary. Generic read callbacks use a `NotStarted` attempt
|
||||
as an adapter to the existing execution API; no typed operation runs and no
|
||||
commit is implied. Tree observations are bounded traversals, not one global tree
|
||||
transaction. Fetch resolves the visible scope Object through the provider and
|
||||
returns its ref, Descriptor and Interface validator together; retained registry
|
||||
artifacts alone never establish publication.
|
||||
|
||||
`FilesystemBackend::execute` must validate ancestor membership/identity and
|
||||
refresh **all** lineage state/validators inside its transaction or lock before
|
||||
@@ -196,14 +246,16 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
|
||||
```
|
||||
|
||||
A regular-file observation returns a structured read interface reference and an
|
||||
object validator. Built-in legacy registrations use scope `/` and copy the opaque
|
||||
registration string verbatim into `name`; they do not emit `scope_ref`. Copy the
|
||||
complete reference from the observation to retrieve that descriptor:
|
||||
object validator. Built-ins explicitly use scope `/` and the local names
|
||||
`file.read`, `symlink.inspect`, and `file.write`. The Linux root does not issue an
|
||||
Object ref, so fetch omits `scope_ref`; its private publication lifetime still
|
||||
binds fetch and call to the pinned deployment root. Copy the complete reference
|
||||
from the observation to retrieve that descriptor:
|
||||
|
||||
```console
|
||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{"interface":{"scope":"/","name":"<read-interface-name-from-observation>"}}'
|
||||
--data '{"interface":{"scope":"/","name":"file.read"}}'
|
||||
```
|
||||
|
||||
Then call a bounded range read:
|
||||
@@ -213,7 +265,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":{"scope":"/","name":"<read-interface-name>"}},
|
||||
"interface":{"reference":{"scope":"/","name":"file.read"}},
|
||||
"operation":"read",
|
||||
"arguments":{"offset":0,"length":4096}
|
||||
}'
|
||||
@@ -227,7 +279,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":{"scope":"/","name":"<write-interface-name>"}},
|
||||
"interface":{"reference":{"scope":"/","name":"file.write"}},
|
||||
"operation":"replace",
|
||||
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||
}'
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::projection::{LinuxBackend, LinuxEntry};
|
||||
use crate::{AccessMode, ResourceLimits};
|
||||
use wip_host::{
|
||||
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
|
||||
RegisteredInterface, RegistrationError, Validator, WipObject,
|
||||
RegisteredInterface, RegistrationError, Scope, Validator, WipObject,
|
||||
};
|
||||
use wip_interface as wip;
|
||||
|
||||
@@ -22,12 +22,19 @@ 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.
|
||||
///
|
||||
/// Built-in local names are `file.read`, `symlink.inspect`, and `file.write`.
|
||||
/// The default deployment registers them at `/`; external compositions may
|
||||
/// register them at non-root scopes attested by `Composition::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 +142,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, Scope, 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 binds `/` to this pinned deployment root. Non-root
|
||||
/// lifetimes are attested by [`Composition::scope`] from the refreshed lineage.
|
||||
///
|
||||
/// `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
|
||||
@@ -120,10 +126,40 @@ pub trait FilesystemBackend {
|
||||
pub trait Composition<E: BackendEntry> {
|
||||
/// Local typed operation implementation and safe public metadata.
|
||||
type Object: WipObject;
|
||||
/// Attest the current non-root scope lifetime of this exact backend entry.
|
||||
///
|
||||
/// Called only within `FilesystemBackend::execute`, for refreshed entries in
|
||||
/// its connected lineage. Return the same handle used for registration only
|
||||
/// while this same Object remains published at `path`. After deletion,
|
||||
/// replacement or movement return `None` or a fresh Scope, even without an
|
||||
/// Object ref (or when the replacement reuses that ref). Ordinary state
|
||||
/// changes retain the handle. In particular a self-scope target must still
|
||||
/// be the current member at `path`: a backend's ordinary pinned-leaf guarantee
|
||||
/// alone may allow execution after leaf replacement and is not sufficient
|
||||
/// evidence for a scope lifetime. Do not re-resolve `path` or infer lifetime from
|
||||
/// a validator/ref alone: use the entry's retained backend identity and the
|
||||
/// application's publication lifecycle, serialized by the backend boundary.
|
||||
///
|
||||
/// The default attests no non-root scopes. The deployment-pinned root is
|
||||
/// bound automatically; this hook is not called for `/`. Returning a Scope
|
||||
/// for a different path is an internal publication error. A scope Object
|
||||
/// need not expose any interfaces and its public ref remains optional.
|
||||
fn scope(
|
||||
&self,
|
||||
_path: &str,
|
||||
_entry: &E,
|
||||
_context: &ExecutionContext,
|
||||
) -> Result<Option<Scope>, HostError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 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 retained scope ancestors to observe
|
||||
/// their metadata while another target is executing. Scope 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,
|
||||
@@ -285,13 +321,78 @@ where
|
||||
|
||||
fn publication(
|
||||
&self,
|
||||
entry: B::Entry,
|
||||
resolved: &ResolvedEntry<B::Entry>,
|
||||
context: &ExecutionContext,
|
||||
) -> Result<PublishedObject<C::Object>, HostError> {
|
||||
let entry = resolved.target();
|
||||
let publication = self
|
||||
.projection
|
||||
.composition
|
||||
.publish(entry.clone(), context)?;
|
||||
let mut current_scopes = Vec::new();
|
||||
let mut path = String::from("/");
|
||||
// Only this refreshed, visible, connected lineage can attest scopes.
|
||||
// Never independently resolve a scope path while a target is executing.
|
||||
for (index, scope_entry) in resolved
|
||||
.ancestors()
|
||||
.iter()
|
||||
.chain(std::iter::once(entry))
|
||||
.enumerate()
|
||||
{
|
||||
if index > 0 {
|
||||
if path != "/" {
|
||||
path.push('/');
|
||||
}
|
||||
path.push_str(scope_entry.name());
|
||||
}
|
||||
if !self
|
||||
.projection
|
||||
.registry
|
||||
.scopes()
|
||||
.any(|scope| scope.path() == path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let scopes = if path == "/" {
|
||||
self.projection
|
||||
.registry
|
||||
.scopes()
|
||||
.filter(|scope| scope.path() == "/")
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
self.projection
|
||||
.composition
|
||||
.scope(&path, scope_entry, context)?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
if scopes.iter().any(|scope| scope.path() != path) {
|
||||
return Err(internal());
|
||||
}
|
||||
if scopes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Refs belong to the Composition's published Object, not necessarily
|
||||
// the raw entry. Self-scope reuses exactly the target publication.
|
||||
let scope_publication = if index == resolved.ancestors().len() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.projection
|
||||
.composition
|
||||
.publish(scope_entry.clone(), context)?,
|
||||
)
|
||||
};
|
||||
let object = scope_publication
|
||||
.as_ref()
|
||||
.map_or(publication.implementation(), |scope| scope.implementation());
|
||||
current_scopes.extend(
|
||||
scopes
|
||||
.iter()
|
||||
.map(|scope| ScopeObservation::new(scope, object)),
|
||||
);
|
||||
}
|
||||
let available = publication
|
||||
.protocol_object()
|
||||
.interfaces
|
||||
@@ -306,19 +407,73 @@ where
|
||||
let mut selected = self
|
||||
.projection
|
||||
.policy
|
||||
.interfaces(self.principal, &entry, &available);
|
||||
.interfaces(self.principal, entry, &available);
|
||||
selected.retain(|reference| {
|
||||
self.projection
|
||||
let reference_value = reference.protocol_reference();
|
||||
current_scopes.iter().any(|current| {
|
||||
self.projection
|
||||
.registry
|
||||
.fetch_interface(&reference_value, current)
|
||||
.is_ok()
|
||||
}) && 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())?;
|
||||
for current in current_scopes {
|
||||
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);
|
||||
}
|
||||
observed = Some(
|
||||
match preflight(context).and_then(|()| {
|
||||
self.publication(&resolved, 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 +488,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 +540,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 +556,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 +566,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 +599,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 +648,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 +674,6 @@ where
|
||||
if !self.lineage_visible(&resolved) {
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||
}
|
||||
let (_ancestors, entry) = resolved.into_parts();
|
||||
if let Err(error) = preflight(context) {
|
||||
return OperationAttempt::reject(match error.kind() {
|
||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||
@@ -512,7 +682,7 @@ where
|
||||
}
|
||||
});
|
||||
}
|
||||
match self.publication(entry, context) {
|
||||
match self.publication(&resolved, context) {
|
||||
Ok(publication) => operation(&publication),
|
||||
Err(error) => OperationAttempt::reject(match error.kind() {
|
||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||
|
||||
@@ -17,6 +17,19 @@
|
||||
//! 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 its deployment-pinned root lifetime automatically inside the backend
|
||||
//! execution boundary. External [`Composition::scope`] implementations attest
|
||||
//! non-root lifetimes using the refreshed connected lineage in that same boundary.
|
||||
//! Without an attestation non-root registrations fail closed. Scope refs come
|
||||
//! from the current published scope Object and remain optional.
|
||||
//! The Linux root survives textual root renames and does not issue an Object ref;
|
||||
//! fetch therefore omits `scope_ref` without weakening the private lifetime check.
|
||||
//!
|
||||
//! Backends and Operations are trusted code, responsible for truthful effects,
|
||||
//! public-only metadata and ongoing execution-context bounds. Policies must be
|
||||
//! stable within a view; changing descriptor visibility requires new reference
|
||||
|
||||
@@ -1612,6 +1612,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scoped_interfaces_follow_the_pinned_deployment_not_textual_replacement() {
|
||||
let temporary = tempdir().expect("temporary parent");
|
||||
let configured = temporary.path().join("root");
|
||||
let moved = temporary.path().join("moved");
|
||||
fs::create_dir(&configured).unwrap();
|
||||
fs::write(configured.join("file"), b"original").unwrap();
|
||||
let engine = projection(&configured, AccessMode::ReadOnly);
|
||||
let object = engine.observe_object("/file").unwrap();
|
||||
let reference = object.interfaces[0].clone();
|
||||
assert_eq!(reference.scope, "/");
|
||||
assert_eq!(reference.name, "file.read");
|
||||
fs::rename(&configured, &moved).unwrap();
|
||||
fs::create_dir(&configured).unwrap();
|
||||
fs::write(configured.join("file"), b"replacement").unwrap();
|
||||
let fetched = engine
|
||||
.fetch_interface(&wip_protocol::FetchInterfaceRequest {
|
||||
interface: reference.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(fetched.scope_ref.is_none());
|
||||
let result = call(
|
||||
&engine,
|
||||
"/file",
|
||||
object.validator,
|
||||
reference.clone(),
|
||||
"read",
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.result, Value::Bytes(b"original".to_vec()));
|
||||
let error = engine
|
||||
.call_operation(wip_host::CallRequest {
|
||||
target: wip_host::Target {
|
||||
path: "/file".into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: wip_host::InterfaceTarget {
|
||||
reference,
|
||||
scope_ref: Some("invented-root-ref".into()),
|
||||
validator: None,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_lock_wait_observes_the_operation_deadline() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
@@ -1622,8 +1671,6 @@ mod tests {
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.expect("open lock owner");
|
||||
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
||||
|
||||
let limits = ResourceLimits {
|
||||
request_timeout: std::time::Duration::from_millis(20),
|
||||
..ResourceLimits::default()
|
||||
@@ -1631,6 +1678,9 @@ mod tests {
|
||||
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
|
||||
.expect("open projection");
|
||||
let observed = projection.observe_object("/file").expect("observe file");
|
||||
// Observation also enters the coherent backend boundary now. Acquire
|
||||
// the interfering lock only after obtaining Client-known metadata.
|
||||
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
||||
let started = std::time::Instant::now();
|
||||
let error = call(
|
||||
&projection,
|
||||
|
||||
@@ -10,7 +10,7 @@ use wip_fs_server::{
|
||||
use wip_host::{
|
||||
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
||||
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
|
||||
RegisteredInterface, Validator, WipObject,
|
||||
RegisteredInterface, Scope, Validator, WipObject,
|
||||
};
|
||||
use wip_http::{
|
||||
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
|
||||
@@ -101,6 +101,9 @@ impl WipObject for Entry {
|
||||
fn description(&self) -> Option<&str> {
|
||||
Some("public memory entry")
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
(self.path == "/").then_some("entry-root")
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
Some(&self.validator)
|
||||
}
|
||||
@@ -122,6 +125,7 @@ struct Seen {
|
||||
#[derive(Default)]
|
||||
struct Controls {
|
||||
bump_before_execute: bool,
|
||||
bump_ancestor: Option<String>,
|
||||
hide_before_execute: bool,
|
||||
hide_ancestor: Option<String>,
|
||||
replace_ancestor: Option<String>,
|
||||
@@ -157,7 +161,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 +203,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;
|
||||
};
|
||||
@@ -274,6 +290,9 @@ impl FilesystemBackend for MemoryBackend {
|
||||
entry.node.data.lock().unwrap().revision += 1;
|
||||
controls.bump_before_execute = false;
|
||||
}
|
||||
if let Some(path) = controls.bump_ancestor.take() {
|
||||
self.0.node(&path).data.lock().unwrap().revision += 1;
|
||||
}
|
||||
if controls.hide_before_execute {
|
||||
entry.node.data.lock().unwrap().hidden = true;
|
||||
}
|
||||
@@ -320,6 +339,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 +396,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 +604,19 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
|
||||
for panic_object in [true, false] {
|
||||
let fixture = Fixture::new();
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
||||
let custom = registry.register(MemoryOperationsDefinition, None).unwrap();
|
||||
let scope = Scope::new("/").unwrap();
|
||||
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
|
||||
let custom = registry
|
||||
.register(
|
||||
&scope,
|
||||
"memory.operations",
|
||||
MemoryOperationsDefinition,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reference = custom.reference().protocol_reference();
|
||||
let engine = Projection::new(
|
||||
MemoryBackend(fixture.shared.clone()),
|
||||
MemoryBackend::new(fixture.shared.clone()),
|
||||
registry,
|
||||
MemoryComposition {
|
||||
defaults,
|
||||
@@ -619,6 +657,8 @@ struct Fixture {
|
||||
custom: wip_protocol::InterfaceReference,
|
||||
read: wip_protocol::InterfaceReference,
|
||||
limits: ResourceLimits,
|
||||
// Client-known descriptors acquired coherently before execution fault injection.
|
||||
descriptors: BTreeMap<(bool, wip_protocol::InterfaceReference), InterfaceDescriptor>,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
@@ -669,9 +709,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 +733,38 @@ impl Fixture {
|
||||
custom,
|
||||
shared: shared.clone(),
|
||||
};
|
||||
let engine = Projection::new(
|
||||
MemoryBackend::new(shared.clone()),
|
||||
registry,
|
||||
composition,
|
||||
policy,
|
||||
limits,
|
||||
);
|
||||
let mut descriptors = BTreeMap::new();
|
||||
if !invalid_selection {
|
||||
for principal in [Principal::Admin, Principal::Limited] {
|
||||
for reference in [&read_ref, &custom_ref] {
|
||||
let reference = reference.protocol_reference();
|
||||
if let Ok(observation) = engine
|
||||
.view(&principal)
|
||||
.fetch_interface(&fetch_request(&reference))
|
||||
{
|
||||
descriptors.insert(
|
||||
(matches!(principal, Principal::Admin), reference),
|
||||
observation.descriptor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shared.seen.lock().unwrap().clear();
|
||||
Self {
|
||||
engine: Projection::new(
|
||||
MemoryBackend(shared.clone()),
|
||||
registry,
|
||||
composition,
|
||||
policy,
|
||||
limits,
|
||||
),
|
||||
engine,
|
||||
shared,
|
||||
custom: custom_ref.protocol_reference(),
|
||||
read: read_ref.protocol_reference(),
|
||||
limits,
|
||||
descriptors,
|
||||
}
|
||||
}
|
||||
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
|
||||
@@ -712,11 +778,7 @@ impl Fixture {
|
||||
principal: &Principal,
|
||||
reference: &wip_protocol::InterfaceReference,
|
||||
) -> InterfaceDescriptor {
|
||||
self.engine
|
||||
.view(principal)
|
||||
.fetch_interface(&fetch_request(reference))
|
||||
.unwrap()
|
||||
.descriptor
|
||||
self.descriptors[&(matches!(principal, Principal::Admin), reference.clone())].clone()
|
||||
}
|
||||
fn request(
|
||||
&self,
|
||||
@@ -930,7 +992,10 @@ fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_cond
|
||||
Some("public memory entry")
|
||||
);
|
||||
assert!(observation.children.is_none());
|
||||
assert!(observation.object.r#ref.is_none());
|
||||
assert_eq!(
|
||||
observation.object.r#ref.as_deref(),
|
||||
(path == "/").then_some("memory-root")
|
||||
);
|
||||
for reference in &observation.object.interfaces {
|
||||
let fetched = fixture
|
||||
.engine
|
||||
@@ -1383,10 +1448,12 @@ fn policy_cannot_add_an_unimplemented_interface() {
|
||||
ProtocolErrorCode::Internal
|
||||
);
|
||||
let request = fixture.request("/plain", &fixture.custom, "denied", None);
|
||||
// This deliberately invalid publication cannot supply a fetch response.
|
||||
// Use the application's authored schema solely to encode the test request.
|
||||
let (response, decoded) = fixture.http_call(
|
||||
&Principal::Admin,
|
||||
&request,
|
||||
&fixture.descriptor(&Principal::Admin, &fixture.custom),
|
||||
&MemoryOperationsDefinition::descriptor(),
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
wire_error(decoded, ProtocolErrorCode::Internal);
|
||||
@@ -2036,3 +2103,153 @@ 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
|
||||
);
|
||||
}
|
||||
|
||||
#[path = "support/scoped.rs"]
|
||||
mod scoped;
|
||||
|
||||
#[test]
|
||||
fn nonroot_registrations_fail_closed_without_composition_lifetime_attestation() {
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
//! External non-root scope regression coverage, sharing the memory backend.
|
||||
use super::*;
|
||||
|
||||
struct ScopedObject {
|
||||
inner: Object,
|
||||
reference: Option<String>,
|
||||
}
|
||||
impl WipObject for ScopedObject {
|
||||
fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
self.reference.as_deref()
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
self.inner.validator()
|
||||
}
|
||||
fn requires_validator(&self, _: &InterfaceRef, operation: &str) -> bool {
|
||||
operation == "read"
|
||||
}
|
||||
}
|
||||
impl FileRead for ScopedObject {
|
||||
fn read(&self, offset: Option<u64>, length: Option<u64>) -> wip::OperationResult<wip::Bytes> {
|
||||
FileRead::read(&self.inner, offset, length)
|
||||
}
|
||||
}
|
||||
struct ScopedComposition {
|
||||
shared: Arc<Shared>,
|
||||
associations: BTreeMap<String, (Arc<Node>, Scope)>,
|
||||
interfaces: Vec<RegisteredInterface<wip_fs_server::FileReadDefinition>>,
|
||||
with_refs: bool,
|
||||
}
|
||||
impl Composition<Entry> for ScopedComposition {
|
||||
type Object = ScopedObject;
|
||||
fn scope(
|
||||
&self,
|
||||
path: &str,
|
||||
entry: &Entry,
|
||||
context: &ExecutionContext,
|
||||
) -> Result<Option<Scope>, HostError> {
|
||||
assert!(matches!(
|
||||
self.shared.transaction.try_lock(),
|
||||
Err(TryLockError::WouldBlock)
|
||||
));
|
||||
assert_eq!(path, entry.path);
|
||||
self.shared.record("scope", path, context);
|
||||
assert_eq!(
|
||||
entry.validator,
|
||||
Entry::snapshot(entry.path.clone(), entry.node.clone()).validator
|
||||
);
|
||||
let nodes = self.shared.nodes.lock().unwrap();
|
||||
// Self-scope also requires current membership; ordinary pinned-leaf
|
||||
// execution intentionally need not check its path mapping.
|
||||
Ok(self.associations.get(path).and_then(|(identity, scope)| {
|
||||
(Arc::ptr_eq(identity, &entry.node)
|
||||
&& nodes
|
||||
.get(path)
|
||||
.is_some_and(|node| Arc::ptr_eq(node, identity)))
|
||||
.then(|| scope.clone())
|
||||
}))
|
||||
}
|
||||
fn publish(
|
||||
&self,
|
||||
entry: Entry,
|
||||
context: &ExecutionContext,
|
||||
) -> Result<PublishedObject<ScopedObject>, HostError> {
|
||||
assert!(matches!(
|
||||
self.shared.transaction.try_lock(),
|
||||
Err(TryLockError::WouldBlock)
|
||||
));
|
||||
self.shared.record("scoped_publish", &entry.path, context);
|
||||
let path = entry.path.clone();
|
||||
let regular = entry.kind() == EntryKind::Regular;
|
||||
let reference = self
|
||||
.with_refs
|
||||
.then(|| format!("published-object-{}", entry.node.id));
|
||||
let mut publication = PublishedObject::new(ScopedObject {
|
||||
inner: Object {
|
||||
entry,
|
||||
custom: self.interfaces[0].reference().clone(),
|
||||
shared: self.shared.clone(),
|
||||
context: context.clone(),
|
||||
},
|
||||
reference,
|
||||
});
|
||||
// Containers expose no interfaces but remain valid fetch subjects.
|
||||
if regular {
|
||||
for interface in &self.interfaces {
|
||||
if interface
|
||||
.reference()
|
||||
.protocol_reference()
|
||||
.validate_for_path(&path)
|
||||
.is_ok()
|
||||
{
|
||||
publication = publication.expose(interface).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(publication)
|
||||
}
|
||||
}
|
||||
struct ScopedPolicy;
|
||||
impl ProjectionPolicy<Entry, ScopedObject> for ScopedPolicy {
|
||||
type Principal = Principal;
|
||||
fn object_visible(&self, principal: &Principal, entry: &Entry) -> bool {
|
||||
!entry.hidden && (!matches!(principal, Principal::Limited) || entry.path != "/secret")
|
||||
}
|
||||
fn interface_visible(&self, principal: &Principal, interface: &InterfaceRef) -> bool {
|
||||
!(matches!(principal, Principal::Limited)
|
||||
&& interface.protocol_reference().scope == "/added")
|
||||
}
|
||||
}
|
||||
type ScopedEngine = Projection<MemoryBackend, ScopedComposition, ScopedPolicy>;
|
||||
fn scoped_engine(shared: Arc<Shared>, with_refs: bool) -> ScopedEngine {
|
||||
configured_engine(shared, with_refs, None)
|
||||
}
|
||||
fn configured_engine(
|
||||
shared: Arc<Shared>,
|
||||
with_refs: bool,
|
||||
interface_validator: Option<Validator>,
|
||||
) -> ScopedEngine {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let mut associations = BTreeMap::new();
|
||||
let mut interfaces = Vec::new();
|
||||
for path in ["/", "/secret", "/secret/nested", "/added"] {
|
||||
let scope = Scope::new(path).unwrap();
|
||||
let validator = if path == "/secret" {
|
||||
interface_validator.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
interfaces.push(
|
||||
registry
|
||||
.register(
|
||||
&scope,
|
||||
"file.read",
|
||||
wip_fs_server::FileReadDefinition,
|
||||
validator,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
associations.insert(path.into(), (shared.node(path), scope));
|
||||
}
|
||||
Projection::new(
|
||||
MemoryBackend::new(shared.clone()),
|
||||
registry,
|
||||
ScopedComposition {
|
||||
shared,
|
||||
associations,
|
||||
interfaces,
|
||||
with_refs,
|
||||
},
|
||||
ScopedPolicy,
|
||||
ResourceLimits::default(),
|
||||
)
|
||||
}
|
||||
fn reference(scope: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: "file.read".into(),
|
||||
}
|
||||
}
|
||||
fn request(path: &str, scope: &str, scope_ref: Option<String>) -> CallOperationRequest {
|
||||
CallOperationRequest {
|
||||
target: Target {
|
||||
path: path.into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: reference(scope),
|
||||
scope_ref,
|
||||
validator: None,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonroot_observe_fetch_call_with_optional_refs_over_http() {
|
||||
for with_refs in [false, true] {
|
||||
let fixture = Fixture::new();
|
||||
let engine = scoped_engine(fixture.shared.clone(), with_refs);
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let limits = ResourceLimits::default();
|
||||
let endpoint = Endpoint::parse("https://example.test/scopes").unwrap();
|
||||
let adapter = HttpAdapter::new(&view, &endpoint, limits);
|
||||
for (path, scope) in [("/secret/nested/data", "/secret"), ("/added", "/added")] {
|
||||
let observe = observe_request(path, 0);
|
||||
let response = adapter.handle_http_with_context(
|
||||
encode_observe_request(&endpoint, &observe, limits.wire).unwrap(),
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
let DecodedResponse::Success(observed) =
|
||||
decode_observe_response(&observe, &response, limits.wire).unwrap()
|
||||
else {
|
||||
panic!("observation must succeed");
|
||||
};
|
||||
assert!(observed.object.interfaces.contains(&reference(scope)));
|
||||
let fetch = fetch_request(&reference(scope));
|
||||
let response = adapter.handle_http_with_context(
|
||||
encode_fetch_interface_request(&endpoint, &fetch, limits.wire).unwrap(),
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
let DecodedResponse::Success(fetched) =
|
||||
decode_fetch_interface_response(&fetch, &response, limits.wire).unwrap()
|
||||
else {
|
||||
panic!("fetch must succeed");
|
||||
};
|
||||
assert_eq!(
|
||||
fetched.scope_ref,
|
||||
with_refs.then(|| format!("published-object-{}", fixture.shared.node(scope).id))
|
||||
);
|
||||
let mut call = request(path, scope, fetched.scope_ref);
|
||||
call.target.validator = observed.object.validator;
|
||||
let response = adapter.handle_http_with_context(
|
||||
encode_call_operation_request(&endpoint, &call, &fetched.descriptor, limits.wire)
|
||||
.unwrap(),
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
let DecodedResponse::Success(result) =
|
||||
decode_call_operation_response(&call, &fetched.descriptor, &response, limits.wire)
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("call must succeed");
|
||||
};
|
||||
assert_eq!(result.result, Value::Bytes(b"hello memory".to_vec()));
|
||||
if !with_refs {
|
||||
call.interface.scope_ref = Some("ref-not-provided-by-publication".into());
|
||||
assert_eq!(
|
||||
view.call_operation(call).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
view.observe_object("/secret")
|
||||
.unwrap()
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
view.observe_object("/secret/nested/data")
|
||||
.unwrap()
|
||||
.interfaces,
|
||||
[
|
||||
reference("/"),
|
||||
reference("/secret"),
|
||||
reference("/secret/nested")
|
||||
]
|
||||
);
|
||||
let limited = engine.view(&Principal::Limited);
|
||||
for scope in ["/secret", "/secret/nested", "/added"] {
|
||||
assert_eq!(
|
||||
limited
|
||||
.fetch_interface(&fetch_request(&reference(scope)))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
limited
|
||||
.observe_object("/secret/nested/data")
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
wip_host::CallErrorKind::NotFound
|
||||
);
|
||||
assert!(
|
||||
!limited
|
||||
.observe_object("/added")
|
||||
.unwrap()
|
||||
.interfaces
|
||||
.contains(&reference("/added"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_refs_and_object_validator_precedence_use_current_atomic_lineage() {
|
||||
let fixture = Fixture::new();
|
||||
let engine = scoped_engine(fixture.shared.clone(), true);
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let mut call = request("/secret/nested/data", "/secret", Some("stale".into()));
|
||||
call.target.validator = Some(vec![0]);
|
||||
assert_eq!(
|
||||
view.call_operation(call.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::ValidatorMismatch
|
||||
);
|
||||
call.target.validator = view.observe_object(&call.target.path).unwrap().validator;
|
||||
assert_eq!(
|
||||
view.call_operation(call.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
call.interface.scope_ref = view
|
||||
.fetch_interface(&fetch_request(&call.interface.reference))
|
||||
.unwrap()
|
||||
.scope_ref;
|
||||
// Scope state changes do not retire its lifetime; the published ref is not
|
||||
// its validator, and the hook runs on refreshed metadata under the lock.
|
||||
fixture.shared.controls.lock().unwrap().bump_ancestor = Some("/secret".into());
|
||||
fixture.shared.seen.lock().unwrap().clear();
|
||||
view.call_operation(call).unwrap();
|
||||
let seen = fixture.shared.seen.lock().unwrap();
|
||||
let resolves: Vec<_> = seen
|
||||
.iter()
|
||||
.filter(|event| event.stage == "resolve")
|
||||
.map(|event| event.path.as_str())
|
||||
.collect();
|
||||
assert_eq!(resolves, ["/secret/nested/data"]);
|
||||
assert!(
|
||||
seen.iter()
|
||||
.any(|event| event.stage == "scope" && event.path == "/secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonroot_interface_validator_checks_follow_scope_ref_checks() {
|
||||
let fixture = Fixture::new();
|
||||
let engine = configured_engine(fixture.shared.clone(), true, Some(Validator::new(vec![7])));
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let fetched = view
|
||||
.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap();
|
||||
assert_eq!(fetched.validator, Some(vec![7]));
|
||||
let mut call = request("/secret/nested/data", "/secret", Some("stale".into()));
|
||||
call.target.validator = view.observe_object(&call.target.path).unwrap().validator;
|
||||
assert_eq!(
|
||||
view.call_operation(call.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
call.interface.scope_ref = fetched.scope_ref;
|
||||
assert_eq!(
|
||||
view.call_operation(call.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceValidatorRequired
|
||||
);
|
||||
call.interface.validator = Some(vec![8]);
|
||||
assert_eq!(
|
||||
view.call_operation(call.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceValidatorMismatch
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node(&call.target.path)
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
call.interface.validator = fetched.validator;
|
||||
view.call_operation(call).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_and_replacement_races_reject_before_dispatch() {
|
||||
for replace in [false, true] {
|
||||
let fixture = Fixture::new();
|
||||
let engine = scoped_engine(fixture.shared.clone(), false);
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let mut call = request("/secret/nested/data", "/secret", None);
|
||||
call.target.validator = view.observe_object(&call.target.path).unwrap().validator;
|
||||
let leaf = fixture.shared.node(&call.target.path);
|
||||
if replace {
|
||||
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/secret".into());
|
||||
} else {
|
||||
fixture.shared.controls.lock().unwrap().hide_ancestor = Some("/secret".into());
|
||||
}
|
||||
assert_eq!(
|
||||
view.call_operation(call).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::NotFound
|
||||
);
|
||||
assert!(leaf.calls.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_replacement_and_self_scope_do_not_reuse_registry_lifetimes() {
|
||||
for with_refs in [false, true] {
|
||||
let fixture = Fixture::new();
|
||||
let engine = scoped_engine(fixture.shared.clone(), with_refs);
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let old_scope = fixture.shared.node("/secret");
|
||||
let old_ref = view
|
||||
.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap()
|
||||
.scope_ref;
|
||||
fixture.shared.nodes.lock().unwrap().remove("/secret");
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
// Reuse even the same id/public ref: refs cannot attest a lifetime.
|
||||
fixture.shared.nodes.lock().unwrap().insert(
|
||||
"/secret".into(),
|
||||
Arc::new(Node {
|
||||
id: old_scope.id,
|
||||
kind: EntryKind::Directory,
|
||||
data: Mutex::new(old_scope.data.lock().unwrap().clone()),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let mut call = request("/secret/nested/data", "/secret", old_ref);
|
||||
call.target.validator = view.observe_object(&call.target.path).unwrap().validator;
|
||||
assert_eq!(
|
||||
view.call_operation(call).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node("/secret/nested/data")
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// Fresh registration/engine attests the new publication.
|
||||
let republished = scoped_engine(fixture.shared.clone(), with_refs);
|
||||
republished
|
||||
.view(&Principal::Admin)
|
||||
.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap();
|
||||
// Scope == target: ordinary execution pins the old leaf, but cannot
|
||||
// attest it as the current scope after its mapping is replaced.
|
||||
let leaf = fixture.shared.node("/added");
|
||||
let mut call = request("/added", "/added", None);
|
||||
call.target.validator = view.observe_object("/added").unwrap().validator;
|
||||
fixture.shared.controls.lock().unwrap().replace_mapping = true;
|
||||
assert_eq!(
|
||||
view.call_operation(call).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert!(leaf.calls.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&reference("/added")))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ Framework-independent Host publication and operation dispatch for the Web Interf
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
wip-host = "0.1.0"
|
||||
wip-interface = "0.1.0"
|
||||
wip-host = "0.2.0"
|
||||
wip-interface = "0.2.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`.
|
||||
Protocol publication and dispatch use `wip_protocol::InterfaceReference { scope, name }` and compare both fields exactly. Registration takes an explicit `Scope` and local name; `InterfaceRef::protocol_reference()` preserves those coordinates without parsing display text. Providers attach a current `ScopeObservation` inside the coherent backend boundary, and fetch returns its optional Object ref as `scope_ref`. Calls check a supplied `scope_ref` against that current binding; omission adds no identity precondition. Retire a Scope on Object deletion, replacement or movement; retained registry artifacts alone do not establish publication.
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
+312
-115
@@ -3,7 +3,7 @@
|
||||
//! # Responsibility boundaries
|
||||
//!
|
||||
//! [`InterfaceRegistry::register`] validates and freezes a generated interface
|
||||
//! descriptor, assigns an opaque [`InterfaceRef`], and leaves that reference
|
||||
//! descriptor at an explicit scope and local name, and leaves that reference
|
||||
//! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
|
||||
//! request-scoped Rust object with a registered interface; its
|
||||
//! [`InterfaceImplementation`] bound proves at compile time that the object
|
||||
@@ -11,7 +11,8 @@
|
||||
//! and a publication with no interfaces is valid.
|
||||
//!
|
||||
//! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
|
||||
//! keeps the resulting object, interface membership, descriptor, implementation,
|
||||
//! keeps the resulting object, current scope Object association/ref, interface
|
||||
//! membership, descriptor, implementation,
|
||||
//! and backend target fixed within the provider's [`ObjectProvider::with_object`]
|
||||
//! callback. Dispatch is qualified by `(interface reference, operation name)`.
|
||||
//! The same frozen operation declaration validates both arguments and results.
|
||||
@@ -29,14 +30,13 @@
|
||||
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use std::any::TypeId;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::error::Error;
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
use std::marker::PhantomData;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use wip_interface::DispatchError;
|
||||
@@ -187,36 +187,104 @@ impl ExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque host-assigned reference to one registered interface artifact.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct InterfaceRef(String);
|
||||
/// 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 InterfaceRef {
|
||||
/// Returns the legacy opaque registration reference.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
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)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Maps the legacy registration reference to a root-scoped protocol reference.
|
||||
///
|
||||
/// The opaque registration string is copied as the name without parsing it.
|
||||
/// Returns the 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(InterfaceReference);
|
||||
|
||||
impl InterfaceRef {
|
||||
/// Returns the local name without parsing or adding display delimiters.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0.name
|
||||
}
|
||||
|
||||
/// Returns the structured Protocol reference.
|
||||
#[must_use]
|
||||
pub fn protocol_reference(&self) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: self.0.clone(),
|
||||
}
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// One atomic observation of an interface reference, descriptor, and validator.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct InterfaceObservation {
|
||||
/// Opaque reference selected by published objects and operation calls.
|
||||
/// Structured reference selected by published objects and operation calls.
|
||||
pub reference: InterfaceRef,
|
||||
/// Canonical descriptor frozen when the interface was registered.
|
||||
pub descriptor: InterfaceDescriptor,
|
||||
/// Optional ref of the scope Object from this same observation.
|
||||
pub scope_ref: Option<String>,
|
||||
/// Optional validator. Its presence makes it a required call precondition.
|
||||
pub validator: Option<Validator>,
|
||||
}
|
||||
@@ -242,7 +310,7 @@ impl InterfaceObservation {
|
||||
let response = FetchInterfaceResponse {
|
||||
interface: self.reference.protocol_reference(),
|
||||
descriptor: self.descriptor.clone(),
|
||||
scope_ref: None,
|
||||
scope_ref: self.scope_ref.clone(),
|
||||
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
||||
};
|
||||
response
|
||||
@@ -259,6 +327,7 @@ impl InterfaceObservation {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InterfaceArtifact {
|
||||
scope: Scope,
|
||||
reference: InterfaceRef,
|
||||
descriptor: InterfaceDescriptor,
|
||||
validator: Option<Validator>,
|
||||
@@ -267,7 +336,7 @@ struct InterfaceArtifact {
|
||||
/// A typed registration token for a generated interface definition.
|
||||
///
|
||||
/// Tokens can be reused by any number of request-scoped objects. They expose an
|
||||
/// opaque reference, not the Rust type name or an address.
|
||||
/// scoped reference and a private publication lifetime, not a Rust address.
|
||||
pub struct RegisteredInterface<D> {
|
||||
artifact: Arc<InterfaceArtifact>,
|
||||
definition: PhantomData<fn() -> D>,
|
||||
@@ -283,170 +352,161 @@ impl<D> Clone for RegisteredInterface<D> {
|
||||
}
|
||||
|
||||
impl<D> RegisteredInterface<D> {
|
||||
/// Returns the opaque protocol reference assigned by the registry.
|
||||
/// Returns the explicit scope/name reference.
|
||||
#[must_use]
|
||||
pub fn reference(&self) -> &InterfaceRef {
|
||||
&self.artifact.reference
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry of validated, immutable interface artifacts.
|
||||
/// Registry of frozen definition artifacts and explicitly scoped registrations.
|
||||
///
|
||||
/// Generated descriptors are never rewritten to contain their assigned
|
||||
/// references. A validator-free artifact is immutable for the lifetime of its
|
||||
/// reference. A validator-bearing artifact requires that exact validator on a
|
||||
/// call. Re-registering a definition is rejected rather than silently changing
|
||||
/// the observation associated with an existing reference.
|
||||
#[derive(Debug)]
|
||||
/// Artifact retention is not proof of current publication. Fetch requires a live
|
||||
/// provider-attested ScopeObservation; execution requires the same lifetime in
|
||||
/// the target's atomic boundary. Collision keys are exact (scope, name) pairs,
|
||||
/// not Rust TypeId: the same Definition can be registered at multiple coordinates.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InterfaceRegistry {
|
||||
namespace: u64,
|
||||
next_reference: u64,
|
||||
definitions: HashSet<TypeId>,
|
||||
artifacts: HashMap<InterfaceRef, Arc<InterfaceArtifact>>,
|
||||
}
|
||||
|
||||
static NEXT_REGISTRY_NAMESPACE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
impl Default for InterfaceRegistry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
namespace: NEXT_REGISTRY_NAMESPACE.fetch_add(1, Ordering::Relaxed),
|
||||
next_reference: 0,
|
||||
definitions: HashSet::new(),
|
||||
artifacts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceRegistry {
|
||||
/// Creates an empty interface registry.
|
||||
/// Creates an empty artifact registry without publishing any Object.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers and freezes one generated interface definition.
|
||||
/// Freezes a Definition at an explicit scope and local name.
|
||||
pub fn register<D>(
|
||||
&mut self,
|
||||
scope: &Scope,
|
||||
name: impl Into<String>,
|
||||
_definition: D,
|
||||
validator: Option<Validator>,
|
||||
) -> Result<RegisteredInterface<D>, RegistrationError>
|
||||
where
|
||||
D: InterfaceDefinition + 'static,
|
||||
{
|
||||
let definition = TypeId::of::<D>();
|
||||
if self.definitions.contains(&definition) {
|
||||
let reference = InterfaceRef(InterfaceReference {
|
||||
scope: scope.path.clone(),
|
||||
name: name.into(),
|
||||
});
|
||||
reference
|
||||
.0
|
||||
.validate()
|
||||
.map_err(RegistrationError::InvalidReference)?;
|
||||
if !scope.active() {
|
||||
return Err(RegistrationError::RetiredScope);
|
||||
}
|
||||
if self.artifacts.contains_key(&reference) {
|
||||
return Err(RegistrationError::AlreadyRegistered);
|
||||
}
|
||||
|
||||
let descriptor = D::descriptor();
|
||||
descriptor
|
||||
.validate()
|
||||
.map_err(RegistrationError::InvalidDescriptor)?;
|
||||
|
||||
let reference = InterfaceRef(format!(
|
||||
"wip-interface:{}:{}",
|
||||
self.namespace, self.next_reference
|
||||
));
|
||||
self.next_reference = self
|
||||
.next_reference
|
||||
.checked_add(1)
|
||||
.ok_or(RegistrationError::ReferenceSpaceExhausted)?;
|
||||
let artifact = Arc::new(InterfaceArtifact {
|
||||
scope: scope.clone(),
|
||||
reference: reference.clone(),
|
||||
descriptor,
|
||||
validator,
|
||||
});
|
||||
self.definitions.insert(definition);
|
||||
self.artifacts.insert(reference, Arc::clone(&artifact));
|
||||
|
||||
Ok(RegisteredInterface {
|
||||
artifact,
|
||||
definition: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves a structured protocol reference to a legacy registration token.
|
||||
/// Ends a scope lifetime and releases its registry entries.
|
||||
///
|
||||
/// Legacy artifacts are published only at root scope. The name is looked up
|
||||
/// verbatim; no scope or name parsing is performed.
|
||||
/// Retained tokens cannot start new calls/fetches. Started calls retain their
|
||||
/// frozen Descriptor. Re-publication requires a fresh Scope.
|
||||
pub fn remove_scope(&mut self, scope: &Scope) {
|
||||
scope.lifetime.store(false, Ordering::Release);
|
||||
self.artifacts
|
||||
.retain(|_, artifact| !artifact.scope.same_lifetime(scope));
|
||||
}
|
||||
|
||||
/// Resolves a stored artifact, not evidence of current publication.
|
||||
#[must_use]
|
||||
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
|
||||
if reference.scope != "/" {
|
||||
return None;
|
||||
}
|
||||
let reference = InterfaceRef(reference.name.clone());
|
||||
let reference = InterfaceRef(reference.clone());
|
||||
self.artifacts.contains_key(&reference).then_some(reference)
|
||||
}
|
||||
|
||||
/// Observes a registered interface by its host-side reference.
|
||||
#[must_use]
|
||||
pub fn observe_object(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
|
||||
self.artifacts.get(reference).map(Self::observation)
|
||||
/// Iterates registered scope handles, possibly repeated for multiple names.
|
||||
pub fn scopes(&self) -> impl Iterator<Item = &Scope> {
|
||||
self.artifacts.values().map(|artifact| &artifact.scope)
|
||||
}
|
||||
|
||||
/// Implements canonical `fetch_interface` semantics for a structured reference.
|
||||
///
|
||||
/// Malformed references map to `InvalidRequest`. Unknown and currently
|
||||
/// unpublished references (including non-root scopes) map to `InterfaceNotFound`.
|
||||
/// Fetches only with a coherent current scope Object attestation.
|
||||
pub fn fetch_interface(
|
||||
&self,
|
||||
reference: &InterfaceReference,
|
||||
current: &ScopeObservation,
|
||||
) -> Result<InterfaceObservation, ProtocolError> {
|
||||
reference.validate().map_err(|error| ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
self.resolve(reference)
|
||||
.and_then(|reference| self.artifacts.get(&reference))
|
||||
.map(Self::observation)
|
||||
.ok_or_else(|| ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceNotFound,
|
||||
message: "interface reference is not currently published".into(),
|
||||
self.artifacts
|
||||
.get(&InterfaceRef(reference.clone()))
|
||||
.filter(|artifact| {
|
||||
artifact.scope.active() && artifact.scope.same_lifetime(¤t.scope)
|
||||
})
|
||||
}
|
||||
|
||||
fn observation(artifact: &Arc<InterfaceArtifact>) -> InterfaceObservation {
|
||||
InterfaceObservation {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,6 +613,7 @@ struct BoundInterface<T> {
|
||||
pub struct PublishedObject<T: WipObject> {
|
||||
implementation: T,
|
||||
interfaces: Vec<BoundInterface<T>>,
|
||||
scopes: Vec<ScopeObservation>,
|
||||
}
|
||||
|
||||
impl<T: WipObject> PublishedObject<T> {
|
||||
@@ -562,13 +623,45 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
Self {
|
||||
implementation,
|
||||
interfaces: Vec::new(),
|
||||
scopes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches a current scope snapshot inside the provider's atomic boundary.
|
||||
///
|
||||
/// Never populate this from a stale prelookup. Target, scope association,
|
||||
/// Descriptor and backend handles must remain coherent through dispatch and
|
||||
/// commit. A later same-path binding replaces the earlier snapshot.
|
||||
#[must_use]
|
||||
pub fn bind_scope(mut self, current: ScopeObservation) -> Self {
|
||||
self.scopes
|
||||
.retain(|old| old.scope.path != current.scope.path);
|
||||
self.scopes.push(current);
|
||||
self
|
||||
}
|
||||
|
||||
fn current_scope(&self, scope: &Scope) -> Option<&ScopeObservation> {
|
||||
self.scopes
|
||||
.iter()
|
||||
.find(|current| scope.active() && scope.same_lifetime(¤t.scope))
|
||||
}
|
||||
|
||||
fn scopes_valid_at(&self, path: &str) -> bool {
|
||||
self.interfaces.iter().all(|bound| {
|
||||
bound.artifact.reference.0.validate_for_path(path).is_ok()
|
||||
&& self
|
||||
.current_scope(&bound.artifact.scope)
|
||||
.is_some_and(|current| {
|
||||
current.scope.path != path
|
||||
|| current.reference() == self.implementation.reference()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Explicitly exposes a registered generated interface in insertion order.
|
||||
///
|
||||
/// The `InterfaceImplementation<T>` bound is generated only when `T`
|
||||
/// implements the corresponding Rust interface trait. Duplicate opaque
|
||||
/// implements the corresponding Rust interface trait. Duplicate scoped
|
||||
/// references are rejected.
|
||||
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
|
||||
where
|
||||
@@ -621,6 +714,7 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
let mut descriptor = bound.artifact.descriptor.clone();
|
||||
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
|
||||
bound.artifact = Arc::new(InterfaceArtifact {
|
||||
scope: bound.artifact.scope.clone(),
|
||||
reference: reference.clone(),
|
||||
descriptor,
|
||||
validator: bound.artifact.validator.clone(),
|
||||
@@ -637,9 +731,13 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
&self.implementation
|
||||
}
|
||||
|
||||
/// Builds canonical protocol object metadata in interface insertion order.
|
||||
/// Builds metadata from the explicit exposure set in insertion order.
|
||||
///
|
||||
/// The observation contains no path, children, descriptor, backend handle,
|
||||
/// This path-free projection is useful while composing a publication, but is
|
||||
/// not proof that its scopes are currently published. Use
|
||||
/// [`ObjectProvider::observe_object`] for a validated current observation, or
|
||||
/// [`Self::protocol_object_at`] inside a provider's coherent boundary.
|
||||
/// The metadata contains no path, children, descriptor, backend handle,
|
||||
/// credential, or provider resolution state.
|
||||
#[must_use]
|
||||
pub fn protocol_object(&self) -> Object {
|
||||
@@ -659,6 +757,30 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a current Object observation inside the provider's boundary.
|
||||
///
|
||||
/// All exposed interfaces must have compatible, live scope associations. The
|
||||
/// provider must still establish those associations atomically; this method
|
||||
/// cannot verify backend lifetime assertions or lock external writers.
|
||||
pub fn protocol_object_at(&self, path: &str) -> Result<Object, CallError> {
|
||||
wip_protocol::validate_path(path)
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
if !self.scopes_valid_at(path) {
|
||||
return Err(CallError::new(
|
||||
CallErrorKind::Internal,
|
||||
"Host published interfaces without current compatible scope Objects",
|
||||
));
|
||||
}
|
||||
let object = self.protocol_object();
|
||||
object.validate_at_path(path).map_err(|_| {
|
||||
CallError::new(
|
||||
CallErrorKind::Internal,
|
||||
"Host produced an invalid object response",
|
||||
)
|
||||
})?;
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
fn dispatch_resolved(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
@@ -720,6 +842,31 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
);
|
||||
};
|
||||
|
||||
let Some(current_scope) = self.current_scope(&bound.artifact.scope) else {
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::InterfaceMismatch,
|
||||
"interface scope Object is not currently associated with this publication",
|
||||
);
|
||||
};
|
||||
if current_scope.scope.path == target.path
|
||||
&& current_scope.reference() != self.implementation.reference()
|
||||
{
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::Internal,
|
||||
"provider produced inconsistent self-scope metadata",
|
||||
);
|
||||
}
|
||||
if interface
|
||||
.scope_ref
|
||||
.as_deref()
|
||||
.is_some_and(|supplied| current_scope.reference() != Some(supplied))
|
||||
{
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::InterfaceMismatch,
|
||||
"scope Object ref does not match the current association",
|
||||
);
|
||||
}
|
||||
|
||||
match (
|
||||
bound.artifact.validator.as_ref(),
|
||||
interface.validator.as_deref(),
|
||||
@@ -958,7 +1105,12 @@ pub enum ProviderResult<T> {
|
||||
///
|
||||
/// `with_object` must resolve the current object at `path` exactly once and pass
|
||||
/// a publication that pins its current interface set, implementations, and
|
||||
/// backend target for the duration of `operation`. The execution context is the
|
||||
/// backend target and all current scope Object associations for the duration of
|
||||
/// `operation`. Attach them with PublishedObject::bind_scope inside this same
|
||||
/// boundary, never via an independent scope prelookup. A new Object at the same
|
||||
/// path needs a fresh Scope; ordinary state changes retain the handle. Providers
|
||||
/// own lifetime/atomicity enforcement, including external writers and backend
|
||||
/// commit/rollback. The execution context is the
|
||||
/// runtime-neutral carrier for cancellation, deadline, and resource controls.
|
||||
/// The borrowed publication cannot escape the callback.
|
||||
///
|
||||
@@ -1022,7 +1174,9 @@ pub trait ObjectProvider {
|
||||
wip_protocol::validate_path(path)
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
let resolved = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.with_object(context, path, PublishedObject::protocol_object)
|
||||
self.with_object(context, path, |publication| {
|
||||
publication.protocol_object_at(path)
|
||||
})
|
||||
}))
|
||||
.map_err(|_| {
|
||||
CallError::new(
|
||||
@@ -1031,7 +1185,7 @@ pub trait ObjectProvider {
|
||||
)
|
||||
})?;
|
||||
let object = match resolved {
|
||||
ProviderResult::Found(object) => object,
|
||||
ProviderResult::Found(object) => object?,
|
||||
ProviderResult::NotFound => {
|
||||
return Err(CallError::new(
|
||||
CallErrorKind::NotFound,
|
||||
@@ -1040,15 +1194,58 @@ pub trait ObjectProvider {
|
||||
}
|
||||
ProviderResult::Failure(error) => return Err(CallError::from_read_failure(error)),
|
||||
};
|
||||
object.validate_at_path(path).map_err(|_| {
|
||||
CallError::new(
|
||||
CallErrorKind::Internal,
|
||||
"Host produced an invalid object response",
|
||||
)
|
||||
})?;
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
/// Fetches Descriptor, scope Object ref and Interface validator atomically.
|
||||
///
|
||||
/// The scope Object may expose no interfaces. Its publication must still
|
||||
/// bind the live ScopeObservation. Registry retention is not publication.
|
||||
fn fetch_interface(
|
||||
&self,
|
||||
registry: &InterfaceRegistry,
|
||||
reference: &InterfaceReference,
|
||||
) -> Result<InterfaceObservation, ProtocolError> {
|
||||
reference.validate().map_err(|error| ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let observed = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.with_object(
|
||||
&ExecutionContext::default(),
|
||||
&reference.scope,
|
||||
|publication| {
|
||||
publication
|
||||
.scopes
|
||||
.iter()
|
||||
.find(|current| current.scope.path == reference.scope)
|
||||
.ok_or_else(interface_not_found)
|
||||
.and_then(|current| {
|
||||
if current.reference() != publication.implementation.reference() {
|
||||
return Err(ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
message: "provider produced inconsistent scope Object metadata"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
registry.fetch_interface(reference, current)
|
||||
})
|
||||
},
|
||||
)
|
||||
}))
|
||||
.map_err(|_| ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
message: "provider panicked while observing an interface".into(),
|
||||
})?;
|
||||
match observed {
|
||||
ProviderResult::Found(observation) => observation,
|
||||
ProviderResult::NotFound => Err(interface_not_found()),
|
||||
ProviderResult::Failure(error) => {
|
||||
Err(CallError::from_read_failure(error).protocol_error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves and executes a canonical path-targeted operation request.
|
||||
///
|
||||
/// Structural path validation precedes provider resolution. For a valid
|
||||
@@ -1168,7 +1365,7 @@ pub trait ObjectProvider {
|
||||
/// Failure to construct an explicit object publication.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PublicationError {
|
||||
/// The same opaque interface reference was exposed more than once.
|
||||
/// The same scope/name Interface reference was exposed more than once.
|
||||
DuplicateInterface(InterfaceRef),
|
||||
/// Projection attempted to add an interface without an implementation.
|
||||
UnexposedInterface(InterfaceRef),
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
|
||||
use wip_host::{
|
||||
CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef,
|
||||
InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult,
|
||||
PublishedObject, RegisteredInterface, Target, Validator, WipObject,
|
||||
PublishedObject, RegisteredInterface, Scope, ScopeObservation, Target, Validator, WipObject,
|
||||
};
|
||||
use wip_interface::{
|
||||
CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value,
|
||||
@@ -17,9 +17,16 @@ use wip_protocol::ProtocolErrorCode;
|
||||
#[test]
|
||||
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let read = registry.register(ReaderDefinition, None).unwrap();
|
||||
let write = registry.register(WriterDefinition, None).unwrap();
|
||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
||||
let root = Scope::new("/").unwrap();
|
||||
let read = registry
|
||||
.register(&root, "reader", ReaderDefinition, None)
|
||||
.unwrap();
|
||||
let write = registry
|
||||
.register(&root, "writer::#日本語", WriterDefinition, None)
|
||||
.unwrap();
|
||||
let hidden = registry
|
||||
.register(&root, "hidden", HiddenDefinition, None)
|
||||
.unwrap();
|
||||
let publication = || {
|
||||
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
|
||||
.expose(&read)
|
||||
@@ -61,8 +68,9 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
let current = ScopeObservation::new(&root, &Document::open("", None, Rc::new(Cell::new(0))));
|
||||
let full = registry
|
||||
.fetch_interface(&read.reference().protocol_reference())
|
||||
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||
.unwrap();
|
||||
let filtered = full
|
||||
.clone()
|
||||
@@ -71,7 +79,7 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
||||
assert_eq!(full.descriptor.operations.len(), 3);
|
||||
assert_eq!(
|
||||
registry
|
||||
.fetch_interface(&read.reference().protocol_reference())
|
||||
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||
.unwrap(),
|
||||
full
|
||||
);
|
||||
@@ -309,6 +317,7 @@ impl Broken for Document {
|
||||
}
|
||||
|
||||
struct TestProvider {
|
||||
root: Scope,
|
||||
reader: RegisteredInterface<ReaderDefinition>,
|
||||
writer: RegisteredInterface<WriterDefinition>,
|
||||
broken: RegisteredInterface<BrokenDefinition>,
|
||||
@@ -326,11 +335,13 @@ struct TestProvider {
|
||||
|
||||
impl TestProvider {
|
||||
fn new(
|
||||
root: Scope,
|
||||
reader: RegisteredInterface<ReaderDefinition>,
|
||||
writer: RegisteredInterface<WriterDefinition>,
|
||||
broken: RegisteredInterface<BrokenDefinition>,
|
||||
) -> Self {
|
||||
Self {
|
||||
root,
|
||||
reader,
|
||||
writer,
|
||||
broken,
|
||||
@@ -362,7 +373,10 @@ impl ObjectProvider for TestProvider {
|
||||
self.observed_cancelled
|
||||
.set(context.cancellation().is_cancelled());
|
||||
self.resolutions.set(self.resolutions.get() + 1);
|
||||
let scope_object = Document::open("", None, Rc::clone(&self.calls));
|
||||
let current_scope = ScopeObservation::new(&self.root, &scope_object);
|
||||
let (document, interfaces) = match path {
|
||||
"/" => (scope_object, 0),
|
||||
"/document" => (
|
||||
Document::strict(
|
||||
"document",
|
||||
@@ -420,6 +434,7 @@ impl ObjectProvider for TestProvider {
|
||||
3 => publication.expose(&self.broken).unwrap(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let publication = publication.bind_scope(current_scope);
|
||||
ProviderResult::Found(operation(&publication))
|
||||
}
|
||||
|
||||
@@ -461,13 +476,25 @@ fn registry_and_provider() -> (
|
||||
RegisteredInterface<HiddenDefinition>,
|
||||
) {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let root = Scope::new("/").unwrap();
|
||||
let reader = registry
|
||||
.register(ReaderDefinition, Some(Validator::new(b"schema-v1")))
|
||||
.register(
|
||||
&root,
|
||||
"reader",
|
||||
ReaderDefinition,
|
||||
Some(Validator::new(b"schema-v1")),
|
||||
)
|
||||
.unwrap();
|
||||
let writer = registry.register(WriterDefinition, None).unwrap();
|
||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
||||
let broken = registry.register(BrokenDefinition, None).unwrap();
|
||||
let provider = TestProvider::new(reader, writer, broken);
|
||||
let writer = registry
|
||||
.register(&root, "writer::#日本語", WriterDefinition, None)
|
||||
.unwrap();
|
||||
let hidden = registry
|
||||
.register(&root, "hidden", HiddenDefinition, None)
|
||||
.unwrap();
|
||||
let broken = registry
|
||||
.register(&root, "broken", BrokenDefinition, None)
|
||||
.unwrap();
|
||||
let provider = TestProvider::new(root, reader, writer, broken);
|
||||
(registry, provider, hidden)
|
||||
}
|
||||
|
||||
@@ -505,8 +532,8 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
let reference = provider.reader.reference();
|
||||
|
||||
assert_ne!(reference.as_str(), "Reader");
|
||||
let observation = registry
|
||||
.fetch_interface(&reference.protocol_reference())
|
||||
let observation = provider
|
||||
.fetch_interface(®istry, &reference.protocol_reference())
|
||||
.unwrap();
|
||||
assert_eq!(observation.reference, *reference);
|
||||
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
|
||||
@@ -518,8 +545,8 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
||||
response.descriptor.validate().unwrap();
|
||||
|
||||
let mut invalid_observation = registry
|
||||
.fetch_interface(&reference.protocol_reference())
|
||||
let mut invalid_observation = provider
|
||||
.fetch_interface(®istry, &reference.protocol_reference())
|
||||
.unwrap();
|
||||
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
||||
invalid_observation
|
||||
@@ -529,11 +556,14 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
||||
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
||||
|
||||
let error = registry
|
||||
.fetch_interface(&wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: "unknown".into(),
|
||||
})
|
||||
let error = provider
|
||||
.fetch_interface(
|
||||
®istry,
|
||||
&wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: "unknown".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
||||
}
|
||||
@@ -1146,14 +1176,13 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
||||
fn registry_boundaries_preserve_names_verbatim_and_match_exact_scope() {
|
||||
let (registry, provider, _) = registry_and_provider();
|
||||
let legacy = provider.writer.reference();
|
||||
let reference = legacy.protocol_reference();
|
||||
assert_eq!(reference.scope, "/");
|
||||
assert_eq!(reference.name, legacy.as_str());
|
||||
// The registry's colon-delimited name remains opaque, not parsed.
|
||||
assert!(reference.name.starts_with("wip-interface:"));
|
||||
assert_eq!(reference.name, "writer::#日本語");
|
||||
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
|
||||
let nested = wip_protocol::InterfaceReference {
|
||||
scope: "/open".into(),
|
||||
@@ -1161,7 +1190,10 @@ fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
||||
};
|
||||
assert!(registry.resolve(&nested).is_none());
|
||||
assert_eq!(
|
||||
registry.fetch_interface(&nested).unwrap_err().code,
|
||||
provider
|
||||
.fetch_interface(®istry, &nested)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let malformed = wip_protocol::InterfaceReference {
|
||||
@@ -1169,7 +1201,10 @@ fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
||||
name: reference.name,
|
||||
};
|
||||
assert_eq!(
|
||||
registry.fetch_interface(&malformed).unwrap_err().code,
|
||||
provider
|
||||
.fetch_interface(®istry, &malformed)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wip_host::{
|
||||
CallErrorKind, CallRequest, ExecutionContext, InterfaceRegistry, InterfaceTarget,
|
||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, RegistrationError, Scope,
|
||||
ScopeObservation, Target, Validator, WipObject,
|
||||
};
|
||||
use wip_interface::{OperationResult, interface};
|
||||
use wip_protocol::{InterfaceReference, ProtocolErrorCode, Value};
|
||||
|
||||
#[interface]
|
||||
pub trait Read {
|
||||
fn read(&self, value: String) -> OperationResult<String>;
|
||||
}
|
||||
|
||||
#[interface]
|
||||
pub trait Count {
|
||||
fn read(&self, value: i64) -> OperationResult<i64>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Metadata {
|
||||
name: String,
|
||||
reference: Option<String>,
|
||||
validator: Option<Validator>,
|
||||
}
|
||||
impl WipObject for Metadata {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
self.reference.as_deref()
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
self.validator.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
type OnStart = Rc<RefCell<Option<Box<dyn Fn()>>>>;
|
||||
|
||||
struct Implementation {
|
||||
metadata: Metadata,
|
||||
calls: Rc<Cell<usize>>,
|
||||
on_start: OnStart,
|
||||
}
|
||||
impl WipObject for Implementation {
|
||||
fn name(&self) -> &str {
|
||||
self.metadata.name()
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
self.metadata.reference()
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
self.metadata.validator()
|
||||
}
|
||||
}
|
||||
impl Read for Implementation {
|
||||
fn read(&self, value: String) -> OperationResult<String> {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
if let Some(action) = self.on_start.borrow_mut().take() {
|
||||
action();
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
struct Provider {
|
||||
interface: RegisteredInterface<ReadDefinition>,
|
||||
current: Rc<RefCell<Option<(Scope, Metadata)>>>,
|
||||
target_path: String,
|
||||
target_exists: Cell<bool>,
|
||||
member: Cell<bool>,
|
||||
calls: Rc<Cell<usize>>,
|
||||
resolutions: Cell<usize>,
|
||||
on_start: OnStart,
|
||||
}
|
||||
impl Provider {
|
||||
fn new(
|
||||
scope: &Scope,
|
||||
interface: RegisteredInterface<ReadDefinition>,
|
||||
target: &str,
|
||||
reference: Option<&str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
interface,
|
||||
current: Rc::new(RefCell::new(Some((
|
||||
scope.clone(),
|
||||
Metadata {
|
||||
name: scope.path().rsplit('/').next().unwrap().into(),
|
||||
reference: reference.map(str::to_owned),
|
||||
validator: Some(Validator::new(b"scope-state-v1")),
|
||||
},
|
||||
)))),
|
||||
target_path: target.into(),
|
||||
target_exists: Cell::new(true),
|
||||
member: Cell::new(true),
|
||||
calls: Rc::new(Cell::new(0)),
|
||||
resolutions: Cell::new(0),
|
||||
on_start: Rc::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
fn request(&self, scope_ref: Option<&str>) -> CallRequest {
|
||||
CallRequest {
|
||||
target: Target {
|
||||
path: self.target_path.clone(),
|
||||
validator: None,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: self.interface.reference().protocol_reference(),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
validator: Some(b"descriptor-v1".to_vec()),
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::from([("value".into(), Value::String("original backend".into()))]),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ObjectProvider for Provider {
|
||||
type Object = Implementation;
|
||||
fn with_object<R>(
|
||||
&self,
|
||||
_: &ExecutionContext,
|
||||
path: &str,
|
||||
callback: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
||||
) -> ProviderResult<R> {
|
||||
self.resolutions.set(self.resolutions.get() + 1);
|
||||
// A single boundary captures both the target and the live scope association.
|
||||
let current = self.current.borrow().clone();
|
||||
let target = path == self.target_path;
|
||||
let metadata = if target {
|
||||
if !self.target_exists.get() {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
if let Some((scope, object)) = ¤t
|
||||
&& scope.path() == path
|
||||
{
|
||||
object.clone()
|
||||
} else {
|
||||
Metadata {
|
||||
name: path.rsplit('/').next().unwrap().into(),
|
||||
reference: None,
|
||||
validator: Some(Validator::new(b"target-v1")),
|
||||
}
|
||||
}
|
||||
} else if let Some((scope, object)) = ¤t {
|
||||
if path != scope.path() {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
object.clone()
|
||||
} else {
|
||||
return ProviderResult::NotFound;
|
||||
};
|
||||
let mut publication = PublishedObject::new(Implementation {
|
||||
metadata,
|
||||
calls: self.calls.clone(),
|
||||
on_start: self.on_start.clone(),
|
||||
});
|
||||
if target && self.member.get() {
|
||||
publication = publication.expose(&self.interface).unwrap();
|
||||
}
|
||||
if let Some((scope, object)) = current {
|
||||
publication = publication.bind_scope(ScopeObservation::new(&scope, &object));
|
||||
}
|
||||
ProviderResult::Found(callback(&publication))
|
||||
}
|
||||
}
|
||||
fn setup(
|
||||
scope: &str,
|
||||
target: &str,
|
||||
reference: Option<&str>,
|
||||
) -> (InterfaceRegistry, Scope, Provider) {
|
||||
let scope = Scope::new(scope).unwrap();
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let interface = registry
|
||||
.register(
|
||||
&scope,
|
||||
"read::#日本語",
|
||||
ReadDefinition,
|
||||
Some(Validator::new(b"descriptor-v1")),
|
||||
)
|
||||
.unwrap();
|
||||
let provider = Provider::new(&scope, interface, target, reference);
|
||||
(registry, scope, provider)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registrations_use_scope_name_not_definition_type_or_delimiters() {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let left = Scope::new("/left").unwrap();
|
||||
let right = Scope::new("/right").unwrap();
|
||||
let a = registry
|
||||
.register(&left, "read::#日本語", ReadDefinition, None)
|
||||
.unwrap();
|
||||
let b = registry
|
||||
.register(&right, "read::#日本語", ReadDefinition, None)
|
||||
.unwrap();
|
||||
assert_ne!(a.reference(), b.reference());
|
||||
assert_eq!(
|
||||
a.reference().protocol_reference(),
|
||||
InterfaceReference {
|
||||
scope: "/left".into(),
|
||||
name: "read::#日本語".into(),
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
registry.register(&left, "read::#日本語", CountDefinition, None),
|
||||
Err(RegistrationError::AlreadyRegistered)
|
||||
));
|
||||
assert!(matches!(
|
||||
registry.register(&left, "", ReadDefinition, None),
|
||||
Err(RegistrationError::InvalidReference(_))
|
||||
));
|
||||
assert!(Scope::new("/invalid/").is_err());
|
||||
registry
|
||||
.register(&left, "another", ReadDefinition, None)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_root_ancestor_and_membership_are_independent() {
|
||||
for (scope, target) in [
|
||||
("/", "/"),
|
||||
("/", "/target"),
|
||||
("/scope", "/scope"),
|
||||
("/scope", "/scope/target"),
|
||||
] {
|
||||
let (registry, _, provider) = setup(scope, target, Some("scope-id"));
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(Some("scope-id")))
|
||||
.unwrap()
|
||||
.result,
|
||||
Value::String("original backend".into())
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("scope-id")
|
||||
);
|
||||
provider.member.set(false);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
// Non-self scope Objects need not implement or expose any Interface.
|
||||
let (registry, _, provider) = setup("/scope", "/scope/target", None);
|
||||
assert!(
|
||||
provider
|
||||
.observe_object("/scope")
|
||||
.unwrap()
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap();
|
||||
for target in ["/sibling/target", "/scopelong", "/"] {
|
||||
let (_, _, provider) = setup("/scope", target, None);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 0);
|
||||
assert_eq!(
|
||||
provider.observe_object(target).unwrap_err().kind(),
|
||||
CallErrorKind::Internal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_scope_ref_and_state_validator_have_distinct_responsibilities() {
|
||||
for current in [Some("scope-id"), None, Some("")] {
|
||||
let (registry, _, provider) = setup("/scope", "/scope/target", current);
|
||||
let observation = provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
observation
|
||||
.protocol_response()
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
current
|
||||
);
|
||||
assert_eq!(
|
||||
observation.validator,
|
||||
Some(Validator::new(b"descriptor-v1"))
|
||||
);
|
||||
provider.call_operation(provider.request(None)).unwrap();
|
||||
if let Some(reference) = current {
|
||||
provider
|
||||
.call_operation(provider.request(Some(reference)))
|
||||
.unwrap();
|
||||
}
|
||||
let before = provider.calls.get();
|
||||
for supplied in ["other", "missing"] {
|
||||
let mut call = provider.request(Some(supplied));
|
||||
call.interface.validator = None;
|
||||
call.operation = "missing".into();
|
||||
assert_eq!(
|
||||
provider.call_operation(call).unwrap_err().kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
assert_eq!(provider.calls.get(), before);
|
||||
provider.current.borrow_mut().as_mut().unwrap().1.validator =
|
||||
Some(Validator::new(b"scope-state-v2"));
|
||||
provider.call_operation(provider.request(current)).unwrap();
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||
.unwrap(),
|
||||
observation
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_precondition_is_membership_stage_in_both_dispatch_paths() {
|
||||
let (_, _, provider) = setup("/scope", "/scope/target", Some("current"));
|
||||
let mut call = provider.request(Some("stale"));
|
||||
call.target.validator = Some(b"wrong".to_vec());
|
||||
assert_eq!(
|
||||
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||
CallErrorKind::ValidatorMismatch
|
||||
);
|
||||
provider.target_exists.set(false);
|
||||
assert_eq!(
|
||||
provider.call_operation(call).unwrap_err().kind(),
|
||||
CallErrorKind::NotFound
|
||||
);
|
||||
provider.target_exists.set(true);
|
||||
let call = provider.request(Some("stale"));
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::new(),
|
||||
call.target,
|
||||
call.interface,
|
||||
call.operation,
|
||||
|_| -> Result<BTreeMap<String, Value>, ()> { panic!("decoder must not run") }
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_republication_and_movement_never_inherit_retained_artifacts() {
|
||||
let (mut registry, scope, provider) = setup("/scope", "/scope/target", Some("old"));
|
||||
let reference = provider.request(None).interface.reference;
|
||||
provider.fetch_interface(®istry, &reference).unwrap();
|
||||
*provider.current.borrow_mut() = None;
|
||||
provider.target_exists.set(false);
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &reference)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::NotFound
|
||||
);
|
||||
provider.target_exists.set(true);
|
||||
// Even the same ref is not proof that a replacement inherits registration.
|
||||
let replacement = Scope::new("/scope").unwrap();
|
||||
*provider.current.borrow_mut() = Some((
|
||||
replacement.clone(),
|
||||
Metadata {
|
||||
name: "scope".into(),
|
||||
reference: Some("old".into()),
|
||||
validator: None,
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
provider
|
||||
.fetch_interface(®istry, &reference)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(Some("old")))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
registry.remove_scope(&scope);
|
||||
assert!(matches!(
|
||||
registry.register(&scope, "new", ReadDefinition, None),
|
||||
Err(RegistrationError::RetiredScope)
|
||||
));
|
||||
let fresh = registry
|
||||
.register(&replacement, reference.name.clone(), ReadDefinition, None)
|
||||
.unwrap();
|
||||
provider.fetch_interface(®istry, &reference).unwrap();
|
||||
// Old target membership still binds the old lifetime, even after new publication.
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
let new_provider = Provider::new(&replacement, fresh, "/scope/target", Some("new"));
|
||||
let mut new_call = new_provider.request(Some("new"));
|
||||
new_call.interface.validator = None;
|
||||
new_provider.call_operation(new_call).unwrap();
|
||||
let moved = Scope::new("/moved").unwrap();
|
||||
new_provider.target_exists.set(false);
|
||||
*new_provider.current.borrow_mut() = Some((
|
||||
moved.clone(),
|
||||
Metadata {
|
||||
name: "moved".into(),
|
||||
reference: Some("new".into()),
|
||||
validator: None,
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
new_provider
|
||||
.fetch_interface(®istry, &reference)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
assert_eq!(
|
||||
new_provider
|
||||
.call_operation(new_provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::NotFound
|
||||
);
|
||||
let moved_interface = registry
|
||||
.register(&moved, reference.name, ReadDefinition, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
moved_interface.reference().protocol_reference().scope,
|
||||
"/moved"
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_after_fetch_rejects_old_ref_but_omission_and_refless_are_allowed() {
|
||||
for previous_ref in [Some("old"), None] {
|
||||
let (mut registry, old_scope, old_provider) =
|
||||
setup("/scope", "/scope/target", previous_ref);
|
||||
let observed = old_provider
|
||||
.fetch_interface(®istry, &old_provider.request(None).interface.reference)
|
||||
.unwrap();
|
||||
registry.remove_scope(&old_scope);
|
||||
let new_scope = Scope::new("/scope").unwrap();
|
||||
let token = registry
|
||||
.register(
|
||||
&new_scope,
|
||||
"read::#日本語",
|
||||
ReadDefinition,
|
||||
Some(Validator::new(b"descriptor-v1")),
|
||||
)
|
||||
.unwrap();
|
||||
let new_provider = Provider::new(&new_scope, token, "/scope/target", None);
|
||||
if let Some(reference) = observed.scope_ref {
|
||||
assert_eq!(
|
||||
new_provider
|
||||
.call_operation(new_provider.request(Some(&reference)))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(new_provider.calls.get(), 0);
|
||||
}
|
||||
new_provider
|
||||
.call_operation(new_provider.request(None))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn started_execution_retains_target_and_same_descriptor_through_replacement() {
|
||||
let (registry, old_scope, provider) = setup("/scope", "/scope/target", Some("old"));
|
||||
let registry = Rc::new(RefCell::new(registry));
|
||||
let current = provider.current.clone();
|
||||
let replacement = Scope::new("/scope").unwrap();
|
||||
let registry_for_action = registry.clone();
|
||||
let replacement_for_action = replacement.clone();
|
||||
*provider.on_start.borrow_mut() = Some(Box::new(move || {
|
||||
let mut registry = registry_for_action.borrow_mut();
|
||||
registry.remove_scope(&old_scope);
|
||||
registry
|
||||
.register(
|
||||
&replacement_for_action,
|
||||
"read::#日本語",
|
||||
CountDefinition,
|
||||
Some(Validator::new(b"descriptor-v2")),
|
||||
)
|
||||
.unwrap();
|
||||
*current.borrow_mut() = Some((
|
||||
replacement_for_action.clone(),
|
||||
Metadata {
|
||||
name: "scope".into(),
|
||||
reference: Some("new".into()),
|
||||
validator: None,
|
||||
},
|
||||
));
|
||||
}));
|
||||
let call = provider.request(Some("old"));
|
||||
let result = provider
|
||||
.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::new(),
|
||||
call.target,
|
||||
call.interface,
|
||||
call.operation,
|
||||
|descriptor| {
|
||||
assert_eq!(descriptor, &ReadDefinition::descriptor());
|
||||
Ok::<_, ()>(call.arguments)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.result, Value::String("original backend".into()));
|
||||
assert_eq!(provider.calls.get(), 1);
|
||||
assert_eq!(provider.resolutions.get(), 1);
|
||||
let new = provider
|
||||
.fetch_interface(
|
||||
®istry.borrow(),
|
||||
&provider.request(None).interface.reference,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(new.descriptor, CountDefinition::descriptor());
|
||||
assert_eq!(new.scope_ref.as_deref(), Some("new"));
|
||||
// The old bound target can no longer start a call against the new descriptor.
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation(provider.request(None))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(provider.calls.get(), 1);
|
||||
}
|
||||
@@ -16,6 +16,6 @@ impl WipObject for Object {
|
||||
|
||||
fn main() {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let search = registry.register(SearchDefinition, None).unwrap();
|
||||
let search = registry.register(&wip_host::Scope::new("/").unwrap(), "search", SearchDefinition, None).unwrap();
|
||||
let _published = PublishedObject::new(Object).expose(&search).unwrap();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ The crate maps the three WIP Core interactions to fixed HTTP routes and strict U
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
wip-http = "0.1.0"
|
||||
wip-http = "0.2.0"
|
||||
```
|
||||
|
||||
```rust
|
||||
|
||||
@@ -6,7 +6,7 @@ The crate re-exports `#[interface]` and `#[derive(WipType)]`, generates Interfac
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
wip-interface = "0.1.0"
|
||||
wip-interface = "0.2.0"
|
||||
```
|
||||
|
||||
```rust
|
||||
|
||||
@@ -6,7 +6,7 @@ This crate defines the canonical Object and Interface Descriptor models, the thr
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
wip-protocol = "0.1.0"
|
||||
wip-protocol = "0.2.0"
|
||||
```
|
||||
|
||||
Interface identity is an exact `InterfaceReference { scope, name }` pair.
|
||||
@@ -16,7 +16,7 @@ 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.
|
||||
for the breaking Rust and HTTP wire changes and consumer migration guidance.
|
||||
|
||||
See the [API documentation](https://docs.rs/wip-protocol) for the complete model and validation rules.
|
||||
|
||||
|
||||
@@ -12,3 +12,7 @@ publish = false
|
||||
wip-host.workspace = true
|
||||
wip-interface.workspace = true
|
||||
wip-protocol.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
wip-client.workspace = true
|
||||
wip-http.workspace = true
|
||||
|
||||
@@ -10,13 +10,20 @@
|
||||
//! request-scoped implementation, backend handle, authorization policy, and
|
||||
//! publication wiring remain host-private. A transport binding would translate
|
||||
//! its own wire routes and envelopes to the protocol call DTOs; no HTTP type is
|
||||
//! part of an application trait or interface descriptor.
|
||||
//! part of an application trait or interface descriptor. Its dev-only conformance
|
||||
//! suite also uses the public Client and HTTP crates over real TCP HTTP to test
|
||||
//! scope replacement/deletion, optional refs, principal visibility and precondition
|
||||
//! precedence. Those dependencies are not required by the example executable.
|
||||
//!
|
||||
//! # Breaking migration exercised here
|
||||
//!
|
||||
//! The canonical API targets operations by `target.path` rather than a mandatory
|
||||
//! object identifier. Object `ref` and object/interface validators are separate,
|
||||
//! optional observation fields. Interface references are structured scope/name values observed
|
||||
//! optional observation fields. This demo registers explicit `catalog`, `audit`
|
||||
//! and `diagnostics` names at the service Object self-scope. A private [`Scope`]
|
||||
//! lifetime and coherent [`ScopeObservation`] bind fetch and call to that Object;
|
||||
//! successful calls carry its optional `scope_ref` identity precondition.
|
||||
//! Interface references are structured scope/name values observed
|
||||
//! separately from descriptors, and descriptors contain neither a Rust trait
|
||||
//! name nor an opaque-reference `name` field. Application records, enums,
|
||||
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
||||
@@ -41,8 +48,8 @@ use std::io;
|
||||
|
||||
use wip_host::{
|
||||
CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget,
|
||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Target, Validator,
|
||||
WipObject,
|
||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Scope, ScopeObservation,
|
||||
Target, Validator, WipObject,
|
||||
};
|
||||
use wip_interface as wip;
|
||||
use wip_interface::WipType;
|
||||
@@ -136,14 +143,20 @@ pub struct DemoReport {
|
||||
pub object_validator: Option<Vec<u8>>,
|
||||
/// Structured interface references published by the object, in exposure order.
|
||||
pub published_interfaces: Vec<InterfaceReference>,
|
||||
/// Root-scoped reference assigned to the catalog descriptor.
|
||||
/// Self-scoped reference assigned to the catalog descriptor.
|
||||
pub catalog_interface_reference: InterfaceReference,
|
||||
/// Root-scoped reference assigned to the audit descriptor.
|
||||
/// Self-scoped reference assigned to the audit descriptor.
|
||||
pub audit_interface_reference: InterfaceReference,
|
||||
/// Descriptor format observed separately for the catalog reference.
|
||||
pub catalog_descriptor_format: String,
|
||||
/// Descriptor format observed separately for the audit reference.
|
||||
pub audit_descriptor_format: String,
|
||||
/// Scope Object ref observed together with the catalog Descriptor.
|
||||
pub catalog_scope_ref: Option<String>,
|
||||
/// Scope Object ref observed together with the audit Descriptor.
|
||||
pub audit_scope_ref: Option<String>,
|
||||
/// Membership rejection when the supplied scope Object ref is stale.
|
||||
pub scope_mismatch: ProtocolErrorCode,
|
||||
/// Typed successful application result decoded from a validated [`Value`].
|
||||
pub published: PublishOutcome,
|
||||
/// Typed application-domain rejection decoded from a validated [`Value`].
|
||||
@@ -182,25 +195,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)
|
||||
@@ -210,7 +232,7 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
|
||||
let published = dispatch_publish(
|
||||
&provider,
|
||||
catalog.reference(),
|
||||
&catalog_response,
|
||||
PublishRequest {
|
||||
title: "Public APIs".into(),
|
||||
body: Some("protocol values in, validated values out".into()),
|
||||
@@ -224,7 +246,7 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
)?;
|
||||
let rejected = dispatch_publish(
|
||||
&provider,
|
||||
catalog.reference(),
|
||||
&catalog_response,
|
||||
PublishRequest {
|
||||
title: String::new(),
|
||||
body: None,
|
||||
@@ -237,7 +259,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
&provider,
|
||||
request(
|
||||
OBJECT_PATH,
|
||||
catalog.reference(),
|
||||
&catalog_response.interface,
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"status",
|
||||
BTreeMap::new(),
|
||||
@@ -247,7 +270,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
&provider,
|
||||
request(
|
||||
OBJECT_PATH,
|
||||
audit.reference(),
|
||||
&audit_response.interface,
|
||||
audit_response.scope_ref.as_deref(),
|
||||
None,
|
||||
"status",
|
||||
BTreeMap::new(),
|
||||
@@ -257,7 +281,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
let host_error = provider
|
||||
.call_operation(request(
|
||||
OBJECT_PATH,
|
||||
catalog.reference(),
|
||||
&catalog_response.interface,
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"backend_status",
|
||||
BTreeMap::new(),
|
||||
@@ -269,7 +294,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
let unknown_protocol_failure = provider
|
||||
.call_operation(request(
|
||||
OBJECT_PATH,
|
||||
catalog.reference(),
|
||||
&catalog_response.interface,
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"uncertain_backend_status",
|
||||
BTreeMap::new(),
|
||||
@@ -280,7 +306,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
let permission_failure = provider
|
||||
.call_operation(request(
|
||||
OBJECT_PATH,
|
||||
catalog.reference(),
|
||||
&catalog_response.interface,
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"restricted_status",
|
||||
BTreeMap::new(),
|
||||
@@ -310,7 +337,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
let interface_mismatch = provider
|
||||
.call_operation(request(
|
||||
OBJECT_PATH,
|
||||
diagnostics.reference(),
|
||||
&diagnostics.reference().protocol_reference(),
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
None,
|
||||
"diagnostics",
|
||||
BTreeMap::new(),
|
||||
@@ -319,14 +347,32 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
.protocol_error()
|
||||
.code;
|
||||
|
||||
let interface_not_found = registry
|
||||
.fetch_interface(&InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: "missing-interface".into(),
|
||||
})
|
||||
let interface_not_found = provider
|
||||
.fetch_interface(
|
||||
®istry,
|
||||
&InterfaceReference {
|
||||
scope: OBJECT_PATH.into(),
|
||||
name: "missing-interface".into(),
|
||||
},
|
||||
)
|
||||
.expect_err("unknown interface references must not resolve")
|
||||
.code;
|
||||
|
||||
let mut stale_scope = request(
|
||||
OBJECT_PATH,
|
||||
&catalog_response.interface,
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"status",
|
||||
BTreeMap::new(),
|
||||
);
|
||||
stale_scope.interface.scope_ref = Some("replaced-service-instance".into());
|
||||
let scope_mismatch = provider
|
||||
.call_operation(stale_scope)
|
||||
.expect_err("stale scope identity must reject before operation dispatch")
|
||||
.protocol_error()
|
||||
.code;
|
||||
|
||||
let precedence_failure = provider
|
||||
.call_operation(CallRequest {
|
||||
target: Target {
|
||||
@@ -354,6 +400,9 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
audit_interface_reference: audit.reference().protocol_reference(),
|
||||
catalog_descriptor_format: catalog_response.descriptor.format,
|
||||
audit_descriptor_format: audit_response.descriptor.format,
|
||||
catalog_scope_ref: catalog_response.scope_ref,
|
||||
audit_scope_ref: audit_response.scope_ref,
|
||||
scope_mismatch,
|
||||
published,
|
||||
rejected,
|
||||
catalog_status,
|
||||
@@ -382,13 +431,14 @@ fn ensure_kind(error: &wip_host::CallError, expected: CallErrorKind) -> Result<(
|
||||
|
||||
fn dispatch_publish(
|
||||
provider: &ApplicationProvider,
|
||||
catalog: &InterfaceRef,
|
||||
catalog: &wip_protocol::FetchInterfaceResponse,
|
||||
request_value: PublishRequest,
|
||||
) -> Result<PublishOutcome, Box<dyn Error>> {
|
||||
let encoded_request = request_value.encode()?;
|
||||
let result = provider.call_operation(request(
|
||||
OBJECT_PATH,
|
||||
catalog,
|
||||
&catalog.interface,
|
||||
catalog.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"publish",
|
||||
BTreeMap::from([("request".into(), encoded_request)]),
|
||||
@@ -406,7 +456,8 @@ fn dispatch_string(
|
||||
|
||||
fn request(
|
||||
path: &str,
|
||||
interface: &InterfaceRef,
|
||||
interface: &InterfaceReference,
|
||||
scope_ref: Option<&str>,
|
||||
interface_validator: Option<&[u8]>,
|
||||
operation: &str,
|
||||
arguments: BTreeMap<String, Value>,
|
||||
@@ -417,8 +468,8 @@ fn request(
|
||||
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: interface.protocol_reference(),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
reference: interface.clone(),
|
||||
validator: interface_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
operation: operation.into(),
|
||||
@@ -427,6 +478,7 @@ fn request(
|
||||
}
|
||||
|
||||
struct ApplicationProvider {
|
||||
scope: Scope,
|
||||
catalog: RegisteredInterface<CatalogDefinition>,
|
||||
audit: RegisteredInterface<AuditDefinition>,
|
||||
}
|
||||
@@ -443,7 +495,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())
|
||||
@@ -248,6 +258,40 @@ fn assert_schema_neutral(value: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_dtos_keep_identity_binding_and_validators_separate() {
|
||||
// Exact literals/destructuring guard the public Rust contract, including
|
||||
// omission of refs rather than requiring a fabricated identity.
|
||||
let reference = wip_protocol::InterfaceReference {
|
||||
scope: "/article-service".into(),
|
||||
name: "catalog::read#raw".into(),
|
||||
};
|
||||
let request = wip_protocol::FetchInterfaceRequest {
|
||||
interface: reference.clone(),
|
||||
};
|
||||
request.validate().unwrap();
|
||||
let wip_protocol::FetchInterfaceResponse {
|
||||
interface,
|
||||
scope_ref,
|
||||
descriptor,
|
||||
validator,
|
||||
} = wip_protocol::FetchInterfaceResponse {
|
||||
interface: reference.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: CatalogDefinition::descriptor(),
|
||||
validator: None,
|
||||
};
|
||||
let target = wip_protocol::InterfaceTarget {
|
||||
reference: interface,
|
||||
scope_ref,
|
||||
validator,
|
||||
};
|
||||
assert_eq!(target.reference, reference);
|
||||
assert!(target.scope_ref.is_none());
|
||||
assert!(target.validator.is_none());
|
||||
assert_eq!(descriptor.format, INTERFACE_FORMAT_V1);
|
||||
}
|
||||
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
//! External-style consumer: a transactional provider, real TCP HTTP, and Client.
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use wip_client::{
|
||||
CallOutcome, Client, ClientLimits, Completion, ObservationState, SecurityContext,
|
||||
};
|
||||
use wip_host::{
|
||||
ExecutionContext, InterfaceRegistry, ObjectProvider, ProviderResult, PublishedObject,
|
||||
RegisteredInterface, Scope, ScopeObservation, Validator, WipObject,
|
||||
};
|
||||
use wip_http::{http, *};
|
||||
use wip_interface as wip;
|
||||
use wip_protocol::{InterfaceReference, ObjectObservation, ProtocolErrorCode, ProtocolInteraction};
|
||||
|
||||
#[wip::interface]
|
||||
pub trait Status {
|
||||
fn status(&self) -> wip::OperationResult<String>;
|
||||
fn uncertain(&self) -> wip::OperationResult<String>;
|
||||
}
|
||||
|
||||
struct Service {
|
||||
name: String,
|
||||
reference: Option<String>,
|
||||
validator: Validator,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
impl WipObject for Service {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
self.reference.as_deref()
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
Some(&self.validator)
|
||||
}
|
||||
}
|
||||
impl Status for Service {
|
||||
fn status(&self) -> wip::OperationResult<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(self.name.clone())
|
||||
}
|
||||
fn uncertain(&self) -> wip::OperationResult<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Err(wip::HostError::internal(
|
||||
"commit acknowledgement lost",
|
||||
wip::EffectGuarantee::Unknown,
|
||||
))
|
||||
}
|
||||
}
|
||||
struct Publication {
|
||||
scope: Scope,
|
||||
api: RegisteredInterface<StatusDefinition>,
|
||||
reference: Option<String>,
|
||||
}
|
||||
struct State {
|
||||
registry: InterfaceRegistry,
|
||||
root: Publication,
|
||||
service: Option<Publication>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
impl State {
|
||||
fn new(reference: Option<&str>) -> Self {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let scope = Scope::new("/").unwrap();
|
||||
let root = Publication {
|
||||
api: registry
|
||||
.register(&scope, "status", StatusDefinition, None)
|
||||
.unwrap(),
|
||||
scope,
|
||||
reference: None,
|
||||
};
|
||||
let mut state = Self {
|
||||
registry,
|
||||
root,
|
||||
service: None,
|
||||
calls: Arc::default(),
|
||||
};
|
||||
state.republish(reference);
|
||||
state
|
||||
}
|
||||
fn remove(&mut self) {
|
||||
if let Some(old) = self.service.take() {
|
||||
self.registry.remove_scope(&old.scope);
|
||||
}
|
||||
}
|
||||
fn republish(&mut self, reference: Option<&str>) {
|
||||
self.remove();
|
||||
let scope = Scope::new("/service").unwrap();
|
||||
self.service = Some(Publication {
|
||||
api: self
|
||||
.registry
|
||||
.register(
|
||||
&scope,
|
||||
"status",
|
||||
StatusDefinition,
|
||||
Some(Validator::new(b"api-v1")),
|
||||
)
|
||||
.unwrap(),
|
||||
scope,
|
||||
reference: reference.map(str::to_owned),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The HTTP adapter holds the State lock across all provider lookups, preconditions
|
||||
// and dispatch. No separate scope prelookup can race a replacement.
|
||||
struct Provider<'a> {
|
||||
state: &'a State,
|
||||
visible: bool,
|
||||
}
|
||||
impl ObjectProvider for Provider<'_> {
|
||||
type Object = Service;
|
||||
fn with_object<R>(
|
||||
&self,
|
||||
_: &ExecutionContext,
|
||||
path: &str,
|
||||
operation: impl FnOnce(&PublishedObject<Service>) -> R,
|
||||
) -> ProviderResult<R> {
|
||||
let publication = match path {
|
||||
"/" => &self.state.root,
|
||||
"/service" if self.visible => match &self.state.service {
|
||||
Some(value) => value,
|
||||
None => return ProviderResult::NotFound,
|
||||
},
|
||||
_ => return ProviderResult::NotFound,
|
||||
};
|
||||
let object = Service {
|
||||
name: if path == "/" { "" } else { "service" }.into(),
|
||||
reference: publication.reference.clone(),
|
||||
validator: Validator::new(b"object-v1"),
|
||||
calls: self.state.calls.clone(),
|
||||
};
|
||||
let current = ScopeObservation::new(&publication.scope, &object);
|
||||
let mut object = PublishedObject::new(object)
|
||||
.bind_scope(current)
|
||||
.expose(&publication.api)
|
||||
.unwrap();
|
||||
if path == "/service" {
|
||||
let root_object = Service {
|
||||
name: "".into(),
|
||||
reference: None,
|
||||
validator: Validator::new(b"object-v1"),
|
||||
calls: self.state.calls.clone(),
|
||||
};
|
||||
object = object
|
||||
.bind_scope(ScopeObservation::new(&self.state.root.scope, &root_object))
|
||||
.expose(&self.state.root.api)
|
||||
.unwrap();
|
||||
}
|
||||
ProviderResult::Found(operation(&object))
|
||||
}
|
||||
}
|
||||
fn limits() -> Limits {
|
||||
Limits::new(65536, 65536, 64).unwrap()
|
||||
}
|
||||
fn reference(scope: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: "status".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(state: &State, path: &str, body: &[u8], visible: bool) -> http::Response<Vec<u8>> {
|
||||
let provider = Provider { state, visible };
|
||||
match path {
|
||||
"/wip/v1/observe" => {
|
||||
let request = decode_observe_request(body, limits()).unwrap();
|
||||
match provider.observe_object(&request.path) {
|
||||
Ok(object) => encode_observe_response(
|
||||
&request,
|
||||
&ObjectObservation {
|
||||
object,
|
||||
children: None,
|
||||
},
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
Err(error) => encode_protocol_error_response(
|
||||
ProtocolInteraction::Observe,
|
||||
&error.protocol_error(),
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
"/wip/v1/fetch_interface" => {
|
||||
let request = decode_fetch_interface_request(body, limits()).unwrap();
|
||||
match provider.fetch_interface(&state.registry, &request.interface) {
|
||||
Ok(observation) => encode_fetch_interface_response(
|
||||
&request,
|
||||
&observation.protocol_response().unwrap(),
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
Err(error) => encode_protocol_error_response(
|
||||
ProtocolInteraction::FetchInterface,
|
||||
&error,
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
"/wip/v1/call_operation" => {
|
||||
let metadata = decode_call_operation_metadata(body, limits()).unwrap();
|
||||
let decoded = Mutex::new(None);
|
||||
let result = provider.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::default(),
|
||||
metadata.target.clone(),
|
||||
metadata.interface.clone(),
|
||||
metadata.operation.clone(),
|
||||
|descriptor| {
|
||||
let request = metadata.decode_request(body, descriptor, limits())?;
|
||||
let arguments = request.arguments.clone();
|
||||
*decoded.lock().unwrap() = Some((request, descriptor.clone()));
|
||||
Ok::<_, CodecError>(arguments)
|
||||
},
|
||||
);
|
||||
match result {
|
||||
Ok(result) => {
|
||||
let (request, descriptor) = decoded.into_inner().unwrap().unwrap();
|
||||
encode_call_operation_response(&request, &descriptor, &result, limits())
|
||||
.unwrap()
|
||||
}
|
||||
Err(error) => encode_protocol_error_response(
|
||||
ProtocolInteraction::CallOperation,
|
||||
&error.protocol_error(),
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
_ => panic!("unexpected route {path}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Small bounded test-only HTTP/1.1 listener. Production serving is deliberately
|
||||
// not an example dependency; both directions still cross an actual TCP socket.
|
||||
fn exchange(
|
||||
state: &Arc<Mutex<State>>,
|
||||
request: http::Request<Vec<u8>>,
|
||||
visible: bool,
|
||||
) -> http::Response<Vec<u8>> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let state = state.clone();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut raw = Vec::new();
|
||||
let (split, length) = loop {
|
||||
let mut chunk = [0; 1024];
|
||||
let count = stream.read(&mut chunk).unwrap();
|
||||
assert!(count > 0);
|
||||
raw.extend_from_slice(&chunk[..count]);
|
||||
assert!(raw.len() <= 70000);
|
||||
if let Some(split) = raw.windows(4).position(|part| part == b"\r\n\r\n") {
|
||||
let head = std::str::from_utf8(&raw[..split]).unwrap();
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Content-Length: "))
|
||||
.unwrap()
|
||||
.parse::<usize>()
|
||||
.unwrap();
|
||||
if raw.len() >= split + 4 + length {
|
||||
break (split, length);
|
||||
}
|
||||
}
|
||||
};
|
||||
let path = std::str::from_utf8(&raw[..split])
|
||||
.unwrap()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap();
|
||||
let response = handle(
|
||||
&state.lock().unwrap(),
|
||||
path,
|
||||
&raw[split + 4..split + 4 + length],
|
||||
visible,
|
||||
);
|
||||
write!(stream, "HTTP/1.1 {} OK\r\nConnection: close\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n", response.status().as_u16(), response.body().len()).unwrap();
|
||||
stream.write_all(response.body()).unwrap();
|
||||
});
|
||||
let mut stream = TcpStream::connect(address).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
write!(stream, "POST {} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", request.uri().path(), request.body().len()).unwrap();
|
||||
stream.write_all(request.body()).unwrap();
|
||||
let mut raw = Vec::new();
|
||||
stream.read_to_end(&mut raw).unwrap();
|
||||
server.join().unwrap();
|
||||
let split = raw.windows(4).position(|part| part == b"\r\n\r\n").unwrap();
|
||||
let status = std::str::from_utf8(&raw[..split])
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.parse::<u16>()
|
||||
.unwrap();
|
||||
http::Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(raw[split + 4..].to_vec())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn setup() -> (Client, wip_client::SessionId) {
|
||||
let mut client = Client::new(ClientLimits::new(1, 16, 16, 16, 16).unwrap(), limits());
|
||||
let session = client
|
||||
.open_session("http://localhost/wip", SecurityContext::new("alice"))
|
||||
.unwrap();
|
||||
(client, session)
|
||||
}
|
||||
fn observe(
|
||||
client: &mut Client,
|
||||
session: &wip_client::SessionId,
|
||||
state: &Arc<Mutex<State>>,
|
||||
path: &str,
|
||||
) {
|
||||
let request = client.refresh_object(session, path).unwrap();
|
||||
let response = exchange(state, request.request, true);
|
||||
client.complete(request.id, response).unwrap();
|
||||
}
|
||||
fn fetch(
|
||||
client: &mut Client,
|
||||
session: &wip_client::SessionId,
|
||||
state: &Arc<Mutex<State>>,
|
||||
reference: &InterfaceReference,
|
||||
) {
|
||||
let request = client
|
||||
.prepare_interface(session, reference.clone())
|
||||
.unwrap();
|
||||
let body = std::str::from_utf8(request.request.body()).unwrap();
|
||||
assert!(body.contains("\"scope\""));
|
||||
assert!(body.contains("\"name\""));
|
||||
let response = exchange(state, request.request, true);
|
||||
assert!(matches!(
|
||||
client.complete(request.id, response).unwrap(),
|
||||
Completion::Interface(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_http_root_self_same_name_and_optional_scope_refs() {
|
||||
for scope_ref in [Some("instance-a"), None] {
|
||||
let state = Arc::new(Mutex::new(State::new(scope_ref)));
|
||||
let (mut client, session) = setup();
|
||||
observe(&mut client, &session, &state, "/");
|
||||
let root = client.object(&session, "/").unwrap();
|
||||
assert_eq!(root.object.unwrap().name, "");
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
for scope in ["/", "/service"] {
|
||||
let selected = reference(scope);
|
||||
fetch(&mut client, &session, &state, &selected);
|
||||
let observation = client.interface(&session, &selected).unwrap().clone();
|
||||
assert_eq!(
|
||||
observation.scope_ref.as_deref(),
|
||||
if scope == "/" { None } else { scope_ref }
|
||||
);
|
||||
let request = client
|
||||
.prepare_call(&session, "/service", &selected, "status", BTreeMap::new())
|
||||
.unwrap();
|
||||
let logical = decode_call_operation_request(
|
||||
request.request.body(),
|
||||
observation.descriptor.as_ref().unwrap(),
|
||||
limits(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(logical.interface.reference, selected);
|
||||
assert_eq!(logical.interface.scope_ref, observation.scope_ref);
|
||||
let response = exchange(&state, request.request, true);
|
||||
assert!(matches!(
|
||||
client.complete(request.id, response).unwrap(),
|
||||
Completion::Call(record) if matches!(record.outcome, CallOutcome::Success(_))
|
||||
));
|
||||
}
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_scope_republication_and_ref_less_replacement_require_explicit_calls() {
|
||||
let selected = reference("/service");
|
||||
let state = Arc::new(Mutex::new(State::new(Some("instance-a"))));
|
||||
let (mut client, session) = setup();
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
fetch(&mut client, &session, &state, &selected);
|
||||
let request = client
|
||||
.prepare_call(&session, "/service", &selected, "status", BTreeMap::new())
|
||||
.unwrap();
|
||||
state.lock().unwrap().republish(Some("instance-b"));
|
||||
let response = exchange(&state, request.request, true);
|
||||
assert_eq!(response.status(), http::StatusCode::CONFLICT);
|
||||
assert!(matches!(client.complete(request.id, response).unwrap(),
|
||||
Completion::Call(record) if matches!(record.outcome,
|
||||
CallOutcome::ProtocolFailure(ref error) if error.code == ProtocolErrorCode::InterfaceMismatch)));
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 0);
|
||||
assert_ne!(
|
||||
client.interface(&session, &selected).unwrap().state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.prepare_call(&session, "/service", &selected, "status", BTreeMap::new())
|
||||
.is_err()
|
||||
);
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
fetch(&mut client, &session, &state, &selected);
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &selected)
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("instance-b")
|
||||
);
|
||||
assert_eq!(
|
||||
state.lock().unwrap().calls.load(Ordering::SeqCst),
|
||||
0,
|
||||
"re-observation is not execution"
|
||||
);
|
||||
|
||||
state.lock().unwrap().remove();
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
assert!(client.interface(&session, &selected).is_none());
|
||||
state.lock().unwrap().republish(None);
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
fetch(&mut client, &session, &state, &selected);
|
||||
let request = client
|
||||
.prepare_call(&session, "/service", &selected, "status", BTreeMap::new())
|
||||
.unwrap();
|
||||
// Ref-less replacement is intentionally not identity-trackable. Current
|
||||
// membership, live publication and both validators still apply at dispatch.
|
||||
state.lock().unwrap().republish(None);
|
||||
let response = exchange(&state, request.request, true);
|
||||
client.complete(request.id, response).unwrap();
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_http_unknown_outcome_is_never_automatically_retried() {
|
||||
let state = Arc::new(Mutex::new(State::new(Some("instance-a"))));
|
||||
let (mut client, session) = setup();
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
let selected = reference("/service");
|
||||
fetch(&mut client, &session, &state, &selected);
|
||||
let request = client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/service",
|
||||
&selected,
|
||||
"uncertain",
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let response = exchange(&state, request.request, true);
|
||||
let completion = client.complete(request.id, response).unwrap();
|
||||
assert!(
|
||||
matches!(completion, Completion::Call(record) if matches!(record.outcome, CallOutcome::Unknown { .. }))
|
||||
);
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 1);
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn principal_visibility_and_validator_precedence_cross_real_http() {
|
||||
let state = Arc::new(Mutex::new(State::new(Some("instance-a"))));
|
||||
let endpoint = Endpoint::parse("http://localhost/wip").unwrap();
|
||||
let selected = reference("/service");
|
||||
let fetch = wip_protocol::FetchInterfaceRequest {
|
||||
interface: selected.clone(),
|
||||
};
|
||||
let response = exchange(
|
||||
&state,
|
||||
encode_fetch_interface_request(&endpoint, &fetch, limits()).unwrap(),
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&fetch, &response, limits()).unwrap(), DecodedResponse::ProtocolFailure { error, .. } if error.code == ProtocolErrorCode::InterfaceNotFound)
|
||||
);
|
||||
let descriptor = StatusDefinition::descriptor();
|
||||
let mut call = wip_protocol::CallOperationRequest {
|
||||
target: wip_protocol::Target {
|
||||
path: "/service".into(),
|
||||
validator: Some(b"stale-object".to_vec()),
|
||||
},
|
||||
interface: wip_protocol::InterfaceTarget {
|
||||
reference: selected,
|
||||
scope_ref: Some("stale-scope".into()),
|
||||
validator: Some(b"stale-api".to_vec()),
|
||||
},
|
||||
operation: "status".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
};
|
||||
for (visible, object_validator, scope_ref, interface_validator, expected) in [
|
||||
(
|
||||
false,
|
||||
b"stale-object".as_slice(),
|
||||
Some("stale-scope"),
|
||||
Some(b"stale-api".as_slice()),
|
||||
ProtocolErrorCode::NotFound,
|
||||
),
|
||||
(
|
||||
true,
|
||||
b"stale-object".as_slice(),
|
||||
Some("stale-scope"),
|
||||
Some(b"stale-api".as_slice()),
|
||||
ProtocolErrorCode::ValidatorMismatch,
|
||||
),
|
||||
(
|
||||
true,
|
||||
b"object-v1".as_slice(),
|
||||
Some("stale-scope"),
|
||||
Some(b"stale-api".as_slice()),
|
||||
ProtocolErrorCode::InterfaceMismatch,
|
||||
),
|
||||
(
|
||||
true,
|
||||
b"object-v1".as_slice(),
|
||||
Some("instance-a"),
|
||||
None,
|
||||
ProtocolErrorCode::InterfaceValidatorRequired,
|
||||
),
|
||||
(
|
||||
true,
|
||||
b"object-v1".as_slice(),
|
||||
None,
|
||||
Some(b"stale-api".as_slice()),
|
||||
ProtocolErrorCode::InterfaceValidatorMismatch,
|
||||
),
|
||||
] {
|
||||
call.target.validator = Some(object_validator.to_vec());
|
||||
call.interface.scope_ref = scope_ref.map(str::to_owned);
|
||||
call.interface.validator = interface_validator.map(<[u8]>::to_vec);
|
||||
let response = exchange(
|
||||
&state,
|
||||
encode_call_operation_request(&endpoint, &call, &descriptor, limits()).unwrap(),
|
||||
visible,
|
||||
);
|
||||
assert!(
|
||||
matches!(decode_call_operation_response(&call, &descriptor, &response, limits()).unwrap(), DecodedResponse::ProtocolFailure { error, .. } if error.code == expected)
|
||||
);
|
||||
}
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "wip-text-view"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Standalone Object and Interface Text View signatures for WIP"
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
readme = "README.md"
|
||||
documentation = "https://docs.rs/wip-text-view"
|
||||
|
||||
[dependencies]
|
||||
wip-protocol.workspace = true
|
||||
@@ -0,0 +1,193 @@
|
||||
# wip-text-view
|
||||
|
||||
Standalone Object and Interface signature rendering for WIP Text View, following
|
||||
`4.2-text-view.md` at reference revision `6086f3c` and the Protocol semantics in
|
||||
`3-protocol.md`. Only `wip-protocol` and the Rust standard library are dependencies.
|
||||
No Client, Host, HTTP I/O, cache, UI framework or retrieval service is required.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
wip-text-view = "0.2.0"
|
||||
wip-protocol = "0.2.0"
|
||||
```
|
||||
|
||||
## Single-entity API
|
||||
|
||||
```rust
|
||||
use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||
use wip_text_view::{Interface, render_object, render_interface};
|
||||
|
||||
let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
|
||||
let object = Object {
|
||||
name: "item".into(), description: None, interfaces: vec![reference.clone()],
|
||||
r#ref: None, validator: None,
|
||||
};
|
||||
let object_signature = render_object(&object)?;
|
||||
assert_eq!(object_signature,
|
||||
"object {\n name: \"item\";\n interfaces: [\"/::example\"];\n}\n");
|
||||
|
||||
let descriptor = InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||
types: vec![], operations: vec![],
|
||||
};
|
||||
let interface = Interface { reference: &reference, descriptor: &descriptor };
|
||||
let interface_signature = render_interface(&interface)?;
|
||||
assert_eq!(interface_signature, "interface \"/::example\" {\n}\n");
|
||||
# Ok::<(), wip_text_view::RenderError>(())
|
||||
```
|
||||
|
||||
`Interface<'a>` is one borrowed SDK input entity containing a complete structured
|
||||
reference and its corresponding descriptor, not a wire schema extension. The
|
||||
caller pairs them from a coherent observation or publication. The descriptor has
|
||||
no identity, so a renderer cannot prove the pairing's provenance or freshness.
|
||||
For example, a caller with a validated `FetchInterfaceResponse` can borrow its
|
||||
`interface` and `descriptor`; it does not pass `scope_ref` or validators.
|
||||
|
||||
An Object has no placement path. This API always emits `object { ... }`, never
|
||||
requests a path and never infers one from name or ref. It requires no descriptor.
|
||||
An Interface needs no Object, separate name/scope/context arguments or lookup.
|
||||
There is no document aggregator, configuration or details selector.
|
||||
|
||||
## Typed display tokens
|
||||
|
||||
```rust
|
||||
use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||
use wip_text_view::{
|
||||
Interface, TokenKind, tokenize_object, tokenize_interface, to_plain_text,
|
||||
render_object, render_interface,
|
||||
};
|
||||
|
||||
let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
|
||||
let object = Object {
|
||||
name: "item".into(), description: None, interfaces: vec![reference.clone()],
|
||||
r#ref: None, validator: None,
|
||||
};
|
||||
let object_tokens = tokenize_object(&object)?;
|
||||
assert_eq!(object_tokens[0].kind(), TokenKind::Keyword);
|
||||
assert_eq!(object_tokens[0].text(), "object");
|
||||
assert_eq!(to_plain_text(&object_tokens), render_object(&object)?);
|
||||
|
||||
let descriptor = InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||
types: vec![], operations: vec![],
|
||||
};
|
||||
let input = Interface { reference: &reference, descriptor: &descriptor };
|
||||
let tokens = tokenize_interface(&input)?;
|
||||
for token in &tokens {
|
||||
// Custom renderers can select presentation by role without reparsing text.
|
||||
if token.kind() == TokenKind::Reference {
|
||||
assert_eq!(token.text(), "\"/::example\"");
|
||||
}
|
||||
}
|
||||
let joined: String = tokens.iter().map(|token| token.text()).collect();
|
||||
assert_eq!(to_plain_text(&tokens), joined);
|
||||
assert_eq!(to_plain_text(&tokens), render_interface(&input)?);
|
||||
# Ok::<(), wip_text_view::RenderError>(())
|
||||
```
|
||||
|
||||
`tokenize_object` / `tokenize_interface` return `Result<Vec<Token>, RenderError>`.
|
||||
They generate **directly from the input structure**, not by lexing a completed
|
||||
Text View string. Each token owns its text (and can outlive the input) and exposes
|
||||
`kind()` / `text()`. Existing render APIs use the same token generation followed
|
||||
by `to_plain_text(&[Token])`; there is no second grammar/escape/layout engine.
|
||||
|
||||
### Kind and splitting contract
|
||||
|
||||
| Kind | One token contains |
|
||||
| --- | --- |
|
||||
| `Keyword` | One grammar keyword, Object property label, or primitive type keyword |
|
||||
| `TypeName` | One type declaration name or Named type reference |
|
||||
| `OperationName` | One Operation declaration name |
|
||||
| `ParameterName` / `FieldName` | One parameter / record field name |
|
||||
| `CaseName` | One enum or union case name |
|
||||
| `String` | One complete quoted Object name or description |
|
||||
| `Reference` | One complete quoted absolute Interface reference, with both quoting layers |
|
||||
| `Documentation` | Entire `/// "summary"` content, including marker and internal space, excluding indentation/LF |
|
||||
| `Symbol` | One punctuation mark, except the indivisible two-character `->` arrow |
|
||||
| `Whitespace` | One inter-token ASCII space |
|
||||
| `Newline` | One LF, including the final LF |
|
||||
| `Indentation` | All leading spaces of a non-root line, two spaces per depth level |
|
||||
|
||||
Quoted identifiers retain their semantic role even when named like keywords.
|
||||
Quotes, backticks and all WIP escapes are part of the data token, never separate
|
||||
symbols. Host spaces inside quoted data remain in that token. Adjacent symbols
|
||||
are separate; no empty tokens or zero-depth indentation tokens are emitted.
|
||||
`TokenKind` is non-exhaustive so consumers should handle future roles with a
|
||||
fallback. The API does not distinguish type declaration names from Named uses,
|
||||
or enum cases from union cases; both share the documented role.
|
||||
|
||||
All layout is included. Concatenate token text **without separators**, not with
|
||||
`join(" ")`. `to_plain_text` is only that concatenation: no interpretation,
|
||||
validation, quoting, escaping or layout completion. An empty slice yields `""`;
|
||||
a filtered/reordered slice is allowed but need not be a complete signature.
|
||||
|
||||
Token text is escaped for **WIP Text View**, not for HTML or other destinations.
|
||||
Custom renderers must escape for their destination and treat Host text as data,
|
||||
not markup or instructions. This crate supplies no HTML, ANSI, color or theme
|
||||
renderer/adapter and no external text lexer/parser or AST.
|
||||
|
||||
## Output and validation
|
||||
|
||||
- UTF-8 plain text with LF, two-space indentation and a final LF, without ANSI,
|
||||
Markdown fences, syntax highlighting or provider envelopes. Nonempty composites
|
||||
and parameter lists use multiple lines; layout is deterministic here, but not a
|
||||
byte-canonical protocol format.
|
||||
- All Object references remain in Host order. All types, then all operations,
|
||||
fields, parameters and cases remain in declaration order, without elision.
|
||||
- Missing vs empty description/documentation, optional vs unit, no payload vs
|
||||
unit payload, and empty record/enum/union/declaration sets remain distinct.
|
||||
- Full absolute references use component quoting **and** outer JSON-string quoting.
|
||||
Identifiers use keywords/backtick rules. All Host strings use standard JSON
|
||||
escapes plus DEL/C1, Unicode line/paragraph separator and bidi-control escapes.
|
||||
No normalization, case conversion, URL encoding or input mutation occurs.
|
||||
- Documentation summaries are displayed in their prescribed positions, including
|
||||
return documentation between `)` and `->`. Details are not displayed. Host text
|
||||
is data, not renderer instructions or markup; quoting alone is not a prompt
|
||||
injection defense. AI adapters must preserve the trust boundary.
|
||||
- Object ref/validator, children, domain data and Interface scope_ref/validator
|
||||
are not signature content. No annotations are inferred from operation names.
|
||||
|
||||
The render functions return `Result<String, RenderError>`; token generation
|
||||
returns `Result<Vec<Token>, RenderError>`. Neither returns partial signatures. Resource checks happen first, followed by existing Protocol validators:
|
||||
Object shape and reference uniqueness, or Interface reference shape plus descriptor
|
||||
structure, duplicate names, unknown Named references and cycles. Descriptor format
|
||||
is checked after semantics: valid but unsupported formats return
|
||||
`UnsupportedDescriptorFormat`, distinct from `InvalidDescriptor`. Supported format
|
||||
is exactly `wip-interface/1`. Other validation errors preserve the Protocol's typed
|
||||
`ValidationError` (also exposed as the error source).
|
||||
|
||||
Bounded input limits are **1 MiB** of total string/opaque byte data, **16,384**
|
||||
declarations/fields/parameters/cases/type expressions/references, **256** named type
|
||||
declarations, and **64** TypeExpr nesting levels. Details and Object runtime
|
||||
metadata count toward input bytes even though they are not rendered. A limit
|
||||
returns `RenderError::ResourceLimit`, never truncates or supplies `...`. Counts
|
||||
bound allocation and recursive validation before those validators run; these are
|
||||
library safety limits, not Protocol restrictions.
|
||||
|
||||
Without path/context, this library cannot attest Object scope ancestry, placement,
|
||||
observation consistency, identity, membership, authorization or currentness.
|
||||
Callers validate those at their observation boundary. Callers also own retrieval,
|
||||
resolution, cache, selection, multiple-signature arrangement and AI/UI adaptation.
|
||||
Text is display-only, not serialization, a digest, identity/cache key or call
|
||||
target. No parser or round-trip API is supplied.
|
||||
|
||||
## Validation
|
||||
|
||||
`tests/signatures.rs` is an external consumer of the public APIs. Goldens cover
|
||||
the spec's Object, issue.read, CloseResult, search/return documentation and
|
||||
identifier examples (with permitted multiline layout), plus nested documentation.
|
||||
The suite checks all TypeExpr variants/positions, ordering and empty distinctions,
|
||||
keyword and Unicode names, both reference quoting layers, all required control
|
||||
escapes, input immutability, metadata exclusion and typed rejection cases. Every
|
||||
existing case compares the render result (including exact errors) with token
|
||||
text concatenation and `to_plain_text`. Additional tests assert complete Object
|
||||
token splitting, all semantic roles, keyword collisions, atomic escaped data,
|
||||
all documentation positions, layout tokens and ownership beyond input lifetime.
|
||||
Input budgets bound token counts per node, indentation depth and escape expansion;
|
||||
token generation checks them before any rendering/validation traversal.
|
||||
|
||||
```console
|
||||
cargo test -p wip-text-view
|
||||
cargo clippy -p wip-text-view --all-targets -- -D warnings
|
||||
RUSTDOCFLAGS='-D warnings' cargo doc -p wip-text-view --no-deps
|
||||
```
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Standalone, single-entity WIP Text View signatures.
|
||||
//!
|
||||
//! [`render_object`] accepts just an [`Object`], and [`render_interface`] accepts
|
||||
//! just an [`Interface`] (a reference paired with its corresponding descriptor).
|
||||
//! No Client, Host, HTTP, UI, retrieval, cache, path or display context is needed.
|
||||
//! The caller supplies a coherent observation; descriptor identity cannot be
|
||||
//! inferred from descriptor contents. No protocol wire fields are added.
|
||||
//!
|
||||
//! Output follows WIP `4.2-text-view.md` (reference revision `6086f3c`): UTF-8
|
||||
//! plain text, LF, two-space indentation, all declarations in original order,
|
||||
//! and documentation summaries only. There is no truncation or details setting.
|
||||
//! Object paths are neither requested nor guessed: output uses `object { ... }`.
|
||||
//! Runtime metadata is never rendered. Host documentation is untrusted data,
|
||||
//! not markup or instructions; quoting does not by itself prevent prompt injection.
|
||||
//!
|
||||
//! Each call checks resource limits, then Protocol shape/semantic validation.
|
||||
//! Interface rendering additionally requires [`wip_protocol::INTERFACE_FORMAT_V1`].
|
||||
//! Limits reject the entire input, never produce partial text: at most 1 MiB of
|
||||
//! input string/byte data, 16,384 declarations/fields/cases/type expressions or
|
||||
//! references, 256 named type declarations (bounding validator graph recursion),
|
||||
//! and 64 nested TypeExpr levels. These are implementation limits, not new
|
||||
//! Protocol semantics. An Object without placement context can only be checked
|
||||
//! for name/reference shape and duplicate references; the caller must validate
|
||||
//! scope ancestry and observation consistency at its observation boundary.
|
||||
//!
|
||||
//! Signatures are display-only: no parser, round-trip, identity comparison,
|
||||
//! cache key or call-target reconstruction is provided. The caller owns fetching,
|
||||
//! freshness, authorization, selection and arrangement of multiple signatures.
|
||||
//!
|
||||
//! ```
|
||||
//! use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||
//! use wip_text_view::{Interface, render_object, render_interface};
|
||||
//! let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
|
||||
//! let object = Object {
|
||||
//! name: "item".into(), description: None, interfaces: vec![reference.clone()],
|
||||
//! r#ref: Some("not a path".into()), validator: Some(vec![1]),
|
||||
//! };
|
||||
//! assert_eq!(render_object(&object)?,
|
||||
//! "object {\n name: \"item\";\n interfaces: [\"/::example\"];\n}\n");
|
||||
//! let descriptor = InterfaceDescriptor {
|
||||
//! format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||
//! types: vec![], operations: vec![],
|
||||
//! };
|
||||
//! let interface = Interface { reference: &reference, descriptor: &descriptor };
|
||||
//! assert_eq!(render_interface(&interface)?, "interface \"/::example\" {\n}\n");
|
||||
//! # Ok::<(), wip_text_view::RenderError>(())
|
||||
//! ```
|
||||
//!
|
||||
//! # Typed display tokens
|
||||
//!
|
||||
//! [`tokenize_object`] and [`tokenize_interface`] traverse the input structure
|
||||
//! directly and return owned [`Token`] sequences, including all layout. Existing
|
||||
//! render APIs use this same generation path followed by [`to_plain_text`]. The
|
||||
//! input limits above also bound token allocation: tokens per node, indentation
|
||||
//! depth and escape expansion are bounded; no partial signature is returned.
|
||||
//! Token text is WIP-escaped, **not HTML-escaped** or escaped for other destinations.
|
||||
//! See [`Token`] for the semantic splitting contract; no renderer/theme is supplied.
|
||||
//!
|
||||
//! ```
|
||||
//! use wip_protocol::{InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||
//! use wip_text_view::{Interface, TokenKind, tokenize_interface, to_plain_text, render_interface};
|
||||
//! let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
|
||||
//! let descriptor = InterfaceDescriptor {
|
||||
//! format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||
//! types: vec![], operations: vec![],
|
||||
//! };
|
||||
//! let input = Interface { reference: &reference, descriptor: &descriptor };
|
||||
//! let tokens = tokenize_interface(&input)?;
|
||||
//! assert_eq!(tokens[0].kind(), TokenKind::Keyword);
|
||||
//! assert_eq!(tokens[0].text(), "interface");
|
||||
//! let concatenated: String = tokens.iter().map(|token| token.text()).collect();
|
||||
//! assert_eq!(to_plain_text(&tokens), concatenated);
|
||||
//! assert_eq!(to_plain_text(&tokens), render_interface(&input)?);
|
||||
//! # Ok::<(), wip_text_view::RenderError>(())
|
||||
//! ```
|
||||
|
||||
#![deny(missing_docs)]
|
||||
|
||||
mod limits;
|
||||
mod render;
|
||||
mod token;
|
||||
|
||||
pub use token::{Token, TokenKind, to_plain_text};
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
use wip_protocol::{
|
||||
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceReference, Object, ValidationError,
|
||||
};
|
||||
|
||||
/// One Interface input entity, borrowing its identity and corresponding data.
|
||||
///
|
||||
/// The caller pairs values from the same observation/publication. The descriptor
|
||||
/// itself has no identity, so rendering cannot verify provenance or freshness.
|
||||
/// This is an SDK display input, not a replacement wire schema or a document.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Interface<'a> {
|
||||
/// Complete, absolute, Client-facing Interface reference.
|
||||
pub reference: &'a InterfaceReference,
|
||||
/// Descriptor corresponding to `reference`.
|
||||
pub descriptor: &'a InterfaceDescriptor,
|
||||
}
|
||||
|
||||
/// Failure to render a complete, validated signature. No partial text is returned.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RenderError {
|
||||
/// Object-independent Protocol validation failed.
|
||||
InvalidObject(ValidationError),
|
||||
/// The Interface's structured reference is malformed.
|
||||
InvalidReference(ValidationError),
|
||||
/// Descriptor structure or semantics failed Protocol validation.
|
||||
InvalidDescriptor(ValidationError),
|
||||
/// A semantically valid descriptor uses a format this renderer cannot interpret.
|
||||
UnsupportedDescriptorFormat {
|
||||
/// Exact unsupported format identifier.
|
||||
format: String,
|
||||
},
|
||||
/// Input exceeds an implementation safety limit, without truncation.
|
||||
ResourceLimit {
|
||||
/// Limit that was exceeded.
|
||||
limit: ResourceLimit,
|
||||
},
|
||||
}
|
||||
|
||||
/// Bounded input dimensions checked before recursive Protocol validation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResourceLimit {
|
||||
/// Total input string and opaque byte length exceeds 1 MiB.
|
||||
InputBytes,
|
||||
/// Total declarations, fields, parameters, cases, expressions or references exceeds 16,384.
|
||||
Nodes,
|
||||
/// More than 256 named type declarations.
|
||||
TypeDeclarations,
|
||||
/// More than 64 nested TypeExpr levels (outer expression is level one).
|
||||
TypeDepth,
|
||||
}
|
||||
|
||||
impl fmt::Display for RenderError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidObject(e) => write!(f, "invalid Object: {e}"),
|
||||
Self::InvalidReference(e) => write!(f, "invalid Interface reference: {e}"),
|
||||
Self::InvalidDescriptor(e) => write!(f, "invalid Interface descriptor: {e}"),
|
||||
Self::UnsupportedDescriptorFormat { format } => {
|
||||
write!(f, "unsupported descriptor format: {format:?}")
|
||||
}
|
||||
Self::ResourceLimit { limit } => write!(f, "Text View input exceeds {limit:?} limit"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RenderError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::InvalidObject(e) | Self::InvalidReference(e) | Self::InvalidDescriptor(e) => {
|
||||
Some(e)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders one complete Object signature, with no placement path or inferred data.
|
||||
///
|
||||
/// Validates shape and ordered-set uniqueness, but cannot validate ancestry or
|
||||
/// root/non-root placement without context. Missing and empty descriptions remain
|
||||
/// distinct; ref and validator bytes are ignored for display. Input is unmodified.
|
||||
/// See [crate documentation](crate) for limits and caller responsibilities.
|
||||
pub fn render_object(object: &Object) -> Result<String, RenderError> {
|
||||
tokenize_object(object).map(|tokens| to_plain_text(&tokens))
|
||||
}
|
||||
|
||||
/// Generates the complete owned display token sequence for one Object signature.
|
||||
///
|
||||
/// Uses the same validation, limits, metadata exclusion and caller responsibilities
|
||||
/// as [`render_object`]. No partial sequence is returned on error. Input is not
|
||||
/// modified. See [`Token`] and [`TokenKind`] for semantic roles and splitting.
|
||||
pub fn tokenize_object(object: &Object) -> Result<Vec<Token>, RenderError> {
|
||||
limits::object(object)?;
|
||||
object.validate().map_err(RenderError::InvalidObject)?;
|
||||
Ok(render::object(object))
|
||||
}
|
||||
|
||||
/// Renders one complete Interface signature from one paired input entity.
|
||||
///
|
||||
/// Validates reference shape and all descriptor declarations/named references,
|
||||
/// including cycles, before checking format support. No lookup or Object is
|
||||
/// needed. All types precede all operations, with original member order; only
|
||||
/// documentation summaries are shown, without interpreting Host text.
|
||||
/// Input is unmodified. See [crate documentation](crate) for safety limits.
|
||||
pub fn render_interface(interface: &Interface<'_>) -> Result<String, RenderError> {
|
||||
tokenize_interface(interface).map(|tokens| to_plain_text(&tokens))
|
||||
}
|
||||
|
||||
/// Generates the complete owned display token sequence for one Interface signature.
|
||||
///
|
||||
/// Uses the same validation, limits, format support and summary-only documentation
|
||||
/// as [`render_interface`]. No partial sequence is returned on error. Input is not
|
||||
/// modified. See [`Token`] and [`TokenKind`] for semantic roles and splitting.
|
||||
/// This traverses structured declarations directly, not a rendered-text lexer.
|
||||
pub fn tokenize_interface(interface: &Interface<'_>) -> Result<Vec<Token>, RenderError> {
|
||||
limits::interface(interface)?;
|
||||
interface
|
||||
.reference
|
||||
.validate()
|
||||
.map_err(RenderError::InvalidReference)?;
|
||||
interface
|
||||
.descriptor
|
||||
.validate()
|
||||
.map_err(RenderError::InvalidDescriptor)?;
|
||||
if interface.descriptor.format != INTERFACE_FORMAT_V1 {
|
||||
return Err(RenderError::UnsupportedDescriptorFormat {
|
||||
format: interface.descriptor.format.clone(),
|
||||
});
|
||||
}
|
||||
Ok(render::interface(interface))
|
||||
}
|
||||
|
||||
// Keep both README examples executable as external-consumer doctests.
|
||||
#[cfg(doctest)]
|
||||
#[doc = include_str!("../README.md")]
|
||||
mod readme {}
|
||||
@@ -0,0 +1,125 @@
|
||||
use wip_protocol::{Documentation, InterfaceReference, Object, TypeExpr};
|
||||
|
||||
use crate::{Interface, RenderError, ResourceLimit};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Budget {
|
||||
bytes: usize,
|
||||
nodes: usize,
|
||||
}
|
||||
|
||||
fn check(value: usize, max: usize, limit: ResourceLimit) -> Result<(), RenderError> {
|
||||
if value > max {
|
||||
Err(RenderError::ResourceLimit { limit })
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
fn bytes(&mut self, len: usize) -> Result<(), RenderError> {
|
||||
self.bytes = self.bytes.saturating_add(len);
|
||||
check(self.bytes, 1024 * 1024, ResourceLimit::InputBytes)
|
||||
}
|
||||
|
||||
fn nodes(&mut self, len: usize) -> Result<(), RenderError> {
|
||||
self.nodes = self.nodes.saturating_add(len);
|
||||
check(self.nodes, 16_384, ResourceLimit::Nodes)
|
||||
}
|
||||
|
||||
fn reference(&mut self, reference: &InterfaceReference) -> Result<(), RenderError> {
|
||||
self.nodes(1)?;
|
||||
self.bytes(reference.scope.len())?;
|
||||
self.bytes(reference.name.len())
|
||||
}
|
||||
|
||||
fn doc(&mut self, doc: &Option<Documentation>) -> Result<(), RenderError> {
|
||||
if let Some(doc) = doc {
|
||||
self.bytes(doc.summary.len())?;
|
||||
self.bytes(doc.details.as_ref().map_or(0, String::len))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn declaration(&mut self, name: &str, doc: &Option<Documentation>) -> Result<(), RenderError> {
|
||||
self.nodes(1)?;
|
||||
self.bytes(name.len())?;
|
||||
self.doc(doc)
|
||||
}
|
||||
|
||||
fn expr(&mut self, expr: &TypeExpr, depth: usize) -> Result<(), RenderError> {
|
||||
check(depth, 64, ResourceLimit::TypeDepth)?;
|
||||
self.nodes(1)?;
|
||||
match expr {
|
||||
TypeExpr::Named { name } => self.bytes(name.len())?,
|
||||
TypeExpr::List { items } => self.expr(items, depth + 1)?,
|
||||
TypeExpr::Record { fields } => {
|
||||
self.nodes(fields.len())?;
|
||||
for field in fields {
|
||||
self.bytes(field.name.len())?;
|
||||
self.doc(&field.documentation)?;
|
||||
self.expr(&field.r#type, depth + 1)?;
|
||||
}
|
||||
}
|
||||
TypeExpr::Enum { cases } => {
|
||||
self.nodes(cases.len())?;
|
||||
for case in cases {
|
||||
self.bytes(case.name.len())?;
|
||||
self.doc(&case.documentation)?;
|
||||
}
|
||||
}
|
||||
TypeExpr::Union { cases } => {
|
||||
self.nodes(cases.len())?;
|
||||
for case in cases {
|
||||
self.bytes(case.name.len())?;
|
||||
self.doc(&case.documentation)?;
|
||||
if let Some(payload) = &case.payload {
|
||||
self.expr(payload, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn object(object: &Object) -> Result<(), RenderError> {
|
||||
let mut budget = Budget::default();
|
||||
budget.bytes(object.name.len())?;
|
||||
budget.bytes(object.description.as_ref().map_or(0, String::len))?;
|
||||
budget.bytes(object.r#ref.as_ref().map_or(0, String::len))?;
|
||||
budget.bytes(object.validator.as_ref().map_or(0, Vec::len))?;
|
||||
check(object.interfaces.len(), 16_384, ResourceLimit::Nodes)?;
|
||||
for reference in &object.interfaces {
|
||||
budget.reference(reference)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn interface(interface: &Interface<'_>) -> Result<(), RenderError> {
|
||||
let mut budget = Budget::default();
|
||||
let descriptor = interface.descriptor;
|
||||
budget.reference(interface.reference)?;
|
||||
budget.bytes(descriptor.format.len())?;
|
||||
budget.doc(&descriptor.documentation)?;
|
||||
check(descriptor.types.len(), 256, ResourceLimit::TypeDeclarations)?;
|
||||
// Check collection counts before traversing even empty names/expressions.
|
||||
check(descriptor.operations.len(), 16_384, ResourceLimit::Nodes)?;
|
||||
for ty in &descriptor.types {
|
||||
budget.declaration(&ty.name, &ty.documentation)?;
|
||||
budget.expr(&ty.definition, 1)?;
|
||||
}
|
||||
for operation in &descriptor.operations {
|
||||
budget.declaration(&operation.name, &operation.documentation)?;
|
||||
check(operation.parameters.len(), 16_384, ResourceLimit::Nodes)?;
|
||||
for parameter in &operation.parameters {
|
||||
budget.declaration(¶meter.name, ¶meter.documentation)?;
|
||||
budget.expr(¶meter.r#type, 1)?;
|
||||
}
|
||||
budget.nodes(1)?; // Return declaration.
|
||||
budget.doc(&operation.returns.documentation)?;
|
||||
budget.expr(&operation.returns.r#type, 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
use std::fmt::Write as _;
|
||||
use wip_protocol::{Documentation, InterfaceReference, Object, OperationDeclaration, TypeExpr};
|
||||
|
||||
use crate::{Interface, Token, TokenKind};
|
||||
use TokenKind::{
|
||||
CaseName, FieldName, Indentation, Keyword, Newline, OperationName, ParameterName, Reference,
|
||||
Symbol, TypeName, Whitespace,
|
||||
};
|
||||
|
||||
// Quoting a component and quoting the complete reference are separate layers.
|
||||
fn reference(reference: &InterfaceReference) -> String {
|
||||
format!(
|
||||
"{}::{}",
|
||||
component(&reference.scope, true),
|
||||
component(&reference.name, false)
|
||||
)
|
||||
}
|
||||
|
||||
fn component(value: &str, scope: bool) -> String {
|
||||
if !value.is_empty()
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b) || (scope && b == b'/'))
|
||||
{
|
||||
value.into()
|
||||
} else {
|
||||
quote(value, '"')
|
||||
}
|
||||
}
|
||||
|
||||
fn identifier(value: &str) -> String {
|
||||
const KEYWORDS: &[&str] = &[
|
||||
"object",
|
||||
"at",
|
||||
"name",
|
||||
"description",
|
||||
"interfaces",
|
||||
"interface",
|
||||
"type",
|
||||
"operation",
|
||||
"enum",
|
||||
"union",
|
||||
"unit",
|
||||
"boolean",
|
||||
"integer",
|
||||
"number",
|
||||
"string",
|
||||
"bytes",
|
||||
"json",
|
||||
"entry",
|
||||
];
|
||||
if value
|
||||
.bytes()
|
||||
.next()
|
||||
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
&& !KEYWORDS.contains(&value)
|
||||
{
|
||||
value.into()
|
||||
} else {
|
||||
quote(value, '`')
|
||||
}
|
||||
}
|
||||
|
||||
fn quote(value: &str, delimiter: char) -> String {
|
||||
let mut output = String::new();
|
||||
output.push(delimiter);
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'`' if delimiter == '`' => output.push_str("\\`"),
|
||||
'\\' => output.push_str("\\\\"),
|
||||
'"' => output.push_str("\\\""),
|
||||
'\u{08}' => output.push_str("\\b"),
|
||||
'\u{0c}' => output.push_str("\\f"),
|
||||
'\n' => output.push_str("\\n"),
|
||||
'\r' => output.push_str("\\r"),
|
||||
'\t' => output.push_str("\\t"),
|
||||
'\u{00}'..='\u{1f}'
|
||||
| '\u{7f}'..='\u{9f}'
|
||||
| '\u{2028}'
|
||||
| '\u{2029}'
|
||||
| '\u{061c}'
|
||||
| '\u{200e}'..='\u{200f}'
|
||||
| '\u{202a}'..='\u{202e}'
|
||||
| '\u{2066}'..='\u{2069}' => {
|
||||
write!(output, "\\u{:04x}", ch as u32).expect("String write");
|
||||
}
|
||||
_ => output.push(ch),
|
||||
}
|
||||
}
|
||||
output.push(delimiter);
|
||||
output
|
||||
}
|
||||
|
||||
pub(crate) fn object(object: &Object) -> Vec<Token> {
|
||||
let mut writer = Writer(Vec::new());
|
||||
writer.word(Keyword, "object");
|
||||
writer.space();
|
||||
writer.symbol("{");
|
||||
writer.newline();
|
||||
writer.indent(1);
|
||||
writer.word(Keyword, "name");
|
||||
writer.symbol(":");
|
||||
writer.space();
|
||||
writer.word(TokenKind::String, quote(&object.name, '"'));
|
||||
writer.symbol(";");
|
||||
writer.newline();
|
||||
if let Some(description) = &object.description {
|
||||
writer.indent(1);
|
||||
writer.word(Keyword, "description");
|
||||
writer.symbol(":");
|
||||
writer.space();
|
||||
writer.word(TokenKind::String, quote(description, '"'));
|
||||
writer.symbol(";");
|
||||
writer.newline();
|
||||
}
|
||||
writer.indent(1);
|
||||
writer.word(Keyword, "interfaces");
|
||||
writer.symbol(":");
|
||||
writer.space();
|
||||
writer.symbol("[");
|
||||
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||
if index > 0 {
|
||||
writer.symbol(",");
|
||||
writer.space();
|
||||
}
|
||||
writer.word(Reference, quote(&reference(interface), '"'));
|
||||
}
|
||||
writer.symbol("]");
|
||||
writer.symbol(";");
|
||||
writer.newline();
|
||||
writer.symbol("}");
|
||||
writer.newline();
|
||||
writer.0
|
||||
}
|
||||
|
||||
pub(crate) fn interface(interface: &Interface<'_>) -> Vec<Token> {
|
||||
let mut writer = Writer(Vec::new());
|
||||
writer.doc(&interface.descriptor.documentation, 0);
|
||||
writer.word(Keyword, "interface");
|
||||
writer.space();
|
||||
writer.word(Reference, quote(&reference(interface.reference), '"'));
|
||||
writer.space();
|
||||
writer.symbol("{");
|
||||
writer.newline();
|
||||
for declaration in &interface.descriptor.types {
|
||||
writer.doc(&declaration.documentation, 1);
|
||||
writer.indent(1);
|
||||
writer.word(Keyword, "type");
|
||||
writer.space();
|
||||
writer.word(TypeName, identifier(&declaration.name));
|
||||
writer.space();
|
||||
writer.symbol("=");
|
||||
writer.space();
|
||||
writer.expr(&declaration.definition, 1);
|
||||
writer.symbol(";");
|
||||
writer.newline();
|
||||
}
|
||||
for operation in &interface.descriptor.operations {
|
||||
writer.operation(operation);
|
||||
}
|
||||
writer.symbol("}");
|
||||
writer.newline();
|
||||
writer.0
|
||||
}
|
||||
|
||||
struct Writer(Vec<Token>);
|
||||
|
||||
impl Writer {
|
||||
fn word(&mut self, kind: TokenKind, text: impl Into<String>) {
|
||||
self.0.push(Token {
|
||||
kind,
|
||||
text: text.into(),
|
||||
});
|
||||
}
|
||||
|
||||
fn symbol(&mut self, text: &str) {
|
||||
self.word(Symbol, text);
|
||||
}
|
||||
|
||||
fn space(&mut self) {
|
||||
self.word(Whitespace, " ");
|
||||
}
|
||||
|
||||
fn newline(&mut self) {
|
||||
self.word(Newline, "\n");
|
||||
}
|
||||
|
||||
fn indent(&mut self, depth: usize) {
|
||||
if depth > 0 {
|
||||
self.word(Indentation, " ".repeat(depth));
|
||||
}
|
||||
}
|
||||
|
||||
fn doc(&mut self, doc: &Option<Documentation>, depth: usize) {
|
||||
if let Some(doc) = doc {
|
||||
self.indent(depth);
|
||||
self.word(
|
||||
TokenKind::Documentation,
|
||||
format!("/// {}", quote(&doc.summary, '"')),
|
||||
);
|
||||
self.newline();
|
||||
}
|
||||
}
|
||||
|
||||
fn member(&mut self, kind: TokenKind, name: &str, required: bool, ty: &TypeExpr, depth: usize) {
|
||||
self.indent(depth);
|
||||
self.word(kind, identifier(name));
|
||||
if !required {
|
||||
self.symbol("?");
|
||||
}
|
||||
self.symbol(":");
|
||||
self.space();
|
||||
self.expr(ty, depth);
|
||||
self.symbol(",");
|
||||
self.newline();
|
||||
}
|
||||
|
||||
fn operation(&mut self, operation: &OperationDeclaration) {
|
||||
self.doc(&operation.documentation, 1);
|
||||
self.indent(1);
|
||||
self.word(Keyword, "operation");
|
||||
self.space();
|
||||
self.word(OperationName, identifier(&operation.name));
|
||||
self.symbol("(");
|
||||
if !operation.parameters.is_empty() {
|
||||
self.newline();
|
||||
for parameter in &operation.parameters {
|
||||
self.doc(¶meter.documentation, 2);
|
||||
self.member(
|
||||
ParameterName,
|
||||
¶meter.name,
|
||||
parameter.required,
|
||||
¶meter.r#type,
|
||||
2,
|
||||
);
|
||||
}
|
||||
self.indent(1);
|
||||
}
|
||||
self.symbol(")");
|
||||
if operation.returns.documentation.is_some() {
|
||||
self.newline();
|
||||
self.doc(&operation.returns.documentation, 1);
|
||||
self.indent(1);
|
||||
} else {
|
||||
self.space();
|
||||
}
|
||||
self.symbol("->");
|
||||
self.space();
|
||||
self.expr(&operation.returns.r#type, 1);
|
||||
self.symbol(";");
|
||||
self.newline();
|
||||
}
|
||||
|
||||
// Starts at the current cursor. Composite closing braces align with the
|
||||
// declaration/field/parameter containing this expression, not its width.
|
||||
fn expr(&mut self, expr: &TypeExpr, depth: usize) {
|
||||
match expr {
|
||||
TypeExpr::Unit => self.word(Keyword, "unit"),
|
||||
TypeExpr::Boolean => self.word(Keyword, "boolean"),
|
||||
TypeExpr::Integer => self.word(Keyword, "integer"),
|
||||
TypeExpr::Number => self.word(Keyword, "number"),
|
||||
TypeExpr::String => self.word(Keyword, "string"),
|
||||
TypeExpr::Bytes => self.word(Keyword, "bytes"),
|
||||
TypeExpr::Json => self.word(Keyword, "json"),
|
||||
TypeExpr::Entry => self.word(Keyword, "entry"),
|
||||
TypeExpr::Named { name } => self.word(TypeName, identifier(name)),
|
||||
TypeExpr::List { items } => {
|
||||
self.symbol("[");
|
||||
self.expr(items, depth);
|
||||
self.symbol("]");
|
||||
}
|
||||
TypeExpr::Record { fields } => {
|
||||
self.symbol("{");
|
||||
if !fields.is_empty() {
|
||||
self.newline();
|
||||
for field in fields {
|
||||
self.doc(&field.documentation, depth + 1);
|
||||
self.member(
|
||||
FieldName,
|
||||
&field.name,
|
||||
field.required,
|
||||
&field.r#type,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
self.indent(depth);
|
||||
}
|
||||
self.symbol("}");
|
||||
}
|
||||
TypeExpr::Enum { cases } => {
|
||||
self.word(Keyword, "enum");
|
||||
self.space();
|
||||
self.symbol("{");
|
||||
if !cases.is_empty() {
|
||||
self.newline();
|
||||
for case in cases {
|
||||
self.doc(&case.documentation, depth + 1);
|
||||
self.indent(depth + 1);
|
||||
self.word(CaseName, identifier(&case.name));
|
||||
self.symbol(",");
|
||||
self.newline();
|
||||
}
|
||||
self.indent(depth);
|
||||
}
|
||||
self.symbol("}");
|
||||
}
|
||||
TypeExpr::Union { cases } => {
|
||||
self.word(Keyword, "union");
|
||||
self.space();
|
||||
self.symbol("{");
|
||||
if !cases.is_empty() {
|
||||
self.newline();
|
||||
for case in cases {
|
||||
self.doc(&case.documentation, depth + 1);
|
||||
self.indent(depth + 1);
|
||||
self.word(CaseName, identifier(&case.name));
|
||||
if let Some(payload) = &case.payload {
|
||||
self.symbol("(");
|
||||
self.expr(payload, depth + 1);
|
||||
self.symbol(")");
|
||||
}
|
||||
self.symbol(",");
|
||||
self.newline();
|
||||
}
|
||||
self.indent(depth);
|
||||
}
|
||||
self.symbol("}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Owned display data, independent of the Protocol wire model.
|
||||
|
||||
/// Semantic display role assigned from the input structure, not from text parsing.
|
||||
///
|
||||
/// Identifiers retain their role even when quoted or identical to a keyword.
|
||||
/// Type declarations and Named expressions both use [`Self::TypeName`]. Enum and
|
||||
/// union cases both use [`Self::CaseName`]. See [`Token`] for splitting rules.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum TokenKind {
|
||||
/// Grammar keyword, including Object property labels and primitive types.
|
||||
Keyword,
|
||||
/// Named type declaration or reference within the same Interface.
|
||||
TypeName,
|
||||
/// Operation declaration name (not an invocation target).
|
||||
OperationName,
|
||||
/// Operation parameter name.
|
||||
ParameterName,
|
||||
/// Record field name.
|
||||
FieldName,
|
||||
/// Enum or union case name.
|
||||
CaseName,
|
||||
/// Quoted Object name or description.
|
||||
String,
|
||||
/// Entire quoted absolute Interface reference, including both quoting layers.
|
||||
Reference,
|
||||
/// Entire `/// "summary"` line content, excluding indentation and LF.
|
||||
Documentation,
|
||||
/// One punctuation mark, or the complete two-character return arrow `->`.
|
||||
Symbol,
|
||||
/// One inter-token ASCII space, never indentation or text inside quotes.
|
||||
Whitespace,
|
||||
/// One LF (`\n`), including the final signature LF.
|
||||
Newline,
|
||||
/// All leading spaces on a non-root line: two ASCII spaces per depth level.
|
||||
Indentation,
|
||||
}
|
||||
|
||||
/// One owned, already escaped fragment of a Text View signature.
|
||||
///
|
||||
/// Each keyword, identifier, quoted string and complete quoted reference is one
|
||||
/// token, including its delimiters and escapes. Documentation is one token for
|
||||
/// the `///` marker, space and quoted summary together. Symbols are individual
|
||||
/// punctuation marks except `->`, which is indivisible. Spaces between tokens,
|
||||
/// each LF, and each nonzero line indentation have separate tokens. No empty
|
||||
/// tokens are emitted; an empty value still has its quotes. Adjacent symbols are
|
||||
/// not merged. Host whitespace inside quoted data stays inside that data token.
|
||||
///
|
||||
/// The generation APIs emit all layout: concatenating [`Self::text`] in order,
|
||||
/// without separators, produces exactly the existing plain-text signature.
|
||||
/// Tokens own their text and can outlive the input; they are display data, not
|
||||
/// an AST, lexer output, Protocol serialization or identity/call-target data.
|
||||
///
|
||||
/// Text is escaped for **WIP Text View**, not for HTML or any other destination.
|
||||
/// Custom renderers must apply destination-specific escaping and must not treat
|
||||
/// Host documentation as markup or instructions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Token {
|
||||
pub(crate) kind: TokenKind,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
impl Token {
|
||||
/// Returns the semantic display role, without reparsing the output text.
|
||||
pub fn kind(&self) -> TokenKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// Returns the exact output fragment, with WIP quoting and escapes applied.
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
}
|
||||
|
||||
/// Concatenates token text in order, with no separators or further processing.
|
||||
///
|
||||
/// This performs no grammar interpretation, validation, quoting, escaping or
|
||||
/// layout completion. An empty slice yields an empty string. Filtering or
|
||||
/// reordering tokens is allowed, but need not produce a complete signature.
|
||||
/// Object/Interface generation limits are checked by [`crate::tokenize_object`]
|
||||
/// and [`crate::tokenize_interface`], not by this concatenation function.
|
||||
pub fn to_plain_text(tokens: &[Token]) -> String {
|
||||
tokens.iter().map(Token::text).collect()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
interface "/::example" {
|
||||
type `string` = {
|
||||
`表示名`: string,
|
||||
};
|
||||
operation `find-item`(
|
||||
`search term`: string,
|
||||
) -> `string`;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// "Issueの内容と関連項目を読む。"
|
||||
interface "/::issue.read" {
|
||||
type Relation = enum {
|
||||
parent,
|
||||
sibling,
|
||||
related,
|
||||
};
|
||||
type RelatedItems = {
|
||||
/// "同じWorldspace内の関連項目。"
|
||||
items: [entry],
|
||||
next_cursor?: string,
|
||||
};
|
||||
/// "本文を取得する。"
|
||||
operation read_body() -> {
|
||||
body: string,
|
||||
};
|
||||
/// "関係を指定して関連項目を取得する。"
|
||||
operation get_related_items(
|
||||
relation: Relation,
|
||||
limit?: integer,
|
||||
) -> RelatedItems;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
interface "/::example" {
|
||||
/// "type summary"
|
||||
type Z = {
|
||||
/// "field summary"
|
||||
z?: unit,
|
||||
a: [union {
|
||||
/// "case summary"
|
||||
z,
|
||||
/// ""
|
||||
a(unit),
|
||||
nested(enum {
|
||||
/// "enum summary"
|
||||
z,
|
||||
a,
|
||||
}),
|
||||
}],
|
||||
};
|
||||
operation z(
|
||||
z: unit,
|
||||
a?: unit,
|
||||
) -> unit;
|
||||
operation a()
|
||||
/// "no params result"
|
||||
-> unit;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
object {
|
||||
name: "123";
|
||||
description: "Issue 123の公開操作。本文はOperationで取得する。";
|
||||
interfaces: ["/::issue.read", "/::issue.lifecycle"];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
interface "/::example" {
|
||||
type CloseResult = union {
|
||||
closed,
|
||||
blocked({
|
||||
dependencies: [entry],
|
||||
}),
|
||||
};
|
||||
operation refresh() -> unit;
|
||||
/// "対象を検索する。"
|
||||
operation search(
|
||||
/// "検索語。"
|
||||
query: string,
|
||||
)
|
||||
/// "条件に一致する項目。"
|
||||
-> {
|
||||
items: [entry],
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -91,10 +91,29 @@ these labels. The output has no extra outer signature string quoting.
|
||||
Keep selection, cache and calls keyed by structured references and operation
|
||||
names, never display text. No parser, scope inference, identity reconstruction,
|
||||
Object/Interface signature renderer or new crate is introduced. Membership,
|
||||
ancestry, authorization and freshness remain runtime concerns. Screen integration
|
||||
is follow-up T-23; the current bounded screen/value/diagnostic rendering is
|
||||
unchanged. At integration, apply existing UI layout bounds to display labels
|
||||
without feeding shortened labels back into identity or calls.
|
||||
ancestry, authorization and freshness remain runtime concerns. Normal Entry,
|
||||
Interface/Operation, editor, confirmation and operation-history screens use these
|
||||
qualified labels with existing bounded layout. Shortened labels never feed back
|
||||
into identity or calls.
|
||||
|
||||
### Rust API and wire migration
|
||||
|
||||
Interface identities in the presentation map, action/selection rows, in-flight
|
||||
subjects and editors are now `InterfaceReference { scope, name }`, not strings.
|
||||
This is a breaking Rust API change; consumers must construct an explicit canonical
|
||||
absolute scope rather than parse a label or infer a scope from a target Entry.
|
||||
`ArgumentEditor` also retains its Entry `path`; `new` defaults to `/`, and
|
||||
`for_target(path)` fixes the chosen target before editing. Normal UI selection
|
||||
sets it automatically. Confirmation never retargets an existing editor.
|
||||
`App::operation_selection` retains the Entry and structured row through temporary
|
||||
loading gaps; the numeric cursor is only a layout position. Retaining this subject
|
||||
does not expose stale operations or authorize execution.
|
||||
|
||||
The TUI uses the scoped Protocol/HTTP Client wire: Interface references are
|
||||
structured, and calls use an Interface target carrying the reference and optional
|
||||
`scope_ref`/validator preconditions. The optional identity is copied by the Client,
|
||||
not invented by the UI; no additional required ref is introduced. This wire is not
|
||||
compatible with the former string-reference request/response shape.
|
||||
|
||||
## Keyboard bindings
|
||||
|
||||
@@ -128,10 +147,12 @@ 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 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
|
||||
- **Interfaces / Operations** identifies every operation by structured `(scope,
|
||||
interface name, operation name)`, displaying `scope::interface#operation` with
|
||||
explicit absolute scope and safely quoted components. 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.
|
||||
@@ -158,6 +179,22 @@ Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable
|
||||
post-dispatch responses, and post-dispatch timeout/disconnect are displayed using
|
||||
the runtime's authoritative transition.
|
||||
|
||||
Every Client transition is mirrored into retained UI Object, tree and Interface
|
||||
state, including scope retirement and cache removal. An open editor/confirmation
|
||||
is bound to private Object/Interface snapshots (not displayed refs/validators).
|
||||
Changed, stale, loading, removed or retargeted observations retire the editor;
|
||||
reobservation never recreates or executes it. Descriptor rows remain identified
|
||||
by the structured reference when another Interface gains or loses operation rows.
|
||||
Explicitly select/load an Interface header to refresh a retained stale descriptor,
|
||||
then select an operation and confirm a new call. Client calls already dispatched
|
||||
retain their original immutable context and are never moved to a new scope.
|
||||
|
||||
Without refs, a same-path replacement with indistinguishable public metadata
|
||||
cannot always be detected; the UI does not synthesize identity or infer deletion
|
||||
from missing tree edges. Optional refs remain optional. Typed `Entry` results are
|
||||
only observed when individually selected; the UI neither bulk-fetches result
|
||||
paths nor adds guessed parent/indexable edges.
|
||||
|
||||
For an unknown outcome, the UI states that execution or side effects may have
|
||||
occurred, marks the related Entry stale, and tells the user to refresh. Refreshing
|
||||
does not recreate the call: the user must select the operation, review fresh input,
|
||||
|
||||
@@ -280,6 +280,9 @@ pub struct App {
|
||||
pub interfaces: BTreeMap<InterfaceReference, InterfaceView>,
|
||||
/// Selected row in the flattened interface/operation view.
|
||||
pub operation_cursor: usize,
|
||||
/// Retained Entry and structured row while current operation rows are hidden.
|
||||
/// This is selection identity only, never authority to execute a stale row.
|
||||
pub operation_selection: Option<(String, OperationRow)>,
|
||||
/// Selected recent-history row.
|
||||
pub history_cursor: usize,
|
||||
/// Active overlay.
|
||||
@@ -310,6 +313,7 @@ impl App {
|
||||
focus: Focus::Tree,
|
||||
interfaces: BTreeMap::new(),
|
||||
operation_cursor: 0,
|
||||
operation_selection: None,
|
||||
history_cursor: 0,
|
||||
modal: Modal::None,
|
||||
in_flight: BTreeMap::new(),
|
||||
@@ -362,7 +366,7 @@ impl App {
|
||||
}
|
||||
InputKey::Enter | InputKey::Character('e') => {
|
||||
let action = Action::Execute {
|
||||
path: self.selected_path.clone(),
|
||||
path: editor.path.clone(),
|
||||
interface: editor.interface.clone(),
|
||||
operation: editor.operation.name.clone(),
|
||||
arguments,
|
||||
@@ -464,17 +468,21 @@ impl App {
|
||||
if let Some(row) = rows.get(self.operation_cursor) {
|
||||
if let Some(operation_name) = &row.operation {
|
||||
if let Some(interface) = self.interfaces.get(&row.interface)
|
||||
&& interface.state == ViewState::Fresh
|
||||
&& let Some(descriptor) = &interface.descriptor
|
||||
&& let Some(operation) = descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.find(|operation| operation.name == *operation_name)
|
||||
{
|
||||
self.modal = Modal::Editor(ArgumentEditor::new(
|
||||
row.interface.clone(),
|
||||
descriptor.clone(),
|
||||
operation.clone(),
|
||||
));
|
||||
self.modal = Modal::Editor(
|
||||
ArgumentEditor::new(
|
||||
row.interface.clone(),
|
||||
descriptor.clone(),
|
||||
operation.clone(),
|
||||
)
|
||||
.for_target(self.selected_path.clone()),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return vec![Action::FetchInterface(row.interface.clone())];
|
||||
@@ -515,6 +523,10 @@ impl App {
|
||||
|
||||
/// Records a pending request separately by interaction type.
|
||||
pub fn request_started(&mut self, id: u64, kind: InFlightKind) {
|
||||
// Object/Interface loading can remove all rows before completion. Keep
|
||||
// the exact subject now rather than recovering identity from a cursor.
|
||||
let selected = self.operation_rows().get(self.operation_cursor).cloned();
|
||||
self.restore_operation_selection(selected);
|
||||
match &kind {
|
||||
InFlightKind::Object(path) => {
|
||||
self.ensure_entry(path).state = ViewState::Loading(id);
|
||||
@@ -683,6 +695,37 @@ impl App {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Preserves a selected structured subject across descriptor/row movement.
|
||||
pub(crate) fn restore_operation_selection(&mut self, selected: Option<OperationRow>) {
|
||||
if let Some(selected) = selected {
|
||||
self.operation_selection = Some((self.selected_path.clone(), selected));
|
||||
} else if self
|
||||
.operation_selection
|
||||
.as_ref()
|
||||
.is_some_and(|(path, _)| path != &self.selected_path)
|
||||
{
|
||||
self.operation_selection = None;
|
||||
}
|
||||
let rows = self.operation_rows();
|
||||
self.operation_cursor = self
|
||||
.operation_selection
|
||||
.as_ref()
|
||||
.and_then(|(_, selected)| {
|
||||
rows.iter().position(|row| row == selected).or_else(|| {
|
||||
rows.iter().position(|row| {
|
||||
row.interface == selected.interface && row.operation.is_none()
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
// Keep identity through a temporarily empty (loading/stale) view. Once
|
||||
// rows are current, the retained identity follows the actual selection,
|
||||
// falling back to its own Interface header if the Operation was retired.
|
||||
if let Some(row) = rows.get(self.operation_cursor) {
|
||||
self.operation_selection = Some((self.selected_path.clone(), row.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_entry(&mut self, path: &str) -> &mut EntryView {
|
||||
if !self.entries.contains_key(path) && self.entries.len() >= MAX_PRESENTATION_ENTRIES {
|
||||
let candidate = self
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct InvalidArguments;
|
||||
/// Pure, terminal-independent editor built from an operation descriptor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArgumentEditor {
|
||||
/// Exact Entry target selected when editing began; never read from display text.
|
||||
pub path: String,
|
||||
/// Structured interface reference selected for the call.
|
||||
pub interface: InterfaceReference,
|
||||
/// Selected operation declaration.
|
||||
@@ -68,6 +70,7 @@ impl ArgumentEditor {
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
path: "/".into(),
|
||||
interface,
|
||||
operation,
|
||||
descriptor,
|
||||
@@ -76,6 +79,13 @@ impl ArgumentEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixes the Entry target for the editor and subsequent confirmation.
|
||||
#[must_use]
|
||||
pub fn for_target(mut self, path: impl Into<String>) -> Self {
|
||||
self.path = path.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Moves to the preceding field without wrapping.
|
||||
pub fn previous_field(&mut self) {
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
//! Presentation and terminal interaction for stateful WIP client sessions.
|
||||
//!
|
||||
//! Interface identity is a structured `wip_protocol::InterfaceReference` with an
|
||||
//! explicit absolute scope. Display-only [`mod@reference`] labels safely quote
|
||||
//! `scope::interface#operation`; shortened labels are never parsed for calls.
|
||||
//! This breaks the former string-reference Rust API and follows the scoped HTTP
|
||||
//! wire format. Ref and validator preconditions remain private Client metadata.
|
||||
//! Editors retain their selected Entry target and are retired when the Client's
|
||||
//! Object/Interface snapshots change; reobservation never executes an operation.
|
||||
|
||||
#![deny(missing_docs)]
|
||||
|
||||
|
||||
+118
-18
@@ -11,11 +11,22 @@ 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)
|
||||
/// Bounded presentation only; invalid references are never repaired or inferred.
|
||||
pub(crate) fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
||||
sanitize(
|
||||
&crate::reference::format_interface_reference(reference)
|
||||
.unwrap_or_else(|_| "(invalid interface reference)".into()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The exact structured subject remains separate from this shortened label.
|
||||
pub(crate) fn operation_label(
|
||||
reference: &wip_protocol::InterfaceReference,
|
||||
operation: &str,
|
||||
) -> String {
|
||||
sanitize(
|
||||
&crate::reference::format_qualified_operation(reference, operation)
|
||||
.unwrap_or_else(|_| "(invalid operation reference)".into()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,7 +243,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.find(|operation| operation.name == *name)
|
||||
});
|
||||
let signature = declaration.map_or_else(
|
||||
|| format!(" ({}, {})", interface_label(&row.interface), name),
|
||||
|| format!(" {}", operation_label(&row.interface, name)),
|
||||
|operation| {
|
||||
let parameters = operation
|
||||
.parameters
|
||||
@@ -253,9 +264,8 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.map(|documentation| format!(" — {}", documentation.summary))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
" ({}, {}) ({parameters}) -> {}{}",
|
||||
interface_label(&row.interface),
|
||||
name,
|
||||
" {} ({parameters}) -> {}{}",
|
||||
operation_label(&row.interface, name),
|
||||
type_name(&operation.returns.r#type),
|
||||
documentation
|
||||
)
|
||||
@@ -361,10 +371,9 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
let area = centered(frame.area(), 86, 78);
|
||||
frame.render_widget(Clear, area);
|
||||
let mut lines = vec![Line::from(format!(
|
||||
"Target: {} Operation: ({}, {})",
|
||||
sanitize(&app.selected_path),
|
||||
interface_label(&editor.interface),
|
||||
sanitize(&editor.operation.name)
|
||||
"Target: {} Operation: {}",
|
||||
sanitize(&editor.path),
|
||||
operation_label(&editor.interface, &editor.operation.name)
|
||||
))];
|
||||
if editor.fields.is_empty() {
|
||||
lines.push(Line::from("(no parameters)"));
|
||||
@@ -412,9 +421,12 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Line::from(format!("Entry: {}", sanitize(&app.selected_path))),
|
||||
Line::from(format!("Entry: {}", sanitize(&editor.path))),
|
||||
Line::from(format!("Interface: {}", interface_label(&editor.interface))),
|
||||
Line::from(format!("Operation: {}", sanitize(&editor.operation.name))),
|
||||
Line::from(format!(
|
||||
"Operation: {}",
|
||||
operation_label(&editor.interface, &editor.operation.name)
|
||||
)),
|
||||
Line::from("Input values:"),
|
||||
];
|
||||
for (name, value) in arguments {
|
||||
@@ -726,7 +738,7 @@ mod tests {
|
||||
}
|
||||
let snapshot = rendered(&app, 140, 40);
|
||||
assert!(snapshot.contains("public root description"));
|
||||
assert!(snapshot.contains("(scope=/, name=demo-interface, run)"));
|
||||
assert!(snapshot.contains("/::demo-interface#run"));
|
||||
assert!(snapshot.contains("SUCCESS"));
|
||||
assert!(snapshot.contains("PROTOCOL ERROR"));
|
||||
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
||||
@@ -822,7 +834,7 @@ mod tests {
|
||||
app.focus = Focus::Operations;
|
||||
app.operation_cursor = app.operation_rows().len() - 1;
|
||||
let snapshot = rendered(&app, 100, 30);
|
||||
assert!(snapshot.contains("(scope=/, name=many, operation-19)"));
|
||||
assert!(snapshot.contains("/::many#operation-19"));
|
||||
|
||||
for index in 0..20 {
|
||||
app.push_history(HistoryItem {
|
||||
@@ -850,13 +862,101 @@ mod tests {
|
||||
scope: "/nested".into(),
|
||||
name: "#::操作".into(),
|
||||
};
|
||||
assert_eq!(interface_label(&reference), "scope=/nested, name=#::操作");
|
||||
assert_eq!(interface_label(&reference), "/nested::\"#::操作\"");
|
||||
assert_ne!(
|
||||
interface_label(&reference),
|
||||
interface_label(&root_reference(&reference.name))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_labels_are_safely_quoted_on_normal_screens_and_modals() {
|
||||
let mut app = App::new("https://example.test/wip/");
|
||||
let local = wip_protocol::InterfaceReference {
|
||||
scope: "/a::b".into(),
|
||||
name: "same#name".into(),
|
||||
};
|
||||
let root = root_reference(&local.name);
|
||||
let operation = "run#\n\u{202e}";
|
||||
let descriptor = InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: operation.into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Unit,
|
||||
},
|
||||
}],
|
||||
};
|
||||
app.apply_object(ObjectObservation {
|
||||
path: "/".into(),
|
||||
object: Some(Object {
|
||||
name: String::new(),
|
||||
description: None,
|
||||
interfaces: vec![root.clone(), local.clone()],
|
||||
r#ref: Some("hidden-object-ref".into()),
|
||||
validator: Some(b"hidden-object-validator".to_vec()),
|
||||
}),
|
||||
validator: Some(b"hidden-object-validator".to_vec()),
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
for reference in [&root, &local] {
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: reference.clone(),
|
||||
descriptor: Some(descriptor.clone()),
|
||||
validator: Some(b"hidden-interface-validator".to_vec()),
|
||||
scope_ref: Some("hidden-scope-ref".into()),
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
}
|
||||
let root_label = "/::\"same#name\"";
|
||||
let local_label = "\"/a::b\"::\"same#name\"";
|
||||
let operation_label = "\"/a::b\"::\"same#name\"#\"run#\\n\\u202e\"";
|
||||
let normal = rendered(&app, 220, 55);
|
||||
assert!(normal.contains(root_label));
|
||||
assert!(normal.contains(local_label));
|
||||
assert!(normal.contains(operation_label));
|
||||
app.focus = Focus::Operations;
|
||||
app.operation_cursor = 3;
|
||||
app.reduce(crate::app::InputKey::Enter);
|
||||
let editor = rendered(&app, 220, 55);
|
||||
assert!(editor.contains(operation_label));
|
||||
assert!(editor.contains("Argument editor"));
|
||||
app.reduce(crate::app::InputKey::Enter);
|
||||
let confirmation = rendered(&app, 220, 55);
|
||||
assert!(confirmation.contains(operation_label));
|
||||
assert!(confirmation.contains("EXPLICIT EXECUTION CONFIRMATION"));
|
||||
for snapshot in [normal, editor, confirmation] {
|
||||
assert!(!snapshot.contains("hidden-"));
|
||||
assert!(!snapshot.contains('\u{202e}'));
|
||||
}
|
||||
assert!(matches!(
|
||||
app.reduce(crate::app::InputKey::Enter).as_slice(),
|
||||
[crate::app::Action::Execute { interface, operation: name, .. }]
|
||||
if interface == &local && name == operation
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortened_and_invalid_labels_never_infer_a_reference() {
|
||||
let reference = root_reference(&"x".repeat(1000));
|
||||
assert!(interface_label(&reference).ends_with('…'));
|
||||
assert_eq!(reference.name.len(), 1000);
|
||||
let invalid = wip_protocol::InterfaceReference {
|
||||
scope: "relative".into(),
|
||||
name: "name".into(),
|
||||
};
|
||||
assert_eq!(interface_label(&invalid), "(invalid interface reference)");
|
||||
assert_eq!(
|
||||
operation_label(&invalid, "run"),
|
||||
"(invalid operation reference)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_characters_and_large_bytes_are_bounded() {
|
||||
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
|
||||
|
||||
+136
-25
@@ -21,7 +21,7 @@ use crate::app::{
|
||||
Action, App, ConnectionState, HistoryItem, HistoryKind, InFlightKind, InputKey, Modal,
|
||||
};
|
||||
use crate::config::{Config, ConfigError};
|
||||
use crate::render::{bounded_value, draw, sanitize};
|
||||
use crate::render::{bounded_value, draw, interface_label, operation_label, sanitize};
|
||||
use crate::terminal::{TerminalGuard, translate_key};
|
||||
|
||||
/// Stable transport failure category.
|
||||
@@ -205,6 +205,12 @@ pub struct Runtime<T: Transport> {
|
||||
sender: mpsc::Sender<WorkerEvent>,
|
||||
receiver: mpsc::Receiver<WorkerEvent>,
|
||||
ignored: BTreeSet<u64>,
|
||||
// Private Client snapshots bind an open editor to the observed lifetime,
|
||||
// including optional refs/validators that must never appear on normal screens.
|
||||
editor_context: Option<(
|
||||
wip_client::ObjectObservation,
|
||||
wip_client::InterfaceObservation,
|
||||
)>,
|
||||
}
|
||||
|
||||
impl<T: Transport> Runtime<T> {
|
||||
@@ -231,6 +237,7 @@ impl<T: Transport> Runtime<T> {
|
||||
sender,
|
||||
receiver,
|
||||
ignored: BTreeSet::new(),
|
||||
editor_context: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -256,7 +263,15 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
/// Applies a key through the reducer and starts any resulting effects.
|
||||
pub fn handle_key(&mut self, key: InputKey) -> bool {
|
||||
let was_editing = matches!(self.app.modal, Modal::Editor(_) | Modal::Confirm { .. });
|
||||
self.reconcile_authoritative();
|
||||
if was_editing && matches!(self.app.modal, Modal::None) {
|
||||
// Do not reinterpret a stale confirmation keystroke as selection
|
||||
// of another row/scope after the old editor has been retired.
|
||||
return false;
|
||||
}
|
||||
let actions = self.app.reduce(key);
|
||||
self.capture_editor_context();
|
||||
let mut quit = false;
|
||||
for action in actions {
|
||||
quit |= self.perform(action);
|
||||
@@ -387,9 +402,14 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) {
|
||||
let selected = self
|
||||
.app
|
||||
.operation_rows()
|
||||
.get(self.app.operation_cursor)
|
||||
.cloned();
|
||||
let id = request.id;
|
||||
self.app.request_started(id.get(), kind.clone());
|
||||
self.reconcile_authoritative();
|
||||
self.reconcile_authoritative_with_selection(selected);
|
||||
let prepared = match self.transport.prepare(request.request) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
@@ -511,6 +531,11 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
|
||||
fn finish(&mut self, event: WorkerEvent) {
|
||||
let selected = self
|
||||
.app
|
||||
.operation_rows()
|
||||
.get(self.app.operation_cursor)
|
||||
.cloned();
|
||||
let kind = self.app.in_flight.get(&event.id.get()).cloned();
|
||||
if kind.is_none() {
|
||||
return;
|
||||
@@ -549,7 +574,7 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reconcile_authoritative();
|
||||
self.reconcile_authoritative_with_selection(selected);
|
||||
self.reconcile_visible_lookahead();
|
||||
}
|
||||
|
||||
@@ -580,9 +605,15 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
}
|
||||
Completion::ProtocolFailure(error) => {
|
||||
let subject = match kind {
|
||||
Some(InFlightKind::Interface(reference)) => {
|
||||
format!(" for {}", interface_label(reference))
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
self.app.push_history(HistoryItem {
|
||||
kind: HistoryKind::DomainFailure,
|
||||
title: format!("Host {:?}", error.code),
|
||||
title: format!("Host {:?}{subject}", error.code),
|
||||
detail: sanitize(&error.message),
|
||||
entry_target: None,
|
||||
});
|
||||
@@ -605,11 +636,12 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
||||
let key = format!(
|
||||
"(scope={}, name={}, {}) on {}",
|
||||
sanitize(&record.context.interface().reference.scope),
|
||||
sanitize(&record.context.interface().reference.name),
|
||||
record.context.operation().name,
|
||||
record.context.object().path
|
||||
"{} on {}",
|
||||
operation_label(
|
||||
&record.context.interface().reference,
|
||||
&record.context.operation().name
|
||||
),
|
||||
sanitize(&record.context.object().path)
|
||||
);
|
||||
self.app.status = match &record.outcome {
|
||||
CallOutcome::Success(_) => "operation completed".into(),
|
||||
@@ -690,12 +722,14 @@ 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 scope={}, name={}",
|
||||
sanitize(&reference.scope),
|
||||
sanitize(&reference.name)
|
||||
),
|
||||
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
|
||||
Some(InFlightKind::Interface(reference)) => {
|
||||
format!("interface {}", interface_label(reference))
|
||||
}
|
||||
Some(InFlightKind::Operation {
|
||||
interface,
|
||||
operation,
|
||||
..
|
||||
}) => operation_label(interface, operation),
|
||||
None => "request".into(),
|
||||
};
|
||||
self.app.push_history(HistoryItem {
|
||||
@@ -752,16 +786,87 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
|
||||
fn reconcile_authoritative(&mut self) {
|
||||
self.sync_all_objects();
|
||||
let evicted_interfaces = self
|
||||
let selected = self
|
||||
.app
|
||||
.interfaces
|
||||
.keys()
|
||||
.filter(|reference| self.client.interface(&self.session, reference).is_none())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for reference in evicted_interfaces {
|
||||
self.app.interfaces.remove(&reference);
|
||||
.operation_rows()
|
||||
.get(self.app.operation_cursor)
|
||||
.cloned();
|
||||
self.reconcile_authoritative_with_selection(selected);
|
||||
}
|
||||
|
||||
fn reconcile_authoritative_with_selection(
|
||||
&mut self,
|
||||
selected: Option<crate::app::OperationRow>,
|
||||
) {
|
||||
self.sync_all_objects();
|
||||
let references = self.app.interfaces.keys().cloned().collect::<Vec<_>>();
|
||||
for reference in references {
|
||||
match self.client.interface(&self.session, &reference).cloned() {
|
||||
Some(observation) => self.app.apply_interface(observation),
|
||||
None => {
|
||||
self.app.interfaces.remove(&reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
let paths = self.app.entries.keys().cloned().collect::<Vec<_>>();
|
||||
for path in paths {
|
||||
if let Some(tree) = self.client.tree(&self.session, &path).cloned() {
|
||||
self.app.apply_tree(tree);
|
||||
} else if let Some(entry) = self.app.entries.get_mut(&path)
|
||||
&& !matches!(entry.tree_state, crate::app::ViewState::Unloaded)
|
||||
{
|
||||
entry.tree_state = crate::app::ViewState::Stale;
|
||||
entry.children.clear();
|
||||
}
|
||||
}
|
||||
self.app.restore_operation_selection(selected);
|
||||
self.reconcile_editor_context();
|
||||
}
|
||||
|
||||
fn capture_editor_context(&mut self) {
|
||||
let editor = match &self.app.modal {
|
||||
Modal::Editor(editor) | Modal::Confirm { editor, .. } => editor,
|
||||
_ => {
|
||||
self.editor_context = None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if self.editor_context.is_none() {
|
||||
self.editor_context = self.client.object(&self.session, &editor.path).zip(
|
||||
self.client
|
||||
.interface(&self.session, &editor.interface)
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
self.reconcile_editor_context();
|
||||
}
|
||||
|
||||
fn reconcile_editor_context(&mut self) {
|
||||
let editor = match &self.app.modal {
|
||||
Modal::Editor(editor) | Modal::Confirm { editor, .. } => editor,
|
||||
_ => {
|
||||
self.editor_context = None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let valid = self
|
||||
.editor_context
|
||||
.as_ref()
|
||||
.is_some_and(|(object, interface)| {
|
||||
object.path == editor.path
|
||||
&& self.app.selected_path == editor.path
|
||||
&& interface.reference == editor.interface
|
||||
&& interface.descriptor.as_ref() == Some(&editor.descriptor)
|
||||
&& object.state == wip_client::ObservationState::Fresh
|
||||
&& interface.state == wip_client::ObservationState::Fresh
|
||||
&& self.client.object(&self.session, &editor.path).as_ref() == Some(object)
|
||||
&& self.client.interface(&self.session, &editor.interface) == Some(interface)
|
||||
});
|
||||
if !valid {
|
||||
self.app.modal = Modal::None;
|
||||
self.editor_context = None;
|
||||
self.app.status =
|
||||
"editor retired: target or interface changed; reobserve and select again".into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,7 +876,9 @@ impl<T: Transport> Runtime<T> {
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| {
|
||||
entry.object.is_some() || self.client.object(&self.session, &entry.path).is_some()
|
||||
entry.object.is_some()
|
||||
|| entry.state != crate::app::ViewState::Unloaded
|
||||
|| self.client.object(&self.session, &entry.path).is_some()
|
||||
})
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
@@ -930,6 +1037,10 @@ impl Display for RuntimeError {
|
||||
|
||||
impl Error for RuntimeError {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "runtime_scoped_tests.rs"]
|
||||
mod scoped_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
//! UI lifetime regressions driven by real Client/HTTP codec completions.
|
||||
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use wip_client::ObservationState;
|
||||
use wip_http::{
|
||||
decode_call_operation_request, encode_fetch_interface_response, encode_observe_response,
|
||||
encode_protocol_error_response,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceReference, Object,
|
||||
ObjectObservation, ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ProtocolInteraction, ReturnDeclaration,
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingTransport(Arc<Mutex<Vec<Request<Vec<u8>>>>>);
|
||||
|
||||
impl Transport for RecordingTransport {
|
||||
type Prepared = Request<Vec<u8>>;
|
||||
|
||||
fn prepare(&self, request: Request<Vec<u8>>) -> Result<Self::Prepared, TransportError> {
|
||||
self.0.lock().unwrap().push(request);
|
||||
Err(TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "deliberate pre-dispatch failure".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute(&self, _: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
|
||||
panic!("test transport must never dispatch");
|
||||
}
|
||||
}
|
||||
|
||||
fn reference(scope: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: "same#::名前".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor() -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "run#::\n\u{202e}".into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Unit,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Runtime<RecordingTransport> {
|
||||
let config = Config::parse(["https://example.test/wip"], None, None, |_| None).unwrap();
|
||||
Runtime::new(&config, RecordingTransport::default()).unwrap()
|
||||
}
|
||||
|
||||
fn observe(runtime: &mut Runtime<RecordingTransport>, identity: Option<&str>) {
|
||||
let request = runtime
|
||||
.client
|
||||
.refresh_object(&runtime.session, "/item")
|
||||
.unwrap();
|
||||
let response = encode_observe_response(
|
||||
&ObserveRequest {
|
||||
path: "/item".into(),
|
||||
depth: 0,
|
||||
},
|
||||
&ObjectObservation {
|
||||
object: Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: vec![reference("/"), reference("/item")],
|
||||
r#ref: identity.map(str::to_owned),
|
||||
validator: None,
|
||||
},
|
||||
children: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(request.id.get(), InFlightKind::Object("/item".into()));
|
||||
// Mirror the actual dispatch-time loading transition before the response.
|
||||
runtime.reconcile_authoritative();
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(response),
|
||||
});
|
||||
}
|
||||
|
||||
fn fetch(runtime: &mut Runtime<RecordingTransport>, scope: &str, identity: Option<&str>) {
|
||||
let request = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference(scope))
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(request.id.get(), InFlightKind::Interface(reference(scope)));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(interface_response(scope, identity)),
|
||||
});
|
||||
}
|
||||
|
||||
fn interface_response(scope: &str, identity: Option<&str>) -> Response<Vec<u8>> {
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: reference(scope),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: reference(scope),
|
||||
scope_ref: identity.map(str::to_owned),
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn populated() -> Runtime<RecordingTransport> {
|
||||
let mut runtime = runtime();
|
||||
observe(&mut runtime, Some("old-scope"));
|
||||
fetch(&mut runtime, "/", Some("root-scope"));
|
||||
fetch(&mut runtime, "/item", Some("old-scope"));
|
||||
runtime.app.navigate_entry("/item");
|
||||
runtime.app.focus = crate::app::Focus::Operations;
|
||||
runtime.app.operation_cursor = 3;
|
||||
runtime
|
||||
}
|
||||
|
||||
fn confirm_local(runtime: &mut Runtime<RecordingTransport>) {
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(&runtime.app.modal, Modal::Editor(editor)
|
||||
if editor.interface == reference("/item") && editor.path == "/item"));
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(runtime.app.modal, Modal::Confirm { .. }));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_names_dispatch_exact_structured_scope_only_after_confirmation() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let call =
|
||||
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
assert_eq!(call.target.path, "/item");
|
||||
assert_eq!(call.interface.reference, reference("/item"));
|
||||
assert_eq!(call.interface.scope_ref.as_deref(), Some("old-scope"));
|
||||
assert_eq!(call.operation, descriptor().operations[0].name);
|
||||
assert!(
|
||||
runtime.app.history[0]
|
||||
.title
|
||||
.contains("/item::\"same#::名前\"#")
|
||||
);
|
||||
assert!(runtime.app.history[0].title.contains("\\n\\u202e"));
|
||||
assert!(!runtime.app.history[0].title.contains("old-scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_loading_preserves_scoped_operation_selection_until_reobservation() {
|
||||
let mut runtime = populated();
|
||||
observe(&mut runtime, Some("old-scope"));
|
||||
let selected = &runtime.app.operation_rows()[runtime.app.operation_cursor];
|
||||
assert_eq!(selected.interface, reference("/item"));
|
||||
assert_eq!(
|
||||
selected.operation.as_deref(),
|
||||
Some(descriptor().operations[0].name.as_str())
|
||||
);
|
||||
assert!(matches!(&runtime.app.operation_selection, Some((path, row))
|
||||
if path == "/item" && row == selected));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_replacement_retires_confirmation_and_requires_explicit_reobservation() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
observe(&mut runtime, Some("new-scope"));
|
||||
let local = &runtime.app.interfaces[&reference("/item")];
|
||||
assert_eq!(local.state, crate::app::ViewState::Stale);
|
||||
assert!(local.descriptor.is_none());
|
||||
assert_eq!(
|
||||
runtime.app.interfaces[&reference("/")].state,
|
||||
crate::app::ViewState::Fresh
|
||||
);
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert!(runtime.app.status.contains("editor retired"));
|
||||
assert_eq!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
|
||||
reference("/item")
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.client
|
||||
.ensure_interface(&runtime.session, reference("/item"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
// Refetch without any execution; selecting and confirming again is required.
|
||||
fetch(&mut runtime, "/item", Some("new-scope"));
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
runtime.app.operation_cursor = 3;
|
||||
confirm_local(&mut runtime);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
let call =
|
||||
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
assert_eq!(call.interface.scope_ref.as_deref(), Some("new-scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_old_fetch_cannot_revive_retired_editor_or_descriptor() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
let request = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/item"))
|
||||
.unwrap();
|
||||
runtime.app.request_started(
|
||||
request.id.get(),
|
||||
InFlightKind::Interface(reference("/item")),
|
||||
);
|
||||
observe(&mut runtime, Some("new-scope"));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(interface_response("/item", Some("old-scope"))),
|
||||
});
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert_ne!(
|
||||
runtime.app.interfaces[&reference("/item")].state,
|
||||
crate::app::ViewState::Fresh
|
||||
);
|
||||
assert!(
|
||||
runtime.app.interfaces[&reference("/item")]
|
||||
.descriptor
|
||||
.is_none()
|
||||
);
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_deletion_and_republication_remove_ui_cache_without_switching_to_root() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
let request = runtime
|
||||
.client
|
||||
.refresh_object(&runtime.session, "/item")
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(request.id.get(), InFlightKind::Object("/item".into()));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(encode_protocol_error_response(
|
||||
ProtocolInteraction::Observe,
|
||||
&ProtocolError {
|
||||
code: ProtocolErrorCode::NotFound,
|
||||
message: "scope deleted".into(),
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
assert!(!runtime.app.interfaces.contains_key(&reference("/item")));
|
||||
assert!(runtime.app.interfaces.contains_key(&reference("/")));
|
||||
assert!(runtime.app.operation_rows().is_empty());
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
observe(&mut runtime, Some("republished-scope"));
|
||||
fetch(&mut runtime, "/item", Some("republished-scope"));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_ref_and_retained_stale_descriptor_are_mirrored_without_synthetic_binding() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
observe(&mut runtime, None);
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert_eq!(
|
||||
runtime.app.interfaces[&reference("/item")].state,
|
||||
crate::app::ViewState::Stale
|
||||
);
|
||||
fetch(&mut runtime, "/item", None);
|
||||
runtime.app.operation_cursor = 3;
|
||||
confirm_local(&mut runtime);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
let call =
|
||||
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
assert_eq!(call.interface.scope_ref, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_mirrors_retained_state_and_preserves_selected_reference_as_rows_shift() {
|
||||
let mut runtime = populated();
|
||||
// Fetch of the first Interface temporarily removes its Operation row. The
|
||||
// selected operation on the second Interface must stay selected by identity.
|
||||
let request = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/"))
|
||||
.unwrap();
|
||||
runtime.reconcile_authoritative();
|
||||
assert_eq!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
|
||||
reference("/item")
|
||||
);
|
||||
assert!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor]
|
||||
.operation
|
||||
.is_some()
|
||||
);
|
||||
let error = runtime
|
||||
.client
|
||||
.fail_transport(request.id, "test failure")
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ClientErrorKind::Transport);
|
||||
runtime.reconcile_authoritative();
|
||||
assert!(matches!(
|
||||
runtime.app.interfaces[&reference("/")].state,
|
||||
crate::app::ViewState::Error(_)
|
||||
));
|
||||
assert_eq!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
|
||||
reference("/item")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_dispatched_call_keeps_original_context_across_scope_republication() {
|
||||
let mut runtime = populated();
|
||||
let call = runtime
|
||||
.client
|
||||
.prepare_call(
|
||||
&runtime.session,
|
||||
"/item",
|
||||
&reference("/item"),
|
||||
&descriptor().operations[0].name,
|
||||
std::collections::BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let logical =
|
||||
decode_call_operation_request(call.request.body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
runtime.client.mark_dispatched(call.id).unwrap();
|
||||
runtime.app.request_started(
|
||||
call.id.get(),
|
||||
InFlightKind::Operation {
|
||||
path: "/item".into(),
|
||||
interface: reference("/item"),
|
||||
operation: logical.operation.clone(),
|
||||
dispatched: true,
|
||||
},
|
||||
);
|
||||
observe(&mut runtime, Some("new-scope"));
|
||||
let mut new_descriptor = descriptor();
|
||||
new_descriptor.operations[0].returns.r#type = TypeExpr::String;
|
||||
let fetch = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/item"))
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(fetch.id.get(), InFlightKind::Interface(reference("/item")));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: fetch.id,
|
||||
response: Ok(encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: reference("/item"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: reference("/item"),
|
||||
scope_ref: Some("new-scope".into()),
|
||||
validator: None,
|
||||
descriptor: new_descriptor.clone(),
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
runtime.finish(WorkerEvent {
|
||||
id: call.id,
|
||||
response: Ok(wip_http::encode_call_operation_response(
|
||||
&logical,
|
||||
&descriptor(),
|
||||
&wip_protocol::CallOperationResponse {
|
||||
result: Value::Unit,
|
||||
validator: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
assert_eq!(runtime.app.history[0].kind, HistoryKind::Success);
|
||||
assert_eq!(runtime.app.history[0].detail, "null");
|
||||
assert_eq!(
|
||||
runtime.app.interfaces[&reference("/item")]
|
||||
.descriptor
|
||||
.as_ref(),
|
||||
Some(&new_descriptor)
|
||||
);
|
||||
assert_eq!(logical.interface.scope_ref.as_deref(), Some("old-scope"));
|
||||
assert!(
|
||||
runtime.transport.0.lock().unwrap().is_empty(),
|
||||
"no redispatch or retry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_entry_results_are_only_observed_on_selection_without_guessed_edges() {
|
||||
let mut runtime = populated();
|
||||
// The original no-validator descriptor is immutable within its scope
|
||||
// lifetime. Republish the scope before testing a different result schema.
|
||||
observe(&mut runtime, Some("entry-result-scope"));
|
||||
let mut descriptor = descriptor();
|
||||
descriptor.operations[0].returns.r#type = TypeExpr::Record {
|
||||
fields: vec![
|
||||
wip_protocol::FieldDeclaration {
|
||||
name: "entries".into(),
|
||||
required: true,
|
||||
documentation: None,
|
||||
r#type: TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Entry),
|
||||
},
|
||||
},
|
||||
wip_protocol::FieldDeclaration {
|
||||
name: "ordinary".into(),
|
||||
required: true,
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
},
|
||||
],
|
||||
};
|
||||
let fetch = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/item"))
|
||||
.unwrap();
|
||||
let completion = runtime
|
||||
.client
|
||||
.complete(
|
||||
fetch.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: reference("/item"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: reference("/item"),
|
||||
scope_ref: Some("entry-result-scope".into()),
|
||||
validator: None,
|
||||
descriptor: descriptor.clone(),
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
runtime.apply_completion(completion, None);
|
||||
let call = runtime
|
||||
.client
|
||||
.prepare_call(
|
||||
&runtime.session,
|
||||
"/item",
|
||||
&reference("/item"),
|
||||
&descriptor.operations[0].name,
|
||||
std::collections::BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let logical =
|
||||
decode_call_operation_request(call.request.body(), &descriptor, default_wire_limits())
|
||||
.unwrap();
|
||||
runtime.client.mark_dispatched(call.id).unwrap();
|
||||
runtime.app.request_started(
|
||||
call.id.get(),
|
||||
InFlightKind::Operation {
|
||||
path: "/item".into(),
|
||||
interface: reference("/item"),
|
||||
operation: descriptor.operations[0].name.clone(),
|
||||
dispatched: true,
|
||||
},
|
||||
);
|
||||
runtime.finish(WorkerEvent {
|
||||
id: call.id,
|
||||
response: Ok(wip_http::encode_call_operation_response(
|
||||
&logical,
|
||||
&descriptor,
|
||||
&wip_protocol::CallOperationResponse {
|
||||
result: Value::Record(std::collections::BTreeMap::from([
|
||||
(
|
||||
"entries".into(),
|
||||
Value::List(vec![
|
||||
Value::String("/hidden/a".into()),
|
||||
Value::String("/hidden/b".into()),
|
||||
]),
|
||||
),
|
||||
("ordinary".into(), Value::String("/not-an-entry".into())),
|
||||
])),
|
||||
validator: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
assert_eq!(
|
||||
runtime
|
||||
.app
|
||||
.history
|
||||
.iter()
|
||||
.filter(|item| item.entry_target.is_some())
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(!runtime.app.entries.contains_key("/hidden/a"));
|
||||
assert!(!runtime.app.entries.contains_key("/hidden/b"));
|
||||
assert!(!runtime.app.entries.contains_key("/not-an-entry"));
|
||||
assert!(runtime.app.entries["/"].children.is_empty());
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
runtime.app.focus = crate::app::Focus::History;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"only the selected typed Entry is observed"
|
||||
);
|
||||
let observe =
|
||||
wip_http::decode_observe_request(requests[0].body(), default_wire_limits()).unwrap();
|
||||
assert_eq!(observe.path, "/hidden/b");
|
||||
assert_eq!(observe.depth, 0);
|
||||
assert!(
|
||||
!runtime.app.entries.contains_key("/hidden"),
|
||||
"no inferred indexable parent"
|
||||
);
|
||||
assert!(!runtime.app.entries.contains_key("/hidden/a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_change_during_confirmation_never_retargets_the_old_editor() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
runtime.app.selected_path = "/".into();
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
runtime
|
||||
.client
|
||||
.interface(&runtime.session, &reference("/item"))
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user